docs/api目录文档分类和补全

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #111
This commit was merged in pull request #111.
This commit is contained in:
2026-07-03 15:00:37 +08:00
parent ee132aa93b
commit a563c1ca4b
102 changed files with 2418 additions and 217 deletions
+71
View File
@@ -0,0 +1,71 @@
# Admin 看广告金币审计
> 所属:Admin 组(前缀 `/admin/api/ad-coin-audit`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](../../README.md)
把「看视频赚金币」(`ad_reward_record`)和「比价信息流广告」(`ad_feed_reward_record`)两类发奖记录,用与**正式发奖完全相同**的公式 [`app/core/rewards.py` `calculate_ad_reward_coin`](../../app/core/rewards.py) 复算一遍 `expected_coin`,与实际入账的 `actual_coin` 对比,核对金币公式是否生效。**纯只读对账**,不发币、不改任何数据。
相关表:[ad_reward_record](../database/ad_reward_record.md)、[ad_feed_reward_record](../database/ad_feed_reward_record.md)。
## 金币公式(展示参照)
```
eCPM元 = getEcpm分 ÷ 100
单份金币 = round( eCPM元 ÷ 1000 × 因子1 × 因子2 × coin_per_yuan ) # coin_per_yuan=10000
```
- **eCPM 口径**:`getEcpm()` 原值单位是分/千次展示;先 ÷100 转元,**因子判档与收益换算都用元**。
- **因子1(eCPM 档,阈值单位=元/千次)**:≤100→0.1,101200→0.3,201400→0.4,>400→0.6(即 ¥100/¥200/¥400 CPM 分界。**真实 eCPM 多 <¥100 CPM,故常落最低档 0.1,高档基本不触发——产品有意取舍**)。
- **因子2(LT,当日第 N 份)**:第1→2.0,第2→1.5,第3→1.3,第410→1.1,≥11→1.0。
- **第 N 份的计数**:看视频每条 granted 记 1 份;信息流每满 10 秒记 1 份。**两个点位各自独立计数**(看视频的份数不影响信息流的份数,反之亦然)。
## GET /admin/api/ad-coin-audit — 复算对比
- 入参(均 query,可选):
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `date` | string | 今天 | 北京时间 `YYYY-MM-DD`,审计某天 |
| `user_id` | int | 全部 | 只看某用户;不传=所有用户 |
| `scene` | string | 两类 | `reward_video` / `feed`;不传=两类都返回 |
| `limit` | int(1~500) | 100 | **展示**明细条数(按时间倒序截断;**份序号在截断前已按全天数据算好**,不影响复算正确性) |
| `only_mismatch` | bool | false | 只展示不一致(✗)行;统计数仍按全量,不受影响 |
- 出参 `200`:`AdCoinAuditOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `date` | string | 审计日期 |
| `formula` | object | 当前公式参数快照(见下) |
| `total` | int | 该筛选下复算**总条数**(全量,不受 `limit`/`only_mismatch` 影响) |
| `mismatch_count` | int | **全量**不一致条数(截断前统计,可信);**=0 说明全部按公式发放** |
| `truncated` | bool | 展示集是否被 `limit` 截断(true=还有未返回的明细,请缩小范围或调大 `limit`) |
| `items` | `AdCoinAuditRow[]` | 展示明细(见下);`only_mismatch=true` 时只含 ✗ 行 |
### AdCoinFormulaOut(`formula`)
| 字段 | 类型 | 说明 |
|---|---|---|
| `description` | string | 公式文字说明 |
| `coin_per_yuan` | int | 金币:元 汇率(10000) |
| `ecpm_unit` | string | eCPM 口径(分/千次展示,SDK getEcpm 原值) |
| `feed_unit_seconds` | int | 信息流每多少秒折 1 份(10) |
| `ecpm_factor_tiers` | `[因子, 下限, 上限\|null][]` | 因子1 档位表(直接读发奖常量,与发奖同源) |
| `lt_factor_tiers` | `[因子, 下限, 上限\|null][]` | 因子2 LT 档位表 |
### AdCoinAuditRow(`items[]`)
| 字段 | 类型 | 说明 |
|---|---|---|
| `scene` | string | `reward_video` / `feed` |
| `record_id` | int | 对应记录表主键 |
| `user_id` | int | |
| `created_at` | datetime | |
| `status` | string | `granted` / `capped`(超每日上限未发) / `ecpm_missing`(缺 eCPM 未发) |
| `ecpm` | string \| null | 本次采用的 eCPM 原始值 |
| `ecpm_factor` | float \| null | 因子1;非 granted 为 null |
| `units` | int | 折算份数:看视频恒 1;信息流 = 满 10 秒份数 |
| `lt_index_start` / `lt_index_end` | int \| null | 本条占用「当日第几份」的起止(看视频起=止;信息流一条可跨多份) |
| `lt_factor_start` / `lt_factor_end` | float \| null | 因子2 的起止值(信息流跨份时会衰减,故给区间) |
| `expected_coin` | int | 按公式复算应发金币(非 granted 恒 0) |
| `actual_coin` | int | 实际入账金币 |
| `matched` | bool | 复算与实发是否一致;非 granted 校验实发是否确为 0 |
## 说明
- **非 granted 行**(capped/ecpm_missing)不占用份序号、应发恒 0,`matched` 用于校验「该不发的确实没发」。
- 用法建议:**按 `user_id`+`date` 定位某次具体核对**最直观;不带 `user_id` 是全用户当天概览。
- 本地联调造数:debug 包看完激励视频会调 [`/api/v1/ad/test-grant`](./ad-test-grant.md),它会用客户端按 `ad_session_id` 上报的真实 eCPM 发奖(取不到兜底 200),所以本审计能看到真实 eCPM 对应的金币。
@@ -0,0 +1,135 @@
# Admin 广告收益报表
> 所属:Admin 组(前缀 `/admin/api/ad-revenue-report`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](../../README.md)
**用户 × 日期 × 广告类型 × 我们的应用 × 我们的代码位** 聚合,回答「每个用户某天、每类广告(激励视频 / 信息流 / 历史 Draw)分别**看了多少条**、**收益多少**、按现算法**发了多少金币**、广告来自**哪个应用的哪个代码位**」。**纯只读**,不发币、不改数据,也**不改发奖逻辑**。
相关表:[ad_ecpm_record](../database/ad_ecpm_record.md)、[ad_reward_record](../database/ad_reward_record.md)、[ad_feed_reward_record](../database/ad_feed_reward_record.md)。
## 数据来源(三流合并,聚合键 = user × ad_type × app_env × our_code_id)
| 指标 | 来源表 | 口径 |
|---|---|---|
| 展示条数 `impressions` | `ad_ecpm_record` | 每行 = 客户端一次广告展示。激励视频每次展示上报一条;**信息流轮播每条展示各上报一条**(每条独立 `ad_session_id`) |
| 收益 `revenue_yuan` | `ad_ecpm_record` | `Σ(eCPM元 ÷ 1000)`,即每条展示预估收益累加(eCPM 原值是分,÷100 转元;÷1000 是每千次→单次)。**预估口径,非结算;测试应用多为 0** |
| 应发/实发金币 `expected_coin`/`actual_coin` | `ad_reward_record`(reward_video)+ `ad_feed_reward_record`(feed) | **复用金币审计逐条复算**(`ad_audit.audit_rows`,与正式发奖同一公式口径,不另写公式),按同维度求和;`matched = 应发==实发`。**只读复算,不改发奖** |
| 来源应用/代码位 `app_env`/`our_code_id` | 上述各表回填 | `prod`(傻瓜比价)/`test`(测试);代码位是**我们后台配的 104xxx**,非底层 rit |
| 底层渠道 `adns` | `ad_ecpm_record` | 实际填充的 ADN 子渠道集合(pangle/gdt/...),附加参考 |
展示与金币来自不同表,做**并集**:有展示无金币(用户中途关、未达发奖)、有金币无展示(未上报 eCPM)各自成行。
## GET /admin/api/ad-revenue-report — 聚合报表
- 入参(均 query,可选):
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `date_from` | string | 今天 | 起始日 北京时间 `YYYY-MM-DD` |
| `date_to` | string | =`date_from` | 结束日 北京时间 `YYYY-MM-DD`,**闭区间**;单日时与 `date_from` 相同 |
| `user_id` | int | 全部 | 只看某用户;不传=所有用户 |
| `ad_type` | string | 全部 | `reward_video` / `feed` / `draw`;不传=全部类型 |
| `feed_scene` | string | 全部 | `comparison`(比价)/ `coupon`(领券)/ `welfare`(福利);**全局筛选**,同时作用于明细 / 合计 / `daily`·`hourly` 趋势;不传=全部场景 |
| `granularity` | string | `day` | `day`=按天 / `hour`=按小时(聚合键再加北京时间小时 0–23);**区间>1 天建议用 day** |
| `limit` | int(1~1000) | 500 | **每页条数**(分页大小);`total`/`total_*`/`daily`/`hourly` 按全量统计不受分页影响 |
| `offset` | int(≥0) | 0 | 分页偏移(已跳过条数)=(页码−1)×`limit` |
| `sort` | string | `time` | 明细排序:`time`=按时间倒序(新→旧) / `ecpm`=按 eCPM 数值倒序 |
约束:`date_to` 不早于 `date_from`、区间最长 **92 天**、日期须 `YYYY-MM-DD`,否则 `422`
- 出参 `200`:`AdRevenueReportOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `date_from` / `date_to` | string | 报表起止日期(闭区间) |
| `daily` | `AdRevenueDaily[]` | 按日期汇总序列(全量,供按天趋势图;不受分页影响) |
| `hourly` | `AdRevenueHourly[]` | 按小时汇总序列(全量,供按小时趋势图;**仅 `granularity=hour` 时非空**;不受分页影响) |
| `type_stats` | `{[ad_type]: AdRevenueTypeStat}` | 按广告类型(`ad_type`)小计(全量);前端取 `draw` / `reward_video` 做分类大盘 |
| `dau` | int \| null | 今日活跃用户数(复用大盘口径 `last_login_at`,今日登录过);**仅查询=今日单天时有值**,历史/多天为 `null` |
| `total` | int | 当前筛选下的**分页总条数**(全量,不受分页影响;= 前端分页器 total) |
| `truncated` | bool | 当前页之后是否还有更多事件(`len(events) > offset + limit`) |
| `total_impressions` | int | 全量展示条数合计 |
| `total_revenue_yuan` | float | 全量**客户端预估**收益合计(元;eCPM 折算) |
| `total_pangle_revenue_yuan` | float \| null | 全量**穿山甲后台预估**收益合计(元;GroMore `revenue`)。穿山甲无用户/类型/场景维度,**仅全量视图**(未按 `user_id`/`ad_type`/`feed_scene` 过滤)有值,否则 `null` |
| `total_pangle_api_revenue_yuan` | float \| null | 全量**穿山甲收益Api**合计(元;GroMore `api_revenue`,各 ADN 回传更接近结算);未配 Reporting / 查当天 / 非全量视图为 `null` |
| `pangle_revenue_available` | bool | 本次是否带穿山甲后台收益(=全量视图且对应日期已同步到 `ad_pangle_daily_revenue`) |
| `total_expected_coin` | int | 全量应发金币合计 |
| `total_actual_coin` | int | 全量实发金币合计 |
| `mismatch_count` | int | 应发≠实发的组数(=0 说明全部按公式发放) |
| `items` | `AdRevenueRow[]` | 逐条广告事件(**按时间倒序:新→旧**);`limit`/`offset` 对全量做分页切片,返回当前页 |
### AdRevenueDaily(`daily[]` — 按天趋势)
| 字段 | 类型 | 说明 |
|---|---|---|
| `date` | string | 北京时间 `YYYY-MM-DD` |
| `impressions` | int | 当天展示条数合计 |
| `revenue_yuan` | float | 当天客户端预估收益合计(元;eCPM 折算) |
| `pangle_revenue_yuan` | float \| null | 当天穿山甲后台预估收益(元;GroMore `revenue`);非全量视图 / 无数据为 `null` |
| `pangle_api_revenue_yuan` | float \| null | 当天穿山甲收益Api(元;GroMore `api_revenue`);未配 / 当天 / 无数据为 `null` |
| `expected_coin` | int | 当天应发金币合计 |
| `actual_coin` | int | 当天实发金币合计 |
### AdRevenueHourly(`hourly[]` — 按小时趋势,仅 `granularity=hour` 时非空)
| 字段 | 类型 | 说明 |
|---|---|---|
| `hour` | int | 北京时间小时 023 |
| `impressions` | int | 该小时展示条数合计 |
| `revenue_yuan` | float | 该小时预估收益合计(元) |
| `expected_coin` | int | 该小时应发金币合计 |
| `actual_coin` | int | 该小时实发金币合计 |
### AdRevenueTypeStat(`type_stats[ad_type]` — 分广告类型小计,供大盘第二行)
| 字段 | 类型 | 说明 |
|---|---|---|
| `impressions` | int | 该类型展示条数合计 |
| `revenue_yuan` | float | 该类型预估收益合计(元);eCPM 由前端用 收益÷展示×1000 算 |
### AdRevenueRow(`items[]`)
| 字段 | 类型 | 说明 |
|---|---|---|
| `report_date` | string | 该组所属日期 北京时间 `YYYY-MM-DD` |
| `user_id` | int | |
| `ad_type` | string | `reward_video` / `feed` / `draw` |
| `app_env` | string \| null | 我们的应用:`prod`(傻瓜比价)/`test`(测试);旧数据为空 |
| `our_code_id` | string \| null | 我们配置的代码位 104xxx;旧数据为空 |
| `hour` | int \| null | 北京时间小时 023(`granularity=hour` 时有值;按天为 null) |
| `impressions` | int | 展示条数 |
| `revenue_yuan` | float | 收益(元),预估口径 |
| `expected_coin` | int | 应发金币(公式复算,与金币审计同源) |
| `actual_coin` | int | 实发金币(实际入账) |
| `matched` | bool | 该组应发==实发(组内任一条不符则 false) |
| `adns` | string[] | 底层填充 ADN 子渠道集合 |
| `impression_records` | `AdRevenueImpression[]` | 该组**逐条展示明细**(前端展开下钻);只要有展示就非空 |
| `records` | `AdRevenueRecord[]` | 该组**逐条发奖复算明细**(前端展开下钻);纯展示无发奖的组为空 |
### AdRevenueImpression(`items[].impression_records[]` — 展开「展示明细」)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | ad_ecpm_record 主键 |
| `created_at` | datetime | |
| `ecpm` | string | 本次展示 eCPM 原始值(分/千次展示) |
| `revenue_yuan` | float | 本次展示预估收益(元)= eCPM元 ÷ 1000 |
| `adn` | string \| null | 实际填充 ADN 子渠道 |
| `slot_id` | string \| null | 底层 mediation rit(非我们配置的广告位 ID) |
### AdRevenueRecord(`items[].records[]` — 展开「发奖明细」)
还原金币审计的逐条列,与发奖同一复算口径。
| 字段 | 类型 | 说明 |
|---|---|---|
| `record_id` | int | 发奖记录主键 |
| `created_at` | datetime | |
| `status` | string | `granted` / `capped` / `ecpm_missing` |
| `ecpm` | string \| null | 本次采用的 eCPM 原始值 |
| `ecpm_factor` | float \| null | 因子1(eCPM 档);非 granted 为空 |
| `units` | int | 折算份数:激励视频恒 1;信息流 = 满 10 秒份数 |
| `lt_index_start` / `lt_index_end` | int \| null | 占用「账号累计第几份」的起止 |
| `lt_factor_start` / `lt_factor_end` | float \| null | 因子2(LT)起止值 |
| `expected_coin` | int | 应发金币 |
| `actual_coin` | int | 实发金币 |
| `matched` | bool | 该条复算与实发是否一致 |
## 说明与局限
- **展示 vs 发奖分离**:信息流轮播一会话可展示多条(都计入 `impressions`),但发奖仍按现规则(一会话发一次),`coin` 不因展示条数变化——这是有意设计(用户中途关只记展示不发奖)。
- **历史 Draw 不可拆**:迁移(Draw→普通信息流)前,Draw 发奖混在 `ad_feed_reward_record` 且无类型标记,金币侧统一记 `feed`;迁移后 Draw 不再产生新数据。展示侧 `ad_type` 由客户端上报区分,故 `draw` 桶基本为空。
- **来源字段从上线起齐全**:`app_env`/`our_code_id` 是本期新增列,历史记录为 NULL(报表来源列留空)。
- **逐条/明细的收益是预估**:`items[].revenue_yuan` 基于客户端上报的 eCPM 折算,非穿山甲后台结算值。
- **穿山甲后台收益(汇总/趋势级)**:`total_pangle_revenue_yuan`(预估 `revenue`)与 `total_pangle_api_revenue_yuan`(收益Api `api_revenue`,更接近结算)来自穿山甲 **GroMore 数据 API**(`integrations/pangle_report` + `scripts/sync_pangle_revenue` 按天 T+1 拉取入 [ad_pangle_daily_revenue](../database/ad_pangle_daily_revenue.md))。穿山甲**不提供分用户/设备/类型/场景维度**(官方明确),最细到 日期×应用×代码位,故只用于汇总与按天趋势的对照,**不挂到逐条事件行**;且仅在全量视图(未按 user/类型/场景过滤)展示。配置见 `.env``PANGLE_REPORT_*`
- **对账聚合级 + 逐条下钻**:行级 `matched` 给出该组(用户×类型×应用×代码位)应发是否==实发;**展开 `records` 即可看该组逐条明细**(eCPM/因子1/份数/LT/因子2/应发/实发/一致)定位到具体记录。独立逐条审计接口 [admin-ad-coin-audit](./admin-ad-coin-audit.md) 仍保留(同一复算口径,可全局按场景/只看不符筛选)。
+35
View File
@@ -0,0 +1,35 @@
# GET /admin/api/audit-logs — 审计日志(谁改了什么,游标分页)
> 所属:Admin·Audit 组(前缀 `/admin/api/audit-logs` | 鉴权:Bearer admin_token(角色:任意已登录 admin | [← 返回 API 索引](../README.md)
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `action` | string | ❌ | null | 按操作类型过滤,如 `admin.create` / `admin.update` |
| `target_type` | string | ❌ | null | 按目标对象类型过滤,如 `admin` |
| `admin_id` | int | ❌ | null | 按操作人(管理员 id)过滤 |
| `limit` | int | ❌ | 50 | 1100 |
| `cursor` | int | ❌ | null | 上一页末条 `id`,首页不传 |
## 出参
响应 `200`:`{ items: AdminAuditLogOut[], next_cursor: int|null }`(按 `id` 倒序;分页见 [索引#游标分页约定](./README.md#游标分页约定)
**AdminAuditLogOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 审计 id(也是游标) |
| `admin_id` | int | 操作人管理员 id |
| `admin_username` | string | 操作人账号(写入时快照) |
| `action` | string | 操作类型,如 `admin.create` / `admin.update` |
| `target_type` | string | 目标对象类型,如 `admin` |
| `target_id` | string \| null | 目标对象 id(字符串) |
| `detail` | object \| null | 操作详情(JSON,如变更字段) |
| `ip` | string \| null | 操作来源 IP(取 XFF 首段或直连 IP,仅记录不鉴权) |
| `created_at` | datetime | 操作时间(UTC) |
## 错误码
- `401` 未带 admin token / token 无效或过期 / 管理员被禁用
## 说明
- 整组(`/admin/api/audit-logs`)守卫为 `get_current_admin`,任意已登录 admin 均可查看,无角色限制。
- 审计日志只增不改不删,任何写操作经 `write_audit` 落一条。数据表见 [admin_audit_log](../database/admin_audit_log.md)。
+73
View File
@@ -0,0 +1,73 @@
# Admin 首页数据配置 — 三统计展示模式
> 所属:Admin 组(前缀 `/admin/api/dashboard-display` | 鉴权:Admin Bearer(改需 operator/super | [← 返回 API 索引](../README.md)
配置客户端首页三个门面数字(帮助用户 / 完成比价 / 累计节省)的展示模式。每个指标可独立选 real/manual/random。用户侧读取见 [platform-stats](./platform-stats.md);表见 [ops_stat_config](../database/ops_stat_config.md)。
> **单位约定**:倍率用**千分比整数**(1.000→`1000`、1.100→`1100`)。`total_saved` 的 `manual_value` / `random_current` / `random_initial` / `random_step_min` / `random_step_max` / `real_offset` 单位都是**分**(前端展示时 ÷100 取元);两个计数指标是个数。
---
## GET /admin/api/dashboard-display — 三指标配置 + 真实值预览
### 出参
响应 `200`:`list[OpsStatItemOut]`(按 help_users / total_compares / total_saved 顺序)
| 字段 | 类型 | 说明 |
|---|---|---|
| `metric` | string | `help_users` / `total_compares` / `total_saved` |
| `label` | string | 帮助用户 / 完成比价 / 累计节省 |
| `unit` | string | 人 / 次 / 分 |
| `mode` | string | `real` / `manual` / `random` |
| `manual_value` | int \| null | manual 固定值(基础单位) |
| `random_mult_min` | int | 倍率下限(千分比,≥1000) |
| `random_mult_max` | int | 倍率上限(千分比) |
| `random_tick_seconds` | int | 更新间隔(秒,默认 86400=1 天) |
| `random_anchor_minutes` | int | 触发时刻对齐偏移(距北京 0 点分钟数:天=每日时刻、小时=每小时第几分、分钟=0) |
| `random_kind` | string | 自增长方式:`mult` ×倍率 / `add` +绝对增量 |
| `random_step_min` | int | add 模式增量下限(基础单位) |
| `random_step_max` | int | add 模式增量上限(基础单位) |
| `real_offset` | int | real 模式保底值(基础单位):展示 = max(真实值, 保底值)。字段名沿用 real_offset(语义已改为"保底" |
| `allow_decrease` | bool | 是否允许展示值下降(默认 false = 只增不减) |
| `random_current` | int \| null | 当前展示值(所有模式,基础单位) |
| `random_last_tick_at` | string \| null | 上次 tick 时刻(ISO |
| `real_value` | int | 当前真实值(纯真实、不含偏移;给运营对比参考,基础单位) |
| `updated_at` | string \| null | 上次修改时间 |
---
## PATCH /admin/api/dashboard-display/{metric} — 改某指标(带审计)
`metric` 路径参数取 `help_users` / `total_compares` / `total_saved`
### 入参 `OpsStatUpdateRequest`(字段均可选,只改传了的)
| 字段 | 类型 | 说明 |
|---|---|---|
| `mode` | string | `real` / `manual` / `random` |
| `manual_value` | int | manual 固定值(基础单位,≥0) |
| `random_mult_min` | int | 倍率下限(千分比,1000~1500 |
| `random_mult_max` | int | 倍率上限(千分比,≥下限,≤1500) |
| `random_tick_seconds` | int | 更新间隔(秒,≥60) |
| `random_anchor_minutes` | int | 触发时刻对齐偏移(分钟,0~1439) |
| `random_kind` | string | 自增长方式:`mult` / `add` |
| `random_step_min` | int | add 增量下限(基础单位,≥0) |
| `random_step_max` | int | add 增量上限(基础单位,≥下限) |
| `real_offset` | int | real 保底值(基础单位,≥0):展示 = max(真实值, 保底值) |
| `allow_decrease` | bool | 允许展示值下降(默认 false=只增不减) |
| `random_initial` | int | 切 random 的初始基数(基础单位);**留空则用当前真实值播种** |
| `apply_now` | bool | 立即更新:不等更新钟点,保存后马上刷新一次(random 走一档,real/manual 取应有值,均经护栏) |
### 出参
响应 `200`:更新后的 `OpsStatItemOut`(同上)。
### 错误
- `400`:mode/kind 非法 / 倍率越界(<1000 或 >1500/ 上限<下限 / 增量负值或上限<下限 / 偏移负值 / tick<60 / 更新时间越界 / manual 负值。
- `403`:角色不足(需 operator 或 super_admin)。
- `404`:无效 metric(不在三指标内,作为 400 文案返回)。
### 说明
- **统一定时刷新**:三模式的展示值(`random_current`)只在「更新时间(按更新间隔)」对齐的北京钟点刷新一次——real 快照查库(+偏移)/ manual 取固定值 / random 按 `random_kind` 走一档(`mult` ×倍率 / `add` +增量)。`random_tick_seconds`+`random_anchor_minutes` 三模式通用。
- **只增不减护栏**(`allow_decrease=false`,默认):real/manual 刷新时新目标值低于当前展示值则保持不降;要下调须显式传 `allow_decrease=true`
- 改 mode/manual/倍率/增量/偏移等到**下个更新钟点**才在客户端生效(展示值不立即变);`random_initial` 立即设起点并重置;`apply_now=true` 立即刷新一次。首次无展示值时按当前模式播种。
- 每次改动写 `admin_audit_log`(action=`ops_stat_config.set`,detail 含改前/改后快照)。
- 客户端在**进首页 / 回前台时**重拉 `/stats` 取最新展示值。
+55
View File
@@ -0,0 +1,55 @@
# Admin 首页轮播种子管理
> 所属:Admin 组(前缀 `/admin/api/marquee-seeds` | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](../README.md)
管理首页轮播「真实+种子混播」的兜底种子。种子是「生成规则」:`masked_user` 可空(空→feed 随机合成名)、金额是 `[min_cents, max_cents]` 区间(feed 每次随机取值)。用户侧 feed 见 [platform-savings-feed](./platform-savings-feed.md);表见 [ops_marquee_seed](../database/ops_marquee_seed.md)。金额单位:分(前端 ÷100 显示元)。
## 复用结构 OpsMarqueeSeedOut
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | |
| `masked_user` | string \| null | 脱敏用户名;`null` = feed 随机合成 |
| `min_cents` | int | 金额区间下限(分) |
| `max_cents` | int | 金额区间上限(分);固定金额则与下限相等 |
| `enabled` | bool | 停用不参与混播 |
| `sort_order` | int | 仅后台列表排序(小在前) |
| `created_at` | string \| null | 创建时间(ISO) |
## GET /admin/api/marquee-seeds — 种子列表
出参 `200`:`list[OpsMarqueeSeedOut]`(按 `sort_order,id`)。
## GET /admin/api/marquee-seeds/preview — 预览实际混播 feed
预览客户端实际会看到的轮播(真实记录会插队、种子随机抽取 / 金额随机 / 名字合成),供运营对效果。**含随机,每次结果不同**。
- 入参:`limit`(query,1~30,默认 8)
- 出参 `200`:`{"items": [{masked_user, saved_amount_cents, time}]}`(条目同 [platform-savings-feed](./platform-savings-feed.md))
## POST /admin/api/marquee-seeds — 新增(带审计)
入参 `OpsMarqueeSeedCreate`:`masked_user`(可选,空 / 不传 → 随机合成)、`min_cents`(必填,≥0)、`max_cents`(必填,≥min,≤1000 元)、`enabled`(默认 true)、`sort_order`(默认 0)。出参:新建的 `OpsMarqueeSeedOut``400`=金额非法。
## POST /admin/api/marquee-seeds/bulk — 批量生成(带审计)
冷启动铺量:一次生成 `count` 条「自动合成名」(masked_user=null)种子,金额均为给定区间,`sort_order` 续在当前最大值之后。
- 入参 `OpsMarqueeSeedBulkCreate`:`count`(1~200)、`min_cents``max_cents``enabled`(默认 true)
- 出参 `200`:`list[OpsMarqueeSeedOut]`(新建的 count 条)。`400`=数量 / 金额非法。
## POST /admin/api/marquee-seeds/batch-delete — 批量删除(带审计)
对选中的多条种子一次删除(一个事务 + 一条审计)。
- 入参 `OpsMarqueeSeedBatchDelete`:`ids`(int 数组;空数组 = no-op;不存在的 id 自动跳过)
- 出参 `200`:`{"deleted": <实际删除条数>}`
## POST /admin/api/marquee-seeds/batch-enable — 批量启用 / 停用(带审计)
对选中的多条种子一次置启用态。
- 入参 `OpsMarqueeSeedBatchEnable`:`ids`(int 数组)、`enabled`(bool)
- 出参 `200`:`{"updated": <实际更新条数>}`
## PATCH /admin/api/marquee-seeds/{seed_id} — 改(带审计)
入参 `OpsMarqueeSeedUpdate`(均可选):`masked_user` / `min_cents` / `max_cents` / `enabled` / `sort_order`
- `masked_user` 约定:**不传**=不改;**传空串**=清空(改回随机合成);**传值**=固定该名。
- 只改单边 `min_cents` / `max_cents` 时,与现有的另一边一起做「上限≥下限」校验。
出参:更新后的 `OpsMarqueeSeedOut``404`=种子不存在;`400`=金额非法。
## DELETE /admin/api/marquee-seeds/{seed_id} — 删(带审计)
出参 `200`:`{"deleted": true}``404`=种子不存在。
## 说明
- 写操作需 operator(super 恒可),均写 `admin_audit_log`(action=`ops_marquee_seed.create` / `update` / `delete` / `bulk_generate` / `batch_delete` / `batch_enable`)。预览为只读,任意已登录 admin 可用。
- 改动客户端**进首页 / 回前台**重拉 feed 时生效。
+77
View File
@@ -0,0 +1,77 @@
# GET /admin/api/stats/overview — 大盘核心指标
> 所属:Admin·数据大盘 组(前缀 `/admin/api/stats` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role | [← 返回 API 索引](../README.md)
## 入参
## 出参
响应 `200`:`DashboardOverview`(全局只读聚合,六大块嵌套)
| 字段 | 类型 | 说明 |
|---|---|---|
| `users` | DashboardUsers | 用户指标 |
| `coins` | DashboardCoins | 金币指标 |
| `cash` | DashboardCash | 现金/提现指标 |
| `comparison` | DashboardComparison | 比价指标 |
| `feedback` | DashboardFeedback | 反馈指标 |
| `cps` | DashboardCps | CPS 收入(P2 未接入) |
**DashboardUsers**
| 字段 | 类型 | 说明 |
|---|---|---|
| `total` | int | 用户总数 |
| `active` | int | 状态 `active` 数 |
| `disabled` | int | 状态 `disabled` 数 |
| `deleted` | int | 状态 `deleted` 数 |
| `new_today` | int | 今日新增(按北京时区切天的 `created_at`) |
| `dau` | int | 日活(今日有 `last_login_at`,北京时区切天) |
**DashboardCoins**
| 字段 | 类型 | 说明 |
|---|---|---|
| `granted_total` | int | 累计发放金币(coin_transaction 中所有 `amount > 0` 之和;负数兑换/扣减不计) |
| `reward_video_coin_total` | int | 普通激励视频累计发放金币(`biz_type=reward_video/ad_reward`) |
| `reward_video_watch_count` | int | 普通激励视频有效发奖视频数(`ad_reward_record.reward_scene=reward_video,status=granted`) |
| `feed_ad_coin_total` | int | 信息流广告累计发放金币(`biz_type=feed_ad_reward`) |
| `feed_ad_watch_count` | int | 信息流广告有效完成视频数(`ad_feed_reward_record.status=granted`) |
| `signin_coin_total` | int | 签到累计发放金币(`biz_type=signin`) |
| `signin_count` | int | 签到次数(`signin_record`) |
| `signin_boost_coin_total` | int | 签到膨胀累计发放金币(`biz_type=signin_boost`) |
| `signin_boost_watch_count` | int | 签到膨胀有效视频数(`signin_boost_record`) |
**DashboardCash**
| 字段 | 类型 | 说明 |
|---|---|---|
| `withdraw_success_cents` | int | 提现成功累计金额(分,`status=success` 之和) |
| `withdraw_pending_count` | int | 提现 `pending` 单数 |
| `withdraw_success_count` | int | 提现 `success` 单数 |
| `withdraw_failed_count` | int | 提现 `failed` 单数 |
**DashboardComparison**
| 字段 | 类型 | 说明 |
|---|---|---|
| `total` | int | 比价记录总数 |
| `success` | int | 比价成功数(`status=success`) |
| `success_rate` | float | 成功率 = success/total,保留 4 位小数;total 为 0 时返回 `0.0` |
**DashboardFeedback**
| 字段 | 类型 | 说明 |
|---|---|---|
| `new` | int | 待处理反馈数(`status=new`) |
**DashboardCps**
| 字段 | 类型 | 说明 |
|---|---|---|
| `available` | bool | CPS 数据是否可用(当前固定 `false`) |
| `note` | string | 说明文案(当前固定 `"CPS 转化数据未接入(P2)"`) |
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
## 说明
- 全部为全局只读聚合(count / sum / DAU / 成功率),不改任何数据。
- `new_today` / `dau` 按**北京时区**(UTC+8)切天,其余金额/计数无时区概念。
- 金额单位:`*_cents` 为分;金币(`*_coin_total`/`granted_total`)为个数。
- CPS 收入数据源未接入(referral-link 只换链接,转化/佣金未回收),`cps` 恒为 `{available:false, note:...}`,前端显示"待接入"。
- 关联表:[user](../database/user.md) / [coin_transaction](../database/coin_transaction.md) / [withdraw_order](../database/withdraw_order.md) / [comparison_record](../database/comparison_record.md) / [feedback](../database/feedback.md)。
@@ -0,0 +1,33 @@
# POST /admin/api/admins — 创建管理员
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins` | 鉴权:Bearer admin_token(角色:super_admin | [← 返回 API 索引](../../README.md)
## 入参
**application/json**:
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `username` | string | ✓ | — | 账号,364 字 |
| `password` | string | ✓ | — | 初始密码,872 字(bcrypt ≤72 字节) |
| `role` | string | ✗ | `operator` | 角色,枚举:`super_admin` / `finance` / `operator` |
## 出参
响应 `200`:`AdminOut`(新建的管理员)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 管理员 id |
| `username` | string | 账号 |
| `role` | string | 角色 |
| `status` | string | 状态(新建默认 `active`) |
| `created_at` | datetime | 创建时间(UTC) |
| `last_login_at` | datetime \| null | 上次登录时间(新建为 null) |
## 错误码
- `401` 未带 admin token / token 无效或过期 / 管理员被禁用
- `403` 角色不足(仅 super_admin)
- `409` 用户名已存在
- `422` 缺字段 / `username` 长度不在 364 / `password` 长度不在 872 / `role` 非法枚举
## 说明
- 创建成功后写一条审计:`action=admin.create``target_type=admin``target_id=新管理员 id``detail={username, role}`。见 [admin_audit_log](../database/admin_audit_log.md)。
- 数据表见 [admin_user](../database/admin_user.md)。
@@ -0,0 +1,39 @@
# PATCH /admin/api/admins/{admin_id} — 改角色/启停/重置密码
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins` | 鉴权:Bearer admin_token(角色:super_admin | [← 返回 API 索引](../../README.md)
## 入参
**路径参数**:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `admin_id` | int | ✓ | 目标管理员 id |
**application/json**(三字段都可选,只改传了的;至少传一个):
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `role` | string | ✗ | 改角色,枚举:`super_admin` / `finance` / `operator` |
| `status` | string | ✗ | 启停,枚举:`active`(启用)/ `disabled`(禁用) |
| `password` | string | ✗ | 重置密码,872 字(传则覆盖原密码) |
## 出参
响应 `200`:`AdminOut`(更新后的管理员)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 管理员 id |
| `username` | string | 账号 |
| `role` | string | 角色 |
| `status` | string | 状态 |
| `created_at` | datetime | 创建时间(UTC) |
| `last_login_at` | datetime \| null | 上次登录时间 |
## 错误码
- `400` 不能禁用自己(`admin_id == 当前 admin.id``status=disabled`) / 无任何变更字段(三字段全空)
- `401` 未带 admin token / token 无效或过期 / 管理员被禁用
- `403` 角色不足(仅 super_admin)
- `404` 管理员不存在
- `422` `role`/`status` 非法枚举 / `password` 长度不在 872
## 说明
- 更新成功后写一条审计:`action=admin.update``target_type=admin``target_id=admin_id``detail` 为本次实际变更字段(如 `{"role": "...", "status": "...", "password": "reset"}`,密码只记 `reset` 不记明文)。见 [admin_audit_log](../database/admin_audit_log.md)。
- 数据表见 [admin_user](../database/admin_user.md)。
@@ -0,0 +1,26 @@
# GET /admin/api/admins — 管理员列表
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins` | 鉴权:Bearer admin_token(角色:super_admin | [← 返回 API 索引](../../README.md)
## 入参
无(按 `id` 升序返回全部,无分页)
## 出参
响应 `200`:`AdminOut[]`
**AdminOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 管理员 id |
| `username` | string | 账号 |
| `role` | string | 角色:`super_admin` / `finance` / `operator` |
| `status` | string | 状态:`active` / `disabled` |
| `created_at` | datetime | 创建时间(UTC) |
| `last_login_at` | datetime \| null | 上次登录时间,从未登录为 null |
## 错误码
- `401` 未带 admin token / token 无效或过期 / 管理员被禁用
- `403` 角色不足(仅 super_admin 可访问,detail 形如 `role 'operator' not allowed (need one of [...])`)
## 说明
账号管理整组(`/admin/api/admins`)的角色守卫为 `require_role()` 无参,即仅 `super_admin` 通过。数据表见 [admin_user](../database/admin_user.md)。
+40
View File
@@ -0,0 +1,40 @@
# POST /admin/api/auth/login — 管理员登录
> 所属:Admin·Auth 组(前缀 `/admin/api/auth` | 鉴权:无 | [← 返回 API 索引](../../README.md)
## 入参
**application/json**:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `username` | string | ✓ | 管理员账号,164 字 |
| `password` | string | ✓ | 密码,1128 字 |
## 出参
响应 `200`:`AdminLoginResponse`
| 字段 | 类型 | 说明 |
|---|---|---|
| `access_token` | string | admin JWT(独立 `ADMIN_JWT_SECRET`、payload `typ=admin`、默认 12h、无 refresh) |
| `token_type` | string | 固定 `Bearer` |
| `expires_in` | int | access_token 剩余秒数(过期需重新登录) |
| `admin` | AdminOut | 当前管理员信息,字段见下 |
**AdminOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 管理员 id |
| `username` | string | 账号 |
| `role` | string | 角色:`super_admin` / `finance` / `operator` |
| `status` | string | 状态:`active` / `disabled` |
| `created_at` | datetime | 创建时间(UTC) |
| `last_login_at` | datetime \| null | 上次登录时间,从未登录为 null |
## 错误码
- `401` 用户名或密码错误(用户名不存在与密码错误统一同文案,防账号枚举)
- `403` 账号已禁用(`status != active`)
- `422``username``password` / 长度超限
- `429` 同 IP 每分钟登录超过 10 次(限流防爆破)
## 说明
- 登录成功后回写 `last_login_at` 为当前时间。
- 后续所有 admin 接口须带 `Authorization: Bearer <access_token>`;admin token 与 App 用户 token 完全隔离。
+24
View File
@@ -0,0 +1,24 @@
# GET /admin/api/auth/me — 当前管理员
> 所属:Admin·Auth 组(前缀 `/admin/api/auth` | 鉴权:Bearer admin_token(角色:任意已登录 admin | [← 返回 API 索引](../../README.md)
## 入参
无(身份取自 Header token
## 出参
响应 `200`:`AdminOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 管理员 id |
| `username` | string | 账号 |
| `role` | string | 角色:`super_admin` / `finance` / `operator` |
| `status` | string | 状态:`active` / `disabled` |
| `created_at` | datetime | 创建时间(UTC) |
| `last_login_at` | datetime \| null | 上次登录时间,从未登录为 null |
## 错误码
- `401` 未带 admin token / token 无效或过期 / `typ` 非 admin / 管理员不存在或被禁用(响应头带 `WWW-Authenticate: Bearer`)
## 说明
@@ -0,0 +1,23 @@
# POST /admin/api/feedbacks/{feedback_id}/handle — 标记反馈已处理
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks` | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过,`require_role("operator")` | [← 返回 API 索引](../../README.md)
## 入参
- 路径:`feedback_id`(int)
- body:无
## 出参
响应 `200`:`OkResponse` = `{ "ok": true }`
幂等说明:将该反馈 `status` 置为 `handled`(不校验原状态,重复调用结果一致)。
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `403` 角色不足(需 `operator``super_admin`)
- `404` 反馈不存在(`detail: "反馈不存在"`)
- `422` `feedback_id` 非合法 int
## 说明
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action="feedback.handle"``target_type="feedback"``target_id=<feedback_id>``detail={"before": <原 status>, "after": "handled"}``ip=<客户端 IP>`
- 状态变更与审计写入在同一事务(`commit=False` 后统一 `db.commit()`)。
- 关联表 [feedback](../database/feedback.md)。
@@ -0,0 +1,40 @@
# GET /admin/api/feedbacks — 反馈工单列表(offset 分页 + 筛选/排序)
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 `require_role`,仅 `get_current_admin` | [← 返回 API 索引](../../README.md)
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `status` | string | ❌ | null | 反馈状态,精确匹配:`new`(待处理) / `handled`(已处理);传空/不传则不筛 |
| `user_id` | int | ❌ | null | 按提交用户 id 精确筛 |
| `content` | string | ❌ | null | 反馈内容模糊匹配(ilike,≤100 字) |
| `created_from` | datetime | ❌ | null | 提交时间 ≥(ISO,统一按 UTC 比较) |
| `created_to` | datetime | ❌ | null | 提交时间 ≤(ISO,统一按 UTC 比较) |
| `sort_by` | string | ❌ | `id` | 排序列:`id` / `created_at` |
| `sort_order` | string | ❌ | `desc` | `asc` / `desc` |
| `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页 next_cursor(**offset 分页**,cursor=offset) |
## 出参
响应 `200`:`{ items: FeedbackOut[], next_cursor: int|null }`(`next_cursor=null` 表示末页)
**FeedbackOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 反馈 id |
| `user_id` | int | 提交用户 id |
| `content` | string | 反馈内容 |
| `contact` | string | 联系方式(微信/QQ/手机,便于回访) |
| `images` | string[] \| null | 截图相对路径列表(如 `["/media/feedback/u1_ab12.jpg"]`),无图为 null |
| `status` | string | 反馈状态:`new`(待处理) / `handled`(已处理) |
| `created_at` | datetime | 提交时间(UTC) |
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `422` `limit` 超出 1100 范围 / `sort_by`·`sort_order` 不在允许集 / 字段类型不合法
## 说明
- **offset 分页**(同 [admin-users-list](./admin-users-list.md)):`cursor` 即 offset,传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。改用 offset 是为了在任意列排序下游标语义统一,代价是翻页期间数据变动可能错位一条(admin 低频可接受)。
- 排序:`sort_by`(id/created_at)× `sort_order`(asc/desc),恒以 `id` 同向兜底次序。
- `status` / `user_id` 精确匹配、`content` 模糊、`created_from`/`created_to` 时间范围,均可叠加。
- 关联表 [feedback](../database/feedback.md);截图为相对路径,经 `GET /media/feedback/<file>` 静态读。
+34
View File
@@ -0,0 +1,34 @@
# POST /admin/api/users/{user_id}/cash — 手动增减/设值现金(带审计)
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md)
主要用于给无现金用户直接发钱、好让其测试提现链路。
## 入参
- 路径:`user_id`(int)
- **application/json**:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `mode` | string | ✗ | `delta`(默认)=增减 / `set`=设为指定值 |
| `amount_cents` | int | ✓ | `delta` 模式:现金变动(分,正=发放,负=扣减,不可为 0);`set` 模式:目标现金值(分,须 ≥ 0) |
| `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) |
## 出参
响应 `200`:`OkResponse` = `{ "ok": true }`
## 错误码
- `400`(`delta`)`amount_cents == 0`(`detail: "amount_cents 不能为 0"`);或负数扣减后余额会变负(`detail: "扣减后现金为负(当前余额 N 分)"`)
- `400`(`set`)目标值为负(`detail: "目标现金值不能为负"`);或目标值等于当前余额(`detail: "当前现金已为 N 分,无需调整"`)
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `403` 角色不足(需 `finance``super_admin`)
- `404` 用户不存在(`detail: "用户不存在"`)
- `422``amount_cents`/`reason``reason` 长度不在 1128、`mode``delta`/`set`、字段类型不合法 / `user_id` 非整数
## 说明
- 金额单位一律为**分**(`*_cents`);本接口只动现金余额,不涉及金币。
- **set 模式**:读当前余额算出差值 `delta = target - 当前余额`,再复用同一套写入逻辑(故只写一笔差值流水)。目标值须 ≥ 0;差值为 0(已等于目标)直接拒绝。
- 扣减保护:实际写入的 `delta < 0` 时若扣减后现金余额 < 0 直接拒绝(运营误操作保护);set 模式目标值 ≥ 0 天然不会扣成负。
- 现金变动写流水 [cash_transaction](../database/cash_transaction.md):`biz_type` 实际差值为正记 `admin_grant`、为负记 `admin_deduct`(set 模式同理,不新增流水类型),`remark = admin:<reason>`(截断至 128 字)。
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.cash.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount_cents(=实际差值), balance_after_cents, reason}`;set 模式额外带 `{mode:"set", target_cents, before_cents}`。并记录操作 IP。
- 现金变动 + 审计在同一事务原子提交(改钱必留痕)。
- 关联用户表 [user](../database/user.md);现金账户 [coin_account](../database/coin_account.md);现金流水 [cash_transaction](../database/cash_transaction.md)。
+32
View File
@@ -0,0 +1,32 @@
# POST /admin/api/users/{user_id}/coins — 手动增减/设值金币(带审计)
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md)
## 入参
- 路径:`user_id`(int)
- **application/json**:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `mode` | string | ✗ | `delta`(默认)=增减 / `set`=设为指定值 |
| `amount` | int | ✓ | `delta` 模式:金币变动(正=增加,负=扣减,不可为 0);`set` 模式:目标金币值(须 ≥ 0) |
| `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) |
## 出参
响应 `200`:`OkResponse` = `{ "ok": true }`
## 错误码
- `400`(`delta`)`amount == 0`(`detail: "amount 不能为 0"`);或负数扣减后余额会变负(`detail: "扣减后金币为负(当前余额 N)"`)
- `400`(`set`)目标值为负(`detail: "目标金币值不能为负"`);或目标值等于当前余额(`detail: "当前金币已为 N,无需调整"`)
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `403` 角色不足(需 `finance``super_admin`)
- `404` 用户不存在(`detail: "用户不存在"`)
- `422``amount`/`reason``reason` 长度不在 1128、`mode``delta`/`set`、字段类型不合法 / `user_id` 非整数
## 说明
- 金币 = 个数(非现金);本接口只动金币余额,不涉及现金(`*_cents`)。
- **set 模式**:读当前余额算出差值 `delta = target - 当前余额`,再复用同一套写入逻辑(故只写一笔差值流水)。目标值须 ≥ 0;差值为 0(已等于目标)直接拒绝。
- 扣减保护:实际写入的 `delta < 0` 时若扣减后金币余额 < 0 直接拒绝(运营误操作保护);set 模式目标值 ≥ 0 天然不会扣成负。
- 金币变动写流水 [coin_transaction](../database/coin_transaction.md):`biz_type` 实际差值为正记 `admin_grant`、为负记 `admin_deduct`(set 模式同理,不新增流水类型),`remark = admin:<reason>`(截断至 128 字)。
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.coins.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount(=实际差值), balance_after, reason}`;set 模式额外带 `{mode:"set", target, before}`。并记录操作 IP。
- 金币变动 + 审计在同一事务原子提交(改钱必留痕)。
- 关联用户表 [user](../database/user.md);金币账户 [coin_account](../database/coin_account.md);金币流水 [coin_transaction](../database/coin_transaction.md)。
+43
View File
@@ -0,0 +1,43 @@
# GET /admin/api/users/{user_id} — 用户 360 详情
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role | [← 返回 API 索引](../../README.md)
## 入参
- 路径:`user_id`(int)
## 出参
响应 `200`:`AdminUserOverview`(基础资料 + 钱包余额 + 各项 count;历史明细走各自分页接口)
| 字段 | 类型 | 说明 |
|---|---|---|
| `user` | AdminUserListItem | 用户基础资料,字段见下 |
| `coin_balance` | int | 当前金币余额(个数);无金币账户时为 0 |
| `cash_balance_cents` | int | 当前现金余额(分);无账户时为 0 |
| `total_coin_earned` | int | 累计获得金币(个数);无账户时为 0 |
| `comparison_total` | int | 比价记录总数 |
| `comparison_success` | int | 比价成功数(`status=success`) |
| `withdraw_total` | int | 提现单总数 |
| `withdraw_success_cents` | int | 提现成功累计金额(分,`status=success` 之和) |
| `feedback_total` | int | 反馈总数 |
**AdminUserListItem**(`user` 字段)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 用户 id |
| `phone` | string | 手机号 |
| `nickname` | string \| null | 昵称,可空 |
| `register_channel` | string | 注册渠道 |
| `status` | string | 用户状态:`active` / `disabled` / `deleted` |
| `wechat_openid` | string \| null | 微信 openid,可空 |
| `created_at` | datetime | 注册时间(UTC) |
| `last_login_at` | datetime | 上次登录时间(UTC) |
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `404` 用户不存在(`detail: "用户不存在"`)
- `422` `user_id` 非整数
## 说明
- 金币三项(`coin_balance` / `cash_balance_cents` / `total_coin_earned`)读 [coin_account](../database/coin_account.md);从未发生金币动作(账户不存在)时统一返回 0。
- 各 count 为聚合数,明细历史走带 `user_id` 过滤的分页接口(金币流水 / 现金流水 / 提现 / 比价 / 反馈)。
- 关联用户表 [user](../database/user.md);金币账户 [coin_account](../database/coin_account.md);提现单 [withdraw_order](../database/withdraw_order.md);比价记录 [comparison_record](../database/comparison_record.md);反馈 [feedback](../database/feedback.md)。
+25
View File
@@ -0,0 +1,25 @@
# POST /admin/api/users/{user_id}/status — 封禁/解封用户
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md)
## 入参
- 路径:`user_id`(int)
- **application/json**:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `status` | string | ✓ | 目标状态,枚举 `active`(解封)/ `disabled`(封禁);注销 `deleted` 不走此接口 |
## 出参
响应 `200`:`OkResponse` = `{ "ok": true }`
## 错误码
- `400` 已注销账号不可改状态(目标用户当前 `status == deleted`,`detail: "已注销账号不可改状态"`)
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `403` 角色不足(需 `operator``super_admin`)
- `404` 用户不存在(`detail: "用户不存在"`)
- `422` `status``active`/`disabled` 枚举 / 缺字段 / `user_id` 非整数
## 说明
- 业务写(改用户状态)与审计写在同一事务原子提交:改了就有痕、有痕就真改了。
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.status.set`,`target_type = user`,`target_id = user_id`,`detail = {before, after}`,并记录操作 IP。
- 关联用户表 [user](../database/user.md)。
+43
View File
@@ -0,0 +1,43 @@
# GET /admin/api/users — 用户列表(筛选+排序+分页)
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role | [← 返回 API 索引](../../README.md)
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `phone` | string | ❌ | null | 手机号**前缀**匹配(`phone LIKE '<值>%'`) |
| `register_channel` | string | ❌ | null | 注册渠道,精确匹配 |
| `status` | string | ❌ | null | 用户状态,精确匹配:`active` / `disabled` / `deleted` |
| `nickname` | string | ❌ | null | 昵称**模糊**匹配(`ILIKE '%<值>%'`) |
| `created_from` / `created_to` | datetime | ❌ | null | 注册时间范围(ISO 8601;按 UTC 比较) |
| `last_login_from` / `last_login_to` | datetime | ❌ | null | 最近登录时间范围(ISO 8601;按 UTC 比较) |
| `sort_by` | string | ❌ | `id` | 排序列:`id` / `created_at` / `last_login_at` |
| `sort_order` | string | ❌ | `desc` | `asc` / `desc` |
| `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页 next_cursor(**offset 偏移量**) |
## 出参
响应 `200`:`{ items: AdminUserListItem[], next_cursor: int|null }`(`next_cursor=null` 表示末页)
**AdminUserListItem**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 用户 id |
| `phone` | string | 手机号 |
| `nickname` | string \| null | 昵称,可空 |
| `register_channel` | string | 注册渠道 |
| `status` | string | 用户状态:`active` / `disabled` / `deleted` |
| `wechat_openid` | string \| null | 微信 openid,可空 |
| `created_at` | datetime | 注册时间(UTC) |
| `last_login_at` | datetime | 上次登录时间(UTC) |
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `422` `limit` 超出 1100 范围 / `sort_by`·`sort_order` 不在白名单 / 日期格式不合法 / 字段类型不合法
## 说明
- **分页为 offset 偏移式**(为支持任意列排序):`cursor` 传上一页返回的 `next_cursor`(实为 offset);`next_cursor=null` 即末页。代价:翻页期间数据变动可能错位一条,admin 低频场景可接受(同提现列表)。
- 排序:`sort_by``id`/`created_at`/`last_login_at`,非法值回落 `id`;同值再按 `id` 同向兜底,次序稳定。
- 筛选:`phone` 前缀、`nickname` 模糊(`ILIKE`)、`register_channel`/`status` 精确、`created_*`/`last_login_*` 时间范围;均可叠加。
- 关联用户表 [user](../database/user.md)。
- 历史明细(金币流水、提现、比价、反馈等)不在本列表,走各自带 `user_id` 过滤的分页接口。
@@ -0,0 +1,35 @@
# GET /admin/api/wallet/cash-transactions — 现金流水(游标分页)
> 所属:Admin·钱包 组(前缀 `/admin/api/wallet` | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role` | [← 返回 API 索引](../../README.md)
跨用户查询全量现金流水,可按 `user_id` / `biz_type` 过滤。游标分页(`id` 倒序)。金额单位一律为**分**。
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `user_id` | int | ❌ | null | 按用户过滤;不传则查全量 |
| `biz_type` | string | ❌ | null | 按业务类型过滤 |
| `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页 next_cursor(查 `id < cursor` |
## 出参
响应 `200`:`{ items: CashTxnOut[], next_cursor: int|null }``next_cursor=null` 表示末页)
**CashTxnOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 流水主键 |
| `user_id` | int | 所属用户 |
| `amount_cents` | int | 现金变动量,单位:**分**(正/负) |
| `balance_after_cents` | int | 本次变动后现金余额,单位:**分** |
| `biz_type` | string | 业务类型 |
| `ref_id` | string \| null | 关联业务 ID |
| `remark` | string \| null | 备注 |
| `created_at` | datetime | 创建时间(北京 wall-clock naive,非 UTC;前端按字面显示,不做时区换算) |
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`
- `422` query 参数校验失败(如 `limit` 越界、类型错误)
## 说明
关联 [cash_transaction](../database/cash_transaction.md)。所有金额字段单位为分。
@@ -0,0 +1,35 @@
# GET /admin/api/wallet/coin-transactions — 金币流水(游标分页)
> 所属:Admin·钱包 组(前缀 `/admin/api/wallet` | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role` | [← 返回 API 索引](../../README.md)
跨用户查询全量金币流水,可按 `user_id` / `biz_type` 过滤。游标分页(`id` 倒序)。
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `user_id` | int | ❌ | null | 按用户过滤;不传则查全量 |
| `biz_type` | string | ❌ | null | 按业务类型过滤 |
| `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页 next_cursor(查 `id < cursor` |
## 出参
响应 `200`:`{ items: CoinTxnOut[], next_cursor: int|null }``next_cursor=null` 表示末页)
**CoinTxnOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 流水主键 |
| `user_id` | int | 所属用户 |
| `amount` | int | 金币变动量(正/负,单位:金币个数,非分) |
| `balance_after` | int | 本次变动后金币余额 |
| `biz_type` | string | 业务类型 |
| `ref_id` | string \| null | 关联业务 ID |
| `remark` | string \| null | 备注 |
| `created_at` | datetime | 创建时间(北京 wall-clock naive,非 UTC;前端按字面显示,不做时区换算) |
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`
- `422` query 参数校验失败(如 `limit` 越界、类型错误)
## 说明
关联 [coin_transaction](../database/coin_transaction.md)。金币为个数计量,非分。
@@ -0,0 +1,37 @@
# POST /admin/api/withdraws/reconcile — 批量对账(扫超时 pending 单)
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws` | 鉴权:Bearer admin_token(角色:`finance``super_admin` 恒通过) | [← 返回 API 索引](../../README.md)
扫描创建时间超过 `older_than_minutes` 分钟、仍为 `pending` 的提现单,逐单调微信查单并归一化(成功落 `success`;失败/已撤销则退款落 `failed`;查到 `WAIT_USER_CONFIRM` 视为用户放弃,撤单+退款)。用于解开"扣了款但转账没发起/没确认"的孤儿单。单笔失败不影响其余(内部 rollback 后继续,下轮再试)。
> 路由注册顺序上,静态路径 `/reconcile` 必须在路径参数 `/{out_bill_no}/refresh` 之前声明。
## 入参
- 路径:无
- query:
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `older_than_minutes` | int | ❌ | 15 | 只扫创建时间早于「现在 − N 分钟」的 pending 单;`ge=0` |
- body:无
## 出参
响应 `200`:`ReconcileResult`
**ReconcileResult**
| 字段 | 类型 | 说明 |
|---|---|---|
| `checked` | int | 本次扫到的超时 pending 单数量 |
| `resolved` | int | 其中被归一化为终态(success/failed)的数量 |
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`
- `403` 角色不足(需 `finance``super_admin`
- `422` query 参数校验失败(如 `older_than_minutes < 0`
- `503` 微信支付未配置(捕获 `WxPayNotConfiguredError``detail="微信支付未配置"`
## 说明
- 调用底层 `app.repositories.wallet.reconcile_pending_withdraws`(内部逐单调微信查单/撤单/退款,各自 commit)。
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md)`action="withdraw.reconcile"``detail``{checked, resolved}`,含触发管理员与客户端 IP)。
- 关联 [withdraw_order](../database/withdraw_order.md)。
@@ -0,0 +1,39 @@
# POST /admin/api/withdraws/{out_bill_no}/refresh — 单笔提现重试查单
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws` | 鉴权:Bearer admin_token(角色:`finance``super_admin` 恒通过) | [← 返回 API 索引](../../README.md)
对单笔提现单调微信查单并归一化:`SUCCESS``success``FAIL`/`CANCELLED`/`CLOSED`→退款+`failed`;查到 `WAIT_USER_CONFIRM` 视为用户放弃(`cancel_if_unconfirmed=True`),撤单+退款;`ACCEPTED`/`PROCESSING` 等仍在途则保持 `pending`。已是终态的单直接返回、不再查。
## 入参
- 路径:`out_bill_no`string — 商户提现单号
- body:无
## 出参
响应 `200`:`WithdrawOrderOut`(归一化后的最新提现单)
**WithdrawOrderOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 提现单主键 |
| `user_id` | int | 所属用户 |
| `out_bill_no` | string | 商户提现单号 |
| `amount_cents` | int | 提现金额,单位:**分** |
| `status` | string | 单状态(`pending` / `success` / `failed` |
| `wechat_state` | string \| null | 微信侧转账状态 |
| `transfer_bill_no` | string \| null | 微信转账单号 |
| `fail_reason` | string \| null | 失败/退款原因 |
| `created_at` | datetime | 创建时间(ISO 8601 UTC |
| `updated_at` | datetime | 更新时间(ISO 8601 UTC |
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`
- `403` 角色不足(需 `finance``super_admin`
- `404` 提现单不存在(`detail="提现单不存在"`
- `503` 微信支付未配置(捕获 `WxPayNotConfiguredError``detail="微信支付未配置"`
> 注:微信查单本身失败(非未配置)时不抛错、不改状态,单保持 `pending` 等下次重试,故无 `502`。
## 说明
- 调用底层 `app.repositories.wallet.refresh_withdraw_status(..., cancel_if_unconfirmed=True)`(内部调微信查单/撤单/退款并 commit)。
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md)`action="withdraw.refresh"``target_id=out_bill_no``detail``{status, wechat_state}`,含触发管理员与客户端 IP)。
- 关联 [withdraw_order](../database/withdraw_order.md)。金额单位为分。
@@ -0,0 +1,37 @@
# GET /admin/api/withdraws — 提现单列表(游标分页)
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws` | 鉴权:Bearer admin_token(角色:任意已登录管理员,列表为只读,仅需 `get_current_admin`,无 `require_role` | [← 返回 API 索引](../../README.md)
跨用户查询全量提现单,可按 `user_id` / `status` 过滤。游标分页(`id` 倒序)。
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `user_id` | int | ❌ | null | 按用户过滤;不传则查全量 |
| `status` | string | ❌ | null | 按状态过滤(如 `pending` / `success` / `failed` |
| `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页 next_cursor(查 `id < cursor` |
## 出参
响应 `200`:`{ items: WithdrawOrderOut[], next_cursor: int|null }``next_cursor=null` 表示末页)
**WithdrawOrderOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 提现单主键 |
| `user_id` | int | 所属用户 |
| `out_bill_no` | string | 商户提现单号(业务主键,重试/对账以此为准) |
| `amount_cents` | int | 提现金额,单位:**分** |
| `status` | string | 单状态(`pending` / `success` / `failed` |
| `wechat_state` | string \| null | 微信侧转账状态(如 `ACCEPTED` / `PROCESSING` / `WAIT_USER_CONFIRM` / `SUCCESS` |
| `transfer_bill_no` | string \| null | 微信转账单号 |
| `fail_reason` | string \| null | 失败/退款原因 |
| `created_at` | datetime | 创建时间(ISO 8601 UTC |
| `updated_at` | datetime | 更新时间(ISO 8601 UTC |
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`
- `422` query 参数校验失败(如 `limit` 越界、类型错误)
## 说明
关联 [withdraw_order](../database/withdraw_order.md)。金额单位为分。