init: 提交 files 服务初始代码

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zxr
2026-08-03 23:51:21 +08:00
commit d29693343b
33 changed files with 2067 additions and 0 deletions

View File

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

116
.gitignore vendored Normal file
View File

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

244
README.md Normal file
View File

@@ -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_<mode>.yaml`;开发模式未设置 `BSM_Prefix` 时,使用当前工作目录。
核心配置示例:
```yaml
ServiceClients:
assets: <assets 调用密钥>
dc-control: <dc-control 调用密钥>
visual: <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`:上传地址有效期,允许范围为 603600 秒。
- `Namespaces`:调用方可使用的命名空间,以及对应的目录、大小和扩展名限制。
- `Cleanup.IntervalSeconds`:扫描并清理超时未完成上传的时间间隔。
OSS 凭证必须通过环境变量提供:
```powershell
$env:FILES_OSS_ACCESS_KEY_ID = '<AccessKey ID>'
$env:FILES_OSS_ACCESS_KEY_SECRET = '<AccessKey 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 原始字符串>
```
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/<file_id>/complete
Service-Name: assets
Secret-Key: <>
```
确认接口会检查 OSS 对象是否存在以及实际大小是否与初始化请求一致。成功后返回文件信息,其中 `url` 是公开访问地址。
### 查询和删除
```http
GET /Files/v1/internal/files/<file_id>
DELETE /Files/v1/internal/files/<file_id>
```
删除接口同时删除 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
来源:实际前端域名
MethodsPUT、GET、HEAD
允许 Headers*
暴露 HeadersETag、x-oss-request-id
```
生产环境应使用 RAM 用户的 AccessKey并只授予目标 Bucket 所需的对象上传、查询和删除权限。

40
cmd/cli/main.go Normal file
View File

@@ -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 <command>")
fmt.Println("\n可用命令:")
fmt.Println(" help - 显示帮助信息")
fmt.Println(" migrate - 迁移文件元数据表")
}

36
cmd/main/main.go Normal file
View File

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

47
etc/files_dev.yaml Normal file
View File

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

47
etc/files_prod.yaml Normal file
View File

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

47
etc/files_test.yaml Normal file
View File

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

14
etc/ops-files.service Normal file
View File

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

View File

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

85
go.mod Normal file
View File

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

228
go.sum Normal file
View File

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

46
internal/auth/actor.go Normal file
View File

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

31
internal/auth/service.go Normal file
View File

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

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

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

17
internal/errors/errors.go Normal file
View File

@@ -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, "数据库操作失败")
)

34
internal/impl/impl.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

7
internal/models/query.go Normal file
View File

@@ -0,0 +1,7 @@
package models
import "git.apinb.com/ops/files/internal/impl"
func InitData() error {
return impl.DBService.AutoMigrate(&FileObject{})
}

View File

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

View File

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

View File

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

View File

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

27
scripts/pack.ps1 Normal file
View File

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