# 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: `(注意:**不要**写 `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: ' \ -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: ' \ -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: ' \ -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: ' ``` > 旧 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 写入失败等) |