Files
hi-server/doc/app-three-apis-zh.md
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

15 KiB
Raw Permalink Blame History

App 端三个接口对接文档

适用:移动端 / 桌面端 App 维护:基于当前 internal/handler + internal/logic 代码反向梳理 时间:2026-05-27

涉及接口:

  1. 文件上传POST /v1/public/file/upload
  2. 订阅列表(含促销 promoGET /v1/public/subscribe/list
  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-packageavatar
file form file 待上传文件二进制

文件约束(来自 etc/ppanel.yamlS3,可调整)

默认值
单文件最大 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 请求示例

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}

成功示例:

{
  "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_typefile
InvalidParams biz_type 为空、文件名为空、size <= 0、超过 MaxUploadSizeContent-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 请求示例

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_hoursexpires_at = user.created_at + window_hours
inactive_user 老用户唤回 登录用户,且距离最近一个订阅过期已超过 params.inactive_months 个月;expires_at = 规则 end_time

多条促销规则命中同一 (subscribe_id, quantity) 时,按 priority DESC, id ASC首条,不是合并。

2.5 响应示例

{
  "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 是 PascalCaseDay / Month / Year,别小写匹配。
  4. 金额单位都是分unit_pricepromo_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(?))。传毫秒会过滤掉所有记录。

请求示例

# 不带时间过滤
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 当前用户在该条记录中的角色:inviterinvitee(详见下表)
peer_hash string 对端用户的脱敏哈希(10 位定长数字字符串),用于"匿名展示朋友"。订单已删 / 对端 id 缺失时为 ""
gift_days int64 本次赠送天数(来源 system_logs.content.amount
order_no string 触发本次赠送的订单号
created_at int64 赠送时间,毫秒级 UnixSQL 端 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 响应示例

非空:

{
  "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
      }
    ]
  }
}

空:

{
  "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 写入失败等)