Compare commits

..

1 Commits

Author SHA1 Message Date
shanshanzhong147 a9878bf9a7 修复(#73): 修正促销迁移表结构
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 00:52:43 -07:00
9 changed files with 49 additions and 650 deletions
+13 -2
View File
@@ -16,7 +16,13 @@ type (
}
FileUploadResponse {
Url string `json:"url"`
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"`
}
FileUploadInitRequest {
@@ -41,7 +47,12 @@ type (
}
FileUploadCompleteResponse {
Url string `json:"url"`
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"`
}
)
-560
View File
@@ -1,560 +0,0 @@
# 提现 & 文件上传 & 日志上报 — 用户端 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 <token>" \
-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 | 文件唯一 ID24 字符 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 | 内容摘要(用于去重) |
@@ -1,10 +1,4 @@
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;
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`;
+2 -20
View File
@@ -11,7 +11,6 @@ import (
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type PromoResult struct {
@@ -149,12 +148,6 @@ func evaluateInactiveUserPromo(
err := db.WithContext(ctx).
Model(&user.Subscribe{}).
Where("user_id = ?", userID).
Order(clause.OrderBy{
Expression: clause.Expr{
SQL: "CASE WHEN expire_time = ? THEN 0 ELSE 1 END",
Vars: []interface{}{permanentSubscribeExpireTime()},
},
}).
Order("expire_time DESC").
Limit(1).
Take(&lastSub).Error
@@ -165,19 +158,8 @@ func evaluateInactiveUserPromo(
return false, time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo inactive user subscription failed")
}
return isInactivePromoEligible(lastSub.ExpireTime, now, params.InactiveMonths), ruleExpiresAt, nil
}
func isInactivePromoEligible(expireTime time.Time, now time.Time, inactiveMonths int) bool {
if expireTime.Equal(permanentSubscribeExpireTime()) {
return false
}
threshold := now.AddDate(0, -inactiveMonths, 0)
return expireTime.Before(threshold) || expireTime.Equal(threshold)
}
func permanentSubscribeExpireTime() time.Time {
return time.UnixMilli(0)
threshold := now.AddDate(0, -params.InactiveMonths, 0)
return lastSub.ExpireTime.Before(threshold) || lastSub.ExpireTime.Equal(threshold), ruleExpiresAt, nil
}
func promoRuleExpiresAt(rule *promo.RuleWithPrice) time.Time {
@@ -1,51 +0,0 @@
package common
import (
"testing"
"time"
)
func TestIsInactivePromoEligible(t *testing.T) {
now := time.Date(2026, time.May, 27, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
expireAt time.Time
want bool
}{
{
name: "expired before inactive threshold is eligible",
expireAt: now.AddDate(0, -4, 0),
want: true,
},
{
name: "expired exactly at inactive threshold is eligible",
expireAt: now.AddDate(0, -3, 0),
want: true,
},
{
name: "recently expired subscription is not eligible",
expireAt: now.AddDate(0, -2, 0),
want: false,
},
{
name: "active future subscription is not eligible",
expireAt: now.Add(time.Hour),
want: false,
},
{
name: "permanent subscription marker is not eligible",
expireAt: time.UnixMilli(0),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isInactivePromoEligible(tt.expireAt, now, 3)
if got != tt.want {
t.Fatalf("isInactivePromoEligible() = %v, want %v", got, tt.want)
}
})
}
}
@@ -61,6 +61,11 @@ func (l *FileUploadCompleteLogic) FileUploadComplete(req *types.FileUploadComple
}
return &types.FileUploadCompleteResponse{
Url: l.svcCtx.S3Store.BuildObjectURL(meta.ObjectKey),
FileId: meta.FileID,
ObjectKey: meta.ObjectKey,
Size: head.ContentLength,
ContentType: head.ContentType,
Etag: head.ETag,
Status: meta.Status,
}, nil
}
@@ -52,13 +52,20 @@ 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)
if _, err := l.svcCtx.S3Store.PutObject(l.ctx, objectKey, file, fileHeader.Size, contentType); err != nil {
putResult, err := l.svcCtx.S3Store.PutObject(l.ctx, objectKey, file, fileHeader.Size, contentType)
if 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{
Url: l.svcCtx.S3Store.BuildObjectURL(objectKey),
FileId: fileID,
FileName: fileHeader.Filename,
ObjectKey: objectKey,
Size: fileHeader.Size,
ContentType: contentType,
Etag: putResult.ETag,
Status: fileUploadCompleteStatus,
}, nil
}
@@ -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 == "" {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account is required for other methods")
if req.Account == "" && req.Content == "" {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account or content is required for other methods")
}
}
+13 -2
View File
@@ -779,7 +779,13 @@ type FileUploadRequest struct {
}
type FileUploadResponse struct {
Url string `json:"url"`
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"`
}
type FileUploadCompleteRequest struct {
@@ -787,7 +793,12 @@ type FileUploadCompleteRequest struct {
}
type FileUploadCompleteResponse struct {
Url string `json:"url"`
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"`
}
type FileUploadInitRequest struct {