commit d9475ac9da94198b02f22b9a35596b21d3a58ca7 Author: 李琦 Date: Sat Aug 15 07:41:11 2026 +0800 初始化 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ba181e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +config.yaml +uploads/ +bin/ +*.exe diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b6b1ecf --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..26c12d0 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/nl-pms-api.iml b/.idea/nl-pms-api.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/nl-pms-api.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a1fc62d --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# nl-pms-api + +code-count(view 桌面端)的文件存储服务:客户端凭密钥上传图片,换取可公开访问的 +http URL。管理员(云端账号 id=1)在 view 的「文件存储」配置里把存储方式设为 +「服务器」并填入本服务地址与密钥后,内容图片(`imageMode=server`)与头像会上传到 +这里,Markdown / 头像直接引用返回的 URL —— 跨设备、跨团队都能访问,不再依赖本地路径。 + +技术栈:Go + gin + gorm,MySQL 与 view 同步服务共用 `code_count` 库(新表 `pms_files`)。 + +## 接口 + +| 方法 | 路径 | 鉴权 | 说明 | +|---|---|---|---| +| GET | `/healthz` | 无 | 健康检查(view「测试连接」调用) | +| POST | `/api/v1/files` | `Authorization: Bearer ` | multipart 上传:`file` 必填,`kind`(avatar\|content)、`userId`、`teamId` 可选;返回 `{id, name, url, size, mime, teamId}` | +| GET | `/api/v1/files` | 同上 | 素材库列表:`scope=mine\|team\|all` + `userId` + `teamId` + `page` + `pageSize`;mine 看自己,team 需为该团队 owner/admin,all 仅超管(userId=1);返回 `{total, items}`(含上传者 `username`) | +| DELETE | `/api/v1/files/:id` | 同上 | 删除素材(记录+磁盘文件):本人、超管 id=1、或该文件归属团队的 owner/admin | +| GET | `/files/*path` | 无 | 文件公开访问(路径含 128 位随机 hex,不可枚举) | + +上传约束:默认单文件 ≤ 20MB(`max_upload_mb`);按内容嗅探只接受 jpeg/png/gif/webp; +同一归属(`userId`+`teamId`)重复上传相同内容直接复用已有记录(秒传), +不同归属各自落盘,保证删除自己的素材不影响他人引用。 + +身份模型:沿用 code-count 的内网信任模型 —— 客户端自报 `userId`/`teamId`, +服务端按 code_count 库的 `team_members`/`users` 判定管理范围(防误操作,不防伪造)。 + +```bash +curl http://127.0.0.1:8788/healthz +curl -H "Authorization: Bearer change-me" -F "file=@a.png" -F "kind=content" \ + http://127.0.0.1:8788/api/v1/files +``` + +## 配置 + +复制 `config.example.yaml` 为 `config.yaml` 后修改(启动可用 `-config` 指定路径)。 +必填:`api_key`(客户端上传密钥)、`mysql.dsn`;`base_url` 建议填客户端可达的地址, +留空则按请求 Host 推断。 + +## 数据库迁移约定(重要) + +- **仅 dev 环境自动迁移**:`config.yaml` 里 `env: dev` 时,启动执行 `AutoMigrate`。 +- **生产绝不迁移**:`env: prod`(默认)启动只检查 `pms_files` 表是否存在, + 缺表直接报错退出,不执行任何 DDL。部署 / 升级前先手工执行: + +```bash +mysql -u root -p < init.sql +``` + +`init.sql` 可重复执行(`CREATE TABLE IF NOT EXISTS`),索引名与 gorm 默认命名一致, +避免 dev / prod 两套 schema 漂移。 + +## 运行 + +```bash +# 开发(自动迁移) +go run . -config config.yaml # config.yaml 里 env: dev + +# 生产(先执行 init.sql,再构建部署) +go build -o bin/nl-pms-api . # Windows 产出 bin/nl-pms-api.exe +./bin/nl-pms-api -config /etc/nl-pms-api/config.yaml +``` + +Windows 可用 nssm / 计划任务托管,Linux 建议 systemd;服务本身无状态, +备份只需 `uploads/` 目录与 `pms_files` 表。 + +## 安全权衡(内网定位) + +- 上传密钥经 view 的 `sync_settings` 明文下发给所有登录客户端(与节日背景图同机制), + 按内网工具定位设计;暴露公网需自行加 HTTPS 反代与更强的凭证体系。 +- 图片 GET 公开:`` 标签无法携带鉴权头,靠随机路径保证不可枚举。 diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..f345a59 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,15 @@ +# nl-pms-api 配置示例:复制为 config.yaml 后按环境修改。 +# env 仅 dev 会在启动时自动迁移表结构(AutoMigrate); +# prod 绝不执行任何 DDL,缺表直接退出 —— 部署前先手工执行 init.sql。 +env: prod +listen: ":8788" +# 拼接文件访问 URL 的前缀,必须是客户端可达的地址(如 http://192.168.1.10:8788); +# 留空则按每次请求的 Host 推断。 +base_url: "" +# 客户端上传密钥:view 管理员在「文件存储」配置里填同一个值(Authorization: Bearer )。 +api_key: "change-me" +storage_dir: "./uploads" +max_upload_mb: 20 +mysql: + # 与 view 同步服务共用 code_count 库;账号需要对 pms_files 表的 SELECT/INSERT 权限。 + dsn: "root:root@tcp(127.0.0.1:3306)/code_count?charset=utf8mb4&parseTime=false&loc=Local" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9d8a841 --- /dev/null +++ b/go.mod @@ -0,0 +1,55 @@ +module nl-pms-api + +go 1.25.5 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/glebarez/sqlite v1.11.0 + gopkg.in/yaml.v3 v3.0.1 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.31.2 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // 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 + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d7b8abe --- /dev/null +++ b/go.sum @@ -0,0 +1,134 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +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= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +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/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +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/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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/init.sql b/init.sql new file mode 100644 index 0000000..3e0c8b5 --- /dev/null +++ b/init.sql @@ -0,0 +1,44 @@ +-- ============================================================ +-- nl-pms-api 初始化脚本(MySQL 5.7+ / 8.x) +-- 用法:mysql -u root -p < init.sql +-- 生产环境服务启动不执行任何建表/迁移(DDL),部署前必须先执行本脚本; +-- 仅 dev 环境(config.yaml 里 env: dev)启动时才 AutoMigrate。 +-- 与 view 同步服务共用 code_count 库(该库由 view/init.sql 创建,这里兜底建库)。 +-- ============================================================ + +CREATE DATABASE IF NOT EXISTS code_count DEFAULT CHARSET utf8mb4; +USE code_count; + +-- 已上传文件元数据(文件内容落盘在 storage_dir,本表只存相对路径与摘要) +-- 归属按 (user_id, team_id) 记录:素材库据此划定"本人 / 团队管理员 / 超管 id=1"的管理范围。 +-- 索引名与 gorm AutoMigrate 的默认命名保持一致,避免 dev/prod 两套 schema 漂移。 +CREATE TABLE IF NOT EXISTS pms_files( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(191) NOT NULL, -- 存储相对路径,如 2026/08/13/<随机hex>.jpg + original VARCHAR(255) NOT NULL DEFAULT '', -- 原始文件名(仅备查) + mime VARCHAR(64) NOT NULL DEFAULT '', + size BIGINT NOT NULL DEFAULT 0, + sha256 CHAR(64) NOT NULL DEFAULT '', -- 内容摘要,同归属秒传去重 + user_id BIGINT NOT NULL DEFAULT 0, -- 上传者(code_count 库 users 表 id,0 为未知) + team_id BIGINT NOT NULL DEFAULT 0, -- 上传时所在团队(teams 表 id,0 为个人) + kind VARCHAR(16) NOT NULL DEFAULT '', -- avatar | content + created_at VARCHAR(32) NOT NULL DEFAULT '', + UNIQUE KEY idx_pms_files_name(name), + KEY idx_pms_files_sha256(sha256), + KEY idx_pms_files_user_id(user_id), + KEY idx_pms_files_team_id(team_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 旧版本升级:补 team_id 归属列与 user_id/team_id 索引(不存在才添加,重复执行无副作用)。 +SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='pms_files' AND COLUMN_NAME='team_id')=0, + 'ALTER TABLE pms_files ADD COLUMN team_id BIGINT NOT NULL DEFAULT 0 AFTER user_id', 'SELECT 1'); +PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='pms_files' AND INDEX_NAME='idx_pms_files_user_id')=0, + 'ALTER TABLE pms_files ADD KEY idx_pms_files_user_id(user_id)', 'SELECT 1'); +PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='pms_files' AND INDEX_NAME='idx_pms_files_team_id')=0, + 'ALTER TABLE pms_files ADD KEY idx_pms_files_team_id(team_id)', 'SELECT 1'); +PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st; diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..588079f --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,62 @@ +package config + +import ( + "errors" + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +// Config 是服务的全部运行配置,来自 YAML 文件(默认 ./config.yaml,可用 -config 指定)。 +type Config struct { + // Env 为 dev 时启动执行 AutoMigrate;其余一律视为 prod,绝不执行 DDL。 + Env string `yaml:"env"` + Listen string `yaml:"listen"` + // BaseURL 拼接文件访问 URL 的前缀(客户端可达的地址);留空则按请求 Host 推断。 + BaseURL string `yaml:"base_url"` + // APIKey 是客户端上传凭证:请求需带 Authorization: Bearer 。 + APIKey string `yaml:"api_key"` + StorageDir string `yaml:"storage_dir"` + MaxUploadMB int64 `yaml:"max_upload_mb"` + MySQL struct { + DSN string `yaml:"dsn"` + } `yaml:"mysql"` +} + +// MaxUploadBytes 返回单文件字节上限。 +func (c *Config) MaxUploadBytes() int64 { return c.MaxUploadMB << 20 } + +// Load 读取并校验配置,缺省值:prod / :8788 / ./uploads / 20MB。 +func Load(path string) (*Config, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("读取配置失败: %w", err) + } + c := &Config{} + if err := yaml.Unmarshal(b, c); err != nil { + return nil, fmt.Errorf("解析配置失败: %w", err) + } + if c.Env != "dev" { + c.Env = "prod" + } + if strings.TrimSpace(c.Listen) == "" { + c.Listen = ":8788" + } + if strings.TrimSpace(c.StorageDir) == "" { + c.StorageDir = "./uploads" + } + if c.MaxUploadMB <= 0 { + c.MaxUploadMB = 20 + } + c.BaseURL = strings.TrimRight(strings.TrimSpace(c.BaseURL), "/") + c.APIKey = strings.TrimSpace(c.APIKey) + if c.APIKey == "" { + return nil, errors.New("配置缺少 api_key(客户端上传凭证,不允许留空开放上传)") + } + if strings.TrimSpace(c.MySQL.DSN) == "" { + return nil, errors.New("配置缺少 mysql.dsn") + } + return c, nil +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..2a97094 --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,36 @@ +package database + +import ( + "errors" + "fmt" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "nl-pms-api/internal/config" + "nl-pms-api/internal/model" +) + +// Open 连接 MySQL 并按环境处理表结构: +// dev 环境自动迁移 pms_files;prod 严禁任何 DDL,缺表直接报错退出(提示执行 init.sql)。 +func Open(cfg *config.Config) (*gorm.DB, error) { + lv := logger.Warn + if cfg.Env == "dev" { + lv = logger.Info + } + db, err := gorm.Open(mysql.Open(cfg.MySQL.DSN), &gorm.Config{Logger: logger.Default.LogMode(lv)}) + if err != nil { + return nil, fmt.Errorf("连接 MySQL 失败: %w", err) + } + if cfg.Env == "dev" { + if err := db.AutoMigrate(&model.File{}); err != nil { + return nil, fmt.Errorf("dev 自动迁移失败: %w", err) + } + return db, nil + } + if !db.Migrator().HasTable(&model.File{}) { + return nil, errors.New("缺少 pms_files 表:生产环境启动不执行迁移,请先手工执行 init.sql") + } + return db, nil +} diff --git a/internal/handler/file.go b/internal/handler/file.go new file mode 100644 index 0000000..444a4e4 --- /dev/null +++ b/internal/handler/file.go @@ -0,0 +1,265 @@ +package handler + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "nl-pms-api/internal/config" + "nl-pms-api/internal/model" +) + +// adminUserID 是 code_count 体系的超级管理员账号(users.id=1),可见/可管全部文件。 +const adminUserID = 1 + +// FileHandler 提供图片上传与素材库管理:内容落盘 storage_dir,元数据入 pms_files 表。 +// 身份沿用 code-count 的内网信任模型:客户端自报 userId/teamId,服务端据 +// code_count 库的 team_members / users 表判定管理范围(防误操作,不防伪造)。 +type FileHandler struct { + DB *gorm.DB + Cfg *config.Config +} + +// extByMime 是允许上传的图片类型白名单(按内容嗅探判定,不信任扩展名)。 +var extByMime = map[string]string{ + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", +} + +// Upload 处理 POST /api/v1/files:multipart 字段 file 必填,kind(avatar|content)、 +// userId、teamId 可选。同一归属(userId+teamId)重复上传同内容直接复用已有记录(秒传); +// 不同归属各自落盘,保证"删除自己的素材"不影响他人。 +// 成功返回 {id, name, url, size, mime},url 可直接放进 / Markdown。 +func (h *FileHandler) Upload(c *gin.Context) { + fh, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_REQUIRED"}) + return + } + if fh.Size > h.Cfg.MaxUploadBytes() { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "FILE_TOO_LARGE"}) + return + } + f, err := fh.Open() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_READ_FAILED"}) + return + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, h.Cfg.MaxUploadBytes()+1)) + if err != nil || int64(len(data)) > h.Cfg.MaxUploadBytes() { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "FILE_TOO_LARGE"}) + return + } + mime := http.DetectContentType(data) + ext, ok := extByMime[mime] + if !ok { + c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "UNSUPPORTED_TYPE"}) + return + } + uid := parseID(c.PostForm("userId")) + tid := parseID(c.PostForm("teamId")) + sum := hex.EncodeToString(func() []byte { s := sha256.Sum256(data); return s[:] }()) + var rec model.File + if h.DB.Where("sha256 = ? AND user_id = ? AND team_id = ?", sum, uid, tid).First(&rec).Error == nil { + c.JSON(http.StatusOK, h.fileResponse(c, rec)) + return + } + name := storedName(ext) + full := filepath.Join(h.Cfg.StorageDir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "SAVE_FAILED"}) + return + } + if err := os.WriteFile(full, data, 0644); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "SAVE_FAILED"}) + return + } + rec = model.File{ + Name: name, + Original: clip(filepath.Base(fh.Filename), 255), + Mime: mime, + Size: int64(len(data)), + SHA256: sum, + UserID: uid, + TeamID: tid, + Kind: normalizeKind(c.PostForm("kind")), + CreatedAt: time.Now().UTC().Format(time.RFC3339), + } + if err := h.DB.Create(&rec).Error; err != nil { + os.Remove(full) + c.JSON(http.StatusInternalServerError, gin.H{"error": "SAVE_FAILED"}) + return + } + c.JSON(http.StatusOK, h.fileResponse(c, rec)) +} + +// fileItem 是素材库列表项(pms_files 联 users 取上传者名)。 +type fileItem struct { + ID int64 `json:"id"` + Name string `json:"name"` + Original string `json:"original"` + Mime string `json:"mime"` + Size int64 `json:"size"` + UserID int64 `json:"userId"` + TeamID int64 `json:"teamId"` + Kind string `json:"kind"` + CreatedAt string `json:"createdAt"` + Username string `json:"username"` + URL string `json:"url" gorm:"-"` +} + +// List 处理 GET /api/v1/files:素材库分页列表。 +// scope=mine 看自己(任何登录用户);scope=team 看指定团队(需为该团队 owner/admin); +// scope=all 看全部(仅 userId=1)。按 id 倒序分页。 +func (h *FileHandler) List(c *gin.Context) { + uid := parseID(c.Query("userId")) + if uid <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "USER_REQUIRED"}) + return + } + var where func(*gorm.DB) *gorm.DB + switch c.Query("scope") { + case "mine": + where = func(db *gorm.DB) *gorm.DB { return db.Where("pms_files.user_id = ?", uid) } + case "team": + tid := parseID(c.Query("teamId")) + if !h.isTeamAdmin(tid, uid) { + c.JSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"}) + return + } + where = func(db *gorm.DB) *gorm.DB { return db.Where("pms_files.team_id = ?", tid) } + case "all": + if uid != adminUserID { + c.JSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"}) + return + } + where = func(db *gorm.DB) *gorm.DB { return db } + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "BAD_SCOPE"}) + return + } + page := parseID(c.Query("page")) + if page < 1 { + page = 1 + } + size := parseID(c.Query("pageSize")) + if size < 1 || size > 100 { + size = 24 + } + var total int64 + if err := h.DB.Table("pms_files").Scopes(where).Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "QUERY_FAILED"}) + return + } + items := []fileItem{} + err := h.DB.Table("pms_files").Scopes(where). + Select("pms_files.id, pms_files.name, pms_files.original, pms_files.mime, pms_files.size, pms_files.user_id, pms_files.team_id, pms_files.kind, pms_files.created_at, COALESCE(u.username,'') AS username"). + Joins("LEFT JOIN users u ON u.id = pms_files.user_id"). + Order("pms_files.id DESC").Limit(int(size)).Offset(int((page - 1) * size)). + Scan(&items).Error + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "QUERY_FAILED"}) + return + } + for i := range items { + items[i].URL = h.publicURL(c, items[i].Name) + } + c.JSON(http.StatusOK, gin.H{"total": total, "items": items}) +} + +// Delete 处理 DELETE /api/v1/files/:id:删除记录与磁盘文件。 +// 允许:本人、超管(userId=1)、该文件归属团队的 owner/admin。 +func (h *FileHandler) Delete(c *gin.Context) { + uid := parseID(c.Query("userId")) + id := parseID(c.Param("id")) + var rec model.File + if h.DB.First(&rec, id).Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "NOT_FOUND"}) + return + } + allowed := uid == adminUserID || + (uid > 0 && rec.UserID == uid) || + (rec.TeamID > 0 && h.isTeamAdmin(rec.TeamID, uid)) + if !allowed { + c.JSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"}) + return + } + if err := h.DB.Delete(&model.File{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "DELETE_FAILED"}) + return + } + // 每条记录独占一个文件(name 唯一),删记录即可删盘;失败不影响结果(孤儿文件可人工清理)。 + _ = os.Remove(filepath.Join(h.Cfg.StorageDir, filepath.FromSlash(rec.Name))) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// isTeamAdmin 查 code_count 库的 team_members:该用户是否为团队 owner/admin。 +func (h *FileHandler) isTeamAdmin(teamID, userID int64) bool { + if teamID <= 0 || userID <= 0 { + return false + } + var n int64 + h.DB.Table("team_members"). + Where("team_id = ? AND user_id = ? AND role IN ('owner','admin')", teamID, userID). + Count(&n) + return n > 0 +} + +func (h *FileHandler) fileResponse(c *gin.Context, f model.File) gin.H { + return gin.H{"id": f.ID, "name": f.Name, "url": h.publicURL(c, f.Name), "size": f.Size, "mime": f.Mime, "teamId": f.TeamID} +} + +// publicURL 拼接文件公开访问地址:优先配置的 base_url,否则按本次请求推断。 +func (h *FileHandler) publicURL(c *gin.Context, name string) string { + base := h.Cfg.BaseURL + if base == "" { + scheme := "http" + if c.Request.TLS != nil { + scheme = "https" + } + base = scheme + "://" + c.Request.Host + } + return base + "/files/" + name +} + +// storedName 生成不可枚举的存储相对路径(日期目录 + 128 位随机 hex), +// 文件公开可读但路径不可猜测,等效 capability URL。 +func storedName(ext string) string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return time.Now().UTC().Format("2006/01/02") + "/" + hex.EncodeToString(b) + ext +} + +func normalizeKind(k string) string { + if k == "avatar" || k == "content" { + return k + } + return "" +} + +func parseID(s string) int64 { + n, _ := strconv.ParseInt(s, 10, 64) + if n < 0 { + return 0 + } + return n +} + +func clip(s string, n int) string { + if len(s) > n { + return s[:n] + } + return s +} diff --git a/internal/handler/file_test.go b/internal/handler/file_test.go new file mode 100644 index 0000000..62b0486 --- /dev/null +++ b/internal/handler/file_test.go @@ -0,0 +1,262 @@ +package handler_test + +import ( + "bytes" + "encoding/json" + "image" + "image/color" + "image/png" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "nl-pms-api/internal/config" + "nl-pms-api/internal/model" + "nl-pms-api/internal/router" +) + +const testKey = "test-key" + +// newTestServer 起一个内存 SQLite 后端的完整路由,并铺好 users / team_members 假数据: +// 1=admin(超管)、2=alice(团队 10 owner)、3=bob(团队 10 member)。 +func newTestServer(t *testing.T) (*httptest.Server, *gorm.DB, *config.Config) { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.File{}); err != nil { + t.Fatalf("migrate: %v", err) + } + db.Exec(`CREATE TABLE users(id INTEGER PRIMARY KEY, username TEXT)`) + db.Exec(`CREATE TABLE team_members(team_id INTEGER, user_id INTEGER, role TEXT)`) + db.Exec(`INSERT INTO users(id, username) VALUES(1,'admin'),(2,'alice'),(3,'bob')`) + db.Exec(`INSERT INTO team_members(team_id,user_id,role) VALUES(10,2,'owner'),(10,3,'member')`) + cfg := &config.Config{Env: "dev", APIKey: testKey, StorageDir: t.TempDir(), MaxUploadMB: 20} + srv := httptest.NewServer(router.New(cfg, db)) + t.Cleanup(srv.Close) + return srv, db, cfg +} + +// pngBytes 生成一张纯色 PNG;tone 不同则内容(sha256)不同。 +func pngBytes(t *testing.T, tone uint8) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + for x := 0; x < 2; x++ { + for y := 0; y < 2; y++ { + img.Set(x, y, color.RGBA{R: tone, G: 90, B: 60, A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode png: %v", err) + } + return buf.Bytes() +} + +type uploadResp struct { + ID int64 `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Mime string `json:"mime"` +} + +func upload(t *testing.T, url, key string, data []byte, userID, teamID string) (*http.Response, uploadResp) { + t.Helper() + var body bytes.Buffer + w := multipart.NewWriter(&body) + fw, err := w.CreateFormFile("file", "test.png") + if err != nil { + t.Fatalf("form file: %v", err) + } + if _, err := fw.Write(data); err != nil { + t.Fatalf("write: %v", err) + } + _ = w.WriteField("kind", "content") + _ = w.WriteField("userId", userID) + _ = w.WriteField("teamId", teamID) + _ = w.Close() + req, _ := http.NewRequest("POST", url+"/api/v1/files", &body) + req.Header.Set("Content-Type", w.FormDataContentType()) + if key != "" { + req.Header.Set("Authorization", "Bearer "+key) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + var out uploadResp + _ = json.NewDecoder(resp.Body).Decode(&out) + resp.Body.Close() + return resp, out +} + +func listFiles(t *testing.T, base, scope, userID, teamID string) (int, int64, []map[string]any) { + t.Helper() + req, _ := http.NewRequest("GET", base+"/api/v1/files?scope="+scope+"&userId="+userID+"&teamId="+teamID, nil) + req.Header.Set("Authorization", "Bearer "+testKey) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("list: %v", err) + } + defer resp.Body.Close() + var out struct { + Total int64 `json:"total"` + Items []map[string]any `json:"items"` + } + _ = json.NewDecoder(resp.Body).Decode(&out) + return resp.StatusCode, out.Total, out.Items +} + +func deleteFile(t *testing.T, base string, id int64, userID string) int { + t.Helper() + req, _ := http.NewRequest("DELETE", base+"/api/v1/files/"+jsonNum(id)+"?userId="+userID, nil) + req.Header.Set("Authorization", "Bearer "+testKey) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("delete: %v", err) + } + resp.Body.Close() + return resp.StatusCode +} + +func jsonNum(n int64) string { + b, _ := json.Marshal(n) + return string(b) +} + +func TestHealthz(t *testing.T) { + srv, _, _ := newTestServer(t) + resp, err := http.Get(srv.URL + "/healthz") + if err != nil || resp.StatusCode != 200 { + t.Fatalf("healthz: %v %v", err, resp) + } +} + +func TestUploadRequiresAuth(t *testing.T) { + srv, _, _ := newTestServer(t) + if resp, _ := upload(t, srv.URL, "", pngBytes(t, 200), "2", "0"); resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", resp.StatusCode) + } + if resp, _ := upload(t, srv.URL, "wrong", pngBytes(t, 200), "2", "0"); resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", resp.StatusCode) + } +} + +func TestUploadServeAndScopedDedupe(t *testing.T) { + srv, db, cfg := newTestServer(t) + data := pngBytes(t, 200) + resp, out := upload(t, srv.URL, testKey, data, "2", "10") + if resp.StatusCode != 200 || out.Name == "" || out.Mime != "image/png" { + t.Fatalf("upload failed: %d %+v", resp.StatusCode, out) + } + if _, err := os.Stat(filepath.Join(cfg.StorageDir, filepath.FromSlash(out.Name))); err != nil { + t.Fatalf("file not on disk: %v", err) + } + // 公开访问返回原始内容 + got, err := http.Get(srv.URL + "/files/" + out.Name) + if err != nil || got.StatusCode != 200 { + t.Fatalf("serve: %v %v", err, got) + } + var served bytes.Buffer + _, _ = served.ReadFrom(got.Body) + if !bytes.Equal(served.Bytes(), data) { + t.Fatal("served content mismatch") + } + // 同归属(user=2, team=10)重复上传 → 秒传复用同一条记录 + _, again := upload(t, srv.URL, testKey, data, "2", "10") + if again.ID != out.ID || again.Name != out.Name { + t.Fatalf("same-owner dedupe failed: %+v vs %+v", again, out) + } + // 不同归属(user=3)上传同内容 → 独立记录独立文件,删除互不影响 + _, other := upload(t, srv.URL, testKey, data, "3", "10") + if other.ID == out.ID || other.Name == out.Name { + t.Fatalf("cross-owner upload should create its own record: %+v", other) + } + var count int64 + db.Model(&model.File{}).Count(&count) + if count != 2 { + t.Fatalf("want 2 rows, got %d", count) + } +} + +func TestUploadRejectsNonImage(t *testing.T) { + srv, _, _ := newTestServer(t) + if resp, _ := upload(t, srv.URL, testKey, []byte("plain text, not an image"), "2", "0"); resp.StatusCode != http.StatusUnsupportedMediaType { + t.Fatalf("want 415, got %d", resp.StatusCode) + } +} + +func TestListScopes(t *testing.T) { + srv, _, _ := newTestServer(t) + upload(t, srv.URL, testKey, pngBytes(t, 10), "2", "10") // alice 团队图 x2 + upload(t, srv.URL, testKey, pngBytes(t, 20), "2", "10") + upload(t, srv.URL, testKey, pngBytes(t, 30), "3", "10") // bob 团队图 + upload(t, srv.URL, testKey, pngBytes(t, 40), "3", "0") // bob 个人图 + + if code, total, items := listFiles(t, srv.URL, "mine", "2", "0"); code != 200 || total != 2 || len(items) != 2 { + t.Fatalf("mine(alice): code=%d total=%d n=%d", code, total, len(items)) + } + // 团队视角:owner 可见团队 3 张(含上传者用户名) + code, total, items := listFiles(t, srv.URL, "team", "2", "10") + if code != 200 || total != 3 { + t.Fatalf("team(owner): code=%d total=%d", code, total) + } + if items[0]["username"] == "" { + t.Fatalf("team items should carry username: %+v", items[0]) + } + // member 无权看团队素材 + if code, _, _ := listFiles(t, srv.URL, "team", "3", "10"); code != http.StatusForbidden { + t.Fatalf("team(member) should be 403, got %d", code) + } + // all 仅超管 + if code, total, _ := listFiles(t, srv.URL, "all", "1", "0"); code != 200 || total != 4 { + t.Fatalf("all(admin): code=%d total=%d", code, total) + } + if code, _, _ := listFiles(t, srv.URL, "all", "2", "0"); code != http.StatusForbidden { + t.Fatalf("all(non-admin) should be 403, got %d", code) + } +} + +func TestDeletePermissions(t *testing.T) { + srv, _, cfg := newTestServer(t) + _, aliceTeam := upload(t, srv.URL, testKey, pngBytes(t, 10), "2", "10") + _, bobTeam := upload(t, srv.URL, testKey, pngBytes(t, 20), "3", "10") + _, bobOwn := upload(t, srv.URL, testKey, pngBytes(t, 30), "3", "0") + _, aliceOwn := upload(t, srv.URL, testKey, pngBytes(t, 40), "2", "0") + + // member 不能删别人的(即使同团队) + if code := deleteFile(t, srv.URL, aliceTeam.ID, "3"); code != http.StatusForbidden { + t.Fatalf("member deleting other's file should be 403, got %d", code) + } + // 团队 owner 可删团队内他人上传的 + if code := deleteFile(t, srv.URL, bobTeam.ID, "2"); code != 200 { + t.Fatalf("owner deleting team file should be 200, got %d", code) + } + if _, err := os.Stat(filepath.Join(cfg.StorageDir, filepath.FromSlash(bobTeam.Name))); !os.IsNotExist(err) { + t.Fatal("deleted file should be removed from disk") + } + // owner 不能删团队外的个人文件 + if code := deleteFile(t, srv.URL, bobOwn.ID, "2"); code != http.StatusForbidden { + t.Fatalf("owner deleting personal file outside team should be 403, got %d", code) + } + // 本人可删自己的 + if code := deleteFile(t, srv.URL, bobOwn.ID, "3"); code != 200 { + t.Fatalf("self delete should be 200, got %d", code) + } + // 超管可删任何 + if code := deleteFile(t, srv.URL, aliceOwn.ID, "1"); code != 200 { + t.Fatalf("admin delete should be 200, got %d", code) + } + // 已删除 → 404 + if code := deleteFile(t, srv.URL, aliceOwn.ID, "1"); code != http.StatusNotFound { + t.Fatalf("double delete should be 404, got %d", code) + } +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..3beb0d4 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,21 @@ +package middleware + +import ( + "crypto/subtle" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// RequireAPIKey 校验 Authorization: Bearer (常数时间比较),不匹配返回 401。 +func RequireAPIKey(key string) gin.HandlerFunc { + return func(c *gin.Context) { + token := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")) + if key == "" || subtle.ConstantTimeCompare([]byte(token), []byte(key)) != 1 { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "UNAUTHORIZED"}) + return + } + c.Next() + } +} diff --git a/internal/model/file.go b/internal/model/file.go new file mode 100644 index 0000000..81ed110 --- /dev/null +++ b/internal/model/file.go @@ -0,0 +1,25 @@ +package model + +// File 是一条已上传文件的元数据,对应 code_count 库的 pms_files 表; +// 文件内容落盘在 storage_dir,本表只存相对路径与摘要。 +// 每条记录独占一个磁盘文件(name 唯一),删除记录时一并删文件; +// 归属按 (user_id, team_id) 记录,素材库据此做"本人/团队管理员/超管"的可见与管理范围。 +// created_at 沿用 code_count 库的惯例存 RFC3339 字符串。 +type File struct { + ID int64 `gorm:"primaryKey" json:"id"` + // Name 是存储相对路径(也是访问路径),如 2026/08/13/<128位随机hex>.jpg。 + Name string `gorm:"size:191;uniqueIndex;not null" json:"name"` + Original string `gorm:"size:255;not null;default:''" json:"original"` + Mime string `gorm:"size:64;not null;default:''" json:"mime"` + Size int64 `gorm:"not null;default:0" json:"size"` + // SHA256 用于秒传去重:同一归属(user_id+team_id)重复上传直接复用已有记录。 + SHA256 string `gorm:"column:sha256;type:char(64);index;not null;default:''" json:"sha256"` + // UserID 是上传者在 code_count 库 users 表的 id,0 表示未知。 + UserID int64 `gorm:"not null;default:0;index" json:"userId"` + // TeamID 是上传时客户端的当前团队(code_count 库 teams 表 id),0 表示个人。 + TeamID int64 `gorm:"not null;default:0;index" json:"teamId"` + Kind string `gorm:"size:16;not null;default:''" json:"kind"` // avatar | content + CreatedAt string `gorm:"size:32;not null;default:''" json:"createdAt"` +} + +func (File) TableName() string { return "pms_files" } diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..c385183 --- /dev/null +++ b/internal/router/router.go @@ -0,0 +1,44 @@ +package router + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "nl-pms-api/internal/config" + "nl-pms-api/internal/handler" + "nl-pms-api/internal/middleware" +) + +// New 组装路由: +// - GET /healthz 健康检查(公开,view「测试连接」用) +// - POST /api/v1/files 上传图片(Bearer 密钥鉴权) +// - GET /api/v1/files 素材库列表(scope=mine|team|all,按角色限定范围) +// - DELETE /api/v1/files/:id 删除素材(本人 / 超管 id=1 / 团队 owner|admin) +// - GET /files/*filepath 文件公开访问(img 标签无法带鉴权头;随机文件名不可枚举) +func New(cfg *config.Config, db *gorm.DB) *gin.Engine { + if cfg.Env != "dev" { + gin.SetMode(gin.ReleaseMode) + } + r := gin.New() + r.Use(gin.Logger(), gin.Recovery()) + r.MaxMultipartMemory = 8 << 20 + + r.GET("/healthz", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true, "service": "nl-pms-api"}) + }) + + h := &handler.FileHandler{DB: db, Cfg: cfg} + api := r.Group("/api/v1", middleware.RequireAPIKey(cfg.APIKey)) + api.POST("/files", h.Upload) + api.GET("/files", h.List) + api.DELETE("/files/:id", h.Delete) + + files := r.Group("/files", func(c *gin.Context) { + // 文件名含随机 hex,内容不可变,允许长缓存。 + c.Header("Cache-Control", "public, max-age=31536000, immutable") + }) + files.Static("/", cfg.StorageDir) + return r +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..2a3187c --- /dev/null +++ b/main.go @@ -0,0 +1,39 @@ +package main + +// nl-pms-api:code-count(view 桌面端)的文件存储服务。 +// 客户端凭配置密钥上传图片(POST /api/v1/files),换取可公开访问的 +// http URL(GET /files/...),Markdown 内容图 / 头像等远程场景直接引用该 URL。 +// +// 表结构约定:仅 dev 环境(config.yaml 里 env: dev)启动时 AutoMigrate; +// 生产环境启动绝不执行任何 DDL,缺表直接退出(需先手工执行 init.sql)。 + +import ( + "flag" + "log" + "os" + + "nl-pms-api/internal/config" + "nl-pms-api/internal/database" + "nl-pms-api/internal/router" +) + +func main() { + cfgPath := flag.String("config", "config.yaml", "配置文件路径") + flag.Parse() + cfg, err := config.Load(*cfgPath) + if err != nil { + log.Fatalf("[nl-pms-api] %v", err) + } + db, err := database.Open(cfg) + if err != nil { + log.Fatalf("[nl-pms-api] %v", err) + } + if err := os.MkdirAll(cfg.StorageDir, 0755); err != nil { + log.Fatalf("[nl-pms-api] 创建存储目录失败: %v", err) + } + r := router.New(cfg, db) + log.Printf("[nl-pms-api] env=%s listen=%s storage=%s", cfg.Env, cfg.Listen, cfg.StorageDir) + if err := r.Run(cfg.Listen); err != nil { + log.Fatalf("[nl-pms-api] %v", err) + } +}