初始化
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
config.yaml
|
||||
uploads/
|
||||
bin/
|
||||
*.exe
|
||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal 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
8
.idea/modules.xml
generated
Normal 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
9
.idea/nl-pms-api.iml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
70
README.md
Normal file
@@ -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 <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/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 公开:`<img>` 标签无法携带鉴权头,靠随机路径保证不可枚举。
|
||||
15
config.example.yaml
Normal file
15
config.example.yaml
Normal 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
55
go.mod
Normal 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
134
go.sum
Normal 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
44
init.sql
Normal 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 表 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;
|
||||
62
internal/config/config.go
Normal file
62
internal/config/config.go
Normal 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
36
internal/database/db.go
Normal 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_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
|
||||
}
|
||||
265
internal/handler/file.go
Normal file
265
internal/handler/file.go
Normal 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/files:multipart 字段 file 必填,kind(avatar|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
|
||||
}
|
||||
262
internal/handler/file_test.go
Normal file
262
internal/handler/file_test.go
Normal 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 生成一张纯色 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)
|
||||
}
|
||||
}
|
||||
21
internal/middleware/auth.go
Normal file
21
internal/middleware/auth.go
Normal 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
25
internal/model/file.go
Normal 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 表的 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" }
|
||||
44
internal/router/router.go
Normal file
44
internal/router/router.go
Normal 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
39
main.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user