3e265bd837
- 还原 /v1/public/user/invite_sales 接口及 /invite/sales 别名(与 invite_records 并存) 逻辑/handler/types 与197fed7d删除前版本完全一致,按fefbd4f5新 routes 结构注册 - 新增迁移 02155_promo_schema_fix: 幂等修复 subscribe_promo / order 列类型与索引偏差 - 同步 etc/ppanel.yaml 数据库连接配置 - 补齐相关需求与设计文档
436 lines
15 KiB
Markdown
436 lines
15 KiB
Markdown
# App 端三个接口对接文档
|
||
|
||
> 适用:移动端 / 桌面端 App
|
||
> 维护:基于当前 `internal/handler` + `internal/logic` 代码反向梳理
|
||
> 时间:2026-05-27
|
||
|
||
涉及接口:
|
||
|
||
1. [文件上传](#1-文件上传) — `POST /v1/public/file/upload`
|
||
2. [订阅列表(含促销 promo)](#2-订阅列表含促销-promo) — `GET /v1/public/subscribe/list`
|
||
3. [邀请赠送记录](#3-邀请赠送记录) — `GET /v1/public/user/invite_records`
|
||
|
||
公共说明:
|
||
|
||
- BaseURL 示例:`https://tapi.hifast.biz`
|
||
- 鉴权头:`Authorization: <JWT>`(注意:**不要**写 `Bearer ` 前缀,本项目 `AuthMiddleware` 直接取 token 值)
|
||
- 业务码包在 `{ code, msg, data }` 信封中,`code = 200` 为成功
|
||
- 默认 `Accept: application/json`,可选 `lang: zh_CN`
|
||
- 经过 `AuthMiddleware` + `DeviceMiddleware` 的接口都需要登录态 + 设备绑定校验
|
||
|
||
---
|
||
|
||
## 1. 文件上传
|
||
|
||
### 1.1 Endpoint
|
||
|
||
```
|
||
POST /v1/public/file/upload
|
||
Content-Type: multipart/form-data
|
||
```
|
||
|
||
- Handler: `internal/handler/public/file/fileUploadHandler.go`
|
||
- Logic: `internal/logic/public/file/fileuploadlogic.go:33`
|
||
- 路由: `internal/handler/routes.go:934`
|
||
- 中间件: `AuthMiddleware` + `DeviceMiddleware`(必须登录)
|
||
|
||
### 1.2 请求
|
||
|
||
#### Form 参数
|
||
|
||
| 字段 | 位置 | 必填 | 说明 |
|
||
|---|---|---|---|
|
||
| `biz_type` | form | 是 | 业务分类标签,会作为对象 key 的一部分(如 `app-package`、`avatar`) |
|
||
| `file` | form file | 是 | 待上传文件二进制 |
|
||
|
||
#### 文件约束(来自 `etc/ppanel.yaml` → `S3`,可调整)
|
||
|
||
| 项 | 默认值 |
|
||
|---|---|
|
||
| 单文件最大 | **104857600 字节(100 MiB)** |
|
||
| 允许的 Content-Type | `application/zip, application/x-zip-compressed, application/gzip, application/x-gzip, application/octet-stream, text/plain, application/json, image/jpeg, image/jpg, image/png, image/webp, image/gif, image/heic, image/heif, image/bmp` |
|
||
|
||
> Content-Type 判定优先级:multipart 文件头里的 `Content-Type` → 文件嗅探(前 512 字节)→ 兜底 `application/octet-stream`。
|
||
> **前端 form 上传时尽量带上 `Content-Type`**,否则被嗅探成 `application/octet-stream` 可能不在白名单里。
|
||
|
||
### 1.3 请求示例
|
||
|
||
```bash
|
||
curl -X POST 'https://tapi.hifast.biz/v1/public/file/upload' \
|
||
-H 'Authorization: <JWT>' \
|
||
-H 'Accept: application/json' \
|
||
-F 'biz_type=app-package' \
|
||
-F 'file=@"/Users/Apple/Documents/avatar.jpg";type=image/jpeg'
|
||
```
|
||
|
||
### 1.4 响应
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|---|---|---|
|
||
| `data.url` | string | 上传完成后的可访问 URL,规则:`{S3.PublicBaseURL or S3.Endpoint}/{bucket}/{prefix}/{YYYY}/{MM}/{DD}/{userId}/{safeFileName}__{fileId}` |
|
||
|
||
成功示例:
|
||
|
||
```json
|
||
{
|
||
"code": 200,
|
||
"msg": "success",
|
||
"data": {
|
||
"url": "http://107.173.50.22:5016/hifastvpn/app-upload/2026/05/28/510/2026-05-27_20.03.55.jpg__226ad097c2ee4e3546e729c5"
|
||
}
|
||
}
|
||
```
|
||
|
||
### 1.5 错误码
|
||
|
||
| 业务码 | 触发场景 |
|
||
|---|---|
|
||
| `InvalidAccess` | 未登录 / JWT 无效 |
|
||
| `ParamError` | 缺少 `biz_type` 或 `file` |
|
||
| `InvalidParams` | `biz_type` 为空、文件名为空、size <= 0、超过 `MaxUploadSize`、`Content-Type` 不在白名单 |
|
||
| `ERROR` | S3 未启用(`S3.Enable=false`) / S3 写入失败 |
|
||
|
||
### 1.6 前端易踩坑
|
||
|
||
1. `file` 字段名必须是 `file`,写 `image` / `upload` 都不行。
|
||
2. `biz_type` 走 form 字段(`form:"biz_type"`),不要塞 query 里。
|
||
3. 上传成功只返回 `url`,不返回 `file_id` / 大小等元数据;如需附加元数据,请走分片协议 `POST /upload/init` + `POST /upload/complete`。
|
||
4. 想上传 PDF / DOC 不会成功——白名单里没有,需要后端调 `S3.AllowedContentTypes`。
|
||
|
||
---
|
||
|
||
## 2. 订阅列表(含促销 promo)
|
||
|
||
### 2.1 Endpoint
|
||
|
||
```
|
||
GET /v1/public/subscribe/list
|
||
```
|
||
|
||
- Handler: `internal/handler/public/subscribe/querySubscribeListHandler.go`
|
||
- Logic: `internal/logic/public/subscribe/querySubscribeListLogic.go:32`
|
||
- Promo 合并逻辑: `internal/logic/public/subscribe/promo.go`
|
||
- 路由: `internal/handler/routes.go:1026`
|
||
- 中间件: **`OptionalAuthMiddleware` + `DeviceMiddleware`**(**未登录也能请求**,但未登录时只能拿到 `rule_type = campaign` 的促销)
|
||
|
||
### 2.2 请求
|
||
|
||
#### Query 参数
|
||
|
||
| 字段 | 类型 | 必填 | 说明 |
|
||
|---|---|---|---|
|
||
| `language` | string | 否 | 语言筛选,传值后返回该语言版本;不传则按系统默认语言返回 |
|
||
|
||
#### 头部说明(影响返回内容)
|
||
|
||
| Header | 影响 |
|
||
|---|---|
|
||
| `Authorization` | 传则识别为登录态,能拿到 `new_user` / `inactive_user` 类型的个性化促销;不传只返回 `campaign` 类型 |
|
||
| `X-App-Id` | **不传**会被识别为"老版本客户端",每个套餐的 `discount` 列表会被**截掉最后一个元素**。新版 App 必须带 `X-App-Id` |
|
||
|
||
### 2.3 请求示例
|
||
|
||
```bash
|
||
curl -X GET 'https://tapi.hifast.biz/v1/public/subscribe/list?language=zh-CN' \
|
||
-H 'Authorization: <JWT>' \
|
||
-H 'X-App-Id: hifast-ios' \
|
||
-H 'Accept: application/json'
|
||
```
|
||
|
||
### 2.4 响应
|
||
|
||
#### 顶层
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|---|---|---|
|
||
| `data.total` | int64 | 返回的套餐数量(= `len(list)`,不是数据库总数) |
|
||
| `data.list` | Subscribe[] | 套餐列表 |
|
||
|
||
#### `Subscribe` 关键字段
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|---|---|---|
|
||
| `id` | int64 | 套餐 ID |
|
||
| `name` | string | 套餐名 |
|
||
| `language` | string | 当前返回的语言版本 |
|
||
| `description` | string | 套餐描述(可能是富文本/Markdown) |
|
||
| `unit_price` | int64 | 单时间单位**原价**,单位:**分** |
|
||
| `unit_time` | string | 时间单位,枚举:`Day` / `Month` / `Year`(注意首字母大写) |
|
||
| `discount` | SubscribeDiscount[] | 量级折扣 + 促销,按 `quantity` 升序 |
|
||
| `node_count` | int64 | 节点数 |
|
||
| `traffic` | int64 | 套餐总流量,单位:字节 |
|
||
| `speed_limit` | int64 | 限速,单位见后端约定 |
|
||
| `device_limit` | int64 | 同时在线设备数限制 |
|
||
| `quota` | int64 | 总配额 |
|
||
| `show` | bool | 是否在前端展示 |
|
||
| `sell` | bool | 是否可售卖(本接口只返回 `sell=true`) |
|
||
| `show_original_price` | bool | 是否展示划线原价 |
|
||
| `reset_cycle` | int64 | 流量重置周期 |
|
||
| `renewal_reset` | bool | 续费时是否重置流量 |
|
||
| `created_at` / `updated_at` | int64 | 秒级 Unix 时间戳 |
|
||
|
||
#### `SubscribeDiscount` 字段
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|---|---|---|
|
||
| `quantity` | int64 | 购买的时间单位数量(如 1 = 1 个月,3 = 3 个月) |
|
||
| `discount` | float64 | 量级折扣比例,0 表示无折扣,0.05 表示再优惠 5% |
|
||
| `map_apple` | string | 对应 Apple IAP 商品 ID |
|
||
| `promo` | SubscribePromo \| null | **#77 新增的促销对象**,命中促销规则时下发,否则为 `null` |
|
||
|
||
#### `SubscribePromo` 字段
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|---|---|---|
|
||
| `rule_name` | string | 促销规则名(运营在后台填写,可直接给用户展示,如"新人首单 8 折") |
|
||
| `rule_type` | string | 规则类型枚举(见下表) |
|
||
| `promo_price` | int64 | **促销价**,单位:**分**。优先级高于 `unit_price * discount`,前端命中促销时按此价显示 |
|
||
| `expires_at` | int64 | 该促销对当前用户的失效时间(**秒级 Unix**),`0` 表示无明确截止 |
|
||
|
||
#### `rule_type` 枚举
|
||
|
||
| 值 | 含义 | 资格判定 |
|
||
|---|---|---|
|
||
| `campaign` | 全员/限时活动 | 仅看 `start_time` / `end_time` 是否在窗口内;**未登录也会下发** |
|
||
| `new_user` | 新用户首单 | 登录用户,且 `now < user.created_at + params.window_hours`;`expires_at = user.created_at + window_hours` |
|
||
| `inactive_user` | 老用户唤回 | 登录用户,且距离最近一个订阅过期已超过 `params.inactive_months` 个月;`expires_at = 规则 end_time` |
|
||
|
||
> 多条促销规则命中同一 `(subscribe_id, quantity)` 时,按 `priority DESC, id ASC` 取**首条**,不是合并。
|
||
|
||
### 2.5 响应示例
|
||
|
||
```json
|
||
{
|
||
"code": 200,
|
||
"msg": "success",
|
||
"data": {
|
||
"total": 1,
|
||
"list": [
|
||
{
|
||
"id": 1,
|
||
"name": "月付套餐",
|
||
"language": "zh-CN",
|
||
"description": "...",
|
||
"unit_price": 1000,
|
||
"unit_time": "Month",
|
||
"show_original_price": true,
|
||
"node_count": 30,
|
||
"traffic": 107374182400,
|
||
"device_limit": 3,
|
||
"discount": [
|
||
{
|
||
"quantity": 1,
|
||
"discount": 0,
|
||
"map_apple": "ios.month1",
|
||
"promo": {
|
||
"rule_name": "新人首单 8 折",
|
||
"rule_type": "new_user",
|
||
"promo_price": 800,
|
||
"expires_at": 1780500000
|
||
}
|
||
},
|
||
{
|
||
"quantity": 3,
|
||
"discount": 0.05,
|
||
"map_apple": "ios.month3",
|
||
"promo": null
|
||
}
|
||
],
|
||
"show": true,
|
||
"sell": true,
|
||
"created_at": 1764547200,
|
||
"updated_at": 1779934580
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2.6 价格计算建议(前端)
|
||
|
||
对每个 `discount` 元素:
|
||
|
||
```
|
||
原价 = unit_price * quantity
|
||
量级折后价 = round(原价 * (1 - discount))
|
||
|
||
if promo != null:
|
||
实付 = promo.promo_price * quantity // 注意:promo_price 是「单价」,乘以 quantity
|
||
划线价 = 原价 // 用于展示「省 XX」
|
||
else:
|
||
实付 = 量级折后价
|
||
划线价 = 原价(show_original_price=true 时展示)
|
||
```
|
||
|
||
> 注意:`promo_price` 设计为**单价**(与 `unit_price` 同级),不是总价。
|
||
> 命中促销时建议同时显示 `rule_name`("新人首单 8 折")和倒计时(基于 `expires_at`)。
|
||
|
||
### 2.7 前端易踩坑
|
||
|
||
1. **必带 `X-App-Id`**——否则 `discount` 数组最后一个会被砍掉。
|
||
2. **促销分登录态**:未登录时只能拿到 `campaign`;未拿到 `new_user`/`inactive_user` 时先检查是否传了 `Authorization`。
|
||
3. **`unit_time` 是 PascalCase**:`Day` / `Month` / `Year`,别小写匹配。
|
||
4. **金额单位都是分**(`unit_price`、`promo_price`),展示时除以 100。
|
||
5. **`expires_at = 0`** 表示无截止,不要展示成 1970 年。
|
||
6. `total` 是当前返回的条数,不是数据库总数(接口在 logic 里强制 `Size: 9999`,相当于不分页)。
|
||
|
||
---
|
||
|
||
## 3. 邀请赠送记录
|
||
|
||
> 当前用户的"邀请赠送天数"流水。包含两类:
|
||
> - 当前用户作为**邀请人**,被邀请的朋友下单触发的赠送;
|
||
> - 当前用户作为**被邀请人**,自己下单触发的对应赠送(双向赠送)。
|
||
>
|
||
> 数据源:`system_logs` 表,`type = 33 (TypeGift)` 且 `content.remark = "邀请赠送"`。
|
||
> 这里**只是赠送天数**,不包含邀请佣金(请走 affiliate 系列接口)。
|
||
|
||
### 3.1 Endpoint
|
||
|
||
```
|
||
GET /v1/public/user/invite_records
|
||
```
|
||
|
||
- Handler: `internal/handler/public/user/getInviteRecordsHandler.go`
|
||
- Logic: `internal/logic/public/user/getInviteRecordsLogic.go:61`
|
||
- 路由: `internal/handler/routes.go:1122`
|
||
- 中间件: `AuthMiddleware` + `DeviceMiddleware`(必须登录)
|
||
|
||
### 3.2 请求
|
||
|
||
#### Query 参数
|
||
|
||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||
|---|---|---|---|---|
|
||
| `page` | int | 否 | `1` | 页码,<1 自动归一为 1 |
|
||
| `size` | int | 否 | `10` | 每页条数,<1 归一为 10,**>100 截断为 100** |
|
||
| `start_time` | int64 | 否 | `0` | 起始时间(**秒级 Unix**),`0` 表示不过滤下界 |
|
||
| `end_time` | int64 | 否 | `0` | 截止时间(**秒级 Unix**),`0` 表示不过滤上界 |
|
||
|
||
> ⚠️ `start_time` / `end_time` 单位是**秒**(后端用 `FROM_UNIXTIME(?)`)。传毫秒会过滤掉所有记录。
|
||
|
||
#### 请求示例
|
||
|
||
```bash
|
||
# 不带时间过滤
|
||
curl -X GET 'https://tapi.hifast.biz/v1/public/user/invite_records?page=1&size=20' \
|
||
-H 'Authorization: <JWT>' \
|
||
-H 'Accept: application/json'
|
||
|
||
# 带时间过滤
|
||
curl -X GET 'https://tapi.hifast.biz/v1/public/user/invite_records?page=1&size=20&start_time=1764547200&end_time=1780099200' \
|
||
-H 'Authorization: <JWT>'
|
||
```
|
||
|
||
> 旧 curl 模板里的 `--data-urlencode 'page=1'` 等对 GET 是 form body,不会被读取,请用 query string。
|
||
|
||
### 3.3 响应
|
||
|
||
#### 顶层
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|---|---|---|
|
||
| `data.total` | int64 | 当前过滤条件下的**记录总数**(用于分页) |
|
||
| `data.list` | InviteRecord[] | 当前页列表,可能为空数组 `[]` |
|
||
|
||
#### `InviteRecord` 字段
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|---|---|---|
|
||
| `role` | string | 当前用户在该条记录中的角色:`inviter` 或 `invitee`(详见下表) |
|
||
| `peer_hash` | string | 对端用户的脱敏哈希(10 位定长数字字符串),用于"匿名展示朋友"。订单已删 / 对端 id 缺失时为 `""` |
|
||
| `gift_days` | int64 | 本次赠送天数(来源 `system_logs.content.amount`) |
|
||
| `order_no` | string | 触发本次赠送的订单号 |
|
||
| `created_at` | int64 | 赠送时间,**毫秒级 Unix**(SQL 端 `UNIX_TIMESTAMP(created_at) * 1000`) |
|
||
|
||
> ⚠️ **时间戳单位不一致**:请求里的 `start_time/end_time` 是**秒**,响应里的 `created_at` 是**毫秒**。前端请区分对待。
|
||
> (与项目其它接口"统一秒级"约定不同,是该接口的当前实现。)
|
||
|
||
#### `role` 取值
|
||
|
||
| 值 | 含义 | `peer_hash` 来源 |
|
||
|---|---|---|
|
||
| `inviter` | 当前用户是**邀请人**,朋友下单触发的赠送 | 被邀请人(即订单的 `user_id`)的脱敏 hash |
|
||
| `invitee` | 当前用户是**被邀请人**,自己下单触发的赠送 | 邀请人(`user.referer_id`)的脱敏 hash |
|
||
|
||
判定规则:默认 `inviter`;若 `order.user_id == 当前用户 id`,切换为 `invitee` 并改用 `referer_id` 计算 hash。
|
||
|
||
#### 排序与分页
|
||
|
||
- 排序:`created_at DESC, id DESC`(最近一条在最前)
|
||
- 分页:`LIMIT size OFFSET (page-1)*size`
|
||
- `total` **不**受 `LIMIT/OFFSET` 影响
|
||
|
||
### 3.4 响应示例
|
||
|
||
非空:
|
||
|
||
```json
|
||
{
|
||
"code": 200,
|
||
"msg": "success",
|
||
"data": {
|
||
"total": 2,
|
||
"list": [
|
||
{
|
||
"role": "inviter",
|
||
"peer_hash": "0382716459",
|
||
"gift_days": 30,
|
||
"order_no": "20260527123456789",
|
||
"created_at": 1779934580000
|
||
},
|
||
{
|
||
"role": "invitee",
|
||
"peer_hash": "1745920031",
|
||
"gift_days": 30,
|
||
"order_no": "20260520112233445",
|
||
"created_at": 1779329780000
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
空:
|
||
|
||
```json
|
||
{
|
||
"code": 200,
|
||
"msg": "success",
|
||
"data": {
|
||
"total": 0,
|
||
"list": []
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.5 错误码
|
||
|
||
| 业务码 | 触发场景 |
|
||
|---|---|
|
||
| `InvalidAccess` | 未登录 / JWT 无效 |
|
||
| `ParamError` | 参数绑定失败 |
|
||
| `DatabaseQueryError` | DB 查询失败(count / 日志 / 订单任一) |
|
||
|
||
### 3.6 前端易踩坑
|
||
|
||
1. 传**毫秒**给 `start_time/end_time` → 永远拿到空集。请传**秒**。
|
||
2. 拿到的 `created_at` 是**毫秒**,**不要再 `*1000`**,直接 `new Date(created_at)` 即可。
|
||
3. 空列表是 `[]` 不是 `null`,可直接 `.map`。
|
||
4. `peer_hash` 可能为 `""`,UI 兜底展示"未知朋友"。
|
||
5. `size` 上限 100,传 1000 会被截断。
|
||
6. 本接口**只含赠送天数**,不含邀请佣金(佣金 → affiliate 接口)。
|
||
|
||
---
|
||
|
||
## 附录:业务码常量速查
|
||
|
||
| 名称 | HTTP 含义 | 出现场景 |
|
||
|---|---|---|
|
||
| `200` | 成功 | `{"code":200,"msg":"success",...}` |
|
||
| `InvalidAccess` | 未授权 | 未登录 / JWT 无效 / 设备未绑定 |
|
||
| `ParamError` | 参数错误 | 请求绑定失败、缺必填项 |
|
||
| `InvalidParams` | 参数校验不通过 | 业务规则校验失败(文件超限、Content-Type 不合法等) |
|
||
| `DatabaseQueryError` | DB 错 | SQL 查询失败 |
|
||
| `ERROR` | 通用错 | 第三方/中间件失败(S3 未启用、S3 写入失败等) |
|