feat: 引入版本化数据库迁移与 Schema 启动门禁
This commit is contained in:
@@ -29,6 +29,8 @@ __pycache__/
|
||||
!scripts/verify.ps1
|
||||
*.py
|
||||
*.sql
|
||||
!server-go/migrations/
|
||||
!server-go/migrations/*.sql
|
||||
# ai-service(Python 推理服务)文件例外
|
||||
!ai-service/
|
||||
!ai-service/app/
|
||||
|
||||
@@ -173,7 +173,7 @@ silk/
|
||||
│ ├─ cmd/server/main.go # 入口
|
||||
│ ├─ internal/
|
||||
│ │ ├─ config/ # 环境变量配置
|
||||
│ │ ├─ database/ # GORM 数据库连接与自动迁移
|
||||
│ │ ├─ database/ # GORM 数据库连接与版本化迁移
|
||||
│ │ ├─ handler/ # HTTP handler(auth/device/alarm/video 等)
|
||||
│ │ ├─ middleware/ # JWT 鉴权、RBAC 权限、CORS、限流、安全头
|
||||
│ │ ├─ model/ # GORM 数据模型 + 权限种子数据
|
||||
@@ -426,7 +426,7 @@ Payload 示例:
|
||||
|
||||
### 10.1 PostgreSQL(业务数据)
|
||||
|
||||
保存用户、蚕房、设备、传感器、阈值、告警、摄像头、录像片段、审计日志、权限等业务数据。GORM 自动迁移建表,系统启动时自动初始化权限种子数据。
|
||||
保存用户、蚕房、设备、传感器、阈值、告警、摄像头、录像片段、审计日志、权限等业务数据。系统启动时先执行 `server-go/migrations/` 下的版本化 SQL 迁移,再初始化权限种子数据;生产环境禁用 AutoMigrate。
|
||||
|
||||
```env
|
||||
PG=postgresql://postgres:pan@localhost:5432/silk
|
||||
@@ -499,6 +499,8 @@ C:\msys64\usr\bin\sshpass.exe -p "pan" C:\msys64\usr\bin\ssh.exe -o StrictHostKe
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `PG` | `postgresql://postgres:pan@localhost:5432/silk` | PostgreSQL 连接串 |
|
||||
| `APP_ENV` | `development` | 运行环境;生产环境应设为 `production` |
|
||||
| `ALLOW_DEV_AUTOMIGRATE` | `false` | 仅开发环境可显式开启 AutoMigrate,生产忽略此开关 |
|
||||
| `REDIS` | `redis://:pan@localhost:6379` | Valkey/Redis 连接串 |
|
||||
| `JWT_SECRET` | `silk-secret-please-change-me` | JWT 签名密钥 |
|
||||
| `JWT_EXPIRES_IN` | `2h` | JWT 有效期 |
|
||||
|
||||
@@ -28,7 +28,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 2. 连接数据库(GORM 自动迁移)
|
||||
// 2. 连接数据库并执行版本化迁移
|
||||
if err := database.Init(cfg); err != nil {
|
||||
slog.Error("数据库连接失败", "err", err)
|
||||
os.Exit(1)
|
||||
|
||||
+12
-7
@@ -5,6 +5,7 @@ go 1.23
|
||||
exclude github.com/rogpeppe/go-internal v1.15.0
|
||||
|
||||
require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.3
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.27
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2
|
||||
@@ -12,9 +13,10 @@ require (
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/redis/go-redis/v9 v9.6.1
|
||||
golang.org/x/crypto v0.25.0
|
||||
golang.org/x/crypto v0.31.0
|
||||
gorm.io/driver/postgres v1.5.9
|
||||
gorm.io/gorm v1.25.10
|
||||
)
|
||||
@@ -41,6 +43,8 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
||||
@@ -49,8 +53,8 @@ require (
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
@@ -58,11 +62,12 @@ require (
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.27.0 // indirect
|
||||
golang.org/x/sync v0.7.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
+70
-15
@@ -1,3 +1,9 @@
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg=
|
||||
@@ -38,20 +44,35 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
|
||||
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
|
||||
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -62,13 +83,22 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
@@ -83,6 +113,7 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
@@ -93,15 +124,29 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4=
|
||||
@@ -124,25 +169,35 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
||||
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
// Config 全局配置,从环境变量加载
|
||||
type Config struct {
|
||||
AppEnv string `env:"APP_ENV" envDefault:"development"`
|
||||
AllowDevAutoMigrate bool `env:"ALLOW_DEV_AUTOMIGRATE" envDefault:"false"`
|
||||
PG string `env:"PG" envDefault:"postgresql://postgres:pan@localhost:5432/silk"`
|
||||
Redis string `env:"REDIS" envDefault:"redis://:pan@localhost:6379"`
|
||||
JWTSecret string `env:"JWT_SECRET" envDefault:"silk-secret-please-change-me"`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"silk-server-go/internal/config"
|
||||
@@ -21,30 +22,38 @@ func Init(cfg *config.Config) error {
|
||||
return err
|
||||
}
|
||||
DB = db
|
||||
// 自动迁移(错误不阻止启动,仅记录警告)
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{}, &model.Room{}, &model.Device{}, &model.Sensor{},
|
||||
&model.Threshold{}, &model.Alarm{}, &model.Camera{}, &model.VideoClip{},
|
||||
&model.AuditLog{}, &model.Telemetry{},
|
||||
&model.Permission{}, &model.RolePermission{},
|
||||
&model.Disease{}, &model.KnowledgeArticle{},
|
||||
&model.InspectionRecord{},
|
||||
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
|
||||
&model.WechatBinding{},
|
||||
&model.WeatherAlert{},
|
||||
&model.LampTest{}, &model.LampTestStep{},
|
||||
&model.Consumable{},
|
||||
&model.Consultation{},
|
||||
&model.SpectrumEntry{},
|
||||
&model.TraceRecord{},
|
||||
); err != nil {
|
||||
slog.Warn("自动迁移有警告(可忽略)", "err", err)
|
||||
if err := RunMigrations(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := CheckSchemaVersion(db, CurrentSchemaVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.AllowDevAutoMigrate && cfg.AppEnv != "production" {
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{}, &model.Room{}, &model.Device{}, &model.Sensor{},
|
||||
&model.Threshold{}, &model.Alarm{}, &model.Camera{}, &model.VideoClip{},
|
||||
&model.AuditLog{}, &model.Telemetry{},
|
||||
&model.Permission{}, &model.RolePermission{},
|
||||
&model.Disease{}, &model.KnowledgeArticle{},
|
||||
&model.InspectionRecord{},
|
||||
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
|
||||
&model.WechatBinding{},
|
||||
&model.WeatherAlert{},
|
||||
&model.LampTest{}, &model.LampTestStep{},
|
||||
&model.Consumable{},
|
||||
&model.Consultation{},
|
||||
&model.SpectrumEntry{},
|
||||
&model.TraceRecord{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("开发环境 AutoMigrate 失败: %w", err)
|
||||
}
|
||||
slog.Warn("开发环境 AutoMigrate 已启用,SQL 迁移仍是生产 schema 事实来源")
|
||||
}
|
||||
// 初始化权限种子数据
|
||||
seedPermissions(db)
|
||||
// 初始化知识库种子数据
|
||||
seedKnowledge(db)
|
||||
slog.Info("数据库连接成功,自动迁移完成")
|
||||
slog.Info("数据库连接成功,版本化迁移完成", "schemaVersion", CurrentSchemaVersion)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
"silk-server-go/migrations"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
|
||||
const CurrentSchemaVersion = "1"
|
||||
|
||||
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
|
||||
func RunMigrations(db *gorm.DB) error {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取数据库连接: %w", err)
|
||||
}
|
||||
|
||||
sourceDriver, err := iofs.New(migrations.FS, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("加载嵌入式迁移文件: %w", err)
|
||||
}
|
||||
|
||||
databaseDriver, err := postgres.WithInstance(sqlDB, &postgres.Config{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("初始化 PostgreSQL 迁移驱动: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("iofs", sourceDriver, "postgres", databaseDriver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建迁移实例: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = m.Close()
|
||||
}()
|
||||
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("执行数据库迁移: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckSchemaVersion 校验 schema_migrations 当前版本与 expected 一致,且不是 dirty。
|
||||
func CheckSchemaVersion(db *gorm.DB, expected string) error {
|
||||
var version int64
|
||||
var dirty bool
|
||||
|
||||
row := db.Raw("SELECT version, dirty FROM schema_migrations ORDER BY version DESC LIMIT 1").Row()
|
||||
if err := row.Scan(&version, &dirty); err != nil {
|
||||
return fmt.Errorf("读取 schema_migrations: %w", err)
|
||||
}
|
||||
if dirty {
|
||||
return fmt.Errorf("schema 迁移处于 dirty 状态,版本 %d", version)
|
||||
}
|
||||
if strconv.FormatInt(version, 10) != expected {
|
||||
return fmt.Errorf("schema 版本不匹配:当前 %d,期望 %s", version, expected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"silk-server-go/migrations"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newMockGormDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock) {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
|
||||
gdb, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open gorm with sqlmock: %v", err)
|
||||
}
|
||||
|
||||
return gdb, mock
|
||||
}
|
||||
|
||||
func TestCheckSchemaVersionRejectsMismatch(t *testing.T) {
|
||||
db, mock := newMockGormDB(t)
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
}()
|
||||
|
||||
mock.ExpectQuery("SELECT version, dirty FROM schema_migrations").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).AddRow(1, false))
|
||||
|
||||
if err := CheckSchemaVersion(db, "2"); err == nil {
|
||||
t.Fatal("expected schema mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSchemaVersionAcceptsMatch(t *testing.T) {
|
||||
db, mock := newMockGormDB(t)
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
}()
|
||||
|
||||
mock.ExpectQuery("SELECT version, dirty FROM schema_migrations").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).AddRow(1, false))
|
||||
|
||||
if err := CheckSchemaVersion(db, "1"); err != nil {
|
||||
t.Fatalf("expected schema version match, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSchemaVersionRejectsMissingVersion(t *testing.T) {
|
||||
db, mock := newMockGormDB(t)
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
}()
|
||||
|
||||
mock.ExpectQuery("SELECT version, dirty FROM schema_migrations").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}))
|
||||
|
||||
if err := CheckSchemaVersion(db, "1"); err == nil {
|
||||
t.Fatal("expected missing schema version error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSchemaVersionRejectsDirty(t *testing.T) {
|
||||
db, mock := newMockGormDB(t)
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
}()
|
||||
|
||||
mock.ExpectQuery("SELECT version, dirty FROM schema_migrations").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).AddRow(1, true))
|
||||
|
||||
if err := CheckSchemaVersion(db, "1"); err == nil {
|
||||
t.Fatal("expected dirty migration error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedMigrationsIncludeBaseline(t *testing.T) {
|
||||
driver, err := iofs.New(migrations.FS, ".")
|
||||
if err != nil {
|
||||
t.Fatalf("load embedded migrations: %v", err)
|
||||
}
|
||||
|
||||
version, err := driver.First()
|
||||
if err != nil {
|
||||
t.Fatalf("read first migration: %v", err)
|
||||
}
|
||||
if version != 1 {
|
||||
t.Fatalf("expected baseline migration version 1, got %d", version)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
DROP TABLE IF EXISTS trace_records CASCADE;
|
||||
DROP TABLE IF EXISTS spectrum_entries CASCADE;
|
||||
DROP TABLE IF EXISTS consultations CASCADE;
|
||||
DROP TABLE IF EXISTS consumables CASCADE;
|
||||
DROP TABLE IF EXISTS lamp_test_steps CASCADE;
|
||||
DROP TABLE IF EXISTS lamp_tests CASCADE;
|
||||
DROP TABLE IF EXISTS weather_alerts CASCADE;
|
||||
DROP TABLE IF EXISTS wechat_bindings CASCADE;
|
||||
DROP TABLE IF EXISTS rearing_records CASCADE;
|
||||
DROP TABLE IF EXISTS batches CASCADE;
|
||||
DROP TABLE IF EXISTS trays CASCADE;
|
||||
DROP TABLE IF EXISTS inspection_records CASCADE;
|
||||
DROP TABLE IF EXISTS knowledge_articles CASCADE;
|
||||
DROP TABLE IF EXISTS diseases CASCADE;
|
||||
DROP TABLE IF EXISTS role_permissions CASCADE;
|
||||
DROP TABLE IF EXISTS permissions CASCADE;
|
||||
DROP TABLE IF EXISTS telemetry CASCADE;
|
||||
DROP TABLE IF EXISTS audit_logs CASCADE;
|
||||
DROP TABLE IF EXISTS video_clips CASCADE;
|
||||
DROP TABLE IF EXISTS cameras CASCADE;
|
||||
DROP TABLE IF EXISTS alarms CASCADE;
|
||||
DROP TABLE IF EXISTS thresholds CASCADE;
|
||||
DROP TABLE IF EXISTS sensors CASCADE;
|
||||
DROP TABLE IF EXISTS devices CASCADE;
|
||||
DROP TABLE IF EXISTS rooms CASCADE;
|
||||
DROP TABLE IF EXISTS users CASCADE;
|
||||
@@ -0,0 +1,420 @@
|
||||
CREATE TABLE users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username text NOT NULL UNIQUE,
|
||||
email text NOT NULL UNIQUE,
|
||||
password_hash text NOT NULL,
|
||||
full_name text,
|
||||
role text NOT NULL DEFAULT 'user',
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE rooms (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
code text,
|
||||
location text,
|
||||
description text,
|
||||
capacity bigint,
|
||||
stage varchar(32),
|
||||
region varchar(64),
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE devices (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
device_key text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
kind text NOT NULL DEFAULT 'sensor',
|
||||
model text,
|
||||
firmware text,
|
||||
online_status text NOT NULL DEFAULT 'online',
|
||||
last_seen timestamptz,
|
||||
room_id uuid NOT NULL,
|
||||
topic text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_devices_room_id ON devices (room_id);
|
||||
|
||||
CREATE TABLE sensors (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
metric text NOT NULL,
|
||||
unit text,
|
||||
data_type text,
|
||||
device_id uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sensors_device_id ON sensors (device_id);
|
||||
|
||||
CREATE TABLE thresholds (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text,
|
||||
min_value double precision NOT NULL,
|
||||
max_value double precision NOT NULL,
|
||||
debounce_seconds bigint NOT NULL DEFAULT 5,
|
||||
severity bigint NOT NULL DEFAULT 3,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
sensor_id uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_thresholds_sensor_id ON thresholds (sensor_id);
|
||||
|
||||
CREATE TABLE alarms (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text,
|
||||
title text,
|
||||
message text,
|
||||
severity text,
|
||||
open boolean NOT NULL DEFAULT true,
|
||||
acknowledged boolean NOT NULL DEFAULT false,
|
||||
acknowledged_at timestamptz,
|
||||
triggered_at timestamptz NOT NULL,
|
||||
resolved_at timestamptz,
|
||||
device_key text,
|
||||
metric text,
|
||||
value double precision,
|
||||
threshold_min double precision,
|
||||
threshold_max double precision
|
||||
);
|
||||
|
||||
CREATE INDEX idx_alarms_device_open_triggered ON alarms (device_key, open, triggered_at);
|
||||
|
||||
CREATE TABLE cameras (
|
||||
id bigserial PRIMARY KEY,
|
||||
room_id text,
|
||||
code varchar(64) NOT NULL,
|
||||
name varchar(128) NOT NULL,
|
||||
rtsp_url varchar(512),
|
||||
http_url varchar(512),
|
||||
username varchar(64),
|
||||
password_enc varchar(255),
|
||||
position varchar(255),
|
||||
resolution varchar(32),
|
||||
fps bigint,
|
||||
is_online boolean NOT NULL DEFAULT true,
|
||||
gb_device_id varchar(20),
|
||||
gb_channel_id varchar(20),
|
||||
gb_auth_id varchar(20),
|
||||
gb_auth_password varchar(255),
|
||||
gb_stream_type varchar(10),
|
||||
gb_transport varchar(10),
|
||||
gb_alarm_channel_id varchar(20),
|
||||
gb_voice_channel_id varchar(20),
|
||||
gb_manufacturer varchar(64),
|
||||
manufacturer_id varchar(64),
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_cameras_room_id ON cameras (room_id);
|
||||
|
||||
CREATE TABLE video_clips (
|
||||
id bigserial PRIMARY KEY,
|
||||
trigger text NOT NULL DEFAULT 'alarm',
|
||||
file_path text,
|
||||
duration_sec real NOT NULL DEFAULT 0,
|
||||
start_at timestamptz NOT NULL,
|
||||
end_at timestamptz,
|
||||
resolution text,
|
||||
size_bytes bigint,
|
||||
notes text,
|
||||
camera_id text NOT NULL,
|
||||
room_id text,
|
||||
alarm_id text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
s3_bucket text,
|
||||
s3_key text
|
||||
);
|
||||
|
||||
CREATE INDEX idx_video_clips_alarm_id ON video_clips (alarm_id);
|
||||
CREATE INDEX idx_video_clips_room_id ON video_clips (room_id);
|
||||
CREATE INDEX idx_video_clips_camera_id ON video_clips (camera_id);
|
||||
|
||||
CREATE TABLE audit_logs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid,
|
||||
username text,
|
||||
action text NOT NULL,
|
||||
resource text,
|
||||
target_id text,
|
||||
description text,
|
||||
ip_address text,
|
||||
user_agent text,
|
||||
metadata jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_logs_resource_target ON audit_logs (resource, target_id);
|
||||
CREATE INDEX idx_audit_logs_action_created ON audit_logs (action, created_at);
|
||||
CREATE INDEX idx_audit_logs_user_created ON audit_logs (user_id, created_at);
|
||||
|
||||
CREATE TABLE telemetry (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
device_key text NOT NULL,
|
||||
metric text NOT NULL,
|
||||
value double precision NOT NULL,
|
||||
timestamp timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_telemetry_device_timestamp ON telemetry (device_key, timestamp);
|
||||
|
||||
CREATE TABLE permissions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_permissions_code ON permissions (code);
|
||||
|
||||
CREATE TABLE role_permissions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
role text NOT NULL,
|
||||
permission_id uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_role_permissions_role_code ON role_permissions (role, permission_id);
|
||||
|
||||
CREATE TABLE diseases (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name varchar(64) NOT NULL,
|
||||
category varchar(32) NOT NULL,
|
||||
pathogen text NOT NULL,
|
||||
transmission text NOT NULL,
|
||||
medium text NOT NULL,
|
||||
incubation text NOT NULL,
|
||||
symptoms text NOT NULL,
|
||||
high_risk_stage text NOT NULL,
|
||||
high_risk_condition text NOT NULL,
|
||||
lethal_time text NOT NULL,
|
||||
spread_trend text NOT NULL,
|
||||
recurrence text NOT NULL,
|
||||
detection text NOT NULL,
|
||||
prevention text NOT NULL,
|
||||
image_url varchar(512),
|
||||
sort_order bigint NOT NULL DEFAULT 0,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_diseases_category ON diseases (category);
|
||||
|
||||
CREATE TABLE knowledge_articles (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kind varchar(32) NOT NULL,
|
||||
title varchar(128) NOT NULL,
|
||||
summary text NOT NULL,
|
||||
content text NOT NULL,
|
||||
sort_order bigint NOT NULL DEFAULT 0,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_knowledge_articles_kind ON knowledge_articles (kind);
|
||||
|
||||
CREATE TABLE inspection_records (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid,
|
||||
room_id uuid,
|
||||
image_url varchar(512),
|
||||
detections jsonb,
|
||||
risk_score double precision,
|
||||
risk_level varchar(16),
|
||||
ai_status varchar(16) NOT NULL DEFAULT 'done',
|
||||
idempotency_key varchar(128),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_inspection_records_idempotency_key ON inspection_records (idempotency_key);
|
||||
CREATE INDEX idx_inspection_records_room_id ON inspection_records (room_id);
|
||||
CREATE INDEX idx_inspection_records_user_id ON inspection_records (user_id);
|
||||
|
||||
CREATE TABLE trays (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name varchar(64) NOT NULL,
|
||||
code varchar(64),
|
||||
position varchar(128),
|
||||
room_id uuid NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_trays_room_id ON trays (room_id);
|
||||
|
||||
CREATE TABLE batches (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
room_id uuid NOT NULL,
|
||||
name varchar(64) NOT NULL,
|
||||
variety varchar(64),
|
||||
source varchar(128),
|
||||
seed_batch_no varchar(64),
|
||||
quarantine_no varchar(64),
|
||||
instar bigint,
|
||||
entered_at timestamptz,
|
||||
mount_at timestamptz,
|
||||
status varchar(16) NOT NULL DEFAULT 'rearing',
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_batches_room_id ON batches (room_id);
|
||||
|
||||
CREATE TABLE rearing_records (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
batch_id uuid NOT NULL,
|
||||
record_date timestamptz NOT NULL,
|
||||
instar bigint,
|
||||
mulberry_source varchar(128),
|
||||
density varchar(64),
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_rearing_records_batch_id ON rearing_records (batch_id);
|
||||
|
||||
CREATE TABLE wechat_bindings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL,
|
||||
open_id varchar(128) NOT NULL,
|
||||
authorized_templates jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_wechat_bindings_open_id ON wechat_bindings (open_id);
|
||||
CREATE UNIQUE INDEX idx_wechat_bindings_user_id ON wechat_bindings (user_id);
|
||||
|
||||
CREATE TABLE weather_alerts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
disease varchar(64) NOT NULL,
|
||||
level varchar(16) NOT NULL,
|
||||
reason text NOT NULL,
|
||||
snapshot jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE lamp_tests (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
method varchar(32) NOT NULL DEFAULT 'lamp',
|
||||
room_id uuid,
|
||||
batch_id uuid,
|
||||
diseases jsonb,
|
||||
status varchar(16) NOT NULL DEFAULT 'pending',
|
||||
sample_info varchar(255),
|
||||
result varchar(16),
|
||||
result_image_url varchar(512),
|
||||
operator_id uuid,
|
||||
resulted_at timestamptz,
|
||||
cross_status varchar(16) NOT NULL DEFAULT 'pending',
|
||||
cross_reason text,
|
||||
extra_data jsonb,
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_lamp_tests_batch_id ON lamp_tests (batch_id);
|
||||
CREATE INDEX idx_lamp_tests_room_id ON lamp_tests (room_id);
|
||||
|
||||
CREATE TABLE lamp_test_steps (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
lamp_test_id uuid NOT NULL,
|
||||
step_no bigint NOT NULL,
|
||||
name varchar(64) NOT NULL,
|
||||
done boolean NOT NULL DEFAULT false,
|
||||
done_at timestamptz,
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_lamp_test_steps_lamp_test_id ON lamp_test_steps (lamp_test_id);
|
||||
|
||||
CREATE TABLE consumables (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name varchar(64) NOT NULL,
|
||||
category varchar(32) NOT NULL,
|
||||
spec varchar(128),
|
||||
quantity double precision NOT NULL,
|
||||
unit varchar(32),
|
||||
min_quantity double precision NOT NULL,
|
||||
expiry_date timestamptz,
|
||||
supplier varchar(128),
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_consumables_category ON consumables (category);
|
||||
|
||||
CREATE TABLE consultations (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
room_id uuid,
|
||||
batch_id uuid,
|
||||
lamp_test_id uuid,
|
||||
title varchar(128) NOT NULL,
|
||||
summary text,
|
||||
snapshot jsonb,
|
||||
status varchar(16) NOT NULL DEFAULT 'pending',
|
||||
expert_id uuid,
|
||||
opinion text,
|
||||
plan text,
|
||||
resolved_at timestamptz,
|
||||
archived_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_consultations_lamp_test_id ON consultations (lamp_test_id);
|
||||
CREATE INDEX idx_consultations_batch_id ON consultations (batch_id);
|
||||
CREATE INDEX idx_consultations_room_id ON consultations (room_id);
|
||||
|
||||
CREATE TABLE spectrum_entries (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
disease varchar(64) NOT NULL,
|
||||
source varchar(128),
|
||||
data_url varchar(512),
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE trace_records (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
room_id uuid,
|
||||
lamp_test_id uuid,
|
||||
consultation_id uuid,
|
||||
disease varchar(64) NOT NULL,
|
||||
status varchar(16) NOT NULL DEFAULT 'pending',
|
||||
origin varchar(32),
|
||||
confidence double precision,
|
||||
auto_report jsonb,
|
||||
checklist jsonb,
|
||||
analysis_report jsonb,
|
||||
expert_note text,
|
||||
lab_note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_trace_records_consultation_id ON trace_records (consultation_id);
|
||||
CREATE INDEX idx_trace_records_lamp_test_id ON trace_records (lamp_test_id);
|
||||
CREATE INDEX idx_trace_records_room_id ON trace_records (room_id);
|
||||
@@ -0,0 +1,6 @@
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed *.sql
|
||||
var FS embed.FS
|
||||
@@ -10,7 +10,7 @@
|
||||
|---|---|---|---|---|
|
||||
| Wave 0 | 决策与基线 | Task 0 固化开放决策与整改基线 | 方案确定 | 无,决策记录已生成 |
|
||||
| Wave 0 | 决策与基线 | Task 1 恢复可复现的多端质量基线 | 部分可用 | CI 待 Task 0 平台落地 |
|
||||
| Wave 1 | P0 安全与正确性 | Task 2 版本化数据库迁移与 Schema 启动门禁 | 未开始 | 无 |
|
||||
| Wave 1 | P0 安全与正确性 | Task 2 版本化数据库迁移与 Schema 启动门禁 | 部分可用 | 本机无 PostgreSQL/Docker,真实空库/回滚演练待环境 |
|
||||
| Wave 1 | P0 安全与正确性 | Task 3 移除默认密钥与默认管理员密码 | 未开始 | 无 |
|
||||
| Wave 1 | P0 安全与正确性 | Task 4 收口视频访问与摄像头密钥输出 | 未开始 | 无 |
|
||||
| Wave 1 | P0 安全与正确性 | Task 5 修复 WebSocket 越权与 AI 流 SSRF | 未开始 | 无 |
|
||||
|
||||
@@ -804,3 +804,35 @@ MVP 沿用 IoTDB(现状);TDengine 作为生产规模化候选(先基准
|
||||
### 回滚点
|
||||
|
||||
- 删除 `docs/decisions/2026-08-13-remediation-decisions.md`,移除 `后续工作计划.md` Wave 0-4 表和本节即可;无运行时影响。
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-13 整改 Task 2:版本化数据库迁移与 Schema 启动门禁
|
||||
|
||||
### 做了什么
|
||||
|
||||
- 采用 `golang-migrate/migrate/v4 v4.18.2`,新增 `server-go/migrations/` 嵌入式迁移包;
|
||||
- 新增 `000001_baseline.up.sql` / `000001_baseline.down.sql`,覆盖当前 26 张业务表、索引和唯一约束;
|
||||
- 新增 `internal/database/migrate.go`:`RunMigrations`、`CheckSchemaVersion`、`CurrentSchemaVersion`;
|
||||
- `database.Init` 改为先执行版本化迁移并校验 `schema_migrations`,生产环境忽略 `ALLOW_DEV_AUTOMIGRATE`;开发环境仅显式开启时允许 AutoMigrate;
|
||||
- `config.Config` 增加 `APP_ENV`、`ALLOW_DEV_AUTOMIGRATE`;README 与物理机部署指南补充迁移说明和环境变量;
|
||||
- 新增 sqlmock 测试,覆盖版本匹配、版本不匹配、空版本、dirty 状态和嵌入式迁移文件解析。
|
||||
|
||||
### 设计思路与决策依据
|
||||
|
||||
- SQL 迁移文件作为唯一 schema 事实来源,不使用旧 `silk-db-backup.dump` 中的 app_runtime/public 结构(两套均为旧版);
|
||||
- 基线 SQL 由当前 GORM 模型 dry-run 生成后人工核对,保留当前模型的 UUID、bigserial、jsonb、varchar 长度、默认值和索引;
|
||||
- 选择 v4.18.2 而不是 v4.19.1,是因为 v4.19.1 要求 Go 1.24,项目基线为 Go 1.23;
|
||||
- 启动阶段任何 schema 版本缺失、落后或 dirty 都直接失败,避免代码与数据库不一致时继续提供 API。
|
||||
|
||||
### 验证结果
|
||||
|
||||
- `go test ./...`、`go vet ./...`、`go build ./...` 全部通过;
|
||||
- `internal/database` 迁移版本检查与嵌入式迁移解析单测通过;
|
||||
- 本机无 Docker/PostgreSQL,未执行真实空库建库、旧库升级和 down 回滚演练;需在开发服务器或本地 PostgreSQL 环境完成,并严格按 AGENTS.md 先备份。
|
||||
|
||||
### 回滚点
|
||||
|
||||
- 本任务前基线提交为 `20bea00`;回滚可还原 Task 2 提交并恢复 go.mod/go.sum;
|
||||
- 若迁移已应用到数据库,必须先恢复迁移前数据库备份,不能只回滚代码而保留不兼容 schema;
|
||||
- 本次未连接开发服务器、未执行生产部署。
|
||||
|
||||
@@ -434,6 +434,8 @@ chmod +x /home/pan/silk-server-go-linux
|
||||
# 创建 .env
|
||||
cat > /home/pan/silk/.env << 'EOF'
|
||||
PORT=3000
|
||||
APP_ENV=production
|
||||
ALLOW_DEV_AUTOMIGRATE=false
|
||||
PG=postgresql://postgres:pan@localhost:5432/silk
|
||||
REDIS=redis://:pan@localhost:6379
|
||||
MQTT=mqtt://pan:pan@localhost:1883
|
||||
@@ -457,6 +459,8 @@ cd /home/pan/silk
|
||||
nohup /home/pan/silk-server-go-linux > server-go.log 2>&1 < /dev/null &
|
||||
```
|
||||
|
||||
> **数据库迁移**:Go 后端启动时会自动执行 `server-go/migrations/` 下的 SQL 迁移并校验 `schema_migrations` 版本;生产环境不要开启 `ALLOW_DEV_AUTOMIGRATE=true`。升级前先按 AGENTS.md 完成数据库备份。
|
||||
|
||||
### 11. 部署 Go 录制服务
|
||||
|
||||
```bash
|
||||
@@ -565,6 +569,8 @@ sudo ceph -s
|
||||
```
|
||||
PORT=3000
|
||||
PG=postgresql://postgres:pan@localhost:5432/silk
|
||||
APP_ENV=production
|
||||
ALLOW_DEV_AUTOMIGRATE=false
|
||||
REDIS=redis://:pan@localhost:6379
|
||||
MQTT=mqtt://pan:pan@localhost:1883
|
||||
IOTDB_URL=http://127.0.0.1:18081
|
||||
|
||||
Reference in New Issue
Block a user