diff --git a/app/api/v1/savings.py b/app/api/v1/savings.py index 50e756d..bbbfa18 100644 --- a/app/api/v1/savings.py +++ b/app/api/v1/savings.py @@ -42,6 +42,7 @@ def battle(user: CurrentUser, db: DbSession) -> SavingsBattleOut: week_saved_cents=b.week_saved_cents, beat_percent=b.beat_percent, streak_days=b.streak_days, + compare_count=b.compare_count, ) diff --git a/app/core/rewards.py b/app/core/rewards.py index 800a006..8f1b745 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -34,8 +34,9 @@ COIN_PER_YUAN: int = 10000 CENTS_PER_YUAN: int = 100 # 1 分对应多少金币 = 100。兑换金币数必须是它的整数倍,否则会出现不足 1 分的零头。 COIN_PER_CENT: int = COIN_PER_YUAN // CENTS_PER_YUAN -# 单次兑换最少 1 元(避免大量 1 分级碎兑) -MIN_EXCHANGE_COIN: int = COIN_PER_YUAN +# 单次兑换最少 1 分(=COIN_PER_CENT;产品定:可兑 1 分起,与 step 同粒度)。早先为 1 元(=COIN_PER_YUAN), +# 用户 2026-06 改为 1 分:让"换现金"小额即可用(也方便联调验证资产卡飞金币动画)。 +MIN_EXCHANGE_COIN: int = COIN_PER_CENT def coins_to_cents(coin_amount: int) -> int: @@ -54,7 +55,7 @@ TASK_ENABLE_NOTIFICATION = "enable_notification" # task_key -> 奖励金币 # 打开消息提醒: 1000 金币(=¥0.1, 客户端原型展示口径; 量级与签到/里程碑相称)。 -# 注意: 已不再 = 兑换下限(MIN_EXCHANGE_COIN=10000), test_exchange_flow 改走 grant_coins 直接供款。 +# 注意: 不再 = 兑换下限(下限已降到 MIN_EXCHANGE_COIN=COIN_PER_CENT=100), test_exchange_flow 改走 grant_coins 直接供款。 TASK_REWARDS: dict[str, int] = { TASK_ENABLE_NOTIFICATION: 1000, } diff --git a/app/models/comparison.py b/app/models/comparison.py index d1de520..f1f593c 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -86,7 +86,7 @@ class ComparisonRecord(Base): # ===== 明细(JSON,越详细越好)===== # 下单菜品 [{name, qty, specs?}] items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) - # 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved}](price/coupon_saved 单位:元,原样存) + # 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名) comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) # 目标平台未找到、跳过的菜名 skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) diff --git a/app/repositories/savings.py b/app/repositories/savings.py index b1901a8..dbbbfe5 100644 --- a/app/repositories/savings.py +++ b/app/repositories/savings.py @@ -52,6 +52,7 @@ class SavingsBattle: week_saved_cents: int # 本周(周一起)已省 beat_percent: int # 超过百分之多少用户 streak_days: int # 连续省钱天数 + compare_count: int # 累计完成比价次数(= 有效记录数:真实用 compare 记录、否则 demo 兜底) def _local_date(dt: datetime): @@ -163,7 +164,10 @@ def get_battle(db: Session, user_id: int) -> SavingsBattle: beat_percent = _compute_beat_percent(db, user_id) return SavingsBattle( - week_saved_cents=week_saved, beat_percent=beat_percent, streak_days=streak + week_saved_cents=week_saved, + beat_percent=beat_percent, + streak_days=streak, + compare_count=len(records), ) diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index 1d2cb22..0e0ace7 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -37,6 +37,9 @@ class ComparisonResultIn(BaseModel): # 必须显式声明: 落库走 model_dump(), pydantic 默认丢未知字段, 不声明这行会被悄悄吞掉。 # 各平台抠到红包即带值(2026-06 起源平台 Phase1 意图识别也抠, 当前仅淘宝源)。见 pricebot 侧 比价红包额留痕-实现方案.md。 coupon_saved: float | None = None + # 优惠**来源名**(展示用, best-effort): 美团"外卖大额神券"/京东"百亿补贴"/淘宝"平台红包"。 + # None=没抠到 → 前端走通用"红包"。同样必须显式声明否则上报边界被 pydantic 静默丢弃(pricebot#38 引入)。 + coupon_name: str | None = None class ComparisonRecordIn(BaseModel): diff --git a/app/schemas/welfare.py b/app/schemas/welfare.py index 9a95ba9..102f27e 100644 --- a/app/schemas/welfare.py +++ b/app/schemas/welfare.py @@ -199,6 +199,7 @@ class SavingsBattleOut(BaseModel): week_saved_cents: int = Field(..., description="本周已省(分)") beat_percent: int = Field(..., description="超过百分之多少用户") streak_days: int = Field(..., description="连续省钱天数") + compare_count: int = Field(..., description="累计完成比价次数(= 比价上报记录数)") class SavingsRecordOut(BaseModel): diff --git a/docs/api/README.md b/docs/api/README.md index b391f42..8a97204 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -3,7 +3,7 @@ > Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770` > 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case** > 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer ` -> 最后更新:2026-05-31(+ 比价战绩里程碑 12d/12e) +> 最后更新:2026-06-04(+ 运营后台 Admin 子应用 A1–A18,见下方「运营后台 Admin」组) > 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。 --- @@ -69,6 +69,26 @@ | **静态资源**(StaticFiles 挂载,见下方 `/media` 静态服务) ||| | - | `GET /media/avatars/` | 无 | 用户头像;返回二进制图片 | | - | `GET /media/feedback/` | 无 | 反馈截图;返回二进制图片 | +| **运营后台 Admin**(独立子应用 `app/admin/`,前缀 `/admin/api`,独立进程 + 独立 admin JWT。鉴权列:`admin`=任意已登录管理员,`operator`/`finance`/`super_admin`=需对应角色(`super_admin` 恒通过)) ||| +| A1 | `POST /admin/api/auth/login` | 无 | [详情](./admin-auth-login.md) | +| A2 | `GET /admin/api/auth/me` | admin | [详情](./admin-auth-me.md) | +| A3 | `GET /admin/api/stats/overview` | admin | [详情](./admin-stats-overview.md) | +| A4 | `GET /admin/api/users` | admin | [详情](./admin-users-list.md) | +| A5 | `GET /admin/api/users/{user_id}` | admin | [详情](./admin-user-detail.md) | +| A6 | `POST /admin/api/users/{user_id}/status` | operator | [详情](./admin-user-status.md) | +| A7 | `POST /admin/api/users/{user_id}/coins` | finance | [详情](./admin-user-coins.md) | +| A8 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin-wallet-coin-transactions.md) | +| A9 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin-wallet-cash-transactions.md) | +| A10 | `GET /admin/api/withdraws` | admin | [详情](./admin-withdraws-list.md) | +| A11 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin-withdraw-reconcile.md) | +| A12 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin-withdraw-refresh.md) | +| A13 | `GET /admin/api/feedbacks` | admin | [详情](./admin-feedbacks-list.md) | +| A14 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin-feedback-handle.md) | +| A15 | `GET /admin/api/admins` | super_admin | [详情](./admin-admins-list.md) | +| A16 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) | +| A17 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) | +| A18 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) | +| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) | > ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。 > `coupon/step` 及外卖比价的 `intent/recognize` / `price/step` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见各接口详情)。 diff --git a/docs/api/admin-admin-create.md b/docs/api/admin-admin-create.md new file mode 100644 index 0000000..f93e2b7 --- /dev/null +++ b/docs/api/admin-admin-create.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 | ✓ | — | 账号,3–64 字 | +| `password` | string | ✓ | — | 初始密码,8–72 字(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` 长度不在 3–64 / `password` 长度不在 8–72 / `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)。 diff --git a/docs/api/admin-admin-update.md b/docs/api/admin-admin-update.md new file mode 100644 index 0000000..a86117a --- /dev/null +++ b/docs/api/admin-admin-update.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 | ✗ | 重置密码,8–72 字(传则覆盖原密码) | + +## 出参 +响应 `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` 长度不在 8–72 + +## 说明 +- 更新成功后写一条审计:`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)。 diff --git a/docs/api/admin-admins-list.md b/docs/api/admin-admins-list.md new file mode 100644 index 0000000..951917e --- /dev/null +++ b/docs/api/admin-admins-list.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)。 diff --git a/docs/api/admin-audit-logs.md b/docs/api/admin-audit-logs.md new file mode 100644 index 0000000..bc93d9f --- /dev/null +++ b/docs/api/admin-audit-logs.md @@ -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 | 1–100 | +| `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)。 diff --git a/docs/api/admin-auth-login.md b/docs/api/admin-auth-login.md new file mode 100644 index 0000000..c228bfa --- /dev/null +++ b/docs/api/admin-auth-login.md @@ -0,0 +1,40 @@ +# POST /admin/api/auth/login — 管理员登录 + +> 所属:Admin·Auth 组(前缀 `/admin/api/auth`) | 鉴权:无 | [← 返回 API 索引](./README.md) + +## 入参 +**application/json**: +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `username` | string | ✓ | 管理员账号,1–64 字 | +| `password` | string | ✓ | 密码,1–128 字 | + +## 出参 +响应 `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 `;admin token 与 App 用户 token 完全隔离。 diff --git a/docs/api/admin-auth-me.md b/docs/api/admin-auth-me.md new file mode 100644 index 0000000..057f2e5 --- /dev/null +++ b/docs/api/admin-auth-me.md @@ -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`) + +## 说明 +无 diff --git a/docs/api/admin-feedback-handle.md b/docs/api/admin-feedback-handle.md new file mode 100644 index 0000000..fff4b5f --- /dev/null +++ b/docs/api/admin-feedback-handle.md @@ -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=`、`detail={"before": <原 status>, "after": "handled"}`、`ip=<客户端 IP>`。 +- 状态变更与审计写入在同一事务(`commit=False` 后统一 `db.commit()`)。 +- 关联表 [feedback](../database/feedback.md)。 diff --git a/docs/api/admin-feedbacks-list.md b/docs/api/admin-feedbacks-list.md new file mode 100644 index 0000000..2a3d002 --- /dev/null +++ b/docs/api/admin-feedbacks-list.md @@ -0,0 +1,34 @@ +# GET /admin/api/feedbacks — 反馈工单列表(游标分页) + +> 所属: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 精确筛 | +| `limit` | int | ❌ | 20 | 1–100 | +| `cursor` | int | ❌ | null | 上一页 next_cursor(按 feedback id 倒序,查 `id < cursor`) | + +## 出参 +响应 `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` 超出 1–100 范围 / 字段类型不合法 + +## 说明 +- 游标分页约定:结果按 feedback `id` 倒序;`cursor` 传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。 +- `status` / `user_id` 均为精确匹配,可叠加。 +- 关联表 [feedback](../database/feedback.md);截图为相对路径,经 `GET /media/feedback/` 静态读。 diff --git a/docs/api/admin-stats-overview.md b/docs/api/admin-stats-overview.md new file mode 100644 index 0000000..d76eb27 --- /dev/null +++ b/docs/api/admin-stats-overview.md @@ -0,0 +1,69 @@ +# 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` 之和;负数兑换/扣减不计) | + +**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` 为分;金币(`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)。 diff --git a/docs/api/admin-user-coins.md b/docs/api/admin-user-coins.md new file mode 100644 index 0000000..c04f5dc --- /dev/null +++ b/docs/api/admin-user-coins.md @@ -0,0 +1,29 @@ +# 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**: +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `amount` | int | ✓ | 金币变动(个数):正=增加,负=扣减;不可为 0 | +| `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) | + +## 出参 +响应 `200`:`OkResponse` = `{ "ok": true }` + +## 错误码 +- `400` `amount == 0`(`detail: "amount 不能为 0"`);或负数扣减后余额会变负(`detail: "扣减后金币为负(当前余额 N)"`) +- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`) +- `403` 角色不足(需 `finance` 或 `super_admin`) +- `404` 用户不存在(`detail: "用户不存在"`) +- `422` 缺 `amount`/`reason`、`reason` 长度不在 1–128、字段类型不合法 / `user_id` 非整数 + +## 说明 +- 金币 = 个数(非现金);本接口只动金币余额,不涉及现金(`*_cents`)。 +- 扣减保护:`amount < 0` 时若扣减后金币余额 < 0 直接拒绝(运营误操作保护)。 +- 金币变动写流水 [coin_transaction](../database/coin_transaction.md):`biz_type` 增加为 `admin_grant`、扣减为 `admin_deduct`,`remark = admin:`(截断至 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}`,并记录操作 IP。 +- 金币变动 + 审计在同一事务原子提交(改钱必留痕)。 +- 关联用户表 [user](../database/user.md);金币账户 [coin_account](../database/coin_account.md);金币流水 [coin_transaction](../database/coin_transaction.md)。 diff --git a/docs/api/admin-user-detail.md b/docs/api/admin-user-detail.md new file mode 100644 index 0000000..36571fe --- /dev/null +++ b/docs/api/admin-user-detail.md @@ -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)。 diff --git a/docs/api/admin-user-status.md b/docs/api/admin-user-status.md new file mode 100644 index 0000000..68d496e --- /dev/null +++ b/docs/api/admin-user-status.md @@ -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)。 diff --git a/docs/api/admin-users-list.md b/docs/api/admin-users-list.md new file mode 100644 index 0000000..287e057 --- /dev/null +++ b/docs/api/admin-users-list.md @@ -0,0 +1,37 @@ +# 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` | +| `limit` | int | ❌ | 20 | 1–100 | +| `cursor` | int | ❌ | null | 上一页 next_cursor(按 user id 倒序,查 `id < cursor`) | + +## 出参 +响应 `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` 超出 1–100 范围 / 字段类型不合法 + +## 说明 +- 游标分页约定:结果按 user `id` 倒序;`cursor` 传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。 +- `phone` 为前缀匹配(`LIKE '<值>%'`),`register_channel` / `status` 为精确匹配;三者可叠加。 +- 关联用户表 [user](../database/user.md)。 +- 历史明细(金币流水、提现、比价、反馈等)不在本列表,走各自带 `user_id` 过滤的分页接口。 diff --git a/docs/api/admin-wallet-cash-transactions.md b/docs/api/admin-wallet-cash-transactions.md new file mode 100644 index 0000000..f5f5da5 --- /dev/null +++ b/docs/api/admin-wallet-cash-transactions.md @@ -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 | 1–100 | +| `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 | 创建时间(ISO 8601 UTC) | + +## 错误码 +- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`) +- `422` query 参数校验失败(如 `limit` 越界、类型错误) + +## 说明 +关联 [cash_transaction](../database/cash_transaction.md)。所有金额字段单位为分。 diff --git a/docs/api/admin-wallet-coin-transactions.md b/docs/api/admin-wallet-coin-transactions.md new file mode 100644 index 0000000..e4ca061 --- /dev/null +++ b/docs/api/admin-wallet-coin-transactions.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 | 1–100 | +| `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 | 创建时间(ISO 8601 UTC) | + +## 错误码 +- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`) +- `422` query 参数校验失败(如 `limit` 越界、类型错误) + +## 说明 +关联 [coin_transaction](../database/coin_transaction.md)。金币为个数计量,非分。 diff --git a/docs/api/admin-withdraw-reconcile.md b/docs/api/admin-withdraw-reconcile.md new file mode 100644 index 0000000..0cba53c --- /dev/null +++ b/docs/api/admin-withdraw-reconcile.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)。 diff --git a/docs/api/admin-withdraw-refresh.md b/docs/api/admin-withdraw-refresh.md new file mode 100644 index 0000000..6e4261c --- /dev/null +++ b/docs/api/admin-withdraw-refresh.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)。金额单位为分。 diff --git a/docs/api/admin-withdraws-list.md b/docs/api/admin-withdraws-list.md new file mode 100644 index 0000000..f90e6c6 --- /dev/null +++ b/docs/api/admin-withdraws-list.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 | 1–100 | +| `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)。金额单位为分。 diff --git a/docs/api/compare-record-report.md b/docs/api/compare-record-report.md index 9f6dde0..162cf2b 100644 --- a/docs/api/compare-record-report.md +++ b/docs/api/compare-record-report.md @@ -31,9 +31,10 @@ **Item**:`{ name: string, qty: int=1, specs: string[]\|null }` -**Result(comparison_results 元素)**:`{ platform_id, platform_name, package, price(元,float\|null), is_source(bool), rank(int\|null), coupon_saved(元,float\|null) }` +**Result(comparison_results 元素)**:`{ platform_id, platform_name, package, price(元,float\|null), is_source(bool), rank(int\|null), coupon_saved(元,float\|null), coupon_name(string\|null) }` - `coupon_saved`:该平台本单**平台主优惠额**(元)——美团红包 / 淘宝平台红包 / 京东优惠券·百亿补贴,**只取那一笔**,不含配送费减免/共减总额。仅外卖目标平台带值,源平台/没用为 `null`,前端记录页据此展示**「已优惠 ¥X」**(null 不展示)。 +- `coupon_name`:优惠**来源名**(展示用,best-effort)——美团「外卖大额神券」/ 京东「百亿补贴」/ 淘宝「平台红包」。仅在该行有 `coupon_saved` 时带;`null` 时前端走通用「红包」。非用户选中的具体券名(选中态不可读)。 ## 服务端派生(客户端不用算) diff --git a/docs/api/savings-battle.md b/docs/api/savings-battle.md index 7bd982e..91940ec 100644 --- a/docs/api/savings-battle.md +++ b/docs/api/savings-battle.md @@ -13,6 +13,7 @@ | `week_saved_cents` | int | 本周已省(分) | | `beat_percent` | int | 超过百分之多少用户 | | `streak_days` | int | 连续省钱天数 | +| `compare_count` | int | 累计完成比价次数(= 比价上报记录数;有真实 `compare` 记录用真实数,否则 demo 兜底) | ## 说明 -「我的」页「省钱战绩」卡数据源。接口已通但无真实业务写入,实际多为 0。 +「我的」页「省钱战绩」卡数据源(三列:本周已省 / 完成比价 / 连续比价)。`compare_count` 与 `/summary` 的 `order_count` 同源(均 = 有效记录数),对应原型「完成比价(次)」列。接口已通但无真实业务写入时走 demo 兜底(seed 约 23 条)。 diff --git a/docs/database/README.md b/docs/database/README.md index 818a70b..8197285 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -3,7 +3,7 @@ > 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。 > ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。 > 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。 -> 最后更新:2026-05-31 +> 最后更新:2026-06-04(+ admin_user / admin_audit_log) --- @@ -24,6 +24,8 @@ | `feedback` | 用户帮助与反馈 | `models/feedback.py` | 反馈 | [详情](./feedback.md) | | `comparison_record` | 比价记录(每次比价完整明细) | `models/comparison.py` | 比价记录 | [详情](./comparison_record.md) | | `comparison_milestone_claim` | 比价战绩里程碑领取记录 | `models/comparison_milestone.py` | 比价记录/福利 | [详情](./comparison_milestone_claim.md) | +| `admin_user` | 运营后台管理员账号(独立鉴权) | `models/admin.py` | Admin 后台 | [详情](./admin_user.md) | +| `admin_audit_log` | 运营后台操作审计日志(只追加) | `models/admin.py` | Admin 后台 | [详情](./admin_audit_log.md) | --- diff --git a/docs/database/admin_audit_log.md b/docs/database/admin_audit_log.md new file mode 100644 index 0000000..7058a3c --- /dev/null +++ b/docs/database/admin_audit_log.md @@ -0,0 +1,30 @@ +# admin_audit_log — 运营后台操作审计日志 + +> 模型 `app/models/admin.py` | 关联接口 [admin-audit-logs](../api/admin-audit-logs.md) | [← 表索引](./README.md) + +每个**写操作**(改钱/改状态/处理反馈等)落一条,记录"谁在何时、对谁、做了什么、前后值"。仅追加、不可删,用于追溯。`admin_username` / `target_id` 冗余存字符串,即使关联对象被删/改名也能追溯。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明 | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `admin_id` | Integer | FK→admin_user.id, index, NOT NULL | 操作者 | +| `admin_username` | String(64) | NOT NULL | 冗余操作者用户名(改名/禁用后仍可追溯) | +| `action` | String(64) | index, NOT NULL | 操作类型,如 `user.coins.grant` / `user.status.set` / `withdraw.refresh` / `feedback.handle` | +| `target_type` | String(32) | NOT NULL | 被操作对象类型,如 `user` / `withdraw` / `feedback` | +| `target_id` | String(64) | nullable | 被操作对象 id(用字符串以兼容 `out_bill_no` 等非整型主键) | +| `detail` | JSON | nullable | 上下文 + 前后值,如 `{"amount":1000,"reason":"...","before":{...},"after":{...}}` | +| `ip` | String(64) | nullable | 操作者客户端 IP(取自 `X-Forwarded-For` 首段,仅记录不鉴权) | +| `created_at` | DateTime(tz) | server_default now(), index, NOT NULL | 操作时间 | + +## 索引与约束 +- PK: `id` +- index: `ix_admin_audit_log_admin_id`(`admin_id`)、`ix_admin_audit_log_action`(`action`)、`ix_admin_audit_log_created_at`(`created_at`) + +## 关系 +- `admin_id` → [`admin_user`](./admin_user.md).`id`(多对一) + +## 说明 +- **JSON 列**:`detail` 用 `JSON().with_variant(JSONB(), "postgresql")` —— PG 上 JSONB,SQLite 退化为通用 JSON(同 `comparison_record.raw_payload`)。 +- **只追加**:无更新/删除接口,审计不可篡改。 +- **IP 可伪造**:`X-Forwarded-For` 可被客户端伪造,nginx 必须用 `proxy_set_header X-Forwarded-For $remote_addr` 覆盖;审计 IP 仅作记录、不参与鉴权。 diff --git a/docs/database/admin_user.md b/docs/database/admin_user.md new file mode 100644 index 0000000..c893782 --- /dev/null +++ b/docs/database/admin_user.md @@ -0,0 +1,28 @@ +# admin_user — 运营后台管理员账号 + +> 模型 `app/models/admin.py` | 关联接口 [admin-auth-login](../api/admin-auth-login.md) / [admin-admins-list](../api/admin-admins-list.md) | [← 表索引](./README.md) + +运营后台的管理员账号,与 App 用户(`user` 表)**完全隔离**:走独立 JWT secret、独立鉴权链(见 `app/admin/`)。密码 bcrypt 存哈希,带角色做权限分级。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明 | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `username` | String(64) | unique, index, NOT NULL | 登录名 | +| `password_hash` | String(255) | NOT NULL | bcrypt 哈希(明文不落库) | +| `role` | String(20) | NOT NULL, default `operator` | `super_admin`(全权+管账号)/ `finance`(钱:提现+金币)/ `operator`(用户+反馈+大盘) | +| `status` | String(20) | NOT NULL, default `active` | `active` / `disabled`(禁用后 token 立即失效) | +| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间 | +| `last_login_at` | DateTime(tz) | nullable | 最近登录时间(登录成功时更新) | + +## 索引与约束 +- PK: `id` +- unique index: `ix_admin_user_username`(`username` 唯一) + +## 关系 +- 被 [`admin_audit_log`](./admin_audit_log.md).`admin_id` 引用(一管理员多条审计)。 + +## 说明 +- **角色权限**:`super_admin` 恒通过所有角色守卫(`require_role`);`finance` 管钱(提现/金币),`operator` 管用户/反馈/大盘。具体守卫见各接口文档。 +- **鉴权隔离**:admin token `typ=admin` + 独立 `ADMIN_JWT_SECRET`(≠ App 的 `JWT_SECRET_KEY`),App 用户 token 无法当 admin 用。admin 无 refresh,过期(默认 12h)重新登录。 +- **初始化**:首个管理员用 `scripts/create_admin.py` 命令行创建(无自助注册接口)。 diff --git a/docs/database/comparison_record.md b/docs/database/comparison_record.md index d938618..ab58252 100644 --- a/docs/database/comparison_record.md +++ b/docs/database/comparison_record.md @@ -29,7 +29,7 @@ | `status` | String(16) | NOT NULL, default `success` | `success`(拿到有效对比)/ `failed`(出错/没采到目标价) | | `information` | String(256) | nullable | done 帧文案;成功=摘要,失败=具体原因(前端失败时当原因展示) | | `items` | JSON(PG: JSONB) | NOT NULL, default [] | 下单菜品 `[{name, qty, specs?}]` | -| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved}]`;`coupon_saved`=该平台主优惠额(元,美团红包/淘宝平台红包/京东优惠券·百亿补贴,只取那一笔,不含配送费/共减总额),各平台抠到红包即带值(2026-06 起源平台 Phase1 意图识别也抠,当前仅淘宝源),没用/没抠到为 null,前端展示「已优惠 ¥X」 | +| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved,coupon_name}]`;`coupon_saved`=该平台主优惠额(元,美团红包/淘宝平台红包/京东优惠券·百亿补贴,只取那一笔,不含配送费/共减总额),各平台抠到红包即带值(2026-06 起源平台 Phase1 意图识别也抠,当前仅淘宝源),没用/没抠到为 null,前端展示「已优惠 ¥X」;`coupon_name`=优惠来源名(展示用 best-effort,美团"外卖大额神券"/京东"百亿补贴"/淘宝"平台红包"),null 走前端通用"红包" | | `skipped_dish_names` | JSON(PG: JSONB) | NOT NULL, default [] | 被跳过的菜名 | | `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报(calibration + done.params 全量) | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | diff --git a/tests/test_compare_record.py b/tests/test_compare_record.py index 7ec7a19..1569d2f 100644 --- a/tests/test_compare_record.py +++ b/tests/test_compare_record.py @@ -33,7 +33,8 @@ def _food_payload(trace_id: str) -> dict: ], "comparison_results": [ {"platform_id": "meituan", "platform_name": "美团", "package": "com.sankuai.meituan", - "price": 123.50, "is_source": False, "rank": 1, "coupon_saved": 7.0}, + "price": 123.50, "is_source": False, "rank": 1, + "coupon_saved": 7.0, "coupon_name": "外卖大额神券"}, {"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "package": "com.taobao.taobao", "price": 128.50, "is_source": True, "rank": 2}, {"platform_id": "jd_waimai", "platform_name": "京东外卖", "package": "com.jingdong.app.mall", @@ -79,7 +80,7 @@ def test_report_and_derive(client) -> None: def test_coupon_saved_passthrough(client) -> None: - """红包优惠额 coupon_saved 上报→落库→读出;仅目标平台带值,源平台不带(null)。""" + """红包优惠额 coupon_saved + 来源名 coupon_name 上报→落库→读出;仅目标平台带值,源平台不带(null)。""" token = _login(client, "13800002010") rid = client.post( "/api/v1/compare/record", json=_food_payload("trace-coupon"), headers=_auth(token) @@ -87,7 +88,9 @@ def test_coupon_saved_passthrough(client) -> None: d = client.get(f"/api/v1/compare/records/{rid}", headers=_auth(token)).json() by_pid = {x["platform_id"]: x for x in d["comparison_results"]} assert by_pid["meituan"]["coupon_saved"] == 7.0 # 目标平台带值 + assert by_pid["meituan"]["coupon_name"] == "外卖大额神券" # 来源名透传(pricebot#38) assert by_pid["taobao_flash"].get("coupon_saved") is None # 源平台不带/为 null + assert by_pid["taobao_flash"].get("coupon_name") is None # 源平台无来源名 def test_source_is_cheapest_no_saving(client) -> None: diff --git a/tests/test_welfare.py b/tests/test_welfare.py index d669ce6..79743ba 100644 --- a/tests/test_welfare.py +++ b/tests/test_welfare.py @@ -133,10 +133,10 @@ def test_exchange_info(client) -> None: def test_exchange_flow(client) -> None: - """先供款够兑换下限的金币 → 兑换 1 元 → 金币扣、现金加 → 现金流水有记录。 + """先供款够兑 1 元的金币 → 兑换 1 元 → 金币扣、现金加 → 现金流水有记录。 - 打开消息提醒任务已降到 1000 金币(不再 = 兑换下限), 不能再靠领任务供款; - 直接 grant_coins 注入 MIN_EXCHANGE_COIN(= COIN_PER_YUAN = 10000)当种子。 + 兑换下限已降到 1 分(MIN_EXCHANGE_COIN = COIN_PER_CENT = 100), 但本用例验证兑换 1 元, + 直接 grant_coins 注入 COIN_PER_YUAN(= 10000)当种子(够兑 1 元)。 """ phone = "13800001005" token = _login(client, phone) @@ -144,7 +144,7 @@ def test_exchange_flow(client) -> None: with SessionLocal() as db: user = get_user_by_phone(db, phone) assert user is not None - crud_wallet.grant_coins(db, user.id, MIN_EXCHANGE_COIN, biz_type="test_seed", remark="测试供款") + crud_wallet.grant_coins(db, user.id, COIN_PER_YUAN, biz_type="test_seed", remark="测试供款") db.commit() # 兑换 10000 金币 → 100 分 @@ -180,6 +180,51 @@ def test_exchange_flow(client) -> None: assert page["items"][0]["biz_type"] == "exchange_in" +def test_exchange_min_floor_one_cent(client) -> None: + """锁定本次下调的兑换下限:正好兑下限额(MIN_EXCHANGE_COIN=100 金币=1 分)应成功。 + + 这是本 PR 的核心新能力(下限 10000→100,可兑 1 分起)。test_exchange_flow 兑的是 + 1 元(10000),在旧下限下也通过,证明不了新下限;本用例供款并兑换正好等于下限的 + 100 金币,断言到账 1 分。若有人把 MIN_EXCHANGE_COIN 改回 10000,100 会因低于下限被 + 判 400,本用例随之失败,从而把新下限值钉死。 + """ + phone = "13800001010" + token = _login(client, phone) + # 供款: 正好注入一个下限额度的金币(=COIN_PER_CENT=100) + with SessionLocal() as db: + user = get_user_by_phone(db, phone) + assert user is not None + crud_wallet.grant_coins( + db, user.id, MIN_EXCHANGE_COIN, biz_type="test_seed", remark="测试供款" + ) + db.commit() + + # 兑换下限额 100 金币 → 1 分 + r = client.post( + "/api/v1/wallet/exchange", + json={"coin_amount": MIN_EXCHANGE_COIN}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + res = r.json() + assert res["coin_amount"] == MIN_EXCHANGE_COIN + assert res["cash_added_cents"] == 1 # coins_to_cents(100) == 1 + assert res["coin_balance"] == 0 + assert res["cash_balance_cents"] == 1 + + # 金币流水有一笔 exchange_out(=-100) + r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token)) + out_txn = next(t for t in r.json()["items"] if t["biz_type"] == "exchange_out") + assert out_txn["amount"] == -MIN_EXCHANGE_COIN + + # 现金流水有且仅有一笔 exchange_in(=+1 分) + r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token)) + cash = r.json()["items"] + assert len(cash) == 1 + assert cash[0]["amount_cents"] == 1 + assert cash[0]["biz_type"] == "exchange_in" + + def test_exchange_insufficient_and_invalid(client) -> None: token = _login(client, "13800001006") @@ -191,10 +236,10 @@ def test_exchange_insufficient_and_invalid(client) -> None: ) assert r.status_code == 409 - # 低于最小额 → 400 + # 低于最小额(下限现为 1 分=100;取 MIN_EXCHANGE_COIN-1=99,既低于下限又非整分)→ 400 r = client.post( "/api/v1/wallet/exchange", - json={"coin_amount": MIN_EXCHANGE_COIN - COIN_PER_CENT}, + json={"coin_amount": MIN_EXCHANGE_COIN - 1}, headers=_auth(token), ) assert r.status_code == 400 @@ -226,6 +271,8 @@ def test_savings_summary_and_battle(client) -> None: assert b["streak_days"] >= 1 assert b["week_saved_cents"] >= 0 assert 0 <= b["beat_percent"] <= 100 + # 完成比价次数与 summary 的 order_count 同源(均 = 有效记录数) + assert b["compare_count"] == s["order_count"] def test_savings_seeder_idempotent(client) -> None: