diff --git a/.gitignore b/.gitignore index 2af4184..e7cee0a 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 1cbaf37..e2fb719 100644 --- a/README.md +++ b/README.md @@ -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 有效期 | diff --git a/server-go/cmd/server/main.go b/server-go/cmd/server/main.go index 8ea219c..a4e1e0d 100644 --- a/server-go/cmd/server/main.go +++ b/server-go/cmd/server/main.go @@ -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) diff --git a/server-go/go.mod b/server-go/go.mod index 03fc01f..764c945 100644 --- a/server-go/go.mod +++ b/server-go/go.mod @@ -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 ) diff --git a/server-go/go.sum b/server-go/go.sum index 738dbe2..6345af8 100644 --- a/server-go/go.sum +++ b/server-go/go.sum @@ -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= diff --git a/server-go/internal/config/config.go b/server-go/internal/config/config.go index 270287b..7511824 100644 --- a/server-go/internal/config/config.go +++ b/server-go/internal/config/config.go @@ -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"` diff --git a/server-go/internal/database/db.go b/server-go/internal/database/db.go index 853f624..43ed346 100644 --- a/server-go/internal/database/db.go +++ b/server-go/internal/database/db.go @@ -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 } diff --git a/server-go/internal/database/migrate.go b/server-go/internal/database/migrate.go new file mode 100644 index 0000000..3874ed2 --- /dev/null +++ b/server-go/internal/database/migrate.go @@ -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 +} diff --git a/server-go/internal/database/migrate_test.go b/server-go/internal/database/migrate_test.go new file mode 100644 index 0000000..00fc19b --- /dev/null +++ b/server-go/internal/database/migrate_test.go @@ -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) + } +} diff --git a/server-go/migrations/000001_baseline.down.sql b/server-go/migrations/000001_baseline.down.sql new file mode 100644 index 0000000..39fdec6 --- /dev/null +++ b/server-go/migrations/000001_baseline.down.sql @@ -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; diff --git a/server-go/migrations/000001_baseline.up.sql b/server-go/migrations/000001_baseline.up.sql new file mode 100644 index 0000000..bab73c0 --- /dev/null +++ b/server-go/migrations/000001_baseline.up.sql @@ -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); diff --git a/server-go/migrations/embed.go b/server-go/migrations/embed.go new file mode 100644 index 0000000..91cca1c --- /dev/null +++ b/server-go/migrations/embed.go @@ -0,0 +1,6 @@ +package migrations + +import "embed" + +//go:embed *.sql +var FS embed.FS diff --git a/后续工作计划.md b/后续工作计划.md index a56ba3e..0513a53 100644 --- a/后续工作计划.md +++ b/后续工作计划.md @@ -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 | 未开始 | 无 | diff --git a/开发交接记录.md b/开发交接记录.md index 028babd..7133808 100644 --- a/开发交接记录.md +++ b/开发交接记录.md @@ -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; +- 本次未连接开发服务器、未执行生产部署。 diff --git a/部署指南(物理机).md b/部署指南(物理机).md index 3fca848..90698be 100644 --- a/部署指南(物理机).md +++ b/部署指南(物理机).md @@ -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