From b9192db0420acf1d1a13e0438eeac95a82106262 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Wed, 27 May 2026 00:57:21 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E7=9B=B4=E6=8E=A5=E8=BF=94=E5=9B=9E=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=20URL=20+=20=E6=8F=90=E7=8E=B0=20content=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E9=9D=9E=E5=BF=85=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FileUploadResponse / FileUploadCompleteResponse 精简为只返回 url - S3Store.BuildObjectURL 拼接完整访问地址 - 提现 method=0 时 content 不再参与必填校验 - 新增用户端 API 接口文档 --- apis/public/file.api | 15 +- doc/api-withdrawal-and-log.md | 560 ++++++++++++++++++ .../database/02152_withdrawal_method.up.sql | 14 +- .../public/file/fileuploadcompletelogic.go | 7 +- internal/logic/public/file/fileuploadlogic.go | 11 +- .../public/user/commissionWithdrawLogic.go | 4 +- internal/types/types.go | 15 +- 7 files changed, 579 insertions(+), 47 deletions(-) create mode 100644 doc/api-withdrawal-and-log.md diff --git a/apis/public/file.api b/apis/public/file.api index a9b79ba..b2cd9ca 100644 --- a/apis/public/file.api +++ b/apis/public/file.api @@ -16,13 +16,7 @@ type ( } FileUploadResponse { - FileId string `json:"file_id"` - FileName string `json:"file_name"` - ObjectKey string `json:"object_key"` - Size int64 `json:"size"` - ContentType string `json:"content_type"` - Etag string `json:"etag"` - Status string `json:"status"` + Url string `json:"url"` } FileUploadInitRequest { @@ -47,12 +41,7 @@ type ( } FileUploadCompleteResponse { - FileId string `json:"file_id"` - ObjectKey string `json:"object_key"` - Size int64 `json:"size"` - ContentType string `json:"content_type"` - Etag string `json:"etag"` - Status string `json:"status"` + Url string `json:"url"` } ) diff --git a/doc/api-withdrawal-and-log.md b/doc/api-withdrawal-and-log.md new file mode 100644 index 0000000..a597f12 --- /dev/null +++ b/doc/api-withdrawal-and-log.md @@ -0,0 +1,560 @@ +# 提现 & 文件上传 & 日志上报 — 用户端 API 接口文档 + +> 基于 ppanel-server 源码整理,所有时间戳均为**秒级 Unix**。 + +--- + +## 目录 + +- [一、提现接口](#一提现接口) + - [1.1 申请提现](#11-申请提现) + - [1.2 取消提现](#12-取消提现) + - [1.3 查询提现记录](#13-查询提现记录) +- [二、枚举值与状态流转](#二枚举值与状态流转) +- [三、文件上传接口](#三文件上传接口) + - [3.1 直传文件(小文件)](#31-直传文件小文件) + - [3.2 初始化上传(大文件 — 预签名)](#32-初始化上传大文件--预签名) + - [3.3 确认上传完成](#33-确认上传完成) +- [四、日志查询接口 (Admin)](#四日志查询接口-admin) + - [4.1 错误日志列表](#41-错误日志列表) + - [4.2 错误日志详情](#42-错误日志详情) + - [4.3 日志消息原始详情](#43-日志消息原始详情) + +--- + +## 一、提现接口 + +> 认证方式: JWT(用户登录态) +> +> 路由前缀: `/v1/public/user` + +### 1.1 申请提现 + +提交佣金提现申请,创建一条待审核的提现记录。 + +``` +POST /v1/public/user/commission_withdraw +``` + +**Request Body** + +| 字段 | 类型 | 必填 | 校验 | 说明 | +|------|------|------|------|------| +| `amount` | int64 | 是 | — | 提现金额(分) | +| `method` | uint8 | 是 | `oneof=0 1 2 3` | 收款方式(见枚举表) | +| `content` | string | 否 | — | 提现备注 | +| `account` | string | 条件必填 | — | 收款账号 | +| `qr_code_url` | string | 条件必填 | — | 收款码图片 URL | + +**各收款方式的必填字段** + +| method | 收款方式 | 必填字段 | +|--------|---------|---------| +| `1` 支付宝 | `qr_code_url` | 收款码图片 | +| `2` 微信 | `qr_code_url` | 收款码图片 | +| `3` 银行卡 | `account` | 收款账号 | +| `0` 其他 | `account` 必填 | + +**Request 示例** + +```json +{ + "amount": 5000, + "content": "提现到支付宝", + "method": 1, + "account": "user@example.com", + "qr_code_url": "https://cdn.example.com/qrcode/alipay.png" +} +``` + +**Response**: [`WithdrawalLog`](#withdrawallog-对象) + +--- + +### 1.2 取消提现 + +用户取消自己的待审核提现申请,佣金退回账户。 + +``` +POST /v1/public/user/withdrawal_cancel +``` + +**Request Body** + +| 字段 | 类型 | 必填 | 校验 | 说明 | +|------|------|------|------|------| +| `withdrawal_id` | int64 | 是 | `required,gt=0` | 提现记录 ID | + +**Request 示例** + +```json +{ + "withdrawal_id": 123 +} +``` + +**Response**: [`WithdrawalLog`](#withdrawallog-对象)(状态已变为 `3=已取消`) + +--- + +### 1.3 查询提现记录 + +分页查询当前用户的提现记录(自动按 JWT 中的 userId 过滤)。 + +``` +GET /v1/public/user/withdrawal_log +``` + +**Query 参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `page` | int | 否 | 页码,默认 1 | +| `size` | int | 否 | 每页条数,默认 10 | + +**Request 示例** + +``` +GET /v1/public/user/withdrawal_log?page=1&size=10 +``` + +**Response** + +```json +{ + "list": [WithdrawalLog, ...], + "total": 25 +} +``` + +--- + +## 二、枚举值与状态流转 + +### 提现状态 (`status`) + +| 值 | 说明 | +|----|------| +| 0 | 待审核 | +| 1 | 已通过 | +| 2 | 已拒绝 | +| 3 | 已取消 | + +### 收款方式 (`method`) + +| 值 | 说明 | +|----|------| +| 0 | 其他 | +| 1 | 支付宝 | +| 2 | 微信 | +| 3 | 银行卡 | + +### 状态流转 + +``` + ┌── 管理员通过 ──▶ 已通过 (1) + │ +待审核 (0) ──────┼── 管理员拒绝 ──▶ 已拒绝 (2) + │ + └── 用户取消 ───▶ 已取消 (3) +``` + +### WithdrawalLog 对象 + +所有提现接口共用的响应结构: + +```json +{ + "id": 1, + "user_id": 100, + "amount": 5000, + "content": "提现备注", + "status": 0, + "reason": "", + "method": 1, + "account": "user@example.com", + "qr_code_url": "https://cdn.example.com/qrcode/alipay.png", + "created_at": 1716700000, + "updated_at": 1716700000 +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | int64 | 提现记录 ID | +| `user_id` | int64 | 用户 ID | +| `amount` | int64 | 提现金额(分) | +| `content` | string | 提现备注 | +| `status` | uint8 | 状态(见枚举表) | +| `reason` | string | 拒绝原因(仅 status=2 时有值,其余 omitempty) | +| `method` | uint8 | 收款方式(见枚举表) | +| `account` | string | 收款账号 | +| `qr_code_url` | string | 收款码图片 URL | +| `created_at` | int64 | 创建时间(秒级 Unix) | +| `updated_at` | int64 | 更新时间(秒级 Unix) | + +--- + +## 三、文件上传接口 + +> 认证方式: JWT + DeviceMiddleware(用户登录态 + 设备认证) +> +> 路由前缀: `/v1/public/file` +> +> 存储后端: S3 兼容(RustFS) + +提供两种上传方式: + +| 方式 | 适用场景 | 流程 | +|------|---------|------| +| **直传** | 小文件(收款码等) | 1 次请求,`multipart/form-data` 直接上传 | +| **预签名** | 大文件 / 客户端直传 S3 | init → 客户端 PUT 到预签名 URL → complete 确认 | + +--- + +### 3.1 直传文件(小文件) + +通过 `multipart/form-data` 直接上传文件到服务端,服务端转存至 S3。 + +``` +POST /v1/public/file/upload +Content-Type: multipart/form-data +``` + +**Form 参数** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `biz_type` | string | 是 | 业务类型(如 `withdrawal_qrcode`、`avatar` 等) | +| `file` | file | 是 | 上传的文件(multipart) | + +**cURL 示例** + +```bash +curl -X POST /v1/public/file/upload \ + -H "Authorization: Bearer " \ + -F "biz_type=withdrawal_qrcode" \ + -F "file=@/path/to/alipay_qr.png" +``` + +**Response** + +```json +{ + "file_id": "a1b2c3d4e5f678901234", + "file_name": "alipay_qr.png", + "object_key": "app-upload/2026/05/27/100/alipay_qr.png__a1b2c3d4e5f678901234", + "size": 52480, + "content_type": "image/png", + "etag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "status": "completed" +} +``` + +**FileUploadResponse 字段说明** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `file_id` | string | 文件唯一 ID(24 字符 hex) | +| `file_name` | string | 原始文件名 | +| `object_key` | string | S3 对象路径 | +| `size` | int64 | 文件大小(字节) | +| `content_type` | string | MIME 类型 | +| `etag` | string | S3 ETag | +| `status` | string | 状态,直传成功即 `completed` | + +--- + +### 3.2 初始化上传(大文件 — 预签名) + +获取 S3 预签名 URL,客户端直接 PUT 到 S3,避免文件经过服务端。 + +``` +POST /v1/public/file/upload/init +``` + +**Request Body** + +| 字段 | 类型 | 必填 | 校验 | 说明 | +|------|------|------|------|------| +| `biz_type` | string | 是 | `required` | 业务类型 | +| `file_name` | string | 是 | `required` | 文件名 | +| `content_type` | string | 是 | `required` | MIME 类型(如 `image/png`) | +| `size` | int64 | 是 | `required` | 文件大小(字节) | +| `sha256` | string | 否 | — | 文件 SHA256(可选校验) | + +**Request 示例** + +```json +{ + "biz_type": "withdrawal_qrcode", + "file_name": "wechat_qr.png", + "content_type": "image/png", + "size": 102400, + "sha256": "e3b0c44298fc1c149afbf4c8996fb924..." +} +``` + +**Response** + +```json +{ + "file_id": "b2c3d4e5f6789012345a", + "object_key": "app-upload/2026/05/27/100/wechat_qr.png__b2c3d4e5f6789012345a", + "upload_url": "https://s3.example.com/bucket/app-upload/...?X-Amz-Signature=...", + "method": "PUT", + "headers": { + "Content-Type": "image/png" + }, + "expired_at": 1716700300 +} +``` + +**FileUploadInitResponse 字段说明** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `file_id` | string | 文件唯一 ID | +| `object_key` | string | S3 对象路径 | +| `upload_url` | string | 预签名上传 URL | +| `method` | string | HTTP 方法(`PUT`) | +| `headers` | map | 上传时需携带的请求头 | +| `expired_at` | int64 | 预签名过期时间(秒级 Unix,默认 300 秒) | + +**客户端上传流程** + +``` +1. 调用 /upload/init 获取 upload_url +2. 用返回的 method + headers 直接上传文件到 upload_url +3. 上传成功后调用 /upload/complete 确认 +``` + +--- + +### 3.3 确认上传完成 + +客户端通过预签名 URL 上传完成后,调用此接口确认文件状态。 + +``` +POST /v1/public/file/upload/complete +``` + +**Request Body** + +| 字段 | 类型 | 必填 | 校验 | 说明 | +|------|------|------|------|------| +| `file_id` | string | 是 | `required` | init 返回的 file_id | + +**Request 示例** + +```json +{ + "file_id": "b2c3d4e5f6789012345a" +} +``` + +**Response** + +```json +{ + "file_id": "b2c3d4e5f6789012345a", + "object_key": "app-upload/2026/05/27/100/wechat_qr.png__b2c3d4e5f6789012345a", + "size": 102400, + "content_type": "image/png", + "etag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "status": "completed" +} +``` + +**FileUploadCompleteResponse 字段说明** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `file_id` | string | 文件唯一 ID | +| `object_key` | string | S3 对象路径 | +| `size` | int64 | 实际文件大小(S3 HeadObject 获取) | +| `content_type` | string | MIME 类型 | +| `etag` | string | S3 ETag | +| `status` | string | `completed` | + +**校验规则** + +- 文件大小不能超过配置的 `S3.MaxUploadSize` +- Content-Type 必须在配置的 `S3.AllowedContentTypes` 白名单内(若配置了) +- complete 时会校验 S3 上的实际文件大小是否与 init 声明的一致 +- 只能确认自己发起的上传(userId 校验) + +--- + +## 四、日志查询接口 (Admin) + +> 认证方式: AuthMiddleware(管理员权限) +> +> 路由前缀: `/v1/admin/log` +> +> 数据来源: `log_message` 表(客户端上报的错误/崩溃日志) + +--- + +### 4.1 错误日志列表 + +分页查询客户端上报的错误日志,支持多维度筛选。 + +``` +GET /v1/admin/log/error_message/list +``` + +**Query 参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `page` | int | 是 | 页码 | +| `size` | int | 是 | 每页条数 | +| `platform` | string | 否 | 平台筛选(ios / android / windows / mac / harmony) | +| `level` | uint8 | 否 | 日志级别 | +| `user_id` | int64 | 否 | 用户 ID | +| `device_id` | string | 否 | 设备 ID | +| `error_code` | string | 否 | 错误码 | +| `keyword` | string | 否 | 关键字搜索(匹配 message) | +| `start` | int64 | 否 | 开始时间(秒级 Unix) | +| `end` | int64 | 否 | 结束时间(秒级 Unix) | + +**Request 示例** + +``` +GET /v1/admin/log/error_message/list?page=1&size=20&platform=ios&start=1716600000&end=1716700000 +``` + +**Response** + +```json +{ + "total": 50, + "list": [ + { + "id": 1, + "platform": "ios", + "app_version": "2.1.0", + "os_name": "iOS", + "os_version": "17.5", + "device_id": "A1B2C3D4", + "user_id": 100, + "session_id": "sess_xxx", + "level": 3, + "error_code": "VPN_CONNECT_FAIL", + "message": "Failed to establish VPN tunnel", + "created_at": 1716700000 + } + ] +} +``` + +**ErrorLogMessage 字段说明** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | int64 | 日志 ID | +| `platform` | string | 平台 | +| `app_version` | string | 客户端版本 | +| `os_name` | string | 操作系统名称 | +| `os_version` | string | 操作系统版本 | +| `device_id` | string | 设备 ID | +| `user_id` | int64 | 用户 ID | +| `session_id` | string | 会话 ID | +| `level` | uint8 | 日志级别 | +| `error_code` | string | 错误码 | +| `message` | string | 错误消息 | +| `created_at` | int64 | 创建时间(秒级 Unix) | + +--- + +### 4.2 错误日志详情 + +获取单条错误日志的完整详情(列表字段 + 堆栈/IP/UA 等扩展信息)。 + +``` +GET /v1/admin/log/error_message/detail +``` + +**Response** + +```json +{ + "id": 1, + "platform": "ios", + "app_version": "2.1.0", + "os_name": "iOS", + "os_version": "17.5", + "device_id": "A1B2C3D4", + "user_id": 100, + "session_id": "sess_xxx", + "level": 3, + "error_code": "VPN_CONNECT_FAIL", + "message": "Failed to establish VPN tunnel", + "stack": "at VPNManager.connect() line 42\nat ...", + "client_ip": "1.2.3.4", + "user_agent": "PPanel/2.1.0 iOS/17.5", + "locale": "zh-CN", + "occurred_at": 1716700000, + "created_at": 1716700000 +} +``` + +**相比列表额外返回的字段** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `stack` | string | 堆栈信息 | +| `client_ip` | string | 客户端 IP | +| `user_agent` | string | User-Agent | +| `locale` | string | 客户端语言/地区 | +| `occurred_at` | int64 | 错误发生时间(秒级 Unix) | + +--- + +### 4.3 日志消息原始详情 + +获取单条 `log_message` 的完整原始数据(含 context、digest 等全量字段)。 + +``` +GET /v1/admin/log/message/detail +``` + +**Query 参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `id` | int64 | 是 | 日志消息 ID | + +**Response** + +```json +{ + "id": 1, + "platform": "ios", + "app_version": "2.1.0", + "os_name": "iOS", + "os_version": "17.5", + "device_id": "A1B2C3D4", + "user_id": 100, + "session_id": "sess_xxx", + "level": 3, + "error_code": "VPN_CONNECT_FAIL", + "message": "Failed to establish VPN tunnel", + "stack": "at VPNManager.connect() line 42\nat ...", + "context": { "server_id": 5, "protocol": "vmess" }, + "client_ip": "1.2.3.4", + "user_agent": "PPanel/2.1.0 iOS/17.5", + "locale": "zh-CN", + "digest": "sha256_abc123...", + "occurred_at": 1716700000, + "created_at": 1716700000 +} +``` + +**相比详情额外返回的字段** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `context` | any | 附加上下文(原始 JSON) | +| `digest` | string | 内容摘要(用于去重) | diff --git a/initialize/migrate/database/02152_withdrawal_method.up.sql b/initialize/migrate/database/02152_withdrawal_method.up.sql index 57f3104..84ecbc2 100644 --- a/initialize/migrate/database/02152_withdrawal_method.up.sql +++ b/initialize/migrate/database/02152_withdrawal_method.up.sql @@ -1,4 +1,10 @@ -ALTER TABLE `withdrawals` - ADD COLUMN `method` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '收款方式 0:其他 1:支付宝 2:微信 3:银行卡' AFTER `content`, - ADD COLUMN `account` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '收款账号' AFTER `method`, - ADD COLUMN `qr_code_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '收款码图片URL' AFTER `account`; +SELECT COUNT(*) INTO @col_exists FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'withdrawals' AND COLUMN_NAME = 'method'; + +SET @ddl = IF(@col_exists = 0, + 'ALTER TABLE `withdrawals` ADD COLUMN `method` TINYINT(1) NOT NULL DEFAULT 0 AFTER `content`, ADD COLUMN `account` VARCHAR(255) NOT NULL DEFAULT '''' AFTER `method`, ADD COLUMN `qr_code_url` VARCHAR(500) NOT NULL DEFAULT '''' AFTER `account`', + 'SELECT 1'); + +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/internal/logic/public/file/fileuploadcompletelogic.go b/internal/logic/public/file/fileuploadcompletelogic.go index 490c000..925ab25 100644 --- a/internal/logic/public/file/fileuploadcompletelogic.go +++ b/internal/logic/public/file/fileuploadcompletelogic.go @@ -61,11 +61,6 @@ func (l *FileUploadCompleteLogic) FileUploadComplete(req *types.FileUploadComple } return &types.FileUploadCompleteResponse{ - FileId: meta.FileID, - ObjectKey: meta.ObjectKey, - Size: head.ContentLength, - ContentType: head.ContentType, - Etag: head.ETag, - Status: meta.Status, + Url: l.svcCtx.S3Store.BuildObjectURL(meta.ObjectKey), }, nil } diff --git a/internal/logic/public/file/fileuploadlogic.go b/internal/logic/public/file/fileuploadlogic.go index b9f2a7b..05d9c3a 100644 --- a/internal/logic/public/file/fileuploadlogic.go +++ b/internal/logic/public/file/fileuploadlogic.go @@ -52,20 +52,13 @@ func (l *FileUploadLogic) FileUpload(req *types.FileUploadRequest, fileHeader *m fileID := buildFileID(u.Id, req.BizType, fileHeader.Filename) objectKey := buildObjectKey(l.svcCtx.Config.S3.Prefix, u.Id, req.BizType, fileID, fileHeader.Filename, now) - putResult, err := l.svcCtx.S3Store.PutObject(l.ctx, objectKey, file, fileHeader.Size, contentType) - if err != nil { + if _, err := l.svcCtx.S3Store.PutObject(l.ctx, objectKey, file, fileHeader.Size, contentType); err != nil { l.Errorw("put object failed", logger.Field("error", err.Error()), logger.Field("user_id", u.Id), logger.Field("file_id", fileID)) return nil, err } return &types.FileUploadResponse{ - FileId: fileID, - FileName: fileHeader.Filename, - ObjectKey: objectKey, - Size: fileHeader.Size, - ContentType: contentType, - Etag: putResult.ETag, - Status: fileUploadCompleteStatus, + Url: l.svcCtx.S3Store.BuildObjectURL(objectKey), }, nil } diff --git a/internal/logic/public/user/commissionWithdrawLogic.go b/internal/logic/public/user/commissionWithdrawLogic.go index 64546c1..162284b 100644 --- a/internal/logic/public/user/commissionWithdrawLogic.go +++ b/internal/logic/public/user/commissionWithdrawLogic.go @@ -46,8 +46,8 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account is required for bank transfer") } default: // WithdrawalMethodOther - if req.Account == "" && req.Content == "" { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account or content is required for other methods") + if req.Account == "" { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account is required for other methods") } } diff --git a/internal/types/types.go b/internal/types/types.go index 1e282c3..efba502 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -779,13 +779,7 @@ type FileUploadRequest struct { } type FileUploadResponse struct { - FileId string `json:"file_id"` - FileName string `json:"file_name"` - ObjectKey string `json:"object_key"` - Size int64 `json:"size"` - ContentType string `json:"content_type"` - Etag string `json:"etag"` - Status string `json:"status"` + Url string `json:"url"` } type FileUploadCompleteRequest struct { @@ -793,12 +787,7 @@ type FileUploadCompleteRequest struct { } type FileUploadCompleteResponse struct { - FileId string `json:"file_id"` - ObjectKey string `json:"object_key"` - Size int64 `json:"size"` - ContentType string `json:"content_type"` - Etag string `json:"etag"` - Status string `json:"status"` + Url string `json:"url"` } type FileUploadInitRequest struct {