"
- path_style: false # MinIO 等自托管服务设为 true
- key_prefix: "" # 对象 key 前缀,如 "uploads/",可用于分目录存储
- cdn_url: "" # CDN 域名(如 https://cdn.example.com),为空则直接读 S3
+ path_style: false # Set true for self-hosted S3 (e.g. MinIO)
+ key_prefix: "" # Optional prefix for all object keys, e.g. "uploads/"
+ cdn_url: "" # CDN base URL (e.g. https://cdn.example.com); falls back to S3 if empty
local_cache:
enabled: false
cache_dir: "./s3_cache"
diff --git a/docs/docs.go b/docs/docs.go
index 3471fa1b..851334fa 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -1492,6 +1492,393 @@ const docTemplate = `{
}
}
},
+ "/api/v1/upload": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "description": "支持各种类型的通用文件上传,支持自动文件类型检测、哈希计算与“秒传”去重",
+ "consumes": [
+ "multipart/form-data"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "upload"
+ ],
+ "summary": "上传文件",
+ "parameters": [
+ {
+ "type": "file",
+ "description": "要上传的文件",
+ "name": "file",
+ "in": "formData",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "业务分类 (例如: avatar, attachment, doc,默认为 generic)",
+ "name": "type",
+ "in": "formData"
+ },
+ {
+ "type": "string",
+ "description": "额外的 JSON 格式元数据",
+ "name": "metadata",
+ "in": "formData"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "上传成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/util.ResponseAny"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/model.Upload"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "400": {
+ "description": "请求参数错误或文件受限",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ },
+ "401": {
+ "description": "未登录",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ },
+ "500": {
+ "description": "内部错误",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/upload/download/batch": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "description": "传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/octet-stream"
+ ],
+ "tags": [
+ "upload"
+ ],
+ "summary": "批量打包下载",
+ "parameters": [
+ {
+ "description": "包含文件 ID 数组的请求体",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/upload.batchDownloadRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功下载打包后的 ZIP",
+ "schema": {
+ "type": "file"
+ }
+ },
+ "400": {
+ "description": "参数错误",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ },
+ "500": {
+ "description": "打包失败",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/upload/download/{id}": {
+ "get": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "description": "根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载",
+ "produces": [
+ "application/octet-stream"
+ ],
+ "tags": [
+ "upload"
+ ],
+ "summary": "下载单文件",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "文件 ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功下载文件",
+ "schema": {
+ "type": "file"
+ }
+ },
+ "400": {
+ "description": "参数错误",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ },
+ "404": {
+ "description": "文件不存在",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ },
+ "500": {
+ "description": "服务内部错误",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/user/access-tokens": {
+ "get": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "description": "返回当前登录用户的所有 active access tokens(脱敏后)",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "user"
+ ],
+ "summary": "获取当前用户的 AccessToken 列表",
+ "responses": {
+ "200": {
+ "description": "令牌列表",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/util.ResponseAny"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/model.AccessToken"
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "401": {
+ "description": "未登录",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "description": "为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "user"
+ ],
+ "summary": "创建一个新的 AccessToken",
+ "parameters": [
+ {
+ "description": "令牌名称",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/user.createTokenRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "新建令牌成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/util.ResponseAny"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/user.tokenResponse"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "400": {
+ "description": "参数错误或超限",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/user/access-tokens/{id}": {
+ "delete": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "description": "撤销并删除一个属于当前用户的 API 访问令牌",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "user"
+ ],
+ "summary": "删除一个 AccessToken",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "令牌ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "删除成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/util.ResponseAny"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "string"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "400": {
+ "description": "参数错误",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/user/access-tokens/{id}/rotate": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "description": "轮换(重新生成)一个属于当前用户的 API 访问令牌的密钥,旧令牌将立即失效",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "user"
+ ],
+ "summary": "轮换一个 AccessToken",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "令牌ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "令牌轮换成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/util.ResponseAny"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/user.tokenResponse"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "400": {
+ "description": "参数错误",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
"/api/v1/user/login": {
"post": {
"description": "使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。",
@@ -1740,6 +2127,32 @@ const docTemplate = `{
}
}
},
+ "model.AccessToken": {
+ "type": "object",
+ "properties": {
+ "created_at": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "last_used_at": {
+ "type": "string"
+ },
+ "masked_token": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "string"
+ },
+ "user_id": {
+ "type": "integer"
+ }
+ }
+ },
"model.AuthSource": {
"type": "object",
"properties": {
@@ -1850,6 +2263,129 @@ const docTemplate = `{
"TrustLevelLeader"
]
},
+ "model.Upload": {
+ "type": "object",
+ "properties": {
+ "created_at": {
+ "type": "string"
+ },
+ "extension": {
+ "description": "文件后缀名 (不含点,如 png, pdf)",
+ "type": "string"
+ },
+ "file_name": {
+ "description": "原始文件名 (例如: image.png)",
+ "type": "string"
+ },
+ "file_path": {
+ "description": "文件相对路径 / S3 Key",
+ "type": "string"
+ },
+ "file_size": {
+ "description": "文件大小(字节)",
+ "type": "integer"
+ },
+ "hash": {
+ "description": "文件哈希 (SHA-256/MD5,可用于排重)",
+ "type": "string"
+ },
+ "id": {
+ "type": "string",
+ "example": "0"
+ },
+ "metadata": {
+ "description": "业务扩展元数据",
+ "allOf": [
+ {
+ "$ref": "#/definitions/model.UploadMetadata"
+ }
+ ]
+ },
+ "mime_type": {
+ "description": "媒体类型 (MIME, 如 image/png)",
+ "type": "string"
+ },
+ "status": {
+ "description": "状态",
+ "allOf": [
+ {
+ "$ref": "#/definitions/model.UploadStatus"
+ }
+ ]
+ },
+ "storage_driver": {
+ "description": "存储引擎驱动 (如 local, s3, oss)",
+ "type": "string"
+ },
+ "type": {
+ "description": "业务标识类型 (如 avatar, doc, attachment)",
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "string"
+ },
+ "user_id": {
+ "type": "string",
+ "example": "0"
+ }
+ }
+ },
+ "model.UploadMetadata": {
+ "type": "object",
+ "properties": {
+ "bucket": {
+ "description": "存储桶名称 (适用于 S3 等)",
+ "type": "string"
+ },
+ "client_ip": {
+ "description": "上传者 IP",
+ "type": "string"
+ },
+ "duration": {
+ "description": "音视频时长 (s)",
+ "type": "number"
+ },
+ "extra": {
+ "description": "其它任意业务自定义元数据",
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "height": {
+ "description": "图像/视频高度 (px)",
+ "type": "integer"
+ },
+ "original_mime": {
+ "description": "原始 MIME 类型",
+ "type": "string"
+ },
+ "user_agent": {
+ "description": "上传者的 UA",
+ "type": "string"
+ },
+ "width": {
+ "description": "图像/视频宽度 (px)",
+ "type": "integer"
+ }
+ }
+ },
+ "model.UploadStatus": {
+ "type": "string",
+ "enum": [
+ "pending",
+ "used",
+ "deleted"
+ ],
+ "x-enum-comments": {
+ "UploadStatusDeleted": "已删除",
+ "UploadStatusPending": "待使用",
+ "UploadStatusUsed": "已使用"
+ },
+ "x-enum-varnames": [
+ "UploadStatusPending",
+ "UploadStatusUsed",
+ "UploadStatusDeleted"
+ ]
+ },
"oauth.AuthSourceView": {
"type": "object",
"properties": {
@@ -2057,6 +2593,29 @@ const docTemplate = `{
}
}
},
+ "upload.batchDownloadRequest": {
+ "type": "object",
+ "required": [
+ "ids"
+ ],
+ "properties": {
+ "ids": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "user.createTokenRequest": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ }
+ }
+ },
"user.listUsersResponse": {
"type": "object",
"properties": {
@@ -2099,6 +2658,17 @@ const docTemplate = `{
}
}
},
+ "user.tokenResponse": {
+ "type": "object",
+ "properties": {
+ "record": {
+ "$ref": "#/definitions/model.AccessToken"
+ },
+ "token": {
+ "type": "string"
+ }
+ }
+ },
"user.updateUserStatusRequest": {
"type": "object",
"properties": {
diff --git a/docs/swagger.json b/docs/swagger.json
index 0efa579b..45c6bd2e 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -1485,6 +1485,393 @@
}
}
},
+ "/api/v1/upload": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "description": "支持各种类型的通用文件上传,支持自动文件类型检测、哈希计算与“秒传”去重",
+ "consumes": [
+ "multipart/form-data"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "upload"
+ ],
+ "summary": "上传文件",
+ "parameters": [
+ {
+ "type": "file",
+ "description": "要上传的文件",
+ "name": "file",
+ "in": "formData",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "业务分类 (例如: avatar, attachment, doc,默认为 generic)",
+ "name": "type",
+ "in": "formData"
+ },
+ {
+ "type": "string",
+ "description": "额外的 JSON 格式元数据",
+ "name": "metadata",
+ "in": "formData"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "上传成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/util.ResponseAny"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/model.Upload"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "400": {
+ "description": "请求参数错误或文件受限",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ },
+ "401": {
+ "description": "未登录",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ },
+ "500": {
+ "description": "内部错误",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/upload/download/batch": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "description": "传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/octet-stream"
+ ],
+ "tags": [
+ "upload"
+ ],
+ "summary": "批量打包下载",
+ "parameters": [
+ {
+ "description": "包含文件 ID 数组的请求体",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/upload.batchDownloadRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功下载打包后的 ZIP",
+ "schema": {
+ "type": "file"
+ }
+ },
+ "400": {
+ "description": "参数错误",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ },
+ "500": {
+ "description": "打包失败",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/upload/download/{id}": {
+ "get": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "description": "根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载",
+ "produces": [
+ "application/octet-stream"
+ ],
+ "tags": [
+ "upload"
+ ],
+ "summary": "下载单文件",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "文件 ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功下载文件",
+ "schema": {
+ "type": "file"
+ }
+ },
+ "400": {
+ "description": "参数错误",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ },
+ "404": {
+ "description": "文件不存在",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ },
+ "500": {
+ "description": "服务内部错误",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/user/access-tokens": {
+ "get": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "description": "返回当前登录用户的所有 active access tokens(脱敏后)",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "user"
+ ],
+ "summary": "获取当前用户的 AccessToken 列表",
+ "responses": {
+ "200": {
+ "description": "令牌列表",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/util.ResponseAny"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/model.AccessToken"
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "401": {
+ "description": "未登录",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "description": "为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "user"
+ ],
+ "summary": "创建一个新的 AccessToken",
+ "parameters": [
+ {
+ "description": "令牌名称",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/user.createTokenRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "新建令牌成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/util.ResponseAny"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/user.tokenResponse"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "400": {
+ "description": "参数错误或超限",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/user/access-tokens/{id}": {
+ "delete": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "description": "撤销并删除一个属于当前用户的 API 访问令牌",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "user"
+ ],
+ "summary": "删除一个 AccessToken",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "令牌ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "删除成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/util.ResponseAny"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "string"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "400": {
+ "description": "参数错误",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/user/access-tokens/{id}/rotate": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "description": "轮换(重新生成)一个属于当前用户的 API 访问令牌的密钥,旧令牌将立即失效",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "user"
+ ],
+ "summary": "轮换一个 AccessToken",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "令牌ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "令牌轮换成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/util.ResponseAny"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/user.tokenResponse"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "400": {
+ "description": "参数错误",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
"/api/v1/user/login": {
"post": {
"description": "使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。",
@@ -1733,6 +2120,32 @@
}
}
},
+ "model.AccessToken": {
+ "type": "object",
+ "properties": {
+ "created_at": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "last_used_at": {
+ "type": "string"
+ },
+ "masked_token": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "string"
+ },
+ "user_id": {
+ "type": "integer"
+ }
+ }
+ },
"model.AuthSource": {
"type": "object",
"properties": {
@@ -1843,6 +2256,129 @@
"TrustLevelLeader"
]
},
+ "model.Upload": {
+ "type": "object",
+ "properties": {
+ "created_at": {
+ "type": "string"
+ },
+ "extension": {
+ "description": "文件后缀名 (不含点,如 png, pdf)",
+ "type": "string"
+ },
+ "file_name": {
+ "description": "原始文件名 (例如: image.png)",
+ "type": "string"
+ },
+ "file_path": {
+ "description": "文件相对路径 / S3 Key",
+ "type": "string"
+ },
+ "file_size": {
+ "description": "文件大小(字节)",
+ "type": "integer"
+ },
+ "hash": {
+ "description": "文件哈希 (SHA-256/MD5,可用于排重)",
+ "type": "string"
+ },
+ "id": {
+ "type": "string",
+ "example": "0"
+ },
+ "metadata": {
+ "description": "业务扩展元数据",
+ "allOf": [
+ {
+ "$ref": "#/definitions/model.UploadMetadata"
+ }
+ ]
+ },
+ "mime_type": {
+ "description": "媒体类型 (MIME, 如 image/png)",
+ "type": "string"
+ },
+ "status": {
+ "description": "状态",
+ "allOf": [
+ {
+ "$ref": "#/definitions/model.UploadStatus"
+ }
+ ]
+ },
+ "storage_driver": {
+ "description": "存储引擎驱动 (如 local, s3, oss)",
+ "type": "string"
+ },
+ "type": {
+ "description": "业务标识类型 (如 avatar, doc, attachment)",
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "string"
+ },
+ "user_id": {
+ "type": "string",
+ "example": "0"
+ }
+ }
+ },
+ "model.UploadMetadata": {
+ "type": "object",
+ "properties": {
+ "bucket": {
+ "description": "存储桶名称 (适用于 S3 等)",
+ "type": "string"
+ },
+ "client_ip": {
+ "description": "上传者 IP",
+ "type": "string"
+ },
+ "duration": {
+ "description": "音视频时长 (s)",
+ "type": "number"
+ },
+ "extra": {
+ "description": "其它任意业务自定义元数据",
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "height": {
+ "description": "图像/视频高度 (px)",
+ "type": "integer"
+ },
+ "original_mime": {
+ "description": "原始 MIME 类型",
+ "type": "string"
+ },
+ "user_agent": {
+ "description": "上传者的 UA",
+ "type": "string"
+ },
+ "width": {
+ "description": "图像/视频宽度 (px)",
+ "type": "integer"
+ }
+ }
+ },
+ "model.UploadStatus": {
+ "type": "string",
+ "enum": [
+ "pending",
+ "used",
+ "deleted"
+ ],
+ "x-enum-comments": {
+ "UploadStatusDeleted": "已删除",
+ "UploadStatusPending": "待使用",
+ "UploadStatusUsed": "已使用"
+ },
+ "x-enum-varnames": [
+ "UploadStatusPending",
+ "UploadStatusUsed",
+ "UploadStatusDeleted"
+ ]
+ },
"oauth.AuthSourceView": {
"type": "object",
"properties": {
@@ -2050,6 +2586,29 @@
}
}
},
+ "upload.batchDownloadRequest": {
+ "type": "object",
+ "required": [
+ "ids"
+ ],
+ "properties": {
+ "ids": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "user.createTokenRequest": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ }
+ }
+ },
"user.listUsersResponse": {
"type": "object",
"properties": {
@@ -2092,6 +2651,17 @@
}
}
},
+ "user.tokenResponse": {
+ "type": "object",
+ "properties": {
+ "record": {
+ "$ref": "#/definitions/model.AccessToken"
+ },
+ "token": {
+ "type": "string"
+ }
+ }
+ },
"user.updateUserStatusRequest": {
"type": "object",
"properties": {
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index 308e9fe3..bec3f4b2 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -26,6 +26,23 @@ definitions:
is_active:
type: boolean
type: object
+ model.AccessToken:
+ properties:
+ created_at:
+ type: string
+ id:
+ type: integer
+ last_used_at:
+ type: string
+ masked_token:
+ type: string
+ name:
+ type: string
+ updated_at:
+ type: string
+ user_id:
+ type: integer
+ type: object
model.AuthSource:
properties:
client_id:
@@ -101,6 +118,93 @@ definitions:
- TrustLevelUser
- TrustLevelActiveUser
- TrustLevelLeader
+ model.Upload:
+ properties:
+ created_at:
+ type: string
+ extension:
+ description: 文件后缀名 (不含点,如 png, pdf)
+ type: string
+ file_name:
+ description: '原始文件名 (例如: image.png)'
+ type: string
+ file_path:
+ description: 文件相对路径 / S3 Key
+ type: string
+ file_size:
+ description: 文件大小(字节)
+ type: integer
+ hash:
+ description: 文件哈希 (SHA-256/MD5,可用于排重)
+ type: string
+ id:
+ example: "0"
+ type: string
+ metadata:
+ allOf:
+ - $ref: '#/definitions/model.UploadMetadata'
+ description: 业务扩展元数据
+ mime_type:
+ description: 媒体类型 (MIME, 如 image/png)
+ type: string
+ status:
+ allOf:
+ - $ref: '#/definitions/model.UploadStatus'
+ description: 状态
+ storage_driver:
+ description: 存储引擎驱动 (如 local, s3, oss)
+ type: string
+ type:
+ description: 业务标识类型 (如 avatar, doc, attachment)
+ type: string
+ updated_at:
+ type: string
+ user_id:
+ example: "0"
+ type: string
+ type: object
+ model.UploadMetadata:
+ properties:
+ bucket:
+ description: 存储桶名称 (适用于 S3 等)
+ type: string
+ client_ip:
+ description: 上传者 IP
+ type: string
+ duration:
+ description: 音视频时长 (s)
+ type: number
+ extra:
+ additionalProperties: {}
+ description: 其它任意业务自定义元数据
+ type: object
+ height:
+ description: 图像/视频高度 (px)
+ type: integer
+ original_mime:
+ description: 原始 MIME 类型
+ type: string
+ user_agent:
+ description: 上传者的 UA
+ type: string
+ width:
+ description: 图像/视频宽度 (px)
+ type: integer
+ type: object
+ model.UploadStatus:
+ enum:
+ - pending
+ - used
+ - deleted
+ type: string
+ x-enum-comments:
+ UploadStatusDeleted: 已删除
+ UploadStatusPending: 待使用
+ UploadStatusUsed: 已使用
+ x-enum-varnames:
+ - UploadStatusPending
+ - UploadStatusUsed
+ - UploadStatusDeleted
oauth.AuthSourceView:
properties:
client_secret_configured:
@@ -239,6 +343,21 @@ definitions:
type:
type: string
type: object
+ upload.batchDownloadRequest:
+ properties:
+ ids:
+ items:
+ type: string
+ minItems: 1
+ type: array
+ required:
+ - ids
+ type: object
+ user.createTokenRequest:
+ properties:
+ name:
+ type: string
+ type: object
user.listUsersResponse:
properties:
total:
@@ -266,6 +385,13 @@ definitions:
username:
type: string
type: object
+ user.tokenResponse:
+ properties:
+ record:
+ $ref: '#/definitions/model.AccessToken'
+ token:
+ type: string
+ type: object
user.updateUserStatusRequest:
properties:
is_active:
@@ -1204,6 +1330,237 @@ paths:
summary: 获取当前登录用户信息
tags:
- oauth
+ /api/v1/upload:
+ post:
+ consumes:
+ - multipart/form-data
+ description: 支持各种类型的通用文件上传,支持自动文件类型检测、哈希计算与“秒传”去重
+ parameters:
+ - description: 要上传的文件
+ in: formData
+ name: file
+ required: true
+ type: file
+ - description: '业务分类 (例如: avatar, attachment, doc,默认为 generic)'
+ in: formData
+ name: type
+ type: string
+ - description: 额外的 JSON 格式元数据
+ in: formData
+ name: metadata
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: 上传成功
+ schema:
+ allOf:
+ - $ref: '#/definitions/util.ResponseAny'
+ - properties:
+ data:
+ $ref: '#/definitions/model.Upload'
+ type: object
+ "400":
+ description: 请求参数错误或文件受限
+ schema:
+ $ref: '#/definitions/util.ResponseAny'
+ "401":
+ description: 未登录
+ schema:
+ $ref: '#/definitions/util.ResponseAny'
+ "500":
+ description: 内部错误
+ schema:
+ $ref: '#/definitions/util.ResponseAny'
+ security:
+ - SessionCookie: []
+ summary: 上传文件
+ tags:
+ - upload
+ /api/v1/upload/download/{id}:
+ get:
+ description: 根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载
+ parameters:
+ - description: 文件 ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/octet-stream
+ responses:
+ "200":
+ description: 成功下载文件
+ schema:
+ type: file
+ "400":
+ description: 参数错误
+ schema:
+ $ref: '#/definitions/util.ResponseAny'
+ "404":
+ description: 文件不存在
+ schema:
+ $ref: '#/definitions/util.ResponseAny'
+ "500":
+ description: 服务内部错误
+ schema:
+ $ref: '#/definitions/util.ResponseAny'
+ security:
+ - SessionCookie: []
+ summary: 下载单文件
+ tags:
+ - upload
+ /api/v1/upload/download/batch:
+ post:
+ consumes:
+ - application/json
+ description: 传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突
+ parameters:
+ - description: 包含文件 ID 数组的请求体
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/upload.batchDownloadRequest'
+ produces:
+ - application/octet-stream
+ responses:
+ "200":
+ description: 成功下载打包后的 ZIP
+ schema:
+ type: file
+ "400":
+ description: 参数错误
+ schema:
+ $ref: '#/definitions/util.ResponseAny'
+ "500":
+ description: 打包失败
+ schema:
+ $ref: '#/definitions/util.ResponseAny'
+ security:
+ - SessionCookie: []
+ summary: 批量打包下载
+ tags:
+ - upload
+ /api/v1/user/access-tokens:
+ get:
+ description: 返回当前登录用户的所有 active access tokens(脱敏后)
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: 令牌列表
+ schema:
+ allOf:
+ - $ref: '#/definitions/util.ResponseAny'
+ - properties:
+ data:
+ items:
+ $ref: '#/definitions/model.AccessToken'
+ type: array
+ type: object
+ "401":
+ description: 未登录
+ schema:
+ $ref: '#/definitions/util.ResponseAny'
+ security:
+ - SessionCookie: []
+ summary: 获取当前用户的 AccessToken 列表
+ tags:
+ - user
+ post:
+ consumes:
+ - application/json
+ description: 为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。
+ parameters:
+ - description: 令牌名称
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/user.createTokenRequest'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: 新建令牌成功
+ schema:
+ allOf:
+ - $ref: '#/definitions/util.ResponseAny'
+ - properties:
+ data:
+ $ref: '#/definitions/user.tokenResponse'
+ type: object
+ "400":
+ description: 参数错误或超限
+ schema:
+ $ref: '#/definitions/util.ResponseAny'
+ security:
+ - SessionCookie: []
+ summary: 创建一个新的 AccessToken
+ tags:
+ - user
+ /api/v1/user/access-tokens/{id}:
+ delete:
+ description: 撤销并删除一个属于当前用户的 API 访问令牌
+ parameters:
+ - description: 令牌ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: 删除成功
+ schema:
+ allOf:
+ - $ref: '#/definitions/util.ResponseAny'
+ - properties:
+ data:
+ type: string
+ type: object
+ "400":
+ description: 参数错误
+ schema:
+ $ref: '#/definitions/util.ResponseAny'
+ security:
+ - SessionCookie: []
+ summary: 删除一个 AccessToken
+ tags:
+ - user
+ /api/v1/user/access-tokens/{id}/rotate:
+ post:
+ description: 轮换(重新生成)一个属于当前用户的 API 访问令牌的密钥,旧令牌将立即失效
+ parameters:
+ - description: 令牌ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: 令牌轮换成功
+ schema:
+ allOf:
+ - $ref: '#/definitions/util.ResponseAny'
+ - properties:
+ data:
+ $ref: '#/definitions/user.tokenResponse'
+ type: object
+ "400":
+ description: 参数错误
+ schema:
+ $ref: '#/definitions/util.ResponseAny'
+ security:
+ - SessionCookie: []
+ summary: 轮换一个 AccessToken
+ tags:
+ - user
/api/v1/user/login:
post:
consumes:
diff --git a/frontend/app/(main)/settings/access-token/page.tsx b/frontend/app/(main)/settings/access-token/page.tsx
new file mode 100644
index 00000000..61a0f6d0
--- /dev/null
+++ b/frontend/app/(main)/settings/access-token/page.tsx
@@ -0,0 +1,7 @@
+"use client"
+
+import {AccessTokenMain} from "@/components/common/settings/access-token"
+
+export default function AccessTokenPage() {
+ return
+}
diff --git a/frontend/app/(main)/settings/files/page.tsx b/frontend/app/(main)/settings/files/page.tsx
new file mode 100644
index 00000000..a91770c4
--- /dev/null
+++ b/frontend/app/(main)/settings/files/page.tsx
@@ -0,0 +1,5 @@
+import {FilesMain} from "@/components/common/settings/files"
+
+export default function FilesPage() {
+ return
+}
diff --git a/frontend/app/(main)/settings/page.tsx b/frontend/app/(main)/settings/page.tsx
index ed581087..2e7353ce 100644
--- a/frontend/app/(main)/settings/page.tsx
+++ b/frontend/app/(main)/settings/page.tsx
@@ -2,7 +2,7 @@
import Link from "next/link"
import {Card, CardContent, CardDescription, CardTitle} from "@/components/ui/card"
-import {Bell, Loader2, Palette, Shield, UserRound} from "lucide-react"
+import {Bell, Key, Loader2, Palette, Shield, UserRound} from "lucide-react"
import {useAuth} from "@/components/providers/auth-provider"
/* 设置项 */
@@ -14,6 +14,13 @@ const settingsItems = [
href: "/settings/profile",
category: "个人设置",
},
+ {
+ title: "访问令牌 (AccessToken)",
+ description: "管理用于 API 访问的个人 Token",
+ icon: Key,
+ href: "/settings/access-token",
+ category: "个人设置",
+ },
{
title: "通知设置",
description: "设置您的通知偏好",
diff --git a/frontend/components/common/docs/api.tsx b/frontend/components/common/docs/api.tsx
index 79fb6051..95580e82 100644
--- a/frontend/components/common/docs/api.tsx
+++ b/frontend/components/common/docs/api.tsx
@@ -1,15 +1,15 @@
-import { type PolicySection } from "./types"
-import { CodeBlock } from "@/components/ui/code-block"
+import {type PolicySection} from "./types"
+import {CodeBlock} from "@/components/ui/code-block"
import {
DocsTable,
- DocsTableHeader,
DocsTableBody,
- DocsTableHead,
- DocsTableRow,
DocsTableCell,
+ DocsTableHead,
+ DocsTableHeader,
+ DocsTableRow,
} from "@/components/ui/docs-table"
-export const DOCS_LAST_UPDATED = "2026-04-20"
+export const DOCS_LAST_UPDATED = "2026-06-07"
/**
* ------------------------------------------------------------------
@@ -18,572 +18,251 @@ export const DOCS_LAST_UPDATED = "2026-04-20"
*/
export const apiSections: PolicySection[] = [
{
- value: "official-service",
- title: "1. 官方 LDC 接口",
+ value: "api-specs",
+ title: "1. 接口规范与鉴权说明",
content: (
-
官方原生接口,使用 Ed25519 签名算法,安全性更高
+
平台统一接口调用格式规范以及开发者访问令牌鉴权方式说明
-
1.1 概览
-
- 协议: 官方 LDC 支付协议
- 服务类型: 支持 type=ldcpay
- 网关基址: https://credit.linux.do/epay
- 签名方式: Ed25519 非对称加密
-
-
-
1.2 对接流程
-
- 控制台创建应用,配置 client_id 并在应用设置中上传商户 Ed25519 公钥
- 根据“签名算法”及商户私钥生成 sign
- 调用 /pay/submit 发起积分流转请求
- 认证完成后,通过异步回调或轮询接口同步状态
-
-
-
1.3 鉴权与签名
-
1.3.1 签名算法
-
-
- 取除 sign 以外的所有非空请求参数
- 将参数按参数名 ASCII 码从到大排序(字典序)
- 使用 k1=v1&k2=v2... 格式拼接成字符串
- 将 应用密钥 (Client Secret) 直接追加到字符串末尾
- 使用商户私钥对最终字符串进行 Ed25519 签名
- 将签名结果转换成 Base64 编码作为 sign 参数
-
-
-
-
-
1.4 积分流转服务
-
- 方法: POST /epay/pay/submit.php
- 编码: application/json 或 application/x-www-form-urlencoded
-
-
+
+
系统所有 API 接口均遵循标准 JSON 响应结构:
- 参数
- 必填
+ 字段
+ 类型
说明
- client_id
- 是
- 应用客户端 ID
+ error_msg
+ string
+ 错误信息。请求成功时为空字符串 `""`,失败时包含错误详情描述。
- type
- 是
- 固定 ldcpay
-
-
- out_trade_no
- 是
- 业务单号
-
-
- money
- 是
- 积分数量,必须保留两位小数(比如,10.00)
-
-
- order_name
- 是
- 商品名称
-
-
- notify_url
- 否
- 会参与签名;可选订单级异步通知地址。长度不超过 100,需为合法 URL。传入后支付成功优先回调该地址,未传则使用应用 notify_url
-
-
- return_url
- 否
- 会参与签名;可选订单级回跳地址。长度不超过 100,需为合法 URL。传入后支付成功页面优先跳转该地址,未传则使用应用 redirect_uri
-
-
- sign
- 是
- 按“签名算法”生成的 Base64 签名串
+ data
+ any
+ 接口返回的具体数据内容。请求失败或无数据返回时为 `null`。
-
1.5 其他接口
-
其他接口定义请参考 3. 其他接口 。
-
-
- ),
- children: [
- { value: "1-1-overview", title: "1.1 概览" },
- { value: "1-2-flow", title: "1.2 对接流程" },
- { value: "1-3-auth-sign", title: "1.3 鉴权与签名" },
- { value: "1-4-submit", title: "1.4 积分流转服务" },
- { value: "1-5-others", title: "1.5 其他接口" },
- ]
- },
- {
- value: "epay-compatibility",
- title: "2. 易支付兼容接口",
- content: (
-
-
-
兼容易支付、CodePay、VPay 等支付协议
-
-
-
2.1 概览
-
- 协议: EasyPay / CodePay / VPay 兼容协议
- 服务类型: 仅支持 type=epay
- 网关基址: https://credit.linux.do/epay
- 订单有效期: 取系统配置 order_expire_minutes(平台端设置)
-
-
-
2.2 常见错误
-
- 不支持的请求类型:type 仅允许 epay
- 签名验证失败:参与签名字段与请求体需一致,密钥直接拼接
- 金额必须大于0 / 积分小数位数不能超过2位
- 订单已过期:超出系统配置有效期
- 订单不存在或已完成:订单号错误、已退回或已完成
- 余额不足:余额退回时用户积分不足
-
-
-
2.3 对接流程
-
- 控制台创建 API Key,记录 pid、key,配置回调地址
- 按“签名算法”生成 sign,调用 /epay/pay/submit.php 创建积分流转服务并跳转认证界面
- 可通过 /epay/api.php 轮询结果,或等待异步回调
- 退回服务时,携带同一 trade_no 和原积分数量,调用积分退回接口
- 回调验签通过后返回 success 完成闭环
-
-
-
2.4 鉴权与签名
-
2.4.1 API Key
-
- pid:Client ID
- key:Client Secret(妥善保管)
- notify_url / return_url:应用级默认回调地址(兜底);创建订单时可在请求中传同名字段作为订单级覆盖,未传时回退应用配置。
-
-
-
2.4.2 签名算法
-
-
- 取所有非空字段(排除 sign、sign_type 字段)
- 将上述字段按 ASCII 升序,依次拼成 k1=v1&k2=v2
- 在末尾追加应用密钥:k1=v1&k2=v2{"{secret}"}
- 整体进行 MD5,取小写十六进制作为 sign
-
-
-
-
-
2.5 积分流转服务
-
- 方法: POST /epay/pay/submit.php
- 编码: application/json 或 application/x-www-form-urlencoded
- 成功: 验签通过后,平台自动创建积分流转服务,并跳转到认证界面(Location=https://credit.linux.do/paying?order_no=...)
- 失败: 返回 JSON {`{"error_msg":"...", "data":null}`}
-
-
-
-
-
- 参数
- 必填
- 说明
-
-
-
-
- pid
- 是
- Client ID
-
-
- type
- 是
- 固定 epay
-
-
- out_trade_no
- 否
- 业务单号,建议全局唯一
-
-
- name
- 是
- 标题,最多 64 字符
-
-
- money
- 是
- 积分数量,最多 2 位小数
-
-
- notify_url
- 否
- 会参与签名;可选订单级异步通知地址。长度不超过 100,需为合法 URL。传入后支付成功优先回调该地址,未传则使用应用 notify_url
-
-
- return_url
- 否
- 会参与签名;可选订单级回跳地址。长度不超过 100,需为合法 URL。传入后支付成功页面优先跳转该地址,未传则使用应用 redirect_uri
-
-
- device
- 否
- 终端标识,可选
-
-
- sign
- 是
- 按“签名算法”生成
-
-
- sign_type
- 否
- 固定 MD5
-
-
-
-
-
请求示例:
-
-
-
2.6 其他接口
-
其他接口定义请参考 3. 其他接口 。
-
-
- ),
- children: [
- { value: "2-1-overview", title: "2.1 概览" },
- { value: "2-2-common-errors", title: "2.2 常见错误" },
- { value: "2-3-flow", title: "2.3 对接流程" },
- { value: "2-4-auth-sign", title: "2.4 鉴权与签名" },
- { value: "2-5-submit", title: "2.5 积分流转服务" },
- { value: "2-6-others", title: "2.6 其他接口" },
- ]
- },
- {
- value: "common-services",
- title: "3. 其他接口",
- content: (
-
-
-
-
3.1 订单查询
-
- 方法: GET /epay/api.php
- 认证: pid + key
- 说明: out_trade_no 必填;act 可传 order,后端不强校验。
-
-
-
-
-
- 参数
- 必填
- 说明
-
-
-
-
- act
- 否
- 可选字段,建议 order
-
-
- pid
- 是
- Client ID
-
-
- key
- 是
- Client Secret
-
-
- out_trade_no
- 是
- 业务单号
-
-
-
-
-
成功响应:
-
-
补充: status 1=成功,0=失败/处理中;不存在会返回 HTTP 404 且 {`{"code":-1,"msg":"服务不存在或已完成"}`}。
-
-
3.2 订单退款
-
- 方法: POST /epay/api.php
- 编码: application/json 或 application/x-www-form-urlencoded
- 限制: 仅支持对已成功的积分流转服务进行积分的全额退回
-
-
-
-
-
-
- 参数
- 必填
- 说明
-
-
-
-
- pid
- 是
- Client ID
-
-
- key
- 是
- Client Secret
-
-
- trade_no
- 是
- 编号
-
-
- money
- 是
- 必须等于原积分流转服务的积分数量
-
-
- out_trade_no
- 否
- 业务单号(兼容)
-
-
-
-
-
-
响应:
-
-
常见失败: 服务不存在/未认证、金额不合法(<=0 或小数超过 2 位)。
-
-
-
3.3 异步通知
-
- 触发: 认证成功后;失败自动重试,最多 5 次(单次 30s 超时)
- 目标: 订单级 notify_url(如有)优先,否则回退到创建应用时设置的 notify_url
- 方式: HTTP GET
-
-
-
-
-
-
- 参数
- 说明
-
-
-
-
- pid
- Client ID
-
-
- trade_no
- 编号
-
-
- out_trade_no
- 业务单号
-
-
- type
- 固定 epay
-
-
- name
- 标题
-
-
- money
- 积分数量,最多 2 位小数
-
-
- trade_status
- 固定 TRADE_SUCCESS
-
-
- sign
- 按“签名算法”生成
-
-
-
-
-
应用需返回 HTTP 200 且响应体为 success(大小写不敏感),否则视为失败并继续重试。
-
-
3.4 商户分发接口
-
- 方法: POST /lpay/distribute
- 编码: application/json
- 认证: Basic Auth (使用 client_id:client_secret 进行 Base64 编码)
-
-
-
-
-
- 参数
- 必填
- 说明
-
-
-
-
- user_id
- 是
- 收款人用户 ID (数字)
-
-
- username
- 是
- 收款人用户名 (用于二次校验)
-
-
- amount
- 是
- 分发积分数量,最多 2 位小数
-
-
- out_trade_no
- 否
- 商户自定义单号
-
-
- remark
- 否
- 分发备注
-
-
-
-
成功响应: {`{"code":1, "data":{"trade_no":"...", "out_trade_no":"..."}}`}
-
-
3.5 用户余额统计
-
- 方法: GET /api/v1/dashboard/stats/user-balance
- 认证: 无需鉴权(公开接口)
- 说明: 获取平台所有用户可用余额的统计数据,结果有缓存,TTL 由系统配置决定
-
-
-
成功响应:
+
成功响应示例:
+
失败响应示例:
+
+
+
1.2 鉴权方式
+
除了公共公开接口(如登录、注册、配置)外,受保护的接口需要携带凭证才能正常访问:
+
+ Session 凭证: 浏览器环境下支持利用常规 Session Cookie 会话保持登录。
+ AccessToken 令牌: 供后台调用或第三方应用集成使用。客户端生成 API 访问令牌后,需要在请求头(Request Header)中携带以进行身份校验。
+
+
+
支持携带令牌的请求头格式(二选一):
+
+ Authorization: Bearer at_xxx
+ X-Access-Token: at_xxx
+
+
+
+ ),
+ children: [
+ { value: "1-1-response-format", title: "1.1 统一响应格式" },
+ { value: "1-2-authentication", title: "1.2 鉴权方式" },
+ ]
+ },
+ {
+ value: "auth-apis",
+ title: "2. 用户与认证接口",
+ content: (
+
+
2.1 用户注册
+
接口: POST /api/v1/user/register
+
说明: 注册本地账户(在后台注册开关开启状态下)。
- 字段
+ 参数
+ 必填
+ 类型
说明
- total_count
- 统计用户总数
+ username
+ 是
+ string
+ 用户名,必须唯一且无空格。
- total_amount
- 所有用户可用余额之和
+ password
+ 是
+ string
+ 密码,长度必须大于等于 8 位。
- avg_amount
- 平均余额
-
-
- median_amount
- 余额中位数
-
-
- min_amount
- 最小余额
-
-
- max_amount
- 最大余额
-
-
- std_dev
- 余额标准差
+ nickname
+ 否
+ string
+ 昵称。未传时默认与用户名一致。
+
+
2.2 密码登录
+
接口: POST /api/v1/user/login
+
说明: 通过常规用户名密码进行登录校验,成功后建立 Session Cookie 会话。
+
+
+
+ 参数
+ 必填
+ 类型
+ 说明
+
+
+
+
+ username
+ 是
+ string
+ 用户名
+
+
+ password
+ 是
+ string
+ 密码
+
+
+
+
+
2.3 退出登录
+
接口: GET /api/v1/user/logout
+
说明: 销毁当前会话 Cookie 并退出登录状态。
+
+
2.4 获取个人资料
+
接口: GET /api/v1/user/self
+
说明: 获取当前登录账户的基本数据模型(包含 ID、角色、昵称等)。
),
children: [
- { value: "3-1-order", title: "3.1 订单查询" },
- { value: "3-2-refund", title: "3.2 订单退款" },
- { value: "3-3-notify", title: "3.3 异步通知" },
- { value: "3-4-distribute", title: "3.4 商户分发接口" },
- { value: "3-5-user-balance", title: "3.5 用户余额统计" },
+ { value: "2-1-register", title: "2.1 用户注册" },
+ { value: "2-2-login", title: "2.2 密码登录" },
+ { value: "2-3-logout", title: "2.3 退出登录" },
+ { value: "2-4-profile", title: "2.4 获取个人资料" },
]
},
+ {
+ value: "token-apis",
+ title: "3. 个人访问令牌 (AccessToken) 接口",
+ content: (
+
+
+
AccessToken 管理相关接口均要求通过 Session 登录后调用,支持普通用户权限。
+
+
+
3.1 获取令牌列表
+
接口: GET /api/v1/user/access-tokens
+
说明: 查询当前用户已创建的所有令牌详情(令牌明文已被脱敏)。
+
+
3.2 新建访问令牌
+
接口: POST /api/v1/user/access-tokens
+
参数: JSON Body {`{"name": "token名称"}`}
+
说明: 生成一个全新访问令牌。返回体中包含一次性明文 Token,切勿遗失。
+
成功返回样例:
+
+
+
3.3 撤销/删除令牌
+
接口: DELETE /api/v1/user/access-tokens/:id
+
说明: 通过 ID 物理删除对应访问令牌,该令牌将立即失效。
+
+
3.4 轮换令牌密钥
+
接口: POST /api/v1/user/access-tokens/:id/rotate
+
说明: 轮换指定令牌的物理密钥值。系统将废弃原有密钥,返回新生成的明文 Token,并将 `last_used_at` 置空,令牌名称与 ID 保持一致。
+
+ ),
+ children: [
+ { value: "3-1-list-token", title: "3.1 获取令牌列表" },
+ { value: "3-2-create-token", title: "3.2 新建访问令牌" },
+ { value: "3-3-delete-token", title: "3.3 撤销/删除令牌" },
+ { value: "3-4-rotate-token", title: "3.4 轮换令牌密钥" },
+ ]
+ },
+ {
+ value: "config-apis",
+ title: "4. 公共配置与管理接口",
+ content: (
+
+
4.1 公共系统配置
+
接口: GET /api/v1/config/public
+
说明: 无感获取当前系统的公开业务设置(如注册是否开启、密码登录是否开启)。供前端页面动态渲染使用。
+
返回数据结构样例:
+
+
+
4.2 系统配置项 CRUD (管理员)
+
说明: 用于在后台对 `system_configs` 配置进行动态变更,要求管理员权限会话调用。
+
+ 获取配置列表: GET /api/v1/admin/system-configs?type=system
+ 新建配置项: POST /api/v1/admin/system-configs
+ 修改指定配置值: PUT /api/v1/admin/system-configs/:key
+ 删除配置项: DELETE /api/v1/admin/system-configs/:key
+
+
+ ),
+ children: [
+ { value: "4-1-public-config", title: "4.1 公共系统配置" },
+ { value: "4-2-admin-configs", title: "4.2 系统配置项 CRUD (管理员)" },
+ ]
+ }
]
diff --git a/frontend/components/common/docs/how-to-use.tsx b/frontend/components/common/docs/how-to-use.tsx
index a22419e6..5189d31c 100644
--- a/frontend/components/common/docs/how-to-use.tsx
+++ b/frontend/components/common/docs/how-to-use.tsx
@@ -1,13 +1,5 @@
-import { type PolicySection } from "./types"
-import { CodeBlock } from "@/components/ui/code-block"
-import {
- DocsTable,
- DocsTableHeader,
- DocsTableBody,
- DocsTableHead,
- DocsTableRow,
- DocsTableCell,
-} from "@/components/ui/docs-table"
+import {type PolicySection} from "./types"
+import {CodeBlock} from "@/components/ui/code-block"
/**
* ------------------------------------------------------------------
@@ -21,261 +13,96 @@ export const howToUseSections: PolicySection[] = [
content: (
-
为社区开发者与用户提供完整的平台使用说明
+
为开发者提供通用全栈开发脚手架 (Boilerplate) 平台使用说明
- 身份认证: 基于 LINUX DO Connect (OAuth)
- 认证方式: 账户积分消耗认证
- 手续费: 动态费率,由服务方承担
- 争议处理: 支持服务方与消费方的双方争议处理
+ 架构底座: Go (Gin + GORM + Redis + Asynq) 后端 + React (Next.js 16 + Tailwind CSS 4 + Shadcn UI) 前端
+ 认证体系: 支持本地常规账号密码注册登录 + 第三方自定义 OIDC (OAuth2) 认证源绑定
+ 访问令牌: 提供开发者个人 AccessToken (API Key),用于通过 Http Header 鉴权直接调用系统 API
+ 可观测性: 集成 Zap 结构化日志与 OpenTelemetry 全链路 Tracing 追踪
)
},
{
- value: "roles",
- title: "2. 角色说明",
+ value: "auth-security",
+ title: "2. 身份认证与安全设置",
content: (
- )
- },
- {
- value: "integration",
- title: "3. 接入积分服务",
- content: (
-
-
3.1 使用 API 接口
-
-
- 创建应用
-
- 前往 控制面板
- 点击顶部右侧 创建应用 按钮
- 填写必要信息:应用名称、应用主页、回调地址、通知地址
-
-
-
- 获取 API 凭证
-
- 在集市中心顶部右侧选择器中选择您的应用
- 在 API 配置 面板中获取:
-
- Client ID:客户端ID,用于标识您的身份
- Client Secret:客户端密钥,用于签名验证(请妥善保管,切勿泄露 )
-
-
-
-
-
- 使用 API 接口
-
-
+
+ 2.2 第三方 OIDC 认证源
+ 用户可以在个人资料页面关联绑定外部授权账户:
+
+ 进入 设置 / 个人资料 页面。
+ 在“第三方账号绑定”栏目下查看当前绑定的账号,或点击未绑定的可用 OIDC 认证源直接触发 OAuth2 绑定流。
+ 绑定成功后,用户在登录界面可直接点击 OIDC 登录按钮实现快捷跳转。
-
- 3.2 使用在线服务
-
- 适用场景: 无代码开发基础,或只用于简单的积分服务。
- 操作步骤:
-
- 前往 控制面板 创建应用,获取 API 凭证
- 选择应用,点击 在线收款 功能
- 创建在线积分服务
- 获取唯一积分服务链接
- 发送给您所服务的客户使用
-
-
-
-
- 3.3 快速集成 New API
-
- 适用场景: New API 站点,LINUX DO Credit 兼容 EasyPay 协议,公益站站长可直接集成。
- 操作步骤:
-
-
- 前往 控制面板 ,点击 创建应用 ,填写 New API 站点信息:
-
-
-
-
- 字段
- 值
-
-
-
-
- 应用名称
- 您的应用名称
-
-
- 应用主页
- https://{"{您的 New API 域名}"}
-
-
- 回调地址
- https://{"{您的 New API 域名}"}/console/log
-
-
- 通知地址
- https://{"{您的 New API 域名}"}/api/user/epay/notify
-
-
-
-
-
- 前往 New API 站点的系统设置,找到 支付设置 。
-
- 配置 LINUX DO Credit 平台参数:
-
-
-
-
- 参数
- 值
-
-
-
-
- 支付地址
- https://credit.linux.do/epay/pay
-
-
- 易支付商户ID
- 您的 Client ID
-
-
- 易支付商户密钥
- 您的 Client Secret
-
-
- 回调地址
- https://{"{您的 New API 域名}"}
-
-
-
-
-
-
- 配置充值方式(JSON 格式):
-
-
-
-
-
-
-
),
children: [
- { value: "3-1-api", title: "3.1 使用 API 接口" },
- { value: "3-2-online", title: "3.2 使用在线服务" },
- { value: "3-3-new-api", title: "3.3 快速集成 New API" },
+ { value: "2-1-login", title: "2.1 常规账号密码认证" },
+ { value: "2-2-oidc", title: "2.2 第三方 OIDC 认证源" },
]
},
{
- value: "usage",
- title: "4. 使用 LINUX DO Credit 积分",
+ value: "access-token",
+ title: "3. 个人访问令牌 (AccessToken) 接口对接",
content: (
-
您可以在任意支持 LINUX DO Credit 的平台下使用积分。在其他平台点击使用 LINUX DO Credit 积分时,会自动跳转到 LINUX DO Credit 的积分流转服务页面,您只需要确认积分流转信息无误,并选择使用 LINUX DO Credit 进行账户认证,即可完成整个交易服务。
-
- )
- },
- {
- value: "fees",
- title: "5. 服务(手续)费",
- content: (
-
-
5.1 规则说明
-
为了更好的维持 LINUX DO Credit 平台的积分服务机制,保证社区积分的生态可持续发展,我们会按照规范进行不同程度的服务(手续)费用收取。
+
为便于开发者或第三方工具直接调用系统 API,平台提供个人访问令牌管理功能。
+
3.1 令牌生成与存储规范
- 承担方: 服务(手续)费默认由服务方承担
- 消费方使用: 不会产生额外费用
- 服务方实收: 订单金额 - 服务(手续)费
+ 一次性明文展示: 创建令牌时生成的随机明文 Token 值 (形如 at_xxx) 仅会在弹窗中展示一次。请立即复制保存,关闭弹窗后系统将无法重新获取。
+ 安全哈希存储: 数据库仅存储 Token 的 SHA-256 哈希指纹,即使数据库泄漏,攻击者也无法通过摘要逆向恢复令牌原文。
-
-
计算公式:
+
+
3.2 携带 Header 进行 API 调用
+
您可以凭借保存的明文 Token 随时调用系统开放接口,系统认证支持以下两种 Http 请求头携带方式之一:
+
+
方式一:Authorization Bearer 头
+
+
+
方式二:X-Access-Token 自定义头
+
-
-
5.2 动态费率
-
费率并非固定不变,会根据以下因素动态调整:
-
- 服务方平台等级
- 服务方平台积分
- LINUX DO Credit 平台活动
-
),
children: [
- { value: "5-1-rules", title: "5.1 规则说明" },
- { value: "5-2-dynamic", title: "5.2 动态费率" },
+ { value: "3-1-generation", title: "3.1 令牌生成与存储规范" },
+ { value: "3-2-usage", title: "3.2 携带 Header 进行 API 调用" },
]
},
{
- value: "dispute",
- title: "6. 争议处理",
+ value: "config-system",
+ title: "4. 动态系统配置项",
content: (
-
为了保障服务方与消费方的合法权益,当积分服务出现纠纷时,可使用争议功能。
+
平台内置了完备的 KV 配置管理模块,允许管理员动态调整系统运行状态:
- 作为服务方,您需要及时响应消费方的争议请求:
-
- 在集市中心或通知中查看到 待处理的争议
- 查看消费方理由,选择操作:
-
- 同意: 认可消费方诉求,积分原路退回给消费方
- 拒绝: 如果您认为已履约,请提交相关证据
-
-
-
-
-
-
-
- 重要: 建议服务方与消费方优先沟通解决。长时间未处理的争议会由 LINUX DO Credit 平台介入仲裁,这可能会影响您的服务方信誉。
-
-
-
- )
- },
- {
- value: "community-balance",
- title: "7. 社区积分",
- content: (
-
-
您的 LINUX DO Credit 平台基础积分主要由 社区积分 (Community Balance) 划转而来。
-
- 基本获取方式: 通过在 LINUX DO 社区的活跃行为获得:
-
-
- 划转规则:
-
- 划转时间:社区积分每日凌晨 00:00 自动划转至可用余额
- 限制说明:划转前不可用于任何积分服务
- 服务费用:目前不收取任何划转 服务费
+ 缓存读取加速: 配置加载基于 GORM 读取数据库,并辅以 Redis Hash 结构进行多层缓存加速,大幅降低配置查询耗时。
+ 核心系统配置项说明:
+
+ site_name:平台展示名称
+ password_login_enabled:密码登录开关
+ registration_enabled:用户自主注册开关
+ max_api_keys_per_user:普通用户创建令牌的最大数限制 (默认5)
@@ -283,46 +110,28 @@ export const howToUseSections: PolicySection[] = [
)
},
{
- value: "settings",
- title: "8. 账户设置",
+ value: "worker-scheduler",
+ title: "5. 异步任务与定时调度",
content: (
-
您可以在 设置 (Settings) 页面管理您的账户信息。
-
功能列表
-
)
},
{
- value: "scripts",
- title: "9. 辅助脚本",
+ value: "tracing-metrics",
+ title: "6. 链路追踪与结构化日志",
content: (
-
为了方便用户随时查看当前的实时积分收入,我们提供了开源的 Userscript 脚本。
+
为了保障分布式微服务架构下的可观测性,平台接入了高级监控组件:
- 功能: 在 LINUX DO 显示实时积分收入,支持拖拽,不影响界面。
- 获取: 仅需安装 Tampermonkey 插件即可使用。
- 安装:
-
- 「LINUX DO Credit」实时积分收入脚本
-
-
-
+ OpenTelemetry Tracing: 自动传递 Tracing 上下文,所有经由 Gin 中间件、外部请求或 GORM 数据库的事务操作都将带有全局唯一的 Span,用于排查链路耗时或调用异常。
+ Zap 结构化日志: 将后端控制台或日志输出格式统一规范化为 JSON,方便与 ELK、Loki 等日志收集分析工具无缝对接。
-
-
- 注:脚本完全开源且安全,仅通过官方 API 获取公开数据,不涉及任何敏感权限。
-
-
)
}
diff --git a/frontend/components/common/docs/privacy.tsx b/frontend/components/common/docs/privacy.tsx
index 3c98e992..7f3198a3 100644
--- a/frontend/components/common/docs/privacy.tsx
+++ b/frontend/components/common/docs/privacy.tsx
@@ -1,4 +1,4 @@
-import { type PolicySection } from "./types"
+import {type PolicySection} from "./types"
/**
* ------------------------------------------------------------------
@@ -15,15 +15,11 @@ export const privacySections: PolicySection[] = [
1.1 身份鉴权信息:
-
当您通过 LINUX DO Connect 登录时,我们会获取您的社区 OpenID(唯一标识符)、加密后的用户名及头像 URL。我们不收集您的手机号、真实姓名或身份证件信息。
+
当您通过本地账号注册或绑定第三方 OIDC 认证源登录时,我们会收集您的用户名、关联的邮箱、加密后的密码哈希指纹及关联的第三方 OpenID 标识符。我们不强制要求绑定手机号、身份证件或任何真实社会信用实体信息。
-
1.2 服务日志信息:
-
为保障系统运行安全及满足法律合规要求,我们会自动收集您的操作日志,包括 IP 地址、访问日期和时间、API 调用记录、User-Agent(浏览器/设备类型)。
-
-
-
1.3 交易与资产信息:
-
若您使用支付功能,我们将记录您的商户订单号、交易金额、交易时间、交易状态摘要。这些信息是账务核对的必要依据。
+
1.2 服务与接口日志信息:
+
为保障系统运行安全及满足安全审计要求,我们会自动收集您的操作日志,包括 IP 地址、访问日期和时间、个人访问令牌 API 调用历史记录、User-Agent(浏览器/设备/请求工具类型)。
@@ -36,10 +32,9 @@ export const privacySections: PolicySection[] = [
我们深知数据安全的重要性,并采取业界领先的技术措施保护您的数据:
- 存储地点: 依照法规要求,我们收集和产生的用户个人信息,存储在独立 的服务器上。我们不会将您的数据传输至境外管辖区。
- 加密技术: 敏感数据(如 支付密码)在数据库中均采用高强度加密算法存储。数据传输全链路采用 SSL/TLS 1.3 协议进行加密,防止网络嗅探。
- 隔离机制: 本平台数据与外部网络物理隔离,且独立于 LINUX DO 社区论坛主数据库,确保单一系统故障不会波及全局数据安全。
- 访问控制: 我们实行严格的最小权限原则(Least Privilege),仅有核心运维人员经授权后方可访问必要的维护数据,且所有操作均有审计日志留存。
+ 存储安全: 用户数据独立存储于专用的云数据库或本地容器化持久层中,仅供授权系统应用挂载读取。
+ 加密技术: 敏感的密码指纹和个人访问令牌哈希在数据库中均采用高强度算法加密存储。API 数据传输链路强制使用 SSL/TLS 进行安全加密。
+ 访问控制: 我们实行严格的最小权限原则(Least Privilege),平台数据不会对外共享,所有系统级内部运维操作均有审计日志可查。
),
@@ -51,12 +46,11 @@ export const privacySections: PolicySection[] = [
我们收集的信息将仅用于以下目的:
- 身份识别: 用于确认您的社区身份,展示您的个人中心数据。
- 业务功能: 处理您的支付指令、API 请求、回调通知及账单生成。
- 安全风控: 利用 IP 及行为日志进行反作弊、反欺诈分析,识别恶意攻击行为,保护平台及其他用户的安全。
- 客户支持: 在您发起工单或申诉时,查询相关日志以协助您解决问题。
+ 身份识别: 用于确认您的注册身份,展示您的个人中心及设置页面数据。
+ 业务功能: 用于识别并处理您的个人访问令牌 API 鉴权指令。
+ 安全风控: 利用 IP 及行为日志进行接口反作弊、反暴力破解分析,保障后台服务稳定性。
-
禁止用途: 我们承诺绝不 利用您的数据进行用户画像分析、个性化广告推送或商业营销。
+
禁止用途: 我们承诺绝不 将您的数据出售给第三方,亦不会向任何机构提供任何用户画像分析或广告推送服务。
),
},
@@ -65,12 +59,12 @@ export const privacySections: PolicySection[] = [
title: "4. 信息共享与对外披露",
content: (
-
4.1 共享原则: 我们坚持数据零共享 策略。除以下极端情况外,我们不会向任何第三方(包括且不限于关联公司、支付宝、微信、银行、广告商)共享您的个人信息:
+
4.1 共享原则: 除以下极端情况外,我们不会向任何第三方(包括且不限于关联公司、商业合作伙伴)共享您的个人信息:
事先获得您的明确授权或同意;
根据适用的法律法规、法律程序的要求、强制性的行政或司法要求所必须的情况下进行提供。
-
4.2 转让与公开披露: 我们不会将您的个人信息转让给任何公司、组织和个人。我们仅在法律法规强制要求,或为了保护平台及用户与公众的人身财产安全免受侵害时,才会公开披露您的信息。
+
4.2 转让与公开披露: 我们不会将您的个人信息转让给任何公司、组织和个人,亦不进行任何公开商业披露。
),
},
@@ -82,16 +76,12 @@ export const privacySections: PolicySection[] = [
依照《中华人民共和国个人信息保护法》,您对您的个人信息享有完整的控制权:
-
5.1 查阅与复制权:
-
您可以随时登录开发者后台,查阅您的概览信息、API Key 状态及历史交易账单。您可以通过“导出账单”功能获取您的数据副本。
+
5.1 查阅与管理权:
+
您可以随时登录本平台,查阅您的基础个人信息,生成、轮换或撤销您的 AccessToken (API 密钥)。
-
5.2 删除与遗忘权:
-
若您决定停止使用本服务,在结清所有应付费用及余额后,您可以申请注销账户 。注销后,我们将立即删除您的所有敏感信息或进行匿名化处理,法律法规规定需保留的日志除外。
-
-
-
5.3 纠正与更正权:
-
若您发现您的信息有误,您有权要求我们更正或补充。您可以在设置页面直接修改您的信息,或通过客服提交工单。
+
5.2 删除与注销权:
+
若您决定停止使用本服务,您可以申请注销账户。注销后,我们将立即从活跃存储媒介中删除您的所有敏感信息,法律法规要求留存的安全日志除外。
@@ -102,8 +92,8 @@ export const privacySections: PolicySection[] = [
title: "6. 政策更新与通知",
content: (
-
随着业务的发展或法律法规的变动,我们可能会适时修订本《隐私政策》。
-
当条款发生重大变更时(例如收集范围扩大、使用目的改变),我们会通过站内信、公告或弹窗等显著方式通知您。若您在政策更新后继续使用本服务,即表示您同意接受更新后的隐私政策约束。
+
随着业务的发展或法律法规的变动,本《隐私政策》条款可能发生变更。
+
当条款发生重大变更时,我们会以显著方式(如平台公告、站内弹窗等)予以通知。如果您继续使用本服务,即表示您同意接受修订后的政策约束。
),
},
diff --git a/frontend/components/common/docs/terms.tsx b/frontend/components/common/docs/terms.tsx
index 87cd4e5f..b57fc3f6 100644
--- a/frontend/components/common/docs/terms.tsx
+++ b/frontend/components/common/docs/terms.tsx
@@ -1,6 +1,6 @@
-import { type PolicySection } from "./types"
+import {type PolicySection} from "./types"
-export const TERMS_LAST_UPDATED = "2025-12-22"
+export const TERMS_LAST_UPDATED = "2026-06-07"
/**
* ------------------------------------------------------------------
@@ -13,8 +13,8 @@ export const termsSections: PolicySection[] = [
title: "1. 缔约申明与服务综述",
content: (
-
1.1 缔约主体: 本《服务协议》(以下简称“本协议”)是您(以下亦称“社区用户”、“开发者”、“消费方”或“服务方”)与 LINUX DO Credit 平台运营团队(以下简称“平台”、“我们”)之间关于使用平台服务所订立的具有法律约束力的契约。
-
1.2 审慎阅读: 请您务必审慎阅读、充分理解各条款内容,特别是免除或者限制责任的条款、争议解决和法律适用条款 。各免责或限责条款将以粗体标识,您应重点阅读。如您不同意本协议的任何内容,请立即停止注册或使用本服务。
+
1.1 缔约主体: 本《服务协议》(以下简称“本协议”)是您(以下称“用户”或“开发者”)与本通用开发脚手架平台(以下简称“本系统”或“平台”)运营维护方之间关于使用本系统所订立的契约。
+
1.2 审慎阅读: 本系统作为一个通用的、面向二次开发的全栈软件底座,旨在为用户提供基础的注册、会话、OIDC 接入、API Key 令牌鉴权及后台管理服务。若您使用本系统,请务必仔细阅读本协议各条款。
1.3 协议构成: 本协议内容包括协议正文及所有我们已经发布或将来可能发布的各类规则、声明、说明。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。
),
@@ -24,16 +24,13 @@ export const termsSections: PolicySection[] = [
title: "2. 服务定义与性质界定",
content: (
-
2.1 社区技术服务: LINUX DO Credit 是基于 LINUX DO 社区生态构建的独立价值交换协议与技术系统。我们仅提供 API 接口调用、数据路由、账单管理等纯技术服务 。
-
2.2 非金融机构申明:
+
2.1 纯技术研发脚手架: 本平台是一个开源/闭源授权的技术二次开发底座。我们仅提供用户注册、API 调用、安全配置维护等纯软件技术服务 。
+
2.2 非金融机构与无承兑申明:
- 非银行机构: 我们不是商业银行、持牌支付机构(如支付宝、微信支付、银联)或清算机构。
- 不提供资金沉淀: 平台不设立资金池,不提供真实法币存取款、转账汇款或支付结算服务。所有涉及资金流转的行为均发生于社区用户与社区支付渠道之间,不涉及真实货币。
- 不提供金融服务: 平台不提供任何金融服务,包括但不限于贷款、融资、投资、理财、保险等金融服务。
- 不提供积分兑现: 平台不提供任何积分兑换服务,包括但不限于积分兑换为真实货币、积分兑换为实物商品、积分兑换为服务等。
- 不提供真实货币交易: 平台不提供任何真实货币交易服务,包括但不限于真实货币交易为积分、真实货币交易为虚拟资产、真实货币交易为服务等。
+ 非持牌金融或支付机构: 本系统不是银行、商户收单或清算结算机构。
+ 不提供资金管理: 系统无真实法币、加密货币或商业代金券充值、存储与兑现功能。若在二开中加入了积分等属性,其最终性质也应限制于虚拟软件积分。
+ 责任自担: 关于用户对本系统进行二次开发并应用于其他生产环境产生的任何业务行为,由二开部署运营主体承担全部合规责任。
-
2.3 服务限定: 本平台建议用于仅支持虚拟商品、软件授权、技术咨询、会员订阅等无实物交付 的场景。关于实物电商、物流发货或涉及线下履约的商业场景造成的任何后果我们概不负责,请妥善保管好自己的个人财产,谨防上当受骗。
),
},
@@ -42,13 +39,12 @@ export const termsSections: PolicySection[] = [
title: "3. 账号注册与使用规范",
content: (
-
3.1 账号体系: 本平台采用 LINUX DO Connect (OAuth) 授权登录体系。您必须拥有合法、有效的 LINUX DO 社区账号方可使用本服务。您的平台账号权益(包括但不限于信誉分、等级)与社区账号严格绑定。
-
3.2 匿名性与真实性:
+
3.1 账号体系: 用户可通过本系统的前端注册表单自助创建账户,或通过系统管理员配置并启用的自定义第三方 OIDC 认证源进行登录关联。
+
3.2 密码及令牌安全责任:
- 无需实名: 我们尊重您的隐私,不强制要求您提供居民身份证、护照或营业执照进行实名认证。
- 操作真实性: 您承诺注册和使用的账号是您本人操作。严禁恶意注册、挂机脚本、自动化程序注册等破坏平台公平性的行为。
+ 密码安全: 您应妥善保管您账户的登录密码。
+ API Token 安全: 个人生成的 AccessToken (API Key) 代表您账户的完整调用权限。因您保管不善导致 Token 泄漏而造成的一切数据丢失或系统损失,均由您自行承担。
-
3.3 账号安全责任: 您应妥善保管您账号的支付密码、Client ID 和 Client Secret。因您保管不善可能导致账号被他人非法使用、资金损失或数据泄露的责任,由您自行承担。 如发现账号异常,请立即通知我们进行冻结。
),
},
@@ -57,58 +53,43 @@ export const termsSections: PolicySection[] = [
title: "4. 用户行为准则(负面清单)",
content: (
-
您在使用本服务时,必须严格遵守《中华人民共和国网络安全法》、《计算机信息网络国际联网安全保护管理办法》等法律法规。严禁利用本平台从事以下活动(“红线条款”):
+
您在使用本系统时,必须严格遵守《中华人民共和国网络安全法》、《计算机信息网络国际联网安全保护管理办法》等法律法规。严禁利用本平台从事以下活动(“红线条款”):
危害国家安全: 反对宪法所确定的基本原则、危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的;
非法信息服务: 黑客攻击工具、DDoS 攻击服务、服务器爆破等其他非法信息服务平台;
黄赌毒关联: 制作、复制、发布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;
侵犯知识产权: 销售盗版软件、盗版影视资源、非法游戏外挂、私服、黑号、社工库数据等;
- 欺诈与虚假: 进行电信诈骗、金融诈骗、传销、虚假广告虚假交易等;
其他违法信息: 涉及散布谣言、宣扬邪教/封建迷信、侮辱/诽谤他人、侵害他人合法权益的。
-
违约处理: 一旦发现您违反上述规定,平台有权不经通知立即永久封禁您的账号、拦截所有 API 请求、冻结账户内所有关联价值,并依法向公安机关、网安部门移交相关线索。
-
- ),
- },
- {
- value: "virtual-assets",
- title: "5. 虚拟资产与交易规则",
- content: (
-
-
5.1 资产性质: 平台内流转的“余额”、“积分”等均为社区虚拟积分 ,仅代表您在社区生态内的活跃度或贡献值。它们不具有任何货币属性 ,严禁兑换为法定货币,也不可用于任何非平台许可的商业交易。
-
5.2 交易不可逆: 鉴于网络技术的实时性,一旦积分消耗或划转指令被执行,该操作即不可撤销。 请您在确认相关积分活动前,务必仔细核对服务方信息。
-
5.3 规则说明: 为营造良好的社区积分环境,平台有权收取一定的服务费(以积分为结算单位)进行调控,具体规则以控制台公示为准。平台保留根据社区运营状况调整积分规则的权利。
+
处理规则: 一旦发现您违反上述规定,平台运维主体有权不经通知立即永久封禁您的账号、拦截所有 AccessToken 请求,并依法向公安机关、网安部门移交相关线索。
),
},
{
value: "liability-limitation",
- title: "6. 免责声明与不可抗力",
+ title: "5. 免责声明与不可抗力",
content: (
-
6.1 基础免责: 本平台服务按“现状”(As-Is)及“现有”(As-Available)状态提供。我们不保证服务一定能满足您的要求,也不保证服务不会中断,对服务的及时性、安全性、准确性都不作担保。
-
6.2 不可抗力: 对于因以下原因导致的服务中断、数据丢失或账号损失,平台不承担赔偿责任:
+
5.1 基础免责: 本开发底座按“现状”(As-Is)及“现有”(As-Available)状态提供。我们不保证服务一定能满足您的特定开发需求,对服务的及时性、安全性、准确性都不作额外担保。
+
5.2 技术服务中断: 对于因以下不可抗力原因导致的服务中断、数据丢失或账号损坏,平台不承担赔偿责任:
自然灾害(台风、地震、海啸、洪水等);
- 政府行为、法律法规或政策调整、行政命令;
- 电信部门技术调整、通讯线路中断、海底光缆故障;
- 黑客攻击、计算机病毒侵入或发作、技术性故障;
- 社区维护、系统升级(我们将尽可能提前公告)。
+ 政府行为、网络安全法律法规调整或行政命令;
+ 电信部门线路技术故障、机房海底光缆损毁;
+ 黑客入侵、勒索病毒感染导致的数据损毁或宕机。
-
6.3 责任上限: 在任何情况下,平台对您所承担的违约赔偿责任总额不超过您在违约行为发生前 1 个月内向平台支付的费用总额。
),
},
{
value: "governing-law",
- title: "7. 法律适用与争议解决",
+ title: "6. 法律适用与争议解决",
content: (
-
7.1 法律适用: 本协议的订立、执行、解释及争议的解决均适用中华人民共和国法律 (不包括港澳台地区法律及冲突法)。
-
7.2 争议解决: 若您和平台发生任何争议或纠纷,首先应友好协商解决;协商不成的,您同意将纠纷或争议提交至平台运营团队所在地有管辖权的人民法院 管辖。
-
7.3 协议变更: 我们有权根据法律法规变化或业务发展需要修改本协议。变更后的协议将在平台公示,自公示之日起生效。若您继续使用服务,视为您已接受修订后的协议。
+
6.1 法律适用: 本协议的订立、执行、解释及争议的解决均适用中华人民共和国法律 (不包括港澳台地区冲突法)。
+
6.2 争议解决: 若发生任何争议或纠纷,首先应友好协商解决;协商不成的,应提交至本系统部署或运营方所在地有管辖权的人民法院管辖。
),
},
diff --git a/frontend/components/common/settings/access-token.tsx b/frontend/components/common/settings/access-token.tsx
new file mode 100644
index 00000000..18638263
--- /dev/null
+++ b/frontend/components/common/settings/access-token.tsx
@@ -0,0 +1,398 @@
+"use client"
+
+import * as React from "react"
+import Link from "next/link"
+import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
+import {motion} from "motion/react"
+import {AlertTriangle, Check, Copy, Info, Key, Loader2, Plus, RefreshCw, Trash2} from "lucide-react"
+
+import {Button} from "@/components/ui/button"
+import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
+import {Input} from "@/components/ui/input"
+import {Label} from "@/components/ui/label"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb"
+import {UserService} from "@/lib/services"
+import type {CreateTokenResponse} from "@/lib/services/user"
+import {toast} from "sonner"
+
+export function AccessTokenMain() {
+ const queryClient = useQueryClient()
+ const [createDialogOpen, setCreateDialogOpen] = React.useState(false)
+ const [viewDialogOpen, setViewDialogOpen] = React.useState(false)
+ const [tokenName, setTokenName] = React.useState("")
+ const [copiedId, setCopiedId] = React.useState(null)
+ const [newCreatedToken, setNewCreatedToken] = React.useState(null)
+
+ // 获取 Token 列表
+ const accessTokensQuery = useQuery({
+ queryKey: ["user", "access-tokens"],
+ queryFn: () => UserService.getAccessTokens(),
+ })
+
+ // 创建 Token
+ const createTokenMutation = useMutation({
+ mutationFn: (name: string) => UserService.createAccessToken(name),
+ onSuccess: (data) => {
+ setNewCreatedToken(data)
+ setTokenName("")
+ setCreateDialogOpen(false)
+ setViewDialogOpen(true)
+ void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
+ toast.success("访问令牌创建成功")
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "创建访问令牌失败")
+ },
+ })
+
+ // 删除 Token
+ const deleteTokenMutation = useMutation({
+ mutationFn: (id: number) => UserService.deleteAccessToken(id),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
+ toast.success("访问令牌已撤销")
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "删除访问令牌失败")
+ },
+ })
+
+ // 轮换 Token
+ const rotateTokenMutation = useMutation({
+ mutationFn: (id: number) => UserService.rotateAccessToken(id),
+ onSuccess: (data) => {
+ setNewCreatedToken(data)
+ setViewDialogOpen(true)
+ void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
+ toast.success("访问令牌轮换成功,旧密钥已失效")
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "轮换访问令牌失败")
+ },
+ })
+
+ const handleCreateToken = (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!tokenName.trim()) {
+ toast.error("请输入令牌名称")
+ return
+ }
+ createTokenMutation.mutate(tokenName.trim())
+ }
+
+ const handleDeleteToken = (id: number, name: string) => {
+ if (window.confirm(`确定要删除并撤销令牌「${name}」吗?删除后此令牌将立即失效且不可恢复。`)) {
+ deleteTokenMutation.mutate(id)
+ }
+ }
+
+ const handleRotateToken = (id: number, name: string) => {
+ if (window.confirm(`确定要轮换令牌「${name}」的密钥吗?轮换后系统将生成全新密钥,原令牌密钥将立即失效。`)) {
+ rotateTokenMutation.mutate(id)
+ }
+ }
+
+ const handleCopyText = async (text: string, id: number) => {
+ try {
+ await navigator.clipboard.writeText(text)
+ setCopiedId(id)
+ toast.success("复制成功")
+ setTimeout(() => setCopiedId(null), 2000)
+ } catch {
+ toast.error("复制失败")
+ }
+ }
+
+ const formatDate = (dateStr?: string) => {
+ if (!dateStr) return "未使用"
+ return new Date(dateStr).toLocaleString("zh-CN", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+ }
+
+ return (
+
+
+
+
+
+
+ 设置
+
+
+
+
+ 访问令牌
+
+
+
+
+
+
+
+
+
+
+
+
个人访问令牌 (AccessToken)
+
管理您的 API 访问密钥,用于开发或第三方工具直接调用系统 API
+
+
+
setCreateDialogOpen(true)}
+ className="bg-indigo-600 hover:bg-indigo-700 text-white shadow-md shadow-indigo-600/10 transition-colors shrink-0"
+ >
+
+ 生成新令牌
+
+
+
+ {/* 安全警告提示 */}
+
+
+
+
安全提示:
+
+ 访问令牌具有您账户的完整接口调用权限。为了您的账户与资产安全,请切勿通过任何代码库提交、即时通讯工具或公共媒介泄露此令牌。推荐按需创建,不使用时及时撤销。
+
+
+
+
+
+
+ 活动令牌列表
+ 当前可用的所有访问令牌
+
+
+ {accessTokensQuery.isPending ? (
+
+
+
+ ) : (accessTokensQuery.data ?? []).length > 0 ? (
+
+ {(accessTokensQuery.data ?? []).map((token) => (
+
+
+
+ {token.name}
+
+
+
+ {token.masked_token}
+
+
+ 创建于: {formatDate(token.created_at)}
+ 最后使用: {formatDate(token.last_used_at)}
+
+
+
+
+ handleCopyText(token.masked_token, token.id)}
+ >
+ {copiedId === token.id ? (
+
+ ) : (
+
+ )}
+ 复制
+
+ handleRotateToken(token.id, token.name)}
+ disabled={rotateTokenMutation.isPending}
+ >
+
+ 轮换
+
+ handleDeleteToken(token.id, token.name)}
+ disabled={deleteTokenMutation.isPending}
+ >
+
+ 撤销
+
+
+
+ ))}
+
+ ) : (
+
+
+
您当前暂无生成任何访问令牌
+
setCreateDialogOpen(true)}
+ >
+
+ 生成第一个令牌
+
+
+ )}
+
+
+
+ {/* 创建令牌 Dialog */}
+
+
+
+
+
+
+ {/* 明文 Token 显示 Dialog (仅显示一次) */}
+ {
+ if (!open) {
+ setNewCreatedToken(null)
+ setViewDialogOpen(false)
+ }
+ }}>
+
+
+
+
+ 令牌密钥已就绪
+
+
+ 这是您唯一一次能够查看此访问令牌明文密钥的机会。请立即将其复制并安全地保存。
+
+
+
+ {newCreatedToken && (
+
+ {/* 明文 Token 文本框 */}
+
+
+ {newCreatedToken.token}
+
+ handleCopyText(newCreatedToken.token, 9999)}
+ >
+ {copiedId === 9999 ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* 强提示 */}
+
+
+
+
重要提示:
+
+ 为了系统安全性,数据库中仅存储令牌的 Hash 摘要值,系统本身无法为您找回此明文密钥。离开此窗口后,您将再也无法查看到它的明文值。
+
+
+
+
+ )}
+
+
+ {
+ setNewCreatedToken(null)
+ setViewDialogOpen(false)
+ }}
+ className="bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-xs h-9 w-full"
+ >
+ 我已经复制并妥善保存
+
+
+
+
+
+ )
+}
diff --git a/frontend/components/common/settings/files.tsx b/frontend/components/common/settings/files.tsx
new file mode 100644
index 00000000..c80d7da6
--- /dev/null
+++ b/frontend/components/common/settings/files.tsx
@@ -0,0 +1,408 @@
+"use client"
+
+import * as React from "react"
+import Link from "next/link"
+import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
+import {AnimatePresence, motion} from "motion/react"
+import {
+ Download,
+ FileArchive,
+ FileAudio,
+ FileImage,
+ FileText,
+ FileVideo,
+ Folder,
+ Loader2,
+ Search,
+ Trash2,
+ Upload,
+ X,
+} from "lucide-react"
+import {toast} from "sonner"
+
+import {Button} from "@/components/ui/button"
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb"
+import {Input} from "@/components/ui/input"
+import {Badge} from "@/components/ui/badge"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+import {formatFileSize, UploadService} from "@/lib/services/upload/upload.service"
+import type {Upload as UploadRecord} from "@/lib/services/upload/types"
+
+/* ─── 工具函数 ─────────────────────────────────────────── */
+
+function getFileIcon(mimeType: string, className = "size-8") {
+ if (mimeType.startsWith("image/")) return
+ if (mimeType.startsWith("video/")) return
+ if (mimeType.startsWith("audio/")) return
+ if (mimeType.includes("zip") || mimeType.includes("tar") || mimeType.includes("gzip"))
+ return
+ return
+}
+
+function formatDate(dateStr: string) {
+ return new Date(dateStr).toLocaleString("zh-CN", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+}
+
+/* ─── 文件管理主组件 ────────────────────────────────────── */
+
+export function FilesMain() {
+ const queryClient = useQueryClient()
+ const [keyword, setKeyword] = React.useState("")
+ const [debouncedKeyword, setDebouncedKeyword] = React.useState("")
+ const [selectedIds, setSelectedIds] = React.useState>(new Set())
+ const [deleteTarget, setDeleteTarget] = React.useState(null)
+ const [page, setPage] = React.useState(1)
+ const pageSize = 24
+
+ // 搜索防抖
+ React.useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebouncedKeyword(keyword)
+ setPage(1)
+ }, 400)
+ return () => clearTimeout(timer)
+ }, [keyword])
+
+ // 文件列表查询
+ const listQuery = useQuery({
+ queryKey: ["files", "my", page, pageSize, debouncedKeyword],
+ queryFn: () => UploadService.listMyFiles(page, pageSize, debouncedKeyword || undefined),
+ })
+
+ const files = listQuery.data?.items ?? []
+ const total = listQuery.data?.total ?? 0
+ const totalPages = Math.ceil(total / pageSize)
+
+ // 删除单文件
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => UploadService.deleteFile(id),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ["files", "my"] })
+ toast.success("文件已删除")
+ setDeleteTarget(null)
+ },
+ onError: (err: Error) => toast.error(err.message || "删除失败"),
+ })
+
+ // 批量 ZIP 下载
+ const batchDownloadMutation = useMutation({
+ mutationFn: (ids: string[]) => UploadService.batchDownload(ids),
+ onSuccess: (blob) => {
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement("a")
+ a.href = url
+ a.download = "batch_download.zip"
+ a.click()
+ URL.revokeObjectURL(url)
+ toast.success("批量下载已开始")
+ },
+ onError: () => toast.error("批量下载失败"),
+ })
+
+ const toggleSelect = (id: string) => {
+ setSelectedIds((prev) => {
+ const next = new Set(prev)
+ next.has(id) ? next.delete(id) : next.add(id)
+ return next
+ })
+ }
+
+ const clearSelection = () => setSelectedIds(new Set())
+
+ const selectAll = () => setSelectedIds(new Set(files.map((f) => f.id)))
+
+ const handleDownload = (file: UploadRecord) => {
+ const url = UploadService.getDownloadUrl(file.id)
+ const a = document.createElement("a")
+ a.href = url
+ a.download = file.file_name
+ a.click()
+ }
+
+ return (
+
+ {/* Breadcrumb */}
+
+
+
+
+
+ 设置
+
+
+
+
+ 文件管理
+
+
+
+
+
+ {/* 头部 */}
+
+
+
+
+
+
+
+ 我的文件
+
+
+ 管理您上传的所有文件,支持下载和批量操作
+
+
+
+
+ {/* 操作按钮区 */}
+
+
+ {selectedIds.size > 0 && (
+
+
+ 已选 {selectedIds.size} 个
+
+ batchDownloadMutation.mutate([...selectedIds])}
+ disabled={batchDownloadMutation.isPending}
+ >
+ {batchDownloadMutation.isPending ? (
+
+ ) : (
+
+ )}
+ 打包下载
+
+
+
+
+
+ )}
+
+
+
+
+ {/* 搜索栏 */}
+
+
+
+ setKeyword(e.target.value)}
+ />
+ {keyword && (
+ setKeyword("")}
+ >
+
+
+ )}
+
+ {files.length > 0 && (
+
+ {selectedIds.size === files.length ? "取消全选" : "全选本页"}
+
+ )}
+ {total > 0 && (
+
共 {total} 个文件
+ )}
+
+
+ {/* 文件网格 */}
+ {listQuery.isPending ? (
+
+
+
+ ) : files.length === 0 ? (
+
+
+
+ {debouncedKeyword ? "没有匹配的文件" : "您还没有上传任何文件"}
+
+
+ ) : (
+
+ {files.map((file, idx) => {
+ const isSelected = selectedIds.has(file.id)
+ return (
+
toggleSelect(file.id)}
+ >
+ {/* 选中指示 */}
+ {isSelected && (
+
+ )}
+
+ {/* 文件图标 / 图片预览 */}
+
+ {file.mime_type.startsWith("image/") ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
{
+ ;(e.currentTarget as HTMLImageElement).style.display = "none"
+ }}
+ />
+ ) : (
+ getFileIcon(file.mime_type)
+ )}
+
+
+ {/* 文件名 */}
+
+ {file.file_name}
+
+
+ {/* 元数据 */}
+
+
{formatFileSize(file.file_size)}
+
{formatDate(file.created_at)}
+
+
+ {/* Hover 操作按钮 */}
+ e.stopPropagation()}
+ >
+ handleDownload(file)}
+ >
+
+
+ setDeleteTarget(file)}
+ >
+
+
+
+
+ )
+ })}
+
+ )}
+
+ {/* 分页 */}
+ {totalPages > 1 && (
+
+ setPage((p) => p - 1)}
+ >
+ 上一页
+
+
+ {page} / {totalPages}
+
+ = totalPages}
+ onClick={() => setPage((p) => p + 1)}
+ >
+ 下一页
+
+
+ )}
+
+ {/* 删除确认 Dialog */}
+ !open && setDeleteTarget(null)}>
+
+
+ 确认删除文件
+
+ 确定要删除文件{" "}
+ 「{deleteTarget?.file_name}」 {" "}
+ 吗?此操作不可撤销。
+
+
+
+ 取消
+ deleteTarget && deleteMutation.mutate(deleteTarget.id)}
+ disabled={deleteMutation.isPending}
+ className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
+ >
+ {deleteMutation.isPending && }
+ 确认删除
+
+
+
+
+
+ )
+}
diff --git a/frontend/components/common/settings/security.tsx b/frontend/components/common/settings/security.tsx
index 801ea097..c1f3edfe 100644
--- a/frontend/components/common/settings/security.tsx
+++ b/frontend/components/common/settings/security.tsx
@@ -254,7 +254,7 @@ export function SecurityMain() {
setSelectedSource(null)
setAuthSourceModalOpen(true)
}}
- className="bg-indigo-600 hover:bg-indigo-700 text-white shadow-md shadow-indigo-600/10 transition-colors"
+ variant="secondary"
>
新增认证源
diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx
index a9a687ec..8543699e 100644
--- a/frontend/components/layout/sidebar.tsx
+++ b/frontend/components/layout/sidebar.tsx
@@ -49,6 +49,7 @@ import {
CreditCard,
FileQuestionMark,
FileText,
+ FolderOpen,
Home,
Layers,
LogOut,
@@ -64,6 +65,7 @@ import {useUser} from "@/contexts/user-context"
const data = {
navMain: [
{ title: "首页", url: "/home", icon: Home },
+ { title: "文件管理", url: "/settings/files", icon: FolderOpen },
],
systemSettings: [
{ title: "系统设置", url: "/settings/security", icon: Settings },
diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts
index ef2ed82b..9b03feab 100644
--- a/frontend/lib/services/index.ts
+++ b/frontend/lib/services/index.ts
@@ -104,6 +104,7 @@ export type {
// 用户服务
export { UserService } from './user';
+export type { AccessToken, CreateTokenResponse } from './user';
// 上传服务
export { UploadService } from './upload';
diff --git a/frontend/lib/services/upload/types.ts b/frontend/lib/services/upload/types.ts
index f1729251..3adf8975 100644
--- a/frontend/lib/services/upload/types.ts
+++ b/frontend/lib/services/upload/types.ts
@@ -1,7 +1,62 @@
/**
- * 上传图片响应
+ * 文件上传元数据
+ */
+export interface UploadMetadata {
+ width?: number
+ height?: number
+ duration?: number
+ original_mime?: string
+ user_agent?: string
+ client_ip?: string
+ bucket?: string
+ extra?: Record
+}
+
+/**
+ * 上传记录
+ */
+export interface Upload {
+ id: string
+ user_id: string
+ file_name: string
+ file_path: string
+ file_size: number
+ mime_type: string
+ extension: string
+ hash: string
+ storage_driver: string
+ type: string
+ status: string
+ metadata: UploadMetadata
+ created_at: string
+ updated_at: string
+}
+
+/**
+ * 上传接口响应(原 UploadImageResponse 兼容)
*/
export interface UploadImageResponse {
/** 上传记录 ID */
- id: string;
+ id: string
+}
+
+/**
+ * 文件列表查询参数
+ */
+export interface ListUploadsQuery {
+ page?: number
+ page_size?: number
+ type?: string
+ extension?: string
+ keyword?: string
+}
+
+/**
+ * 文件列表分页响应
+ */
+export interface ListUploadsResponse {
+ total: number
+ page: number
+ page_size: number
+ items: Upload[]
}
\ No newline at end of file
diff --git a/frontend/lib/services/upload/upload.service.ts b/frontend/lib/services/upload/upload.service.ts
index 14d64ae8..700945c4 100644
--- a/frontend/lib/services/upload/upload.service.ts
+++ b/frontend/lib/services/upload/upload.service.ts
@@ -1,6 +1,6 @@
-import { BaseService } from '../core/base.service';
-import type { UploadImageResponse } from './types';
-import type { InternalAxiosRequestConfig } from 'axios';
+import {BaseService} from '../core/base.service'
+import type {ListUploadsResponse, Upload, UploadImageResponse} from './types'
+import type {InternalAxiosRequestConfig} from 'axios'
/**
* 根据上传ID构造文件访问URL
@@ -8,8 +8,19 @@ import type { InternalAxiosRequestConfig } from 'axios';
* @returns 文件访问URL
*/
export function getFileUrl(id: string | number | null | undefined): string | null {
- if (!id) return null;
- return `/f/${id}`;
+ if (!id) return null
+ return `/f/${id}`
+}
+
+/**
+ * 格式化文件大小
+ */
+export function formatFileSize(bytes: number): string {
+ if (bytes === 0) return '0 B'
+ const k = 1024
+ const sizes = ['B', 'KB', 'MB', 'GB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
}
/**
@@ -17,72 +28,91 @@ export function getFileUrl(id: string | number | null | undefined): string | nul
* 处理文件上传相关的 API 请求
*/
export class UploadService extends BaseService {
- protected static readonly basePath = '/api/v1/upload';
+ protected static readonly basePath = '/api/v1/upload'
/**
- * 上传红包封面图片
- * @param file - 图片文件
- * @param type - 封面类型 (cover: 背景封面, heterotypic: 异形装饰)
- * @returns 上传后的图片URL
- * @throws {ValidationError} 当文件格式或大小不符合要求时
- * @throws {UnauthorizedError} 当未登录时
- *
- * @example
- * ```typescript
- * const file = e.target.files[0];
- * const result = await UploadService.uploadRedEnvelopeCover(file, 'cover');
- * console.log('图片URL:', result.url);
- * ```
+ * 通用文件上传
+ * @param file - 文件对象
+ * @param type - 业务分类(如 avatar、attachment、generic)
+ * @param metadata - 可选额外 JSON 元数据
*/
- static async uploadRedEnvelopeCover(
+ static async uploadFile(
file: File,
- type: 'cover' | 'heterotypic'
- ): Promise {
- // 验证文件类型
- const allowedTypes = ['image/jpeg', 'image/png', 'image/jpg', 'image/webp'];
- if (!allowedTypes.includes(file.type)) {
- throw new Error('只支持 JPG、PNG、WEBP 格式的图片');
+ type: string = 'generic',
+ metadata?: Record
+ ): Promise {
+ const formData = new FormData()
+ formData.append('file', file)
+ formData.append('type', type)
+ if (metadata) {
+ formData.append('metadata', JSON.stringify(metadata))
}
- // 验证文件大小 (最大 2MB)
- const maxSize = 2 * 1024 * 1024;
- if (file.size > maxSize) {
- throw new Error('图片大小不能超过 2MB');
- }
-
- // 创建 FormData
- const formData = new FormData();
- formData.append('file', file);
- formData.append('type', type);
-
- return this.post('/redenvelope/cover', formData, {
- headers: {
- 'Content-Type': 'multipart/form-data',
- },
- } as InternalAxiosRequestConfig);
+ return this.post('', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ } as InternalAxiosRequestConfig)
}
/**
- * 将 base64 图片转换为 Blob 并上传
- * @param base64 - base64 编码的图片
- * @param type - 封面类型
- * @param filename - 文件名
- * @returns 上传后的图片URL
+ * 获取我的文件列表
+ * @param page - 页码(1-based)
+ * @param pageSize - 每页数量
+ * @param keyword - 搜索关键词(文件名模糊)
+ * @param type - 业务分类过滤
+ * @param extension - 扩展名过滤
+ */
+ static async listMyFiles(
+ page = 1,
+ pageSize = 20,
+ keyword?: string,
+ type?: string,
+ extension?: string
+ ): Promise {
+ const params: Record = { page, page_size: pageSize }
+ if (keyword) params.keyword = keyword
+ if (type) params.type = type
+ if (extension) params.extension = extension
+ return this.get('/my', { params })
+ }
+
+ /**
+ * 删除文件
+ */
+ static async deleteFile(id: string): Promise {
+ return this.delete(`/${id}`)
+ }
+
+ /**
+ * 获取单文件下载 URL(触发 attachment 下载)
+ */
+ static getDownloadUrl(id: string): string {
+ return `/api/v1/upload/download/${id}`
+ }
+
+ /**
+ * 批量 ZIP 打包下载
+ * @param ids - 文件 ID 数组
+ */
+ static async batchDownload(ids: string[]): Promise {
+ const response = await this.post('/download/batch', { ids }, {
+ responseType: 'blob',
+ } as InternalAxiosRequestConfig)
+ return response
+ }
+
+ /**
+ * 将 base64 图片转换为 Blob 并上传(兼容旧接口)
*/
static async uploadBase64Image(
base64: string,
- type: 'cover' | 'heterotypic',
+ type: string = 'generic',
filename: string = 'image.png'
): Promise {
- // 将 base64 转换为 Blob
- const response = await fetch(base64);
- const blob = await response.blob();
-
- // 创建 File 对象,确保正确的 MIME 类型
- const mimeType = base64.match(/data:([^;]+);/)?.[1] || 'image/png';
- const file = new File([blob], filename, { type: mimeType });
-
- // 上传文件
- return this.uploadRedEnvelopeCover(file, type);
+ const response = await fetch(base64)
+ const blob = await response.blob()
+ const mimeType = base64.match(/data:([^;]+);/)?.[1] || 'image/png'
+ const file = new File([blob], filename, { type: mimeType })
+ const result = await this.uploadFile(file, type)
+ return { id: result.id }
}
-}
\ No newline at end of file
+}
diff --git a/frontend/lib/services/user/index.ts b/frontend/lib/services/user/index.ts
index a56f153c..64a825cd 100644
--- a/frontend/lib/services/user/index.ts
+++ b/frontend/lib/services/user/index.ts
@@ -6,3 +6,4 @@
*/
export { UserService } from './user.service';
+export type { AccessToken, CreateTokenResponse } from './user.service';
diff --git a/frontend/lib/services/user/user.service.ts b/frontend/lib/services/user/user.service.ts
index 8c4d1985..e9ba36e4 100644
--- a/frontend/lib/services/user/user.service.ts
+++ b/frontend/lib/services/user/user.service.ts
@@ -1,4 +1,19 @@
-import { BaseService } from '../core/base.service';
+import {BaseService} from '../core/base.service';
+
+export interface AccessToken {
+ id: number;
+ user_id: number;
+ name: string;
+ masked_token: string;
+ last_used_at?: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface CreateTokenResponse {
+ token: string;
+ record: AccessToken;
+}
/**
* 用户服务
@@ -6,5 +21,35 @@ import { BaseService } from '../core/base.service';
*/
export class UserService extends BaseService {
protected static readonly basePath = '/api/v1/user';
-}
+ /**
+ * 获取当前用户的 AccessToken 列表
+ */
+ static async getAccessTokens(): Promise {
+ return this.get('/access-tokens');
+ }
+
+ /**
+ * 创建一个新的 AccessToken
+ * @param name - 令牌名称
+ */
+ static async createAccessToken(name: string): Promise {
+ return this.post('/access-tokens', { name });
+ }
+
+ /**
+ * 删除一个 AccessToken
+ * @param id - 令牌 ID
+ */
+ static async deleteAccessToken(id: number): Promise {
+ return this.delete(`/access-tokens/${id}`);
+ }
+
+ /**
+ * 轮换一个 AccessToken 密钥
+ * @param id - 令牌 ID
+ */
+ static async rotateAccessToken(id: number): Promise {
+ return this.post(`/access-tokens/${id}/rotate`);
+ }
+}
diff --git a/internal/apps/oauth/middlewares.go b/internal/apps/oauth/middlewares.go
index 34471531..4eb2e997 100644
--- a/internal/apps/oauth/middlewares.go
+++ b/internal/apps/oauth/middlewares.go
@@ -18,6 +18,7 @@ package oauth
import (
"net/http"
+ "time"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/common"
@@ -44,19 +45,45 @@ func LoginRequired() gin.HandlerFunc {
ctx, span := otel_trace.Start(c.Request.Context(), "LoginRequired")
defer span.End()
- // load user
- userId := GetUserIDFromContext(c)
- if userId <= 0 {
- c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil})
- return
+ // check token in headers
+ tokenStr := c.GetHeader("X-Access-Token")
+ if tokenStr == "" {
+ authHeader := c.GetHeader("Authorization")
+ if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
+ tokenStr = authHeader[7:]
+ }
}
- // load user from db to make sure is active
var user model.User
- tx := db.DB(ctx).Where("id = ? AND is_active = ?", userId, true).First(&user)
- if tx.Error != nil {
- c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error_msg": tx.Error.Error(), "data": nil})
- return
+ var authenticated bool
+
+ if tokenStr != "" {
+ tokenHash := model.HashToken(tokenStr)
+ var tokenRecord model.AccessToken
+ if err := db.DB(ctx).Where("token_hash = ?", tokenHash).First(&tokenRecord).Error; err == nil {
+ if err := db.DB(ctx).Where("id = ? AND is_active = ?", tokenRecord.UserID, true).First(&user).Error; err == nil {
+ authenticated = true
+ // update token last used time
+ now := time.Now()
+ db.DB(ctx).Model(&tokenRecord).Update("last_used_at", &now)
+ }
+ }
+ }
+
+ if !authenticated {
+ // load user from session
+ userId := GetUserIDFromContext(c)
+ if userId <= 0 {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil})
+ return
+ }
+
+ // load user from db to make sure is active
+ tx := db.DB(ctx).Where("id = ? AND is_active = ?", userId, true).First(&user)
+ if tx.Error != nil {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil})
+ return
+ }
}
// log
diff --git a/internal/apps/upload/errs.go b/internal/apps/upload/errs.go
index eb18d7b5..5c59a57f 100644
--- a/internal/apps/upload/errs.go
+++ b/internal/apps/upload/errs.go
@@ -18,7 +18,7 @@ package upload
const (
ErrNoFileSelected = "请选择要上传的文件"
- ErrInvalidCoverType = "无效的封面类型"
+ ErrInvalidUploadType = "无效的上传类型"
ErrFileTooLarge = "图片大小不能超过 2MB"
ErrUnsupportedFormat = "只支持 JPG、PNG、WEBP 格式的图片"
ErrInvalidImage = "无效的图片文件"
@@ -28,5 +28,5 @@ const (
ErrOpenFileFailed = "打开文件失败"
ErrInvalidFilePath = "非法文件路径"
ErrSaveUploadRecordFailed = "保存上传记录失败"
- ErrQueryHistoryCoverFailed = "查询历史封面失败"
+ ErrQueryHistoryUploadFailed = "查询历史上传记录失败"
)
diff --git a/internal/apps/upload/file_server.go b/internal/apps/upload/file_server.go
index 9ef97d3e..afb1ecfa 100644
--- a/internal/apps/upload/file_server.go
+++ b/internal/apps/upload/file_server.go
@@ -59,6 +59,11 @@ func ServeFileByID(c *gin.Context) {
return
}
+ if upload.StorageDriver == "local" || (upload.StorageDriver == "" && !storage.IsEnabled()) {
+ c.File(upload.FilePath)
+ return
+ }
+
// Retrieve file from S3 (via CDN if configured)
obj, err := storage.GetObjectViaCache(c.Request.Context(), upload.FilePath)
if err != nil {
diff --git a/internal/apps/upload/routers.go b/internal/apps/upload/routers.go
new file mode 100644
index 00000000..a80d3357
--- /dev/null
+++ b/internal/apps/upload/routers.go
@@ -0,0 +1,534 @@
+/*
+Copyright 2026 linux.do
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package upload
+
+import (
+ "archive/zip"
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/linux-do/credit/internal/apps/oauth"
+ "github.com/linux-do/credit/internal/common/response"
+ "github.com/linux-do/credit/internal/config"
+ "github.com/linux-do/credit/internal/db"
+ "github.com/linux-do/credit/internal/db/idgen"
+ "github.com/linux-do/credit/internal/logger"
+ "github.com/linux-do/credit/internal/model"
+ "github.com/linux-do/credit/internal/storage"
+ "github.com/linux-do/credit/internal/util"
+ "gorm.io/gorm"
+)
+
+const maxUploadSize = 32 * 1024 * 1024 // 32MB
+
+type batchDownloadRequest struct {
+ IDs []string `json:"ids" binding:"required,min=1"`
+}
+
+// UploadFile 通用上传文件接口
+// @Summary 上传文件
+// @Description 支持各种类型的通用文件上传,支持自动文件类型检测、哈希计算与“秒传”去重
+// @Tags upload
+// @Accept multipart/form-data
+// @Produce json
+// @Param file formData file true "要上传的文件"
+// @Param type formData string false "业务分类 (例如: avatar, attachment, doc,默认为 generic)"
+// @Param metadata formData string false "额外的 JSON 格式元数据"
+// @Security SessionCookie
+// @Success 200 {object} util.ResponseAny{data=model.Upload} "上传成功"
+// @Failure 400 {object} util.ResponseAny "请求参数错误或文件受限"
+// @Failure 401 {object} util.ResponseAny "未登录"
+// @Failure 500 {object} util.ResponseAny "内部错误"
+// @Router /api/v1/upload [post]
+func UploadFile(c *gin.Context) {
+ currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
+ ctx := c.Request.Context()
+
+ header, err := c.FormFile("file")
+ if err != nil {
+ response.RespondFailure(c, ErrNoFileSelected)
+ return
+ }
+
+ file, err := header.Open()
+ if err != nil {
+ response.RespondFailure(c, ErrOpenFileFailed)
+ return
+ }
+ defer file.Close()
+
+ // 校验大小
+ if header.Size > maxUploadSize {
+ response.RespondFailure(c, "文件大小不能超过 32MB")
+ return
+ }
+
+ // 2. 提取文件基本元数据
+ origName := header.Filename
+ ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(origName), "."))
+ if ext == "" {
+ ext = "bin"
+ }
+
+ // 3. 校验文件后缀是否在允许的系统配置列表中
+ var sc model.SystemConfig
+ if err := sc.GetByKey(ctx, model.ConfigKeyUploadAllowedExtensions); err == nil && sc.Value != "" {
+ allowedExts := strings.Split(strings.ToLower(sc.Value), ",")
+ allowed := false
+ for _, allowedExt := range allowedExts {
+ if strings.TrimSpace(allowedExt) == ext {
+ allowed = true
+ break
+ }
+ }
+ if !allowed {
+ response.RespondFailure(c, ErrUnsupportedFormat)
+ return
+ }
+ }
+
+ // 4. 读取文件并计算 Hash
+ hashWriter := sha256.New()
+ var buf bytes.Buffer
+ size, err := io.Copy(&buf, io.TeeReader(file, hashWriter))
+ if err != nil {
+ response.RespondFailure(c, ErrProcessFileFailed)
+ return
+ }
+
+ fileHash := hex.EncodeToString(hashWriter.Sum(nil))
+
+ mimeType := http.DetectContentType(buf.Bytes()[:min(512, int(size))])
+ if mimeType == "application/octet-stream" && header.Header.Get("Content-Type") != "" {
+ mimeType = header.Header.Get("Content-Type")
+ }
+
+ // 6. 秒传匹配校验:校验数据库中是否存在相同 Hash 且大小一致的可用文件
+ var existing model.Upload
+ err = db.DB(ctx).Where("hash = ? AND file_size = ? AND status IN (?, ?)", fileHash, size, model.UploadStatusPending, model.UploadStatusUsed).First(&existing).Error
+ if err == nil {
+ // 命中了相同文件,直接生成新记录指向已有的存储路径(实现秒传)
+ id := idgen.NextUint64ID()
+ newUpload := model.Upload{
+ ID: id,
+ UserID: currUser.ID,
+ FileName: origName,
+ FilePath: existing.FilePath,
+ FileSize: size,
+ MimeType: mimeType,
+ Extension: ext,
+ Hash: fileHash,
+ StorageDriver: existing.StorageDriver,
+ Type: c.DefaultPostForm("type", "generic"),
+ Status: model.UploadStatusUsed,
+ Metadata: existing.Metadata,
+ }
+
+ if err := db.DB(ctx).Create(&newUpload).Error; err != nil {
+ response.RespondFailure(c, ErrSaveUploadRecordFailed)
+ return
+ }
+
+ logger.InfoF(ctx, "文件触发秒传成功! ID: %d, Path: %s", id, existing.FilePath)
+ response.RespondSuccess(c, newUpload)
+ return
+ } else if !errors.Is(err, gorm.ErrRecordNotFound) {
+ response.RespondFailure(c, "文件校验失败")
+ return
+ }
+
+ // 7. 解析可选元数据字段
+ metadataStr := c.DefaultPostForm("metadata", "")
+ var meta model.UploadMetadata
+ if metadataStr != "" {
+ if err := json.Unmarshal([]byte(metadataStr), &meta); err != nil {
+ response.RespondFailure(c, "元数据 JSON 格式不合法")
+ return
+ }
+ }
+
+ meta.OriginalMime = mimeType
+ meta.UserAgent = c.Request.UserAgent()
+ meta.ClientIP = c.ClientIP()
+
+ id := idgen.NextUint64ID()
+ subPath := fmt.Sprintf("uploads/%s/%d.%s", time.Now().Format("2006/01/02"), id, ext)
+
+ var storageDriver string
+
+ // 8. 写入底层存储驱动 (优先 S3 驱动,无配置或未开启则 fallback 至本地文件)
+ if storage.IsEnabled() {
+ storageDriver = "s3"
+ meta.Bucket = config.Config.S3.Bucket
+ fullKey := storage.BuildKey(subPath)
+
+ err = storage.PutObject(ctx, fullKey, bytes.NewReader(buf.Bytes()), size, mimeType)
+ if err != nil {
+ logger.ErrorF(ctx, "S3 存储上传失败: %v", err)
+ response.RespondFailure(c, ErrSaveFileFailed)
+ return
+ }
+ } else {
+ storageDriver = "local"
+ localDir := filepath.Join("uploads", time.Now().Format("2006/01/02"))
+ if err := os.MkdirAll(localDir, 0755); err != nil {
+ logger.ErrorF(ctx, "创建本地上传目录失败: %v", err)
+ response.RespondFailure(c, ErrSaveFileFailed)
+ return
+ }
+
+ localPath := filepath.Join(localDir, fmt.Sprintf("%d.%s", id, ext))
+ if err := os.WriteFile(localPath, buf.Bytes(), 0644); err != nil {
+ logger.ErrorF(ctx, "本地磁盘写入文件失败: %v", err)
+ response.RespondFailure(c, ErrSaveFileFailed)
+ return
+ }
+ // 统一使用相对路径,方便将来环境移植或备份
+ subPath = localPath
+ }
+
+ // 9. 保存文件记录至数据库
+ newUpload := model.Upload{
+ ID: id,
+ UserID: currUser.ID,
+ FileName: origName,
+ FilePath: subPath,
+ FileSize: size,
+ MimeType: mimeType,
+ Extension: ext,
+ Hash: fileHash,
+ StorageDriver: storageDriver,
+ Type: c.DefaultPostForm("type", "generic"),
+ Status: model.UploadStatusUsed,
+ Metadata: meta,
+ }
+
+ if err := db.DB(ctx).Create(&newUpload).Error; err != nil {
+ // 失败时若为本地存储,可以尝试清理已保存的垃圾文件
+ if storageDriver == "local" {
+ _ = os.Remove(subPath)
+ }
+ response.RespondFailure(c, ErrSaveUploadRecordFailed)
+ return
+ }
+
+ response.RespondSuccess(c, newUpload)
+}
+
+// DownloadFile 通用单文件下载接口
+// @Summary 下载单文件
+// @Description 根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载
+// @Tags upload
+// @Produce octet-stream
+// @Param id path string true "文件 ID"
+// @Security SessionCookie
+// @Success 200 {file} file "成功下载文件"
+// @Failure 400 {object} util.ResponseAny "参数错误"
+// @Failure 404 {object} util.ResponseAny "文件不存在"
+// @Failure 500 {object} util.ResponseAny "服务内部错误"
+// @Router /api/v1/upload/download/{id} [get]
+func DownloadFile(c *gin.Context) {
+ ctx := c.Request.Context()
+ idStr := c.Param("id")
+ uploadID, err := strconv.ParseUint(idStr, 10, 64)
+ if err != nil {
+ response.RespondFailure(c, "无效的文件 ID")
+ return
+ }
+
+ var upload model.Upload
+ if err := db.DB(ctx).Where("id = ? AND status IN (?, ?)", uploadID, model.UploadStatusPending, model.UploadStatusUsed).First(&upload).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+ response.RespondFailure(c, "查询文件记录失败")
+ return
+ }
+
+ // 设置下载 Attachment 响应头 (支持 UTF-8 中文文件名转义)
+ c.Header("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", url.PathEscape(upload.FileName)))
+ c.Header("Content-Type", upload.MimeType)
+ c.Header("Content-Length", strconv.FormatInt(upload.FileSize, 10))
+
+ // 根据存储驱动类型提供流式文件服务
+ if upload.StorageDriver == "local" || (upload.StorageDriver == "" && !storage.IsEnabled()) {
+ c.File(upload.FilePath)
+ return
+ }
+
+ // 从 S3/CDN 加载并返回
+ obj, err := storage.GetObjectViaCache(ctx, upload.FilePath)
+ if err != nil {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+
+ if obj.CachePath != "" {
+ c.File(obj.CachePath)
+ return
+ }
+
+ defer obj.Body.Close()
+ _, _ = io.Copy(c.Writer, obj.Body)
+}
+
+// BatchDownloadFiles 批量打包 ZIP 下载接口
+// @Summary 批量打包下载
+// @Description 传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突
+// @Tags upload
+// @Accept json
+// @Produce octet-stream
+// @Param request body upload.batchDownloadRequest true "包含文件 ID 数组的请求体"
+// @Security SessionCookie
+// @Success 200 {file} file "成功下载打包后的 ZIP"
+// @Failure 400 {object} util.ResponseAny "参数错误"
+// @Failure 500 {object} util.ResponseAny "打包失败"
+// @Router /api/v1/upload/download/batch [post]
+func BatchDownloadFiles(c *gin.Context) {
+ ctx := c.Request.Context()
+
+ var req batchDownloadRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.RespondFailure(c, "参数绑定失败,请传入有效的文件 ID 数组")
+ return
+ }
+
+ // 转换 ID 列表
+ var ids []uint64
+ for _, idStr := range req.IDs {
+ id, err := strconv.ParseUint(idStr, 10, 64)
+ if err != nil {
+ response.RespondFailure(c, fmt.Sprintf("无效的 ID 值: %s", idStr))
+ return
+ }
+ ids = append(ids, id)
+ }
+
+ // 查库获取所有匹配且正常的文件记录
+ var uploads []model.Upload
+ if err := db.DB(ctx).Where("id IN ? AND status IN (?, ?)", ids, model.UploadStatusPending, model.UploadStatusUsed).Find(&uploads).Error; err != nil {
+ response.RespondFailure(c, "检索文件记录失败")
+ return
+ }
+
+ if len(uploads) == 0 {
+ response.RespondFailure(c, "没有找到任何有效的文件记录进行打包")
+ return
+ }
+
+ // 设置 ZIP 格式流的响应头
+ c.Header("Content-Type", "application/zip")
+ c.Header("Content-Disposition", "attachment; filename=\"batch_download.zip\"")
+
+ // 开启实时 ZIP 压缩器并直接输出给 Response Writer
+ zipWriter := zip.NewWriter(c.Writer)
+ defer zipWriter.Close()
+
+ // 用于解决 ZIP 内部文件名称发生碰撞冲突的问题
+ usedNames := make(map[string]int)
+
+ for _, upload := range uploads {
+ // 校验防冲突重命名逻辑
+ fileName := upload.FileName
+ if count, exists := usedNames[fileName]; exists {
+ usedNames[fileName] = count + 1
+ ext := filepath.Ext(fileName)
+ base := strings.TrimSuffix(fileName, ext)
+ fileName = fmt.Sprintf("%s_%d%s", base, count, ext)
+ } else {
+ usedNames[fileName] = 1
+ }
+
+ // 在 ZIP 包内建新条目
+ zipFileEntry, err := zipWriter.Create(fileName)
+ if err != nil {
+ logger.ErrorF(ctx, "ZIP 添加条目失败 [%s]: %v", fileName, err)
+ continue
+ }
+
+ // 打开底层文件数据源
+ var rc io.ReadCloser
+ if upload.StorageDriver == "local" || (upload.StorageDriver == "" && !storage.IsEnabled()) {
+ fileSrc, err := os.Open(upload.FilePath)
+ if err != nil {
+ logger.ErrorF(ctx, "打包时读取本地文件失败: %v", err)
+ continue
+ }
+ rc = fileSrc
+ } else {
+ obj, err := storage.GetObject(ctx, upload.FilePath)
+ if err != nil {
+ logger.ErrorF(ctx, "打包时拉取 S3 文件失败: %v", err)
+ continue
+ }
+ rc = obj.Body
+ }
+
+ // 流式拷贝到 ZIP entry
+ _, err = io.Copy(zipFileEntry, rc)
+ _ = rc.Close()
+ if err != nil {
+ logger.ErrorF(ctx, "写入 ZIP 流失败: %v", err)
+ }
+ }
+}
+
+type listMyFilesRequest struct {
+ Page int `form:"page"`
+ PageSize int `form:"page_size"`
+ Keyword string `form:"keyword"`
+ Type string `form:"type"`
+ Extension string `form:"extension"`
+}
+
+type listMyFilesResponse struct {
+ Total int64 `json:"total"`
+ Page int `json:"page"`
+ PageSize int `json:"page_size"`
+ Items []model.Upload `json:"items"`
+}
+
+// ListMyFiles 获取当前用户上传的文件列表
+// @Summary 获取我的文件列表
+// @Description 分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤
+// @Tags upload
+// @Produce json
+// @Param page query int false "页码(默认 1)"
+// @Param page_size query int false "每页数量(默认 20,最大 100)"
+// @Param keyword query string false "文件名关键词(模糊匹配)"
+// @Param type query string false "业务分类过滤"
+// @Param extension query string false "扩展名过滤"
+// @Security SessionCookie
+// @Success 200 {object} util.ResponseAny{data=listMyFilesResponse} "查询成功"
+// @Failure 401 {object} util.ResponseAny "未登录"
+// @Router /api/v1/upload/my [get]
+func ListMyFiles(c *gin.Context) {
+ currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
+ ctx := c.Request.Context()
+
+ var req listMyFilesRequest
+ if err := c.ShouldBindQuery(&req); err != nil {
+ response.RespondFailure(c, "参数错误")
+ return
+ }
+ if req.Page <= 0 {
+ req.Page = 1
+ }
+ if req.PageSize <= 0 || req.PageSize > 100 {
+ req.PageSize = 20
+ }
+
+ query := db.DB(ctx).Model(&model.Upload{}).
+ Where("user_id = ? AND status != ?", currUser.ID, model.UploadStatusDeleted)
+
+ if req.Keyword != "" {
+ query = query.Where("file_name ILIKE ?", "%"+req.Keyword+"%")
+ }
+ if req.Type != "" {
+ query = query.Where("type = ?", req.Type)
+ }
+ if req.Extension != "" {
+ query = query.Where("extension = ?", strings.ToLower(req.Extension))
+ }
+
+ var total int64
+ if err := query.Count(&total).Error; err != nil {
+ response.RespondFailure(c, "查询文件数量失败")
+ return
+ }
+
+ var items []model.Upload
+ offset := (req.Page - 1) * req.PageSize
+ if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&items).Error; err != nil {
+ response.RespondFailure(c, "查询文件列表失败")
+ return
+ }
+
+ response.RespondSuccess(c, listMyFilesResponse{
+ Total: total,
+ Page: req.Page,
+ PageSize: req.PageSize,
+ Items: items,
+ })
+}
+
+// DeleteFile 软删除文件记录
+// @Summary 删除文件
+// @Description 将文件状态置为 deleted(软删除),不会立即清理底层存储对象
+// @Tags upload
+// @Produce json
+// @Param id path string true "文件 ID"
+// @Security SessionCookie
+// @Success 200 {object} util.ResponseAny "删除成功"
+// @Failure 403 {object} util.ResponseAny "无权操作"
+// @Failure 404 {object} util.ResponseAny "文件不存在"
+// @Router /api/v1/upload/{id} [delete]
+func DeleteFile(c *gin.Context) {
+ currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
+ ctx := c.Request.Context()
+
+ idStr := c.Param("id")
+ uploadID, err := strconv.ParseUint(idStr, 10, 64)
+ if err != nil {
+ response.RespondFailure(c, "无效的文件 ID")
+ return
+ }
+
+ var upload model.Upload
+ if err := db.DB(ctx).Where("id = ? AND status != ?", uploadID, model.UploadStatusDeleted).First(&upload).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+ response.RespondFailure(c, "查询文件记录失败")
+ return
+ }
+
+ // 仅允许文件所有者或管理员删除
+ if upload.UserID != currUser.ID && !currUser.IsAdmin {
+ c.AbortWithStatus(http.StatusForbidden)
+ return
+ }
+
+ if err := db.DB(ctx).Model(&upload).Update("status", model.UploadStatusDeleted).Error; err != nil {
+ response.RespondFailure(c, "删除文件失败")
+ return
+ }
+
+ response.RespondSuccess(c, nil)
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/internal/apps/upload/routers_test.go b/internal/apps/upload/routers_test.go
new file mode 100644
index 00000000..0440bd9c
--- /dev/null
+++ b/internal/apps/upload/routers_test.go
@@ -0,0 +1,516 @@
+/*
+Copyright 2026 linux.do
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package upload
+
+import (
+ "archive/zip"
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/linux-do/credit/internal/apps/oauth"
+ "github.com/linux-do/credit/internal/db"
+ "github.com/linux-do/credit/internal/model"
+ "github.com/linux-do/credit/internal/storage"
+ "github.com/linux-do/credit/internal/testhelper"
+ "github.com/linux-do/credit/internal/util"
+)
+
+type testResponse struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+ Data json.RawMessage `json:"data"`
+}
+
+func setupTestRouter(authUser *model.User) *gin.Engine {
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ uploadGroup := r.Group("/api/v1/upload")
+
+ // Mock authentication middleware
+ uploadGroup.Use(func(c *gin.Context) {
+ if authUser != nil {
+ util.SetToContext(c, oauth.UserObjKey, authUser)
+ }
+ c.Next()
+ })
+
+ uploadGroup.POST("", UploadFile)
+ uploadGroup.GET("/download/:id", DownloadFile)
+ uploadGroup.POST("/download/batch", BatchDownloadFiles)
+ return r
+}
+
+func createMultipartRequest(t *testing.T, fieldName, fileName string, fileContent []byte, extraFields map[string]string) (string, *bytes.Buffer) {
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+
+ part, err := writer.CreateFormFile(fieldName, fileName)
+ if err != nil {
+ t.Fatalf("failed to create form file: %v", err)
+ }
+
+ _, err = part.Write(fileContent)
+ if err != nil {
+ t.Fatalf("failed to write file content: %v", err)
+ }
+
+ for k, v := range extraFields {
+ err = writer.WriteField(k, v)
+ if err != nil {
+ t.Fatalf("failed to write form field: %v", err)
+ }
+ }
+
+ err = writer.Close()
+ if err != nil {
+ t.Fatalf("failed to close multipart writer: %v", err)
+ }
+
+ return writer.FormDataContentType(), body
+}
+
+func TestUploadFile(t *testing.T) {
+ dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
+ defer cleanup()
+ defer os.RemoveAll("uploads") // Clean up local files created during tests
+
+ authUser := &model.User{ID: 1001, Username: "test_user"}
+ router := setupTestRouter(authUser)
+
+ // Mock Storage Client
+ mockFiles := make(map[string][]byte)
+ var putCount int
+
+ restoreStorage := storage.MockStorage(
+ func(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
+ data, err := io.ReadAll(body)
+ if err != nil {
+ return err
+ }
+ mockFiles[key] = data
+ putCount++
+ return nil
+ },
+ func(ctx context.Context, key string) (*storage.ObjectInfo, error) {
+ data, ok := mockFiles[key]
+ if !ok {
+ return nil, os.ErrNotExist
+ }
+ return &storage.ObjectInfo{
+ Body: io.NopCloser(bytes.NewReader(data)),
+ ContentLength: int64(len(data)),
+ ContentType: "application/octet-stream",
+ }, nil
+ },
+ func(ctx context.Context, key string) error {
+ delete(mockFiles, key)
+ return nil
+ },
+ )
+ defer restoreStorage()
+
+ // 开启 S3 Storage
+ storage.IsEnabledFunc = func() bool { return true }
+ defer func() {
+ storage.IsEnabledFunc = func() bool { return false }
+ }()
+
+ t.Run("upload allowed image file successfully", func(t *testing.T) {
+ putCount = 0
+ imgContent := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89") // Valid PNG header
+ contentType, body := createMultipartRequest(t, "file", "test.png", imgContent, map[string]string{
+ "type": "avatar",
+ "metadata": `{"extra":{"source":"test_runner"}}`,
+ })
+
+ req, _ := http.NewRequest("POST", "/api/v1/upload", body)
+ req.Header.Set("Content-Type", contentType)
+
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
+ }
+
+ var resp testResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to unmarshal response: %v", err)
+ }
+
+ if !resp.Success {
+ t.Fatalf("expected success response, got failure: %s", resp.Message)
+ }
+
+ // Verify database record
+ var uploadRecord model.Upload
+ if err := json.Unmarshal(resp.Data, &uploadRecord); err != nil {
+ t.Fatalf("failed to unmarshal upload record: %v", err)
+ }
+
+ var dbRecord model.Upload
+ if err := dbConn.First(&dbRecord, uploadRecord.ID).Error; err != nil {
+ t.Fatalf("failed to retrieve database record: %v", err)
+ }
+
+ if dbRecord.FileName != "test.png" || dbRecord.Extension != "png" {
+ t.Errorf("incorrect filename or extension: %s, %s", dbRecord.FileName, dbRecord.Extension)
+ }
+
+ if dbRecord.MimeType != "image/png" {
+ t.Errorf("incorrect mime type detected: %s", dbRecord.MimeType)
+ }
+
+ if dbRecord.StorageDriver != "s3" {
+ t.Errorf("expected storage driver s3, got %s", dbRecord.StorageDriver)
+ }
+
+ if dbRecord.Metadata.Extra["source"] != "test_runner" {
+ t.Errorf("expected extra meta 'source' to be 'test_runner', got %v", dbRecord.Metadata.Extra)
+ }
+
+ if putCount != 1 {
+ t.Errorf("expected 1 storage Put operation, got %d", putCount)
+ }
+ })
+
+ t.Run("upload blocked extension file", func(t *testing.T) {
+ // System config allowed: jpg,png,webp. Uploading docx should be blocked.
+ contentType, body := createMultipartRequest(t, "file", "contract.docx", []byte("fake docx content"), nil)
+ req, _ := http.NewRequest("POST", "/api/v1/upload", body)
+ req.Header.Set("Content-Type", contentType)
+
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
+ }
+
+ var resp testResponse
+ json.Unmarshal(w.Body.Bytes(), &resp)
+ if resp.Success || !strings.Contains(resp.Message, ErrUnsupportedFormat) {
+ t.Errorf("expected unsupported format error, got: %v", resp)
+ }
+ })
+
+ t.Run("instant upload deduplication (秒传)", func(t *testing.T) {
+ putCount = 0
+ imgContent := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01")
+
+ // Upload first time
+ contentType1, body1 := createMultipartRequest(t, "file", "avatar1.png", imgContent, map[string]string{"type": "avatar"})
+ req1, _ := http.NewRequest("POST", "/api/v1/upload", body1)
+ req1.Header.Set("Content-Type", contentType1)
+ w1 := httptest.NewRecorder()
+ router.ServeHTTP(w1, req1)
+
+ if w1.Code != http.StatusOK {
+ t.Fatalf("first upload failed: %s", w1.Body.String())
+ }
+ if putCount != 1 {
+ t.Errorf("expected 1 put count on first upload, got %d", putCount)
+ }
+
+ // Upload same file second time (different filename, same content)
+ contentType2, body2 := createMultipartRequest(t, "file", "avatar2.png", imgContent, map[string]string{"type": "avatar"})
+ req2, _ := http.NewRequest("POST", "/api/v1/upload", body2)
+ req2.Header.Set("Content-Type", contentType2)
+ w2 := httptest.NewRecorder()
+ router.ServeHTTP(w2, req2)
+
+ if w2.Code != http.StatusOK {
+ t.Fatalf("second upload failed: %s", w2.Body.String())
+ }
+
+ var resp2 testResponse
+ json.Unmarshal(w2.Body.Bytes(), &resp2)
+
+ if !resp2.Success {
+ t.Fatalf("second upload was unsuccessful: %s", resp2.Message)
+ }
+
+ var uploadRecord2 model.Upload
+ if err := json.Unmarshal(resp2.Data, &uploadRecord2); err != nil {
+ t.Fatalf("failed to unmarshal second upload record: %v", err)
+ }
+
+ // Check if it triggered another storage put
+ if putCount != 1 {
+ t.Errorf("PutObject was triggered again! Expected deduplication (putCount=1), got putCount=%d", putCount)
+ }
+
+ // Check if database contains both records sharing the same FilePath
+ var records []model.Upload
+ dbConn.Where("hash = ?", uploadRecord2.Hash).Find(&records)
+ if len(records) != 2 {
+ t.Errorf("expected 2 database records sharing the same hash, got %d", len(records))
+ }
+ if records[0].FilePath != records[1].FilePath {
+ t.Errorf("file paths are different: %s vs %s", records[0].FilePath, records[1].FilePath)
+ }
+ if records[0].ID == records[1].ID {
+ t.Error("database record IDs should be unique")
+ }
+
+ t.Logf("Instant upload success. Record 1: %d, Record 2: %d", records[0].ID, records[1].ID)
+ })
+
+ t.Run("upload in local storage fallback mode", func(t *testing.T) {
+ // Turn off S3
+ storage.IsEnabledFunc = func() bool { return false }
+
+ // Seed allowed extensions configuration to allow txt files
+ var sc model.SystemConfig
+ dbConn.Where("key = ?", model.ConfigKeyUploadAllowedExtensions).First(&sc)
+ sc.Value = "jpg,png,webp,txt"
+ dbConn.Save(&sc)
+ _ = db.HSetJSON(context.Background(), model.SystemConfigRedisHashKey, sc.Key, &sc)
+
+ contentType, body := createMultipartRequest(t, "file", "doc.txt", []byte("hello world generic document file"), map[string]string{
+ "type": "document",
+ })
+ req, _ := http.NewRequest("POST", "/api/v1/upload", body)
+ req.Header.Set("Content-Type", contentType)
+
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
+ }
+
+ var resp testResponse
+ json.Unmarshal(w.Body.Bytes(), &resp)
+
+ if !resp.Success {
+ t.Fatalf("local upload failed: %s", resp.Message)
+ }
+
+ var localRecord model.Upload
+ if err := json.Unmarshal(resp.Data, &localRecord); err != nil {
+ t.Fatalf("failed to unmarshal local upload record: %v", err)
+ }
+
+ if localRecord.StorageDriver != "local" {
+ t.Errorf("expected storage driver local, got %s", localRecord.StorageDriver)
+ }
+
+ // Confirm file was actually written to local disk
+ fileContent, err := os.ReadFile(localRecord.FilePath)
+ if err != nil {
+ t.Fatalf("failed to read local file: %v", err)
+ }
+
+ if string(fileContent) != "hello world generic document file" {
+ t.Errorf("unexpected local file contents: %s", string(fileContent))
+ }
+ })
+}
+
+func TestDownloadFile(t *testing.T) {
+ dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
+ defer cleanup()
+ defer os.RemoveAll("uploads")
+
+ authUser := &model.User{ID: 1001, Username: "test_user"}
+ router := setupTestRouter(authUser)
+
+ // Seed upload records in DB
+ localUpload := model.Upload{
+ ID: 2001,
+ UserID: 1001,
+ FileName: "中文文件名.txt",
+ FilePath: "uploads/test_download.txt",
+ FileSize: 12,
+ MimeType: "text/plain",
+ Extension: "txt",
+ StorageDriver: "local",
+ Status: model.UploadStatusUsed,
+ }
+
+ // Create local file
+ err := os.MkdirAll("uploads", 0755)
+ if err != nil {
+ t.Fatalf("failed to create directory: %v", err)
+ }
+ err = os.WriteFile(localUpload.FilePath, []byte("hello download"), 0644)
+ if err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+
+ dbConn.Create(&localUpload)
+
+ t.Run("download file successfully", func(t *testing.T) {
+ req, _ := http.NewRequest("GET", "/api/v1/upload/download/2001", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
+ }
+
+ if w.Body.String() != "hello download" {
+ t.Errorf("expected body 'hello download', got '%s'", w.Body.String())
+ }
+
+ // Verify Content-Disposition header (supports UTF-8 escaping)
+ contentDisp := w.Header().Get("Content-Disposition")
+ expectedDisp := "attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6%E5%90%8D.txt"
+ if contentDisp != expectedDisp {
+ t.Errorf("expected Content-Disposition header %q, got %q", expectedDisp, contentDisp)
+ }
+
+ if w.Header().Get("Content-Type") != "text/plain" {
+ t.Errorf("expected Content-Type text/plain, got %s", w.Header().Get("Content-Type"))
+ }
+ })
+
+ t.Run("download non-existent file", func(t *testing.T) {
+ req, _ := http.NewRequest("GET", "/api/v1/upload/download/9999", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Errorf("expected status 404, got %d", w.Code)
+ }
+ })
+}
+
+func TestBatchDownloadFiles(t *testing.T) {
+ dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
+ defer cleanup()
+ defer os.RemoveAll("uploads")
+
+ authUser := &model.User{ID: 1001, Username: "test_user"}
+ router := setupTestRouter(authUser)
+
+ // Create and write files locally
+ err := os.MkdirAll("uploads", 0755)
+ if err != nil {
+ t.Fatalf("failed to create local dir: %v", err)
+ }
+
+ _ = os.WriteFile("uploads/f1.txt", []byte("file1 content"), 0644)
+ _ = os.WriteFile("uploads/f2.txt", []byte("file2 content"), 0644)
+ _ = os.WriteFile("uploads/f3.txt", []byte("duplicate name file content"), 0644)
+
+ // Seed upload records. Note f2 and f3 have the same FileName "file_a.txt" to trigger name collision resolution.
+ uploads := []model.Upload{
+ {
+ ID: 3001,
+ UserID: 1001,
+ FileName: "file_a.txt",
+ FilePath: "uploads/f1.txt",
+ FileSize: 13,
+ MimeType: "text/plain",
+ Extension: "txt",
+ StorageDriver: "local",
+ Status: model.UploadStatusUsed,
+ },
+ {
+ ID: 3002,
+ UserID: 1001,
+ FileName: "file_b.txt",
+ FilePath: "uploads/f2.txt",
+ FileSize: 13,
+ MimeType: "text/plain",
+ Extension: "txt",
+ StorageDriver: "local",
+ Status: model.UploadStatusUsed,
+ },
+ {
+ ID: 3003,
+ UserID: 1001,
+ FileName: "file_a.txt", // COLLISION with 3001!
+ FilePath: "uploads/f3.txt",
+ FileSize: 28,
+ MimeType: "text/plain",
+ Extension: "txt",
+ StorageDriver: "local",
+ Status: model.UploadStatusUsed,
+ },
+ }
+
+ for _, up := range uploads {
+ dbConn.Create(&up)
+ }
+
+ t.Run("batch download zip successfully and check duplicate renaming", func(t *testing.T) {
+ reqBody, _ := json.Marshal(batchDownloadRequest{
+ IDs: []string{"3001", "3002", "3003"},
+ })
+ req, _ := http.NewRequest("POST", "/api/v1/upload/download/batch", bytes.NewReader(reqBody))
+ req.Header.Set("Content-Type", "application/json")
+
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
+ }
+
+ if w.Header().Get("Content-Type") != "application/zip" {
+ t.Errorf("expected Content-Type application/zip, got %s", w.Header().Get("Content-Type"))
+ }
+
+ // Unzip in-memory
+ zipReader, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len()))
+ if err != nil {
+ t.Fatalf("failed to read zip buffer: %v", err)
+ }
+
+ if len(zipReader.File) != 3 {
+ t.Errorf("expected 3 files inside the ZIP, got %d", len(zipReader.File))
+ }
+
+ // Extract files to check their contents and name collision resolutions
+ extracted := make(map[string]string)
+ for _, f := range zipReader.File {
+ rc, err := f.Open()
+ if err != nil {
+ t.Fatalf("failed to open zip file entry %s: %v", f.Name, err)
+ }
+ content, _ := io.ReadAll(rc)
+ rc.Close()
+ extracted[f.Name] = string(content)
+ }
+
+ // Checks
+ if extracted["file_a.txt"] != "file1 content" {
+ t.Errorf("file_a.txt content incorrect: %q", extracted["file_a.txt"])
+ }
+ if extracted["file_b.txt"] != "file2 content" {
+ t.Errorf("file_b.txt content incorrect: %q", extracted["file_b.txt"])
+ }
+ // The second file_a.txt should be renamed to file_a_1.txt
+ if extracted["file_a_1.txt"] != "duplicate name file content" {
+ t.Errorf("file_a_1.txt content incorrect: %q. Extracted files: %v", extracted["file_a_1.txt"], extracted)
+ }
+
+ t.Logf("Successfully unzipped batch. Extracted files: %+v", extracted)
+ })
+}
diff --git a/internal/apps/user/access_tokens.go b/internal/apps/user/access_tokens.go
new file mode 100644
index 00000000..ff7d95e4
--- /dev/null
+++ b/internal/apps/user/access_tokens.go
@@ -0,0 +1,219 @@
+/*
+Copyright 2025 linux.do
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package user
+
+import (
+ "strconv"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/linux-do/credit/internal/apps/oauth"
+ "github.com/linux-do/credit/internal/common/response"
+ "github.com/linux-do/credit/internal/db"
+ "github.com/linux-do/credit/internal/model"
+ "github.com/linux-do/credit/internal/util"
+)
+
+type createTokenRequest struct {
+ Name string `json:"name"`
+}
+
+type tokenResponse struct {
+ Token string `json:"token"`
+ Record model.AccessToken `json:"record"`
+}
+
+// ListAccessTokens 获取当前用户的 AccessToken 列表
+// @Summary 获取当前用户的 AccessToken 列表
+// @Description 返回当前登录用户的所有 active access tokens(脱敏后)
+// @Tags user
+// @Produce json
+// @Security SessionCookie
+// @Success 200 {object} util.ResponseAny{data=[]model.AccessToken} "令牌列表"
+// @Failure 401 {object} util.ResponseAny "未登录"
+// @Router /api/v1/user/access-tokens [get]
+func ListAccessTokens(c *gin.Context) {
+ currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
+ ctx := c.Request.Context()
+
+ var tokens []model.AccessToken
+ if err := db.DB(ctx).Where("user_id = ?", currUser.ID).Order("created_at desc").Find(&tokens).Error; err != nil {
+ response.RespondFailure(c, err.Error())
+ return
+ }
+
+ response.RespondSuccess(c, tokens)
+}
+
+// CreateAccessToken 创建一个新的 AccessToken
+// @Summary 创建一个新的 AccessToken
+// @Description 为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。
+// @Tags user
+// @Accept json
+// @Produce json
+// @Param request body user.createTokenRequest true "令牌名称"
+// @Security SessionCookie
+// @Success 200 {object} util.ResponseAny{data=user.tokenResponse} "新建令牌成功"
+// @Failure 400 {object} util.ResponseAny "参数错误或超限"
+// @Router /api/v1/user/access-tokens [post]
+func CreateAccessToken(c *gin.Context) {
+ currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
+ ctx := c.Request.Context()
+
+ var req createTokenRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.RespondFailure(c, "参数绑定失败")
+ return
+ }
+
+ req.Name = strings.TrimSpace(req.Name)
+ if req.Name == "" {
+ response.RespondFailure(c, "令牌名称不能为空")
+ return
+ }
+
+ // 检查最大限制(基于 ConfigKeyMaxAPIKeysPerUser 配置,默认值为 5)
+ maxLimit := 5
+ if val, err := model.GetIntByKey(ctx, model.ConfigKeyMaxAPIKeysPerUser); err == nil {
+ maxLimit = val
+ }
+
+ var count int64
+ if err := db.DB(ctx).Model(&model.AccessToken{}).Where("user_id = ?", currUser.ID).Count(&count).Error; err != nil {
+ response.RespondFailure(c, err.Error())
+ return
+ }
+
+ if int(count) >= maxLimit {
+ response.RespondFailure(c, "已达到访问令牌最大创建数量限制")
+ return
+ }
+
+ // 生成 Token
+ tokenStr, err := model.GenerateTokenString()
+ if err != nil {
+ response.RespondFailure(c, "生成令牌失败")
+ return
+ }
+
+ tokenHash := model.HashToken(tokenStr)
+ maskedToken := model.MaskTokenString(tokenStr)
+
+ tokenRecord := model.AccessToken{
+ UserID: currUser.ID,
+ Name: req.Name,
+ TokenHash: tokenHash,
+ MaskedToken: maskedToken,
+ }
+
+ if err := db.DB(ctx).Create(&tokenRecord).Error; err != nil {
+ response.RespondFailure(c, err.Error())
+ return
+ }
+
+ response.RespondSuccess(c, tokenResponse{
+ Token: tokenStr,
+ Record: tokenRecord,
+ })
+}
+
+// DeleteAccessToken 删除一个 AccessToken
+// @Summary 删除一个 AccessToken
+// @Description 撤销并删除一个属于当前用户的 API 访问令牌
+// @Tags user
+// @Produce json
+// @Param id path string true "令牌ID"
+// @Security SessionCookie
+// @Success 200 {object} util.ResponseAny{data=string} "删除成功"
+// @Failure 400 {object} util.ResponseAny "参数错误"
+// @Router /api/v1/user/access-tokens/{id} [delete]
+func DeleteAccessToken(c *gin.Context) {
+ currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
+ ctx := c.Request.Context()
+
+ idStr := c.Param("id")
+ id, err := strconv.ParseUint(idStr, 10, 64)
+ if err != nil {
+ response.RespondFailure(c, "无效的令牌ID")
+ return
+ }
+
+ tx := db.DB(ctx).Where("id = ? AND user_id = ?", id, currUser.ID).Delete(&model.AccessToken{})
+ if tx.Error != nil {
+ response.RespondFailure(c, tx.Error.Error())
+ return
+ }
+
+ if tx.RowsAffected == 0 {
+ response.RespondFailure(c, "令牌不存在或无权操作")
+ return
+ }
+
+ response.RespondSuccess(c, "删除成功")
+}
+
+// RotateAccessToken 轮换一个 AccessToken
+// @Summary 轮换一个 AccessToken
+// @Description 轮换(重新生成)一个属于当前用户的 API 访问令牌的密钥,旧令牌将立即失效
+// @Tags user
+// @Produce json
+// @Param id path string true "令牌ID"
+// @Security SessionCookie
+// @Success 200 {object} util.ResponseAny{data=user.tokenResponse} "令牌轮换成功"
+// @Failure 400 {object} util.ResponseAny "参数错误"
+// @Router /api/v1/user/access-tokens/{id}/rotate [post]
+func RotateAccessToken(c *gin.Context) {
+ currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
+ ctx := c.Request.Context()
+
+ idStr := c.Param("id")
+ id, err := strconv.ParseUint(idStr, 10, 64)
+ if err != nil {
+ response.RespondFailure(c, "无效的令牌ID")
+ return
+ }
+
+ var tokenRecord model.AccessToken
+ if err := db.DB(ctx).Where("id = ? AND user_id = ?", id, currUser.ID).First(&tokenRecord).Error; err != nil {
+ response.RespondFailure(c, "令牌不存在或无权操作")
+ return
+ }
+
+ // 生成新的 Token
+ newTokenStr, err := model.GenerateTokenString()
+ if err != nil {
+ response.RespondFailure(c, "生成令牌失败")
+ return
+ }
+
+ newTokenHash := model.HashToken(newTokenStr)
+ newMaskedToken := model.MaskTokenString(newTokenStr)
+
+ tokenRecord.TokenHash = newTokenHash
+ tokenRecord.MaskedToken = newMaskedToken
+ tokenRecord.LastUsedAt = nil // 轮换后重置使用时间
+
+ if err := db.DB(ctx).Save(&tokenRecord).Error; err != nil {
+ response.RespondFailure(c, err.Error())
+ return
+ }
+
+ response.RespondSuccess(c, tokenResponse{
+ Token: newTokenStr,
+ Record: tokenRecord,
+ })
+}
diff --git a/internal/config/model.go b/internal/config/model.go
index 1923fb0f..86d7a856 100644
--- a/internal/config/model.go
+++ b/internal/config/model.go
@@ -27,7 +27,6 @@ type configModel struct {
Scheduler schedulerConfig `mapstructure:"scheduler"`
Worker workerConfig `mapstructure:"worker"`
ClickHouse clickHouseConfig `mapstructure:"clickhouse"`
- LinuxDo linuxDoConfig `mapstructure:"linuxdo"`
OpenAPIRisk openAPIRiskConfig `mapstructure:"openapi_risk"`
Otel otelConfig `mapstructure:"otel"`
S3 s3Config `mapstructure:"s3"`
@@ -42,7 +41,6 @@ type appConfig struct {
APIPrefix string `mapstructure:"api_prefix"`
GracefulShutdownTimeout int `mapstructure:"graceful_shutdown_timeout"`
FrontendURL string `mapstructure:"frontend_url"`
- FrontendPayURL string `mapstructure:"frontend_pay_url"`
SessionCookieName string `mapstructure:"session_cookie_name"`
SessionSecret string `mapstructure:"session_secret"`
SessionDomain string `mapstructure:"session_domain"`
@@ -163,11 +161,6 @@ type QueueConfig struct {
Priority int `mapstructure:"priority"`
}
-// linuxDoConfig
-type linuxDoConfig struct {
- ApiKey string `mapstructure:"api_key"`
-}
-
// openAPIRiskConfig OpenAPI 用户风险配置
type openAPIRiskConfig struct {
Enabled bool `mapstructure:"enabled"`
diff --git a/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go
index 412099f2..3144abfb 100644
--- a/internal/db/migrator/migrator.go
+++ b/internal/db/migrator/migrator.go
@@ -37,6 +37,7 @@ func Migrate() {
&model.ExternalAccount{},
&model.SystemConfig{},
&model.Upload{},
+ &model.AccessToken{},
); err != nil {
log.Fatalf("[PostgreSQL] auto migrate failed: %v\n", err)
}
diff --git a/internal/model/access_token.go b/internal/model/access_token.go
new file mode 100644
index 00000000..c73e5148
--- /dev/null
+++ b/internal/model/access_token.go
@@ -0,0 +1,60 @@
+/*
+Copyright 2025 linux.do
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package model
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "time"
+)
+
+type AccessToken struct {
+ ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
+ UserID uint64 `json:"user_id" gorm:"index;not null"`
+ Name string `json:"name" gorm:"size:128;not null"`
+ TokenHash string `json:"-" gorm:"size:64;uniqueIndex;not null"`
+ MaskedToken string `json:"masked_token" gorm:"size:64;not null"`
+ LastUsedAt *time.Time `json:"last_used_at"`
+ CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
+ UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
+}
+
+// GenerateTokenString 生成加密安全的随机 Token 值
+func GenerateTokenString() (string, error) {
+ bytes := make([]byte, 24)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("at_%s", hex.EncodeToString(bytes)), nil
+}
+
+// HashToken 计算 Token 的 SHA-256 哈希值用于数据库存储与查询
+func HashToken(token string) string {
+ h := sha256.New()
+ h.Write([]byte(token))
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+// MaskTokenString 生成脱敏显示的 Token,仅保留前缀和最后四位
+func MaskTokenString(token string) string {
+ if len(token) <= 8 {
+ return "at_****"
+ }
+ return fmt.Sprintf("%s...%s", token[:7], token[len(token)-4:])
+}
diff --git a/internal/model/uploads.go b/internal/model/uploads.go
index 206a310d..f26d65ab 100644
--- a/internal/model/uploads.go
+++ b/internal/model/uploads.go
@@ -29,20 +29,32 @@ const (
UploadStatusDeleted UploadStatus = "deleted" // 已删除
)
-// UploadType 上传类型常量
-const (
- UploadTypeCover = "cover" // 红包背景封面
- UploadTypeHeterotypic = "heterotypic" // 红包异形装饰
-)
+// UploadMetadata 自定义可扩展的 JSON 字段存储非核心或可选的文件元数据
+type UploadMetadata struct {
+ Width int `json:"width,omitempty"` // 图像/视频宽度 (px)
+ Height int `json:"height,omitempty"` // 图像/视频高度 (px)
+ Duration float64 `json:"duration,omitempty"` // 音视频时长 (s)
+ OriginalMime string `json:"original_mime,omitempty"` // 原始 MIME 类型
+ UserAgent string `json:"user_agent,omitempty"` // 上传者的 UA
+ ClientIP string `json:"client_ip,omitempty"` // 上传者 IP
+ Bucket string `json:"bucket,omitempty"` // 存储桶名称 (适用于 S3 等)
+ Extra map[string]any `json:"extra,omitempty"` // 其它任意业务自定义元数据
+}
// Upload 上传文件记录
type Upload struct {
- ID uint64 `json:"id,string" gorm:"primaryKey"`
- UserID uint64 `json:"user_id,string" gorm:"index;not null"`
- FilePath string `json:"file_path" gorm:"size:500;not null;uniqueIndex"` // 文件路径
- FileSize int64 `json:"file_size" gorm:"not null"` // 文件大小(字节)
- Type string `json:"type" gorm:"column:type;size:50;not null;index"` // 类型 (cover, heterotypic)
- Status UploadStatus `json:"status" gorm:"type:varchar(20);not null"` // 状态
- CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
- UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
+ ID uint64 `json:"id,string" gorm:"primaryKey"`
+ UserID uint64 `json:"user_id,string" gorm:"index;not null"`
+ FileName string `json:"file_name" gorm:"size:255;not null"` // 原始文件名 (例如: image.png)
+ FilePath string `json:"file_path" gorm:"size:500;not null;index"` // 文件相对路径 / S3 Key
+ FileSize int64 `json:"file_size" gorm:"not null"` // 文件大小(字节)
+ MimeType string `json:"mime_type" gorm:"size:100;not null"` // 媒体类型 (MIME, 如 image/png)
+ Extension string `json:"extension" gorm:"size:50;not null"` // 文件后缀名 (不含点,如 png, pdf)
+ Hash string `json:"hash" gorm:"size:64;index"` // 文件哈希 (SHA-256/MD5,可用于排重)
+ StorageDriver string `json:"storage_driver" gorm:"size:50;not null"` // 存储引擎驱动 (如 local, s3, oss)
+ Type string `json:"type" gorm:"column:type;size:50;not null;index"` // 业务标识类型 (如 avatar, doc, attachment)
+ Status UploadStatus `json:"status" gorm:"type:varchar(20);not null"` // 状态
+ Metadata UploadMetadata `json:"metadata" gorm:"serializer:json;type:jsonb"` // 业务扩展元数据
+ CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
+ UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
diff --git a/internal/router/router.go b/internal/router/router.go
index a2fc814a..14d86d5c 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -127,13 +127,27 @@ func Serve() {
userRouter.POST("/register", user.Register)
userRouter.GET("/logout", user.Logout)
userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo)
+
+ // Access Token
+ tokenRouter := userRouter.Group("/access-tokens")
+ tokenRouter.Use(oauth.LoginRequired())
+ {
+ tokenRouter.GET("", user.ListAccessTokens)
+ tokenRouter.POST("", user.CreateAccessToken)
+ tokenRouter.DELETE("/:id", user.DeleteAccessToken)
+ tokenRouter.POST("/:id/rotate", user.RotateAccessToken)
+ }
}
// Upload
uploadRouter := apiV1Router.Group("/upload")
uploadRouter.Use(oauth.LoginRequired())
{
- // Keep generic uploads if needed
+ uploadRouter.POST("", upload.UploadFile)
+ uploadRouter.GET("/my", upload.ListMyFiles)
+ uploadRouter.DELETE("/:id", upload.DeleteFile)
+ uploadRouter.GET("/download/:id", upload.DownloadFile)
+ uploadRouter.POST("/download/batch", upload.BatchDownloadFiles)
}
// Config (public)
diff --git a/internal/storage/s3.go b/internal/storage/s3.go
index f57f0925..e4da1cd0 100644
--- a/internal/storage/s3.go
+++ b/internal/storage/s3.go
@@ -74,17 +74,52 @@ func init() {
log.Printf("[Storage] S3 storage initialized (bucket: %s, prefix: %s, cdn: %s)\n", bucket, keyPrefix, cdnURL)
}
-func IsEnabled() bool {
+var IsEnabledFunc = func() bool {
return client != nil
}
+func IsEnabled() bool {
+ return IsEnabledFunc()
+}
+
// BuildKey constructs a full S3 object key with the configured prefix.
func BuildKey(path string) string {
return keyPrefix + path
}
+var (
+ // PutObjectFunc enables mocking S3 uploads in tests.
+ PutObjectFunc = putObjectDefault
+ // GetObjectFunc enables mocking S3 downloads in tests.
+ GetObjectFunc = getObjectDefault
+ // DeleteObjectFunc enables mocking S3 deletion in tests.
+ DeleteObjectFunc = deleteObjectDefault
+)
+
+// MockStorage is a test helper to mock S3 storage operations.
+// It returns a function that restores original implementations.
+func MockStorage(
+ mockPut func(ctx context.Context, key string, body io.Reader, size int64, contentType string) error,
+ mockGet func(ctx context.Context, key string) (*ObjectInfo, error),
+ mockDelete func(ctx context.Context, key string) error,
+) func() {
+ origPut, origGet, origDelete := PutObjectFunc, GetObjectFunc, DeleteObjectFunc
+ PutObjectFunc = mockPut
+ GetObjectFunc = mockGet
+ DeleteObjectFunc = mockDelete
+ return func() {
+ PutObjectFunc = origPut
+ GetObjectFunc = origGet
+ DeleteObjectFunc = origDelete
+ }
+}
+
// PutObject uploads a file to S3.
func PutObject(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
+ return PutObjectFunc(ctx, key, body, size, contentType)
+}
+
+func putObjectDefault(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
ctx, span := otel_trace.Start(ctx, "S3.PutObject", trace.WithSpanKind(trace.SpanKindClient))
defer span.End()
@@ -125,6 +160,10 @@ type ObjectInfo struct {
// GetObject retrieves a file directly from S3.
func GetObject(ctx context.Context, key string) (*ObjectInfo, error) {
+ return GetObjectFunc(ctx, key)
+}
+
+func getObjectDefault(ctx context.Context, key string) (*ObjectInfo, error) {
ctx, span := otel_trace.Start(ctx, "S3.GetObject", trace.WithSpanKind(trace.SpanKindClient))
defer span.End()
@@ -206,6 +245,10 @@ func GetObjectViaProxy(ctx context.Context, key string) (*ObjectInfo, error) {
// DeleteObject deletes a file from S3.
func DeleteObject(ctx context.Context, key string) error {
+ return DeleteObjectFunc(ctx, key)
+}
+
+func deleteObjectDefault(ctx context.Context, key string) error {
ctx, span := otel_trace.Start(ctx, "S3.DeleteObject", trace.WithSpanKind(trace.SpanKindClient))
defer span.End()