Files
hi-server/doc/app-three-apis-zh.md
T
shanshanzhong147 3e265bd837
Build docker and publish / build (20.15.1) (push) Failing after 13m47s
Build docker and publish / build (20.15.1) (pull_request) Failing after 15m7s
新功能: 还原 v1/public/user/invite_sales 接口 + promo schema 修复迁移
- 还原 /v1/public/user/invite_sales 接口及 /invite/sales 别名(与 invite_records 并存)
  逻辑/handler/types 与 197fed7d 删除前版本完全一致,按 fefbd4f5 新 routes 结构注册
- 新增迁移 02155_promo_schema_fix: 幂等修复 subscribe_promo / order 列类型与索引偏差
- 同步 etc/ppanel.yaml 数据库连接配置
- 补齐相关需求与设计文档
2026-05-28 20:46:37 -07:00

436 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 写入失败等) |