From d29693343b1b49a8db2487d585dbcefc07f7dbad Mon Sep 17 00:00:00 2001 From: zxr <271055687@qq.com> Date: Mon, 3 Aug 2026 23:51:21 +0800 Subject: [PATCH] =?UTF-8?q?init:=20=E6=8F=90=E4=BA=A4=20files=20=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=88=9D=E5=A7=8B=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .gitea/workflows/cicd.yaml | 86 ++++++++++ .gitignore | 116 ++++++++++++++ README.md | 244 +++++++++++++++++++++++++++++ cmd/cli/main.go | 40 +++++ cmd/main/main.go | 36 +++++ etc/files_dev.yaml | 47 ++++++ etc/files_prod.yaml | 47 ++++++ etc/files_test.yaml | 47 ++++++ etc/ops-files.service | 14 ++ etc/supervisor.ops-files.conf | 8 + go.mod | 85 ++++++++++ go.sum | 228 +++++++++++++++++++++++++++ internal/auth/actor.go | 46 ++++++ internal/auth/service.go | 31 ++++ internal/config/config.go | 147 +++++++++++++++++ internal/errors/errors.go | 17 ++ internal/impl/impl.go | 34 ++++ internal/jobs/pending_upload.go | 79 ++++++++++ internal/lifecycle/deletion.go | 89 +++++++++++ internal/logic/files/complete.go | 61 ++++++++ internal/logic/files/delete.go | 50 ++++++ internal/logic/files/detail.go | 68 ++++++++ internal/logic/files/init.go | 139 ++++++++++++++++ internal/logic/files/object_key.go | 10 ++ internal/logic/files/types.go | 30 ++++ internal/logic/ping/hello.go | 11 ++ internal/models/file_object.go | 46 ++++++ internal/models/query.go | 7 + internal/routers/register.go | 27 ++++ internal/storage/client.go | 31 ++++ internal/storage/object.go | 76 +++++++++ internal/storage/presign.go | 43 +++++ scripts/pack.ps1 | 27 ++++ 33 files changed, 2067 insertions(+) create mode 100644 .gitea/workflows/cicd.yaml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cmd/cli/main.go create mode 100644 cmd/main/main.go create mode 100644 etc/files_dev.yaml create mode 100644 etc/files_prod.yaml create mode 100644 etc/files_test.yaml create mode 100644 etc/ops-files.service create mode 100644 etc/supervisor.ops-files.conf create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/actor.go create mode 100644 internal/auth/service.go create mode 100644 internal/config/config.go create mode 100644 internal/errors/errors.go create mode 100644 internal/impl/impl.go create mode 100644 internal/jobs/pending_upload.go create mode 100644 internal/lifecycle/deletion.go create mode 100644 internal/logic/files/complete.go create mode 100644 internal/logic/files/delete.go create mode 100644 internal/logic/files/detail.go create mode 100644 internal/logic/files/init.go create mode 100644 internal/logic/files/object_key.go create mode 100644 internal/logic/files/types.go create mode 100644 internal/logic/ping/hello.go create mode 100644 internal/models/file_object.go create mode 100644 internal/models/query.go create mode 100644 internal/routers/register.go create mode 100644 internal/storage/client.go create mode 100644 internal/storage/object.go create mode 100644 internal/storage/presign.go create mode 100644 scripts/pack.ps1 diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml new file mode 100644 index 0000000..902d9a5 --- /dev/null +++ b/.gitea/workflows/cicd.yaml @@ -0,0 +1,86 @@ +name: Files Release +run-name: ${{ gitea.actor }} 发布 Files + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: 清理临时目录 + run: rm -rf * + + - name: 设置 Go + uses: https://git.apinb.com/github/setup-go@v5 + with: + go-version: '1.25.1' + + - name: 检出代码 + uses: https://git.apinb.com/github/checkout@v4 + + - name: 缓存 Go 模块 + uses: https://git.apinb.com/github/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: 提取发布版本 + id: version + run: echo "tag_name=$(echo ${{ gitea.ref }} | sed 's/^refs\/tags\///')" >> $GITHUB_OUTPUT + + - name: 写入版本信息 + run: echo "export srv_version='${{ steps.version.outputs.tag_name }}';export srv_name='ops-files'" > files_last_version.sh + + - name: 配置 Go 模块代理 + run: | + go env -w GOPROXY=https://goproxy.cn + go env -w GOPRIVATE=git.apinb.com/* + go env -w GONOPROXY=git.apinb.com/* + go env -w GOINSECURE=git.apinb.com/* + go env -w GONOSUMDB=git.apinb.com/* + go env -w GOEXPERIMENT=jsonv2 + + - name: 构建 Files 服务 + run: go build -o ops-files ./cmd/main/main.go + + - name: 上传版本信息 + uses: https://git.apinb.com/github/minio-upload@main + with: + endpoint: ${{ secrets.DEPLOY_STORAGE_ENDPOINT }} + access-key-id: ${{ secrets.DEPLOY_STORAGE_ACCESS_KEY_ID }} + access-key-secret: ${{ secrets.DEPLOY_STORAGE_ACCESS_KEY_SECRET }} + bucket: ${{ secrets.DEPLOY_STORAGE_BUCKET }} + source: ./files_last_version.sh + insecure: true + recursive: true + + - name: 上传服务二进制 + uses: https://git.apinb.com/github/minio-upload@main + with: + endpoint: ${{ secrets.DEPLOY_STORAGE_ENDPOINT }} + access-key-id: ${{ secrets.DEPLOY_STORAGE_ACCESS_KEY_ID }} + access-key-secret: ${{ secrets.DEPLOY_STORAGE_ACCESS_KEY_SECRET }} + bucket: ${{ secrets.DEPLOY_STORAGE_BUCKET }} + source: ./ops-files + target: /ops-files@${{ steps.version.outputs.tag_name }}/ + insecure: true + recursive: false + + - name: 上传部署文件 + uses: https://git.apinb.com/github/minio-upload@main + with: + endpoint: ${{ secrets.DEPLOY_STORAGE_ENDPOINT }} + access-key-id: ${{ secrets.DEPLOY_STORAGE_ACCESS_KEY_ID }} + access-key-secret: ${{ secrets.DEPLOY_STORAGE_ACCESS_KEY_SECRET }} + bucket: ${{ secrets.DEPLOY_STORAGE_BUCKET }} + source: ./etc + target: /ops-files@${{ steps.version.outputs.tag_name }}/ + insecure: true + recursive: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06144b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,116 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a + +# Folders +_obj +_test +.DS_Store + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +# Cgo generated +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +# Test coverage +*.coverprofile +coverage.html +coverage.txt + +# Build output +/bin/ +/dist/ +/build/ + +# IDE specific files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.project +.settings/ +*.sublime-project +*.sublime-workspace + +# Environment variables +.env +.env.local +.env.*.local + +# Log files +*.log +/logs/ +/log/ + +# Database +*.db +*.sqlite +*.sqlite3 + +# Cache +/cache/ +*.cache + +# Temporary files +/tmp/ +/temp/ +*.tmp + +# OS specific +.DS_Store +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ + +# Config files with sensitive information (optional, adjust based on needs) +# etc/*_prod.yaml +# etc/*.key + +# Go sum backup +go.sum.backup + +# Debug files +__debug_bin + +# Air (live reload tool) +.air.toml +tmp/ + +# Delve debugger +__debug_bin* + +# Generated files +*.gen.go + +# Performance test files +*.bench + +/ops-files +/files diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d58720 --- /dev/null +++ b/README.md @@ -0,0 +1,244 @@ +# Files 文件服务 + +Files 是 OPS 的公共文件服务,统一管理文件元数据,并通过阿里云 OSS 预签名地址让调用方直传文件。调用方只选择已配置的命名空间,不能指定 OSS 厂商、Bucket 或对象路径。 + +当前仅支持阿里云 OSS。服务不在本地保存文件,已完成上传的文件持续保留,临时文件由调用方在使用结束后主动删除;只有超时且未完成的上传会被后台任务自动清理。 + +## 主要能力 + +- 生成 OSS 预签名 PUT 地址,文件不经过 Files 服务中转。 +- 校验命名空间、扩展名、文件大小和上传后的对象大小。 +- 记录文件归属、状态、对象路径、ETag 和公开访问地址。 +- 支持 JWT 用户调用和内部服务调用,二者数据相互隔离。 +- 支持查询与删除本人或本服务创建的文件。 +- 自动清理超过预签名有效期但未完成的上传。 + +## 运行依赖 + +- Go 1.25.1 +- PostgreSQL +- Redis +- 阿里云 OSS Bucket + +## 配置 + +配置文件位于 `etc`: + +```text +etc/files_dev.yaml +etc/files_test.yaml +etc/files_prod.yaml +``` + +运行环境由 `BSM_RuntimeMode` 决定,默认值为 `dev`。服务读取 `${BSM_Prefix}/etc/files_.yaml`;开发模式未设置 `BSM_Prefix` 时,使用当前工作目录。 + +核心配置示例: + +```yaml +ServiceClients: + assets: + dc-control: + visual: + +ObjectStorage: + Provider: aliyun + Endpoint: https://oss-cn-beijing.aliyuncs.com + Region: cn-beijing + Bucket: ops-app + PublicBaseURL: https://ops-app.oss-cn-beijing.aliyuncs.com + AccessKeyID: ${FILES_OSS_ACCESS_KEY_ID} + AccessKeySecret: ${FILES_OSS_ACCESS_KEY_SECRET} + PresignTTLSeconds: 600 + +Namespaces: + reports: + Prefix: reports + MaxSizeMB: 512 + AllowedExtensions: [.csv, .xlsx, .json, .pdf, .zip] + +Cleanup: + IntervalSeconds: 600 +``` + +配置说明: + +- `ServiceClients`:允许调用内部接口的服务名和密钥,服务名必须与请求头 `Service-Name` 完全一致。 +- `Provider`:当前只能填写 `aliyun`。 +- `Endpoint`:OSS SDK 请求地址,不包含 Bucket 名称。 +- `PublicBaseURL`:完成上传后返回给调用方的公开访问地址前缀。 +- `PresignTTLSeconds`:上传地址有效期,允许范围为 60~3600 秒。 +- `Namespaces`:调用方可使用的命名空间,以及对应的目录、大小和扩展名限制。 +- `Cleanup.IntervalSeconds`:扫描并清理超时未完成上传的时间间隔。 + +OSS 凭证必须通过环境变量提供: + +```powershell +$env:FILES_OSS_ACCESS_KEY_ID = '' +$env:FILES_OSS_ACCESS_KEY_SECRET = '' +``` + +使用用户接口时,还要保证 Files 与 JWT 签发方使用相同密钥: + +```powershell +$env:BSM_JwtSecretKey = '<与 JWT 签发方一致的密钥>' +``` + +不要将 AccessKey、数据库密码或生产环境服务密钥写入 README 或提交到代码仓库。 + +## 本地启动 + +在 `files` 目录执行: + +```powershell +$env:BSM_RuntimeMode = 'dev' +go run ./cmd/main/main.go +``` + +默认监听配置文件中的 `12452` 端口。启动时会自动创建或更新 `files_object` 表。 + +如需只执行表结构迁移: + +```powershell +go run ./cmd/cli/main.go migrate +``` + +健康检查: + +```text +HEAD / +GET /Files/v1/ping/hello +``` + +## 构建 + +构建当前 Windows 环境程序: + +```powershell +go build -o build/files.exe ./cmd/main/main.go +``` + +打包 Linux amd64 程序: + +```powershell +powershell -ExecutionPolicy Bypass -File ./scripts/pack.ps1 +``` + +产物为 `build/ops-files`。 + +## 调用鉴权 + +文件接口提供两套路径,功能和请求结构相同。 + +用户接口使用 JWT: + +```text +Authorization: +``` + +JWT 直接放入 `Authorization`,不要添加 `Bearer ` 前缀。 + +内部服务接口使用服务名和独立密钥: + +```text +Service-Name: assets +Secret-Key: <与 ServiceClients.assets 一致的值> +``` + +服务密钥只用于服务之间调用,不是阿里云 AccessKey。调用方不能通过请求选择 OSS 厂商或 Bucket。 + +## 文件接口 + +| 操作 | 用户接口 | 内部服务接口 | +| --- | --- | --- | +| 初始化上传 | `POST /Files/v1/uploads/init` | `POST /Files/v1/internal/uploads/init` | +| 确认上传完成 | `POST /Files/v1/uploads/:file_id/complete` | `POST /Files/v1/internal/uploads/:file_id/complete` | +| 查询文件 | `GET /Files/v1/files/:file_id` | `GET /Files/v1/internal/files/:file_id` | +| 删除文件 | `DELETE /Files/v1/files/:file_id` | `DELETE /Files/v1/internal/files/:file_id` | + +同一个文件只能由创建它的用户或内部服务访问。用户接口和内部服务接口之间不能交叉访问文件。 + +### 上传流程 + +1. 调用初始化接口: + +```http +POST /Files/v1/internal/uploads/init +Content-Type: application/json +Service-Name: assets +Secret-Key: <服务密钥> + +{ + "namespace": "assets", + "filename": "example.png", + "size": 102400, + "content_type": "image/png" +} +``` + +成功结果的 `details` 中包含: + +```json +{ + "file_id": "文件标识", + "object_key": "assets/年/月/文件标识.png", + "upload": { + "method": "PUT", + "url": "OSS 预签名地址", + "headers": { + "接口返回的请求头": "接口返回的值" + }, + "expires_at": "过期时间" + } +} +``` + +2. 使用 `upload.method` 和 `upload.url` 将文件内容直接上传到 OSS,并原样携带 `upload.headers`。此请求不携带 Files 的 JWT、`Service-Name` 或 `Secret-Key`。 + +3. OSS 上传成功后调用确认接口: + +```http +POST /Files/v1/internal/uploads//complete +Service-Name: assets +Secret-Key: <服务密钥> +``` + +确认接口会检查 OSS 对象是否存在以及实际大小是否与初始化请求一致。成功后返回文件信息,其中 `url` 是公开访问地址。 + +### 查询和删除 + +```http +GET /Files/v1/internal/files/ +DELETE /Files/v1/internal/files/ +``` + +删除接口同时删除 OSS 对象和文件元数据。处于 `ready` 状态的临时文件不会自动删除,调用方必须在业务使用结束后主动调用删除接口。 + +## 响应约定 + +业务接口使用统一响应结构: + +```json +{ + "code": 0, + "message": "", + "details": {}, + "timeseq": 0 +} +``` + +- `code = 0` 表示成功。 +- 业务错误通常仍返回 HTTP 200,通过非零 `code` 判断失败。 +- JWT 或内部服务鉴权失败返回 HTTP 401。 + +## OSS 前置设置 + +当前 `PublicBaseURL` 使用 OSS Bucket 公网域名,因此 Bucket 需要设置为“公共读”,不能设置为“公共读写”。如果由浏览器直接上传,还需要给 Bucket 配置 CORS: + +```text +来源:实际前端域名 +Methods:PUT、GET、HEAD +允许 Headers:* +暴露 Headers:ETag、x-oss-request-id +``` + +生产环境应使用 RAM 用户的 AccessKey,并只授予目标 Bucket 所需的对象上传、查询和删除权限。 diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..4a9c9b5 --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "os" + + "git.apinb.com/ops/files/internal/config" + "git.apinb.com/ops/files/internal/impl" + "git.apinb.com/ops/files/internal/models" +) + +const ServiceKey = "Files" + +func main() { + if len(os.Args) < 2 || os.Args[1] == "help" { + printHelp() + return + } + + switch os.Args[1] { + case "migrate": + config.New(ServiceKey) + impl.NewImpl() + if err := models.InitData(); err != nil { + panic(err) + } + default: + fmt.Printf("未知命令: %s\n", os.Args[1]) + printHelp() + } +} + +func printHelp() { + fmt.Println("文件服务 CLI 工具") + fmt.Println("\n用法:") + fmt.Println(" ops-files-cli ") + fmt.Println("\n可用命令:") + fmt.Println(" help - 显示帮助信息") + fmt.Println(" migrate - 迁移文件元数据表") +} diff --git a/cmd/main/main.go b/cmd/main/main.go new file mode 100644 index 0000000..e0e49aa --- /dev/null +++ b/cmd/main/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/bsm-sdk/core/middleware" + "git.apinb.com/ops/files/internal/config" + "git.apinb.com/ops/files/internal/impl" + "git.apinb.com/ops/files/internal/jobs" + "git.apinb.com/ops/files/internal/models" + "git.apinb.com/ops/files/internal/routers" + "github.com/gin-gonic/gin" +) + +const ServiceKey = "Files" + +func main() { + config.New(ServiceKey) + impl.NewImpl() + if err := models.InitData(); err != nil { + panic(err) + } + jobs.StartPendingUploadCleanup() + + app := gin.Default() + middleware.Mode(app) + app.Use(middleware.Cors()) + app.Use(gin.Recovery()) + app.HEAD("/", infra.Health) + routers.Register(ServiceKey, app) + + if err := app.Run(fmt.Sprintf(":%s", config.Spec.Port)); err != nil { + panic(err) + } +} diff --git a/etc/files_dev.yaml b/etc/files_dev.yaml new file mode 100644 index 0000000..f10170a --- /dev/null +++ b/etc/files_dev.yaml @@ -0,0 +1,47 @@ +Service: files +Port: 12452 +SecretKey: ops-files + +Databases: + Driver: postgres + Source: + - host=8.137.107.29 user=postgres password=Weidong2023~! dbname=ops_dev port=19432 sslmode=disable TimeZone=Asia/Shanghai + +Cache: redis://null:Weidong2023~!@8.137.107.29:19379/ + +MicroService: + Enable: false + Anonymous: + - files.ping.hello + +ServiceClients: + assets: ops-assets + dc-control: ops-dc-control + visual: ops-visual + +ObjectStorage: + Provider: aliyun + Endpoint: https://oss-cn-beijing.aliyuncs.com + Region: cn-beijing + Bucket: ops-app + PublicBaseURL: https://ops-app.oss-cn-beijing.aliyuncs.com + AccessKeyID: ${FILES_OSS_ACCESS_KEY_ID} + AccessKeySecret: ${FILES_OSS_ACCESS_KEY_SECRET} + PresignTTLSeconds: 600 + +Namespaces: + reports: + Prefix: reports + MaxSizeMB: 512 + AllowedExtensions: [.csv, .xlsx, .json, .pdf, .zip] + assets: + Prefix: assets + MaxSizeMB: 50 + AllowedExtensions: [.png, .jpg, .jpeg, .webp, .gif, .svg, .pdf, .doc, .docx, .xls, .xlsx] + visual: + Prefix: visual + MaxSizeMB: 100 + AllowedExtensions: [.png, .jpg, .jpeg, .webp, .gif, .svg, .json, .zip, .glb, .gltf] + +Cleanup: + IntervalSeconds: 600 diff --git a/etc/files_prod.yaml b/etc/files_prod.yaml new file mode 100644 index 0000000..f10170a --- /dev/null +++ b/etc/files_prod.yaml @@ -0,0 +1,47 @@ +Service: files +Port: 12452 +SecretKey: ops-files + +Databases: + Driver: postgres + Source: + - host=8.137.107.29 user=postgres password=Weidong2023~! dbname=ops_dev port=19432 sslmode=disable TimeZone=Asia/Shanghai + +Cache: redis://null:Weidong2023~!@8.137.107.29:19379/ + +MicroService: + Enable: false + Anonymous: + - files.ping.hello + +ServiceClients: + assets: ops-assets + dc-control: ops-dc-control + visual: ops-visual + +ObjectStorage: + Provider: aliyun + Endpoint: https://oss-cn-beijing.aliyuncs.com + Region: cn-beijing + Bucket: ops-app + PublicBaseURL: https://ops-app.oss-cn-beijing.aliyuncs.com + AccessKeyID: ${FILES_OSS_ACCESS_KEY_ID} + AccessKeySecret: ${FILES_OSS_ACCESS_KEY_SECRET} + PresignTTLSeconds: 600 + +Namespaces: + reports: + Prefix: reports + MaxSizeMB: 512 + AllowedExtensions: [.csv, .xlsx, .json, .pdf, .zip] + assets: + Prefix: assets + MaxSizeMB: 50 + AllowedExtensions: [.png, .jpg, .jpeg, .webp, .gif, .svg, .pdf, .doc, .docx, .xls, .xlsx] + visual: + Prefix: visual + MaxSizeMB: 100 + AllowedExtensions: [.png, .jpg, .jpeg, .webp, .gif, .svg, .json, .zip, .glb, .gltf] + +Cleanup: + IntervalSeconds: 600 diff --git a/etc/files_test.yaml b/etc/files_test.yaml new file mode 100644 index 0000000..f10170a --- /dev/null +++ b/etc/files_test.yaml @@ -0,0 +1,47 @@ +Service: files +Port: 12452 +SecretKey: ops-files + +Databases: + Driver: postgres + Source: + - host=8.137.107.29 user=postgres password=Weidong2023~! dbname=ops_dev port=19432 sslmode=disable TimeZone=Asia/Shanghai + +Cache: redis://null:Weidong2023~!@8.137.107.29:19379/ + +MicroService: + Enable: false + Anonymous: + - files.ping.hello + +ServiceClients: + assets: ops-assets + dc-control: ops-dc-control + visual: ops-visual + +ObjectStorage: + Provider: aliyun + Endpoint: https://oss-cn-beijing.aliyuncs.com + Region: cn-beijing + Bucket: ops-app + PublicBaseURL: https://ops-app.oss-cn-beijing.aliyuncs.com + AccessKeyID: ${FILES_OSS_ACCESS_KEY_ID} + AccessKeySecret: ${FILES_OSS_ACCESS_KEY_SECRET} + PresignTTLSeconds: 600 + +Namespaces: + reports: + Prefix: reports + MaxSizeMB: 512 + AllowedExtensions: [.csv, .xlsx, .json, .pdf, .zip] + assets: + Prefix: assets + MaxSizeMB: 50 + AllowedExtensions: [.png, .jpg, .jpeg, .webp, .gif, .svg, .pdf, .doc, .docx, .xls, .xlsx] + visual: + Prefix: visual + MaxSizeMB: 100 + AllowedExtensions: [.png, .jpg, .jpeg, .webp, .gif, .svg, .json, .zip, .glb, .gltf] + +Cleanup: + IntervalSeconds: 600 diff --git a/etc/ops-files.service b/etc/ops-files.service new file mode 100644 index 0000000..1db011c --- /dev/null +++ b/etc/ops-files.service @@ -0,0 +1,14 @@ +[Unit] +Description=Files Service +After=network.target + +[Service] +Type=simple +User=ops +WorkingDirectory=/opt/ops/files +ExecStart=/opt/ops/files/ops-files +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target diff --git a/etc/supervisor.ops-files.conf b/etc/supervisor.ops-files.conf new file mode 100644 index 0000000..6fd4782 --- /dev/null +++ b/etc/supervisor.ops-files.conf @@ -0,0 +1,8 @@ +[program:ops-files] +command=/data/app/ops-files +directory=/data/app +autostart=true +autorestart=true +user=root +redirect_stderr=true +stdout_logfile=/data/app/logs/ops-files.log diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..162435a --- /dev/null +++ b/go.mod @@ -0,0 +1,85 @@ +module git.apinb.com/ops/files + +go 1.25.1 + +require ( + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect + gorm.io/driver/mysql v1.6.0 // indirect +) + +require ( + github.com/allegro/bigcache/v3 v3.1.0 // indirect + github.com/gin-gonic/gin v1.11.0 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.2 // indirect + github.com/bytedance/sonic/loader v0.4.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/coreos/go-semver v0.3.1 // indirect + github.com/coreos/go-systemd/v22 v22.6.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.11 // indirect + github.com/gin-contrib/cors v1.7.6 // indirect + github.com/gin-contrib/sse v1.1.0 // 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.28.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golang/protobuf v1.5.4 // 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.5.1 // indirect + github.com/quic-go/quic-go v0.55.0 // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.etcd.io/etcd/api/v3 v3.6.5 // indirect + go.uber.org/mock v0.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/tools v0.38.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda // indirect +) + +require ( + git.apinb.com/bsm-sdk/core v0.1.7 + github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.3 + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.6 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect + github.com/redis/go-redis/v9 v9.16.0 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.6.5 // indirect + go.etcd.io/etcd/client/v3 v3.6.5 + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + golang.org/x/time v0.4.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect + google.golang.org/grpc v1.76.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/postgres v1.6.0 // indirect + gorm.io/gorm v1.31.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7e0f9b3 --- /dev/null +++ b/go.sum @@ -0,0 +1,228 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +git.apinb.com/bsm-sdk/core v0.1.7 h1:TDRK0RSpM8SQFBUmSdQmsvUv3282sAD6TWxAoEsr6eo= +git.apinb.com/bsm-sdk/core v0.1.7/go.mod h1:rCmMma8R2pvByImgoZDm2OPLdr+IUNr7LBPyayb8aN0= +github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.3 h1:mFFeD2k8QE+pYd1uFik2XG85+vilL5uj9P5mHQTM1Xk= +github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.3/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M= +github.com/allegro/bigcache/v3 v3.1.0 h1:H2Vp8VOvxcrB91o86fUSVJFqeuz8kpyyB02eH3bSzwk= +github.com/allegro/bigcache/v3 v3.1.0/go.mod h1:aPyh7jEvrog9zAwx5N7+JUQX5dZTSGpxF1LAR4dr35I= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +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.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= +github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980= +github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o= +github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= +github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= +github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= +github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= +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.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +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.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +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.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +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/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/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +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.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= +github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= +github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4= +github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +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.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +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= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.etcd.io/etcd/api/v3 v3.6.5 h1:pMMc42276sgR1j1raO/Qv3QI9Af/AuyQUW6CBAWuntA= +go.etcd.io/etcd/api/v3 v3.6.5/go.mod h1:ob0/oWA/UQQlT1BmaEkWQzI0sJ1M0Et0mMpaABxguOQ= +go.etcd.io/etcd/client/pkg/v3 v3.6.5 h1:Duz9fAzIZFhYWgRjp/FgNq2gO1jId9Yae/rLn3RrBP8= +go.etcd.io/etcd/client/pkg/v3 v3.6.5/go.mod h1:8Wx3eGRPiy0qOFMZT/hfvdos+DjEaPxdIDiCDUv/FQk= +go.etcd.io/etcd/client/v3 v3.6.5 h1:yRwZNFBx/35VKHTcLDeO7XVLbCBFbPi+XV4OC3QJf2U= +go.etcd.io/etcd/client/v3 v3.6.5/go.mod h1:ZqwG/7TAFZ0BJ0jXRPoJjKQJtbFo/9NIY8uoFFKcCyo= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +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.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/time v0.4.0 h1:Z81tqI5ddIoXDPvVQ7/7CC9TnLM7ubaFG2qXYd5BbYY= +golang.org/x/time v0.4.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE= +google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +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/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= +gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/auth/actor.go b/internal/auth/actor.go new file mode 100644 index 0000000..188bd2f --- /dev/null +++ b/internal/auth/actor.go @@ -0,0 +1,46 @@ +package auth + +import ( + "git.apinb.com/bsm-sdk/core/middleware" + "github.com/gin-gonic/gin" +) + +const ( + ActorTypeUser = "user" + ActorTypeService = "service" + + actorContextKey = "files.auth.actor" +) + +type Actor struct { + Type string + ID uint + Identity string +} + +func FromContext(ctx *gin.Context) (Actor, bool) { + if actor, ok := ctx.Get(actorContextKey); ok { + serviceActor, ok := actor.(Actor) + if ok && serviceActor.Type == ActorTypeService && serviceActor.ID == 0 && serviceActor.Identity != "" { + return serviceActor, true + } + } + + claims, err := middleware.ParseAuth(ctx) + if err != nil || claims == nil || claims.ID == 0 || claims.Identity == "" { + return Actor{}, false + } + + return Actor{ + Type: ActorTypeUser, + ID: claims.ID, + Identity: claims.Identity, + }, true +} + +func withActor(ctx *gin.Context, actor Actor) { + if actor.Type != ActorTypeService || actor.ID != 0 || actor.Identity == "" { + return + } + ctx.Set(actorContextKey, actor) +} diff --git a/internal/auth/service.go b/internal/auth/service.go new file mode 100644 index 0000000..2fb06da --- /dev/null +++ b/internal/auth/service.go @@ -0,0 +1,31 @@ +package auth + +import ( + "crypto/subtle" + "log" + "net/http" + "strings" + + "git.apinb.com/ops/files/internal/config" + "github.com/gin-gonic/gin" +) + +func ServiceAuth() gin.HandlerFunc { + return func(ctx *gin.Context) { + serviceName := strings.TrimSpace(ctx.GetHeader("Service-Name")) + secretKey := strings.TrimSpace(ctx.GetHeader("Secret-Key")) + serviceSecret, exists := config.Spec.ServiceClients[serviceName] + if serviceName == "" || secretKey == "" || !exists || strings.TrimSpace(serviceSecret) == "" || subtle.ConstantTimeCompare([]byte(serviceSecret), []byte(secretKey)) != 1 { + log.Printf("服务鉴权失败: service=%q", serviceName) + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + ctx.Abort() + return + } + + withActor(ctx, Actor{ + Type: ActorTypeService, + Identity: serviceName, + }) + ctx.Next() + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2442c43 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,147 @@ +package config + +import ( + "log" + "net" + "regexp" + "strconv" + "strings" + + "git.apinb.com/bsm-sdk/core/conf" +) + +var ( + Spec SrvConfig + configNameRegexp = regexp.MustCompile(`^[a-z0-9-]+$`) +) + +type SrvConfig struct { + conf.Base `yaml:",inline"` + Databases *conf.DBConf `yaml:"Databases"` + MicroService *conf.MicroServiceConf `yaml:"MicroService"` + Etcd *conf.EtcdConf `yaml:"Etcd"` + ObjectStorage ObjectStorageConf `yaml:"ObjectStorage"` + Namespaces map[string]NamespaceConf `yaml:"Namespaces"` + ServiceClients map[string]string `yaml:"ServiceClients"` + Cleanup CleanupConf `yaml:"Cleanup"` +} + +type ObjectStorageConf struct { + Provider string `yaml:"Provider"` + Endpoint string `yaml:"Endpoint"` + Region string `yaml:"Region"` + Bucket string `yaml:"Bucket"` + PublicBaseURL string `yaml:"PublicBaseURL"` + AccessKeyID string `yaml:"AccessKeyID"` + AccessKeySecret string `yaml:"AccessKeySecret"` + PresignTTLSeconds int64 `yaml:"PresignTTLSeconds"` +} + +type NamespaceConf struct { + Prefix string `yaml:"Prefix"` + MaxSizeMB int64 `yaml:"MaxSizeMB"` + AllowedExtensions []string `yaml:"AllowedExtensions"` +} + +type CleanupConf struct { + IntervalSeconds int64 `yaml:"IntervalSeconds"` +} + +func New(srvKey string) { + conf.New(srvKey, &Spec) + + Spec.Port = conf.CheckPort(Spec.Port) + Spec.BindIP = conf.CheckIP(Spec.BindIP) + Spec.Addr = net.JoinHostPort(Spec.BindIP, Spec.Port) + + validate() + conf.PrintInfo(Spec.Addr) +} + +func validate() { + validateRequired("Service", Spec.Service) + validateRequired("Cache", Spec.Cache) + + if Spec.ObjectStorage.Provider != "aliyun" { + configError("ObjectStorage.Provider", "必须为 aliyun") + } + + validateRequired("ObjectStorage.Endpoint", Spec.ObjectStorage.Endpoint) + validateRequired("ObjectStorage.Region", Spec.ObjectStorage.Region) + validateRequired("ObjectStorage.Bucket", Spec.ObjectStorage.Bucket) + validateRequired("ObjectStorage.PublicBaseURL", Spec.ObjectStorage.PublicBaseURL) + validateRequired("ObjectStorage.AccessKeyID", Spec.ObjectStorage.AccessKeyID) + validateRequired("ObjectStorage.AccessKeySecret", Spec.ObjectStorage.AccessKeySecret) + + if Spec.ObjectStorage.PresignTTLSeconds < 60 || Spec.ObjectStorage.PresignTTLSeconds > 3600 { + configError("ObjectStorage.PresignTTLSeconds", "必须在 60 到 3600 秒之间") + } + + if len(Spec.Namespaces) == 0 { + configError("Namespaces", "不能为空") + } + for name, namespace := range Spec.Namespaces { + path := "Namespaces." + name + if !configNameRegexp.MatchString(name) { + configError(path, "名称仅允许小写字母、数字和连字符") + } + if !isSafeRelativeObjectPath(namespace.Prefix) { + configError(path+".Prefix", "必须是安全的相对对象路径") + } + if namespace.MaxSizeMB <= 0 { + configError(path+".MaxSizeMB", "必须大于 0") + } + if len(namespace.AllowedExtensions) == 0 { + configError(path+".AllowedExtensions", "不能为空") + } + for index, extension := range namespace.AllowedExtensions { + extensionPath := path + ".AllowedExtensions[" + stringIndex(index) + "]" + if !strings.HasPrefix(extension, ".") { + configError(extensionPath, "必须以 . 开头") + } + namespace.AllowedExtensions[index] = strings.ToLower(extension) + } + Spec.Namespaces[name] = namespace + } + + if len(Spec.ServiceClients) == 0 { + configError("ServiceClients", "不能为空") + } + for name, secret := range Spec.ServiceClients { + path := "ServiceClients." + name + if !configNameRegexp.MatchString(name) { + configError(path, "服务名仅允许小写字母、数字和连字符") + } + validateRequired(path, secret) + } + + if Spec.Cleanup.IntervalSeconds <= 0 { + configError("Cleanup.IntervalSeconds", "必须大于 0") + } +} + +func validateRequired(path, value string) { + if strings.TrimSpace(value) == "" { + configError(path, "不能为空") + } +} + +func isSafeRelativeObjectPath(path string) bool { + if path == "" || strings.HasPrefix(path, "/") || strings.Contains(path, "\\") { + return false + } + for _, segment := range strings.Split(path, "/") { + if segment == "." || segment == ".." { + return false + } + } + return true +} + +func configError(path, message string) { + log.Fatalf("配置项 %s 无效:%s", path, message) +} + +func stringIndex(index int) string { + return strconv.Itoa(index) +} diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 0000000..d52ef63 --- /dev/null +++ b/internal/errors/errors.go @@ -0,0 +1,17 @@ +package errors + +import "git.apinb.com/bsm-sdk/core/errcode" + +var ( + ErrInvalidParameter = errcode.NewError(2101, "参数错误") + ErrNamespaceNotAllowed = errcode.NewError(2102, "命名空间不允许") + ErrExtensionNotAllowed = errcode.NewError(2103, "扩展名不允许") + ErrInvalidFileSize = errcode.NewError(2104, "文件大小不合法") + ErrFileNotFound = errcode.NewError(2105, "文件不存在") + ErrUploadStatusConflict = errcode.NewError(2106, "上传状态冲突") + ErrUploadExpired = errcode.NewError(2107, "上传已过期") + ErrObjectInfoMismatch = errcode.NewError(2108, "OSS 对象信息不匹配") + ErrUnauthorizedOperation = errcode.NewError(2109, "无权操作") + ErrObjectStorageOperation = errcode.NewError(2110, "OSS 操作失败") + ErrDatabaseOperation = errcode.NewError(2111, "数据库操作失败") +) diff --git a/internal/impl/impl.go b/internal/impl/impl.go new file mode 100644 index 0000000..b4d397d --- /dev/null +++ b/internal/impl/impl.go @@ -0,0 +1,34 @@ +package impl + +import ( + "git.apinb.com/bsm-sdk/core/cache/redis" + "git.apinb.com/bsm-sdk/core/logger" + "git.apinb.com/bsm-sdk/core/with" + "git.apinb.com/ops/files/internal/config" + "git.apinb.com/ops/files/internal/storage" + clientv3 "go.etcd.io/etcd/client/v3" + "gorm.io/gorm" +) + +var ( + RedisService *redis.RedisClient + EtcdService *clientv3.Client + DBService *gorm.DB + StorageService *storage.Client + Logger *logger.Logger +) + +// NewImpl 初始化各类服务实例。 +func NewImpl() { + DBService = with.Databases(config.Spec.Databases, nil) + + var err error + StorageService, err = storage.New(config.Spec.ObjectStorage) + if err != nil { + panic(err) + } + + RedisService = with.RedisCache(config.Spec.Cache) + EtcdService = with.Etcd(config.Spec.Etcd) + logger.New(nil) +} diff --git a/internal/jobs/pending_upload.go b/internal/jobs/pending_upload.go new file mode 100644 index 0000000..5bfd2a5 --- /dev/null +++ b/internal/jobs/pending_upload.go @@ -0,0 +1,79 @@ +package jobs + +import ( + "context" + "time" + + "git.apinb.com/bsm-sdk/core/logger" + "git.apinb.com/ops/files/internal/config" + "git.apinb.com/ops/files/internal/impl" + "git.apinb.com/ops/files/internal/lifecycle" + "git.apinb.com/ops/files/internal/models" +) + +func StartPendingUploadCleanup() { + go func() { + cleanupPendingUploads() + + ticker := time.NewTicker(time.Duration(config.Spec.Cleanup.IntervalSeconds) * time.Second) + defer ticker.Stop() + for range ticker.C { + cleanupPendingUploads() + } + }() +} + +func cleanupPendingUploads() { + var fileObjects []models.FileObject + now := time.Now() + result := impl.DBService. + Where( + "(status = ? AND expires_at <= ?) OR (status = ? AND (delete_lease_until IS NULL OR delete_lease_until <= ?))", + models.FileStatusPending, + now, + models.FileStatusDeleting, + now, + ). + Order("id ASC"). + Limit(100). + Find(&fileObjects) + if result.Error != nil { + logger.Error("stage=scan") + return + } + + for _, fileObject := range fileObjects { + var lease lifecycle.DeletionLease + var claimed bool + var err error + now = time.Now() + if fileObject.Status == models.FileStatusPending { + lease, claimed, err = lifecycle.ClaimExpiredPendingDeletion(impl.DBService, fileObject.ID, now) + } else { + lease, claimed, err = lifecycle.ClaimDeletionRetry(impl.DBService, fileObject.ID, now) + } + if err != nil { + logger.Errorf("identity=%s stage=claim", fileObject.Identity) + continue + } + if !claimed { + continue + } + + cleanupClaimedFile(fileObject, lease) + } +} + +func cleanupClaimedFile(fileObject models.FileObject, lease lifecycle.DeletionLease) { + operationCtx, cancel := context.WithTimeout(context.Background(), lifecycle.DeleteOperationTimeout) + defer cancel() + + if err := impl.StorageService.Delete(operationCtx, fileObject.ObjectKey); err != nil { + logger.Errorf("identity=%s stage=oss_delete", fileObject.Identity) + return + } + + if err := lifecycle.FinalizeDeletion(impl.DBService.WithContext(operationCtx), fileObject.ID, lease.Token); err != nil { + logger.Errorf("identity=%s stage=finalize", fileObject.Identity) + } +} diff --git a/internal/lifecycle/deletion.go b/internal/lifecycle/deletion.go new file mode 100644 index 0000000..2f57259 --- /dev/null +++ b/internal/lifecycle/deletion.go @@ -0,0 +1,89 @@ +package lifecycle + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/utils" + "git.apinb.com/ops/files/internal/models" + "gorm.io/gorm" +) + +const ( + deletionLeaseDuration = 5 * time.Minute + DeleteOperationTimeout = 30 * time.Second +) + +type DeletionLease struct { + Token string + LeaseUntil time.Time +} + +func ClaimDeletion(db *gorm.DB, id uint, allowedStatuses []models.FileStatus, now time.Time) (DeletionLease, bool, error) { + query := db.Model(&models.FileObject{}). + Where("id = ? AND status IN ?", id, allowedStatuses) + return claimDeletion(query, now) +} + +func ClaimExpiredPendingDeletion(db *gorm.DB, id uint, now time.Time) (DeletionLease, bool, error) { + query := db.Model(&models.FileObject{}). + Where("id = ? AND status = ? AND expires_at <= ?", id, models.FileStatusPending, now) + return claimDeletion(query, now) +} + +func ClaimDeletionRetry(db *gorm.DB, id uint, now time.Time) (DeletionLease, bool, error) { + query := db.Model(&models.FileObject{}). + Where("id = ? AND status = ?", id, models.FileStatusDeleting). + Where("delete_lease_until IS NULL OR delete_lease_until <= ?", now) + return claimDeletion(query, now) +} + +func FinalizeDeletion(db *gorm.DB, id uint, token string) error { + if token == "" { + return gorm.ErrRecordNotFound + } + + return db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&models.FileObject{}). + Where("id = ? AND status = ? AND delete_token = ?", id, models.FileStatusDeleting, token). + Updates(map[string]any{ + "status": models.FileStatusExpired, + "delete_token": "", + "delete_lease_until": nil, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + + result = tx.Where("id = ? AND status = ?", id, models.FileStatusExpired). + Delete(&models.FileObject{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return nil + }) +} + +func claimDeletion(query *gorm.DB, now time.Time) (DeletionLease, bool, error) { + lease := DeletionLease{ + Token: utils.ULID(), + LeaseUntil: now.Add(deletionLeaseDuration), + } + result := query.Updates(map[string]any{ + "status": models.FileStatusDeleting, + "delete_token": lease.Token, + "delete_lease_until": lease.LeaseUntil, + }) + if result.Error != nil { + return DeletionLease{}, false, result.Error + } + if result.RowsAffected != 1 { + return DeletionLease{}, false, nil + } + return lease, true, nil +} diff --git a/internal/logic/files/complete.go b/internal/logic/files/complete.go new file mode 100644 index 0000000..0500338 --- /dev/null +++ b/internal/logic/files/complete.go @@ -0,0 +1,61 @@ +package files + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/infra" + fileerrors "git.apinb.com/ops/files/internal/errors" + "git.apinb.com/ops/files/internal/impl" + "git.apinb.com/ops/files/internal/models" + "github.com/gin-gonic/gin" +) + +func CompleteUpload(ctx *gin.Context) { + fileObject, ok := loadOwnedFile(ctx) + if !ok { + return + } + if fileObject.Status != models.FileStatusPending { + infra.Response.Error(ctx, fileerrors.ErrUploadStatusConflict) + return + } + + if fileObject.ExpiresAt == nil || !fileObject.ExpiresAt.After(time.Now()) { + infra.Response.Error(ctx, fileerrors.ErrUploadExpired) + return + } + + objectInfo, err := impl.StorageService.Stat(ctx.Request.Context(), fileObject.ObjectKey) + if err != nil { + infra.Response.Error(ctx, fileerrors.ErrObjectStorageOperation) + return + } + if objectInfo.Size != fileObject.ExpectedSize { + infra.Response.Error(ctx, fileerrors.ErrObjectInfoMismatch) + return + } + + completedAt := time.Now() + result := impl.DBService.Model(&models.FileObject{}). + Where("id = ? AND status = ? AND expires_at > ?", fileObject.ID, models.FileStatusPending, completedAt). + Updates(map[string]any{ + "status": models.FileStatusReady, + "actual_size": objectInfo.Size, + "etag": objectInfo.ETag, + "completed_at": completedAt, + }) + if result.Error != nil { + infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation) + return + } + if result.RowsAffected != 1 { + infra.Response.Error(ctx, fileerrors.ErrUploadStatusConflict) + return + } + + fileObject.Status = models.FileStatusReady + fileObject.ActualSize = objectInfo.Size + fileObject.ETag = objectInfo.ETag + fileObject.CompletedAt = &completedAt + infra.Response.Success(ctx, newFileResponse(fileObject)) +} diff --git a/internal/logic/files/delete.go b/internal/logic/files/delete.go new file mode 100644 index 0000000..ad6a9c0 --- /dev/null +++ b/internal/logic/files/delete.go @@ -0,0 +1,50 @@ +package files + +import ( + "context" + "time" + + "git.apinb.com/bsm-sdk/core/infra" + fileerrors "git.apinb.com/ops/files/internal/errors" + "git.apinb.com/ops/files/internal/impl" + "git.apinb.com/ops/files/internal/lifecycle" + "git.apinb.com/ops/files/internal/models" + "github.com/gin-gonic/gin" +) + +func Delete(ctx *gin.Context) { + fileObject, ok := loadOwnedFile(ctx) + if !ok { + return + } + + deletableStatuses := []models.FileStatus{ + models.FileStatusPending, + models.FileStatusReady, + models.FileStatusExpired, + } + lease, claimed, err := lifecycle.ClaimDeletion(impl.DBService, fileObject.ID, deletableStatuses, time.Now()) + if err != nil { + infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation) + return + } + if !claimed { + infra.Response.Error(ctx, fileerrors.ErrUploadStatusConflict) + return + } + + operationCtx, cancel := context.WithTimeout(ctx.Request.Context(), lifecycle.DeleteOperationTimeout) + defer cancel() + + if err := impl.StorageService.Delete(operationCtx, fileObject.ObjectKey); err != nil { + infra.Response.Error(ctx, fileerrors.ErrObjectStorageOperation) + return + } + + if err := lifecycle.FinalizeDeletion(impl.DBService.WithContext(operationCtx), fileObject.ID, lease.Token); err != nil { + infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation) + return + } + + infra.Response.Success(ctx, nil) +} diff --git a/internal/logic/files/detail.go b/internal/logic/files/detail.go new file mode 100644 index 0000000..2921997 --- /dev/null +++ b/internal/logic/files/detail.go @@ -0,0 +1,68 @@ +package files + +import ( + "errors" + "strings" + + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/ops/files/internal/auth" + fileerrors "git.apinb.com/ops/files/internal/errors" + "git.apinb.com/ops/files/internal/impl" + "git.apinb.com/ops/files/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +func Detail(ctx *gin.Context) { + fileObject, ok := loadOwnedFile(ctx) + if !ok { + return + } + if fileObject.Status != models.FileStatusReady { + infra.Response.Error(ctx, fileerrors.ErrFileNotFound) + return + } + + infra.Response.Success(ctx, newFileResponse(fileObject)) +} + +func loadOwnedFile(ctx *gin.Context) (models.FileObject, bool) { + actor, ok := auth.FromContext(ctx) + if !ok { + infra.Response.Error(ctx, fileerrors.ErrUnauthorizedOperation) + return models.FileObject{}, false + } + + identity := strings.TrimSpace(ctx.Param("identity")) + if identity == "" { + infra.Response.Error(ctx, fileerrors.ErrInvalidParameter) + return models.FileObject{}, false + } + + var fileObject models.FileObject + if err := impl.DBService. + Where("identity = ? AND owner_type = ? AND owner_identity = ?", identity, actor.Type, actor.Identity). + First(&fileObject).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + infra.Response.Error(ctx, fileerrors.ErrFileNotFound) + } else { + infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation) + } + return models.FileObject{}, false + } + + return fileObject, true +} + +func newFileResponse(fileObject models.FileObject) FileResponse { + return FileResponse{ + FileID: fileObject.Identity, + ObjectKey: fileObject.ObjectKey, + URL: impl.StorageService.PublicURL(fileObject.ObjectKey), + Filename: fileObject.OriginalName, + Size: fileObject.ActualSize, + ContentType: fileObject.ContentType, + ETag: fileObject.ETag, + Status: fileObject.Status, + } +} diff --git a/internal/logic/files/init.go b/internal/logic/files/init.go new file mode 100644 index 0000000..280fd75 --- /dev/null +++ b/internal/logic/files/init.go @@ -0,0 +1,139 @@ +package files + +import ( + "math" + "path/filepath" + "strings" + "time" + "unicode/utf8" + + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/bsm-sdk/core/utils" + "git.apinb.com/ops/files/internal/auth" + "git.apinb.com/ops/files/internal/config" + fileerrors "git.apinb.com/ops/files/internal/errors" + "git.apinb.com/ops/files/internal/impl" + "git.apinb.com/ops/files/internal/models" + "github.com/gin-gonic/gin" +) + +const bytesPerMB int64 = 1024 * 1024 + +func InitUpload(ctx *gin.Context) { + var request InitUploadRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + infra.Response.Error(ctx, fileerrors.ErrInvalidParameter) + return + } + + actor, ok := auth.FromContext(ctx) + if !ok { + infra.Response.Error(ctx, fileerrors.ErrUnauthorizedOperation) + return + } + + namespace, namespaceConfig, err := validateNamespace(request.Namespace) + if err != nil { + infra.Response.Error(ctx, err) + return + } + + filename, extension, err := validateFilename(request.Filename, namespaceConfig) + if err != nil { + infra.Response.Error(ctx, err) + return + } + if utf8.RuneCountInString(filename) > 255 { + infra.Response.Error(ctx, fileerrors.ErrInvalidParameter) + return + } + + if !validFileSize(request.Size, namespaceConfig.MaxSizeMB) { + infra.Response.Error(ctx, fileerrors.ErrInvalidFileSize) + return + } + + contentType := strings.TrimSpace(request.ContentType) + if contentType == "" || utf8.RuneCountInString(contentType) > 255 { + infra.Response.Error(ctx, fileerrors.ErrInvalidParameter) + return + } + + fileID := utils.ULID() + objectKey := buildObjectKey(namespaceConfig.Prefix, time.Now(), fileID, extension) + upload, err := impl.StorageService.PresignPut(ctx.Request.Context(), objectKey, contentType) + if err != nil { + infra.Response.Error(ctx, fileerrors.ErrObjectStorageOperation) + return + } + + fileObject := models.FileObject{ + Identity: fileID, + Namespace: namespace, + Provider: config.Spec.ObjectStorage.Provider, + Bucket: impl.StorageService.Bucket(), + ObjectKey: objectKey, + OriginalName: filename, + Extension: extension, + ContentType: contentType, + ExpectedSize: request.Size, + Status: models.FileStatusPending, + OwnerType: actor.Type, + OwnerID: actor.ID, + OwnerIdentity: actor.Identity, + ExpiresAt: &upload.ExpiresAt, + } + if err := impl.DBService.Create(&fileObject).Error; err != nil { + infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation) + return + } + + infra.Response.Success(ctx, InitUploadResponse{ + FileID: fileID, + ObjectKey: objectKey, + Upload: upload, + }) +} + +func validateNamespace(value string) (string, config.NamespaceConf, error) { + namespace := strings.TrimSpace(value) + namespaceConfig, ok := config.Spec.Namespaces[namespace] + if !ok { + return "", config.NamespaceConf{}, fileerrors.ErrNamespaceNotAllowed + } + return namespace, namespaceConfig, nil +} + +func validateFilename(value string, namespaceConfig config.NamespaceConf) (string, string, error) { + filename := strings.TrimSpace(value) + if strings.ContainsRune(filename, 0) { + return "", "", fileerrors.ErrInvalidParameter + } + + filename = filepath.Base(filename) + if filename == "" || filename == "." || filename == ".." { + return "", "", fileerrors.ErrInvalidParameter + } + + extension := strings.ToLower(filepath.Ext(filename)) + if extension == "" || !containsExtension(namespaceConfig.AllowedExtensions, extension) { + return "", "", fileerrors.ErrExtensionNotAllowed + } + return filename, extension, nil +} + +func containsExtension(extensions []string, extension string) bool { + for _, allowedExtension := range extensions { + if allowedExtension == extension { + return true + } + } + return false +} + +func validFileSize(size, maxSizeMB int64) bool { + if size < 1 || maxSizeMB < 1 || maxSizeMB > math.MaxInt64/bytesPerMB { + return false + } + return size <= maxSizeMB*bytesPerMB +} diff --git a/internal/logic/files/object_key.go b/internal/logic/files/object_key.go new file mode 100644 index 0000000..2ac4e6f --- /dev/null +++ b/internal/logic/files/object_key.go @@ -0,0 +1,10 @@ +package files + +import ( + "path" + "time" +) + +func buildObjectKey(prefix string, now time.Time, fileID, extension string) string { + return path.Join(prefix, now.Format("2006"), now.Format("01"), fileID+extension) +} diff --git a/internal/logic/files/types.go b/internal/logic/files/types.go new file mode 100644 index 0000000..4b44c97 --- /dev/null +++ b/internal/logic/files/types.go @@ -0,0 +1,30 @@ +package files + +import ( + "git.apinb.com/ops/files/internal/models" + "git.apinb.com/ops/files/internal/storage" +) + +type InitUploadRequest struct { + Namespace string `json:"namespace" binding:"required"` + Filename string `json:"filename" binding:"required"` + Size int64 `json:"size" binding:"required"` + ContentType string `json:"content_type" binding:"required"` +} + +type InitUploadResponse struct { + FileID string `json:"file_id"` + ObjectKey string `json:"object_key"` + Upload storage.UploadInstruction `json:"upload"` +} + +type FileResponse struct { + FileID string `json:"file_id"` + ObjectKey string `json:"object_key"` + URL string `json:"url"` + Filename string `json:"filename"` + Size int64 `json:"size"` + ContentType string `json:"content_type"` + ETag string `json:"etag"` + Status models.FileStatus `json:"status"` +} diff --git a/internal/logic/ping/hello.go b/internal/logic/ping/hello.go new file mode 100644 index 0000000..14941dd --- /dev/null +++ b/internal/logic/ping/hello.go @@ -0,0 +1,11 @@ +package ping + +import ( + "git.apinb.com/bsm-sdk/core/infra" + "github.com/gin-gonic/gin" +) + +func Hello(ctx *gin.Context) { + infra.Response.Success(ctx, "Files Service is running!") + return +} diff --git a/internal/models/file_object.go b/internal/models/file_object.go new file mode 100644 index 0000000..8c0e7c4 --- /dev/null +++ b/internal/models/file_object.go @@ -0,0 +1,46 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type FileStatus string + +const ( + FileStatusPending FileStatus = "pending" + FileStatusReady FileStatus = "ready" + FileStatusDeleting FileStatus = "deleting" + FileStatusExpired FileStatus = "expired" +) + +type FileObject struct { + ID uint `gorm:"primaryKey" json:"id"` + Identity string `gorm:"size:64;not null;uniqueIndex" json:"identity"` + Namespace string `gorm:"size:64;not null" json:"namespace"` + Provider string `gorm:"size:32;not null" json:"provider"` + Bucket string `gorm:"size:255;not null" json:"bucket"` + ObjectKey string `gorm:"size:512;not null;uniqueIndex" json:"object_key"` + OriginalName string `gorm:"size:255;not null" json:"original_name"` + Extension string `gorm:"size:32" json:"extension"` + ContentType string `gorm:"size:255" json:"content_type"` + ExpectedSize int64 `gorm:"not null" json:"expected_size"` + ActualSize int64 `json:"actual_size"` + ETag string `gorm:"size:255" json:"etag"` + Status FileStatus `gorm:"size:16;not null;index:idx_files_object_status_expires_at,priority:1;index:idx_files_object_status_delete_lease,priority:1" json:"status"` + OwnerType string `gorm:"size:64;not null;index:idx_files_object_owner_type_identity,priority:1" json:"owner_type"` + OwnerID uint `gorm:"not null" json:"owner_id"` + OwnerIdentity string `gorm:"size:64;not null;index:idx_files_object_owner_type_identity,priority:2" json:"owner_identity"` + ExpiresAt *time.Time `gorm:"index:idx_files_object_status_expires_at,priority:2" json:"expires_at"` + CompletedAt *time.Time `json:"completed_at"` + DeleteToken string `gorm:"size:26" json:"-"` + DeleteLeaseUntil *time.Time `gorm:"index:idx_files_object_status_delete_lease,priority:2" json:"-"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` +} + +func (FileObject) TableName() string { + return "files_object" +} diff --git a/internal/models/query.go b/internal/models/query.go new file mode 100644 index 0000000..9b135d4 --- /dev/null +++ b/internal/models/query.go @@ -0,0 +1,7 @@ +package models + +import "git.apinb.com/ops/files/internal/impl" + +func InitData() error { + return impl.DBService.AutoMigrate(&FileObject{}) +} diff --git a/internal/routers/register.go b/internal/routers/register.go new file mode 100644 index 0000000..44892fd --- /dev/null +++ b/internal/routers/register.go @@ -0,0 +1,27 @@ +package routers + +import ( + "fmt" + + "git.apinb.com/bsm-sdk/core/middleware" + "git.apinb.com/ops/files/internal/auth" + "git.apinb.com/ops/files/internal/logic/files" + "git.apinb.com/ops/files/internal/logic/ping" + "github.com/gin-gonic/gin" +) + +// Register 注册基础服务路由。 +func Register(srvKey string, engine *gin.Engine) { + v1Group := engine.Group(fmt.Sprintf("/%s/v1", srvKey)) + v1Group.GET("/ping/hello", ping.Hello) + + registerFileRoutes(v1Group.Group("", middleware.JwtAuth(true))) + registerFileRoutes(v1Group.Group("/internal", auth.ServiceAuth())) +} + +func registerFileRoutes(group *gin.RouterGroup) { + group.POST("/uploads/init", files.InitUpload) + group.POST("/uploads/:identity/complete", files.CompleteUpload) + group.GET("/files/:identity", files.Detail) + group.DELETE("/files/:identity", files.Delete) +} diff --git a/internal/storage/client.go b/internal/storage/client.go new file mode 100644 index 0000000..c97c18f --- /dev/null +++ b/internal/storage/client.go @@ -0,0 +1,31 @@ +package storage + +import ( + "time" + + "git.apinb.com/ops/files/internal/config" + "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss" + "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials" +) + +type Client struct { + client *oss.Client + bucket string + publicBaseURL string + presignTTL time.Duration +} + +func New(cfg config.ObjectStorageConf) (*Client, error) { + ossConfig := oss.LoadDefaultConfig(). + WithRegion(cfg.Region). + WithEndpoint(cfg.Endpoint). + WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.AccessKeySecret)). + WithSignatureVersion(oss.SignatureVersionV4) + + return &Client{ + client: oss.NewClient(ossConfig), + bucket: cfg.Bucket, + publicBaseURL: cfg.PublicBaseURL, + presignTTL: time.Duration(cfg.PresignTTLSeconds) * time.Second, + }, nil +} diff --git a/internal/storage/object.go b/internal/storage/object.go new file mode 100644 index 0000000..11f5a0d --- /dev/null +++ b/internal/storage/object.go @@ -0,0 +1,76 @@ +package storage + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss" +) + +type ObjectInfo struct { + Size int64 + ETag string + LastModified time.Time +} + +func (c *Client) Stat(ctx context.Context, objectKey string) (ObjectInfo, error) { + if strings.TrimSpace(objectKey) == "" { + return ObjectInfo{}, fmt.Errorf("对象键不能为空") + } + + result, err := c.client.GetObjectMeta(ctx, &oss.GetObjectMetaRequest{ + Bucket: oss.Ptr(c.bucket), + Key: oss.Ptr(objectKey), + }) + if err != nil { + return ObjectInfo{}, fmt.Errorf("查询对象元数据: %w", err) + } + if result == nil { + return ObjectInfo{}, fmt.Errorf("对象元数据为空") + } + if result.ContentLength < 0 { + return ObjectInfo{}, fmt.Errorf("对象大小无效: %d", result.ContentLength) + } + if result.ETag == nil { + return ObjectInfo{}, fmt.Errorf("对象 ETag 缺失") + } + etag := strings.TrimSpace(strings.Trim(strings.TrimSpace(*result.ETag), "\"")) + if etag == "" { + return ObjectInfo{}, fmt.Errorf("对象 ETag 无效") + } + if result.LastModified == nil || result.LastModified.IsZero() { + return ObjectInfo{}, fmt.Errorf("对象最后修改时间缺失") + } + + return ObjectInfo{ + Size: result.ContentLength, + ETag: etag, + LastModified: *result.LastModified, + }, nil +} + +func (c *Client) Delete(ctx context.Context, objectKey string) error { + if strings.TrimSpace(objectKey) == "" { + return fmt.Errorf("对象键不能为空") + } + + _, err := c.client.DeleteObject(ctx, &oss.DeleteObjectRequest{ + Bucket: oss.Ptr(c.bucket), + Key: oss.Ptr(objectKey), + }) + if err != nil { + return fmt.Errorf("删除对象: %w", err) + } + + return nil +} + +func (c *Client) PublicURL(objectKey string) string { + return strings.TrimRight(c.publicBaseURL, "/") + "/" + strings.TrimLeft(objectKey, "/") +} + +func (c *Client) Bucket() string { + return c.bucket +} diff --git a/internal/storage/presign.go b/internal/storage/presign.go new file mode 100644 index 0000000..ce47921 --- /dev/null +++ b/internal/storage/presign.go @@ -0,0 +1,43 @@ +package storage + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss" +) + +type UploadInstruction struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + ExpiresAt time.Time `json:"expires_at"` +} + +func (c *Client) PresignPut(ctx context.Context, objectKey, contentType string) (UploadInstruction, error) { + if strings.TrimSpace(objectKey) == "" { + return UploadInstruction{}, fmt.Errorf("对象键不能为空") + } + if strings.TrimSpace(contentType) == "" { + return UploadInstruction{}, fmt.Errorf("内容类型不能为空") + } + + result, err := c.client.Presign(ctx, &oss.PutObjectRequest{ + Bucket: oss.Ptr(c.bucket), + Key: oss.Ptr(objectKey), + ContentType: oss.Ptr(contentType), + ForbidOverwrite: oss.Ptr("true"), + }, oss.PresignExpires(c.presignTTL)) + if err != nil { + return UploadInstruction{}, fmt.Errorf("生成上传预签名: %w", err) + } + + return UploadInstruction{ + Method: result.Method, + URL: result.URL, + Headers: result.SignedHeaders, + ExpiresAt: result.Expiration, + }, nil +} diff --git a/scripts/pack.ps1 b/scripts/pack.ps1 new file mode 100644 index 0000000..c25892e --- /dev/null +++ b/scripts/pack.ps1 @@ -0,0 +1,27 @@ +# 保存调用进程的 Go 构建环境,脚本结束后恢复。 +$hasGOOS = Test-Path Env:GOOS +$hasGOARCH = Test-Path Env:GOARCH +$hasGOFLAGS = Test-Path Env:GOFLAGS +$originalGOOS = $env:GOOS +$originalGOARCH = $env:GOARCH +$originalGOFLAGS = $env:GOFLAGS + +Push-Location (Join-Path $PSScriptRoot '..') +try { + New-Item -ItemType Directory -Force -Path './build' | Out-Null + + $env:GOOS = 'linux' + $env:GOARCH = 'amd64' + $env:GOFLAGS = '-buildvcs=false' + + & go build -o './build/ops-files' './cmd/main/main.go' + if ($LASTEXITCODE -ne 0) { + throw "go build 失败,退出码:$LASTEXITCODE" + } +} +finally { + if ($hasGOOS) { $env:GOOS = $originalGOOS } else { Remove-Item Env:GOOS } + if ($hasGOARCH) { $env:GOARCH = $originalGOARCH } else { Remove-Item Env:GOARCH } + if ($hasGOFLAGS) { $env:GOFLAGS = $originalGOFLAGS } else { Remove-Item Env:GOFLAGS } + Pop-Location +}