# 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 写入失败等） |
