初始化

This commit is contained in:
李琦
2026-08-15 07:41:11 +08:00
commit d9475ac9da
18 changed files with 1109 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
config.yaml
uploads/
bin/
*.exe

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/nl-pms-api.iml" filepath="$PROJECT_DIR$/.idea/nl-pms-api.iml" />
</modules>
</component>
</project>

9
.idea/nl-pms-api.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

70
README.md Normal file
View File

@@ -0,0 +1,70 @@
# nl-pms-api
code-countview 桌面端)的文件存储服务:客户端凭密钥上传图片,换取可公开访问的
http URL。管理员云端账号 id=1在 view 的「文件存储」配置里把存储方式设为
「服务器」并填入本服务地址与密钥后,内容图片(`imageMode=server`)与头像会上传到
这里Markdown / 头像直接引用返回的 URL —— 跨设备、跨团队都能访问,不再依赖本地路径。
技术栈Go + gin + gormMySQL 与 view 同步服务共用 `code_count` 库(新表 `pms_files`)。
## 接口
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
| GET | `/healthz` | 无 | 健康检查view「测试连接」调用 |
| POST | `/api/v1/files` | `Authorization: Bearer <api_key>` | 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/adminall 仅超管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 公开:`<img>` 标签无法携带鉴权头,靠随机路径保证不可枚举。

15
config.example.yaml Normal file
View File

@@ -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>)。
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"

55
go.mod Normal file
View File

@@ -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
)

134
go.sum Normal file
View File

@@ -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=

44
init.sql Normal file
View File

@@ -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 表 id0 为未知)
team_id BIGINT NOT NULL DEFAULT 0, -- 上传时所在团队teams 表 id0 为个人)
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;

62
internal/config/config.go Normal file
View File

@@ -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 <api_key>。
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
}

36
internal/database/db.go Normal file
View File

@@ -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_filesprod 严禁任何 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
}

265
internal/handler/file.go Normal file
View File

@@ -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/filesmultipart 字段 file 必填kindavatar|content
// userId、teamId 可选。同一归属userId+teamId重复上传同内容直接复用已有记录秒传
// 不同归属各自落盘,保证"删除自己的素材"不影响他人。
// 成功返回 {id, name, url, size, mime}url 可直接放进 <img src> / 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
}

View File

@@ -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 生成一张纯色 PNGtone 不同则内容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)
}
}

View File

@@ -0,0 +1,21 @@
package middleware
import (
"crypto/subtle"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// RequireAPIKey 校验 Authorization: Bearer <api_key>(常数时间比较),不匹配返回 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()
}
}

25
internal/model/file.go Normal file
View File

@@ -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 表的 id0 表示未知。
UserID int64 `gorm:"not null;default:0;index" json:"userId"`
// TeamID 是上传时客户端的当前团队code_count 库 teams 表 id0 表示个人。
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" }

44
internal/router/router.go Normal file
View File

@@ -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
}

39
main.go Normal file
View File

@@ -0,0 +1,39 @@
package main
// nl-pms-apicode-countview 桌面端)的文件存储服务。
// 客户端凭配置密钥上传图片POST /api/v1/files换取可公开访问的
// http URLGET /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)
}
}