Files
group_fqcd_jr/app/service/customer_benefit_service.py
wangjianlong_0626 4766e3bd98 feat(benefit): 客户权益功能(T010)+ 修投顾迁移契约里写死 head 的脆弱断言
## 1. 新增客户权益(用户端)

`GET /api/v1/users/me/entitlements`(T010,权限 `benefit:read:self`):

- **层级**由 `fin_customer_profile.total_asset` **实时判定**
  (门槛来自 `knowledge/product/高净值客户服务规范.md`:
  金卡 50 万 / 白金 200 万 / 钻石 600 万 / 私行 1000 万;低于 50 万为普通客户);
- **权益按层级累积展开**(文档原文"含全部下级权益,新增以下"):
  金卡 9 条 / 白金 20 / 钻石 33 / 私行 54,各档已逐档实测;
- 返回**升级提示**(`next_tier`:下一层级与门槛),前端可直接渲染"再投 X 元升级"。

### 新增表 `fin_customer_benefit`(1 张)

层级 → 权益目录,54 条种子数据(`tools/seed_customer_benefits.py`,按 `benefit_code` 幂等)。

**基线合规证明**(规则 1/3/4):只新增这一张表;**未**重命名/删除任何已有表;
**未**重命名/删除/复用任何已有字段,**未**改任何已有字段的类型、可空性或业务含义;
未改 `docs/00`。
复核:`tools/audit_schema.py` → `90 business tables, no missing or unexpected tables`。

### 两条设计取舍

1. **不落"某客户享有哪些权益"**:层级可算,权益由层级推出,两者都不落库。
   与 `docs/00` L159(不保留 `net_worth_flag`,因为可算)同一取向。
2. **权益只存各层新增条目**,累积由服务层 `tier_chain()` 展开 ——
   否则改一条权益要改四处,漏一处就出现"白金没有金卡权益"。

### 数据来源与一处刻意省略

逐条照抄知识文档,不新增文档里没有的权益。**私行那条
「7×24小时私人银行专线:400-XXX-XXXX 转 8」不写号码** ——
文档里是占位符,而对客号码的唯一来源是 `customer_service_rules.CONTACT_PHONE`
(本线此前修过"同一客服给客户两个不同号码"的缺陷)。把占位符抄进库等于再造一份假号码。

## 2. 修投顾迁移契约里写死的断言

`tests/unit/test_advisor_migration_contract.py` 原先断言

```python
assert script.get_heads()[0] == "20260911_merge_adv_risk_heads"
```

那是"投顾迁移刚加完那一刻"的快照 —— 本 PR 一新增迁移(`20260912_customer_benefit`)
它就变红,**而红的原因与投顾链的对错无关**:断言测到的是时间,不是契约。

原意是"投顾链接在这条主链上、没另起分支"。改为断言**投顾链尾是当前 head 的祖先**
(链尾从 `ADVISOR_FILES[-1]` 派生,不写死),既保住原意又不受后续迁移影响。
`len(script.get_heads()) == 1`(链不分叉)与"投顾文件首尾相接"两条原样保留。

## 3. 顺带发现的既有缺口(**不在本次改动范围**)

`app/api/controllers/trading.py` 的 **T001–T009 未调用 `AuthorizationService.require`**:
`docs/05` §19 为它们登记了权限码(`account:read:self` / `trade:order:*` / `holding:read:self`),
但代码只做认证 + 开户测评门槛,**没有执行 RBAC 权限检查**。
对照:仓库里 **26 个 service** 都调了 `require`,`trade_service` 不在其中。

本线的 T010 **按正确做法实现**:`CustomerBenefitService.entitlements_for` 先鉴权再读数据,
且**鉴权在读取客户资产之前**(有测试断言"拒绝时未查库")。
T001–T009 如何补,需架构师定口径后另行处理。

## 4. 文档

- 新增 `docs/41-客户权益功能说明.md`:表登记 + 基线合规证明 + 分层口径 + 累积规则 +
  数据来源 + 权限 + 与仪表盘的关系 + 上述缺口
- `docs/05` §19 登记 T010,并**单独注明它引入了新表**(避免被误读为
  "T 段数据库零变更"的一部分)
- `AGENTS.md` 表数 89 → **90** 张业务表

## 验证

- `pytest tests/unit/service/test_customer_benefit_service.py` → **20 passed**
  (含边界:499999.99 不是金卡、500000 整是金卡、1000 万整是私行;累积条数;升级提示;
  鉴权先于读数据)
- 全量 `pytest tests` → `2 failed, 1469 passed, 1 skipped`
  (2 个失败为既有环境项:httpx 把中文序列化成 `\uXXXX`,非本次引入)
- `ruff check app tests tools alembic` → `All checks passed`
- `mypy app` → **0 错 / 252 文件**
- 真机:`GET /users/me/entitlements` → `200`;各档分层与累积条数逐档实测通过
- `audit_schema.py` → 90 张业务表无缺失/意外;文档守卫 55 份无编号冲突;
  端点编号无重复;RBAC 种子一致性通过
2026-09-12 17:24:37 +08:00

191 lines
7.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""客户权益:按可投资资产判定层级,并展开该层级(含以下各层)应享有的权益。
### 口径来源
`knowledge/product/高净值客户服务规范.md`(公司内部服务标准,四级分层 + 各层权益):
| 层级 | 名称 | 可投资资产门槛 |
|---|---|---|
| `gold` | 金卡 | 50 万 - 200 万 |
| `platinum` | 白金 | 200 万 - 600 万 |
| `diamond` | 钻石 | 600 万 - 1000 万 |
| `private` | 私行 | 1000 万以上 |
低于 50 万为**普通客户**(无层级)。文档写的是「以客户可投资金融资产(不含自住房产)
为主要分层依据」,本实现取 `fin_customer_profile.total_asset` —— 基线
(`docs/00` L213/L220)把它定为「风控研判所用资产快照」且「按统一口径计算」,
是系统里唯一可用的资产口径;**不另造口径**。
### 两条设计约束
1. **权益按层级累积**(文档原文"含全部金卡权益,新增以下"):
白金含金卡全部条目、依此类推。展开在**服务层**做,表里不重复存父级条目
—— 否则同一权益要改多处。
2. **不把"某客户享有哪些权益"落库**:它可由层级实时推出。
基线的同一取向见 `docs/00` L159(不保留 `net_worth_flag`,因为可算)。
"""
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.contracts import RequestContext
from app.model.benefit import CustomerBenefit
from app.model.fund import FundCustomerProfile
from app.service.authorization_service import AuthorizationService
#: 本模块使用的权限码;定义源是 `tools/seed_test_rbac.py` 的 `PERMISSIONS`。
PERMISSION_READ_SELF = "benefit:read:self"
@dataclass(frozen=True)
class TierSpec:
"""一个层级的码、中文名与门槛(含)。"""
code: str
label: str
min_total_asset: Decimal
#: **从高到低**排列:判定时取第一个满足门槛的层级。
#: 顺序即累积顺序,`_tier_chain` 依赖它,不要随意重排。
TIERS: tuple[TierSpec, ...] = (
TierSpec("private", "私行", Decimal("10000000")),
TierSpec("diamond", "钻石", Decimal("6000000")),
TierSpec("platinum", "白金", Decimal("2000000")),
TierSpec("gold", "金卡", Decimal("500000")),
)
HEADLINE = "普通客户"
def _spec(code: str) -> TierSpec | None:
return next((t for t in TIERS if t.code == code), None)
def resolve_tier(total_asset: Decimal | int | float | None) -> TierSpec | None:
"""按可投资资产判定层级;低于最低门槛(50 万)或资产缺失时返回 None。"""
if total_asset is None:
return None
amount = Decimal(str(total_asset))
return next((t for t in TIERS if amount >= t.min_total_asset), None)
def tier_chain(spec: TierSpec) -> tuple[str, ...]:
"""该层级**及以下**所有层级码(用于累积展开)。
例:钻石 → `("gold", "platinum", "diamond")`。
`TIERS` 是从高到低,故取其尾部到该层级为止。
"""
codes_low_to_high = [t.code for t in reversed(TIERS)]
return tuple(codes_low_to_high[: codes_low_to_high.index(spec.code) + 1])
class CustomerBenefitService:
"""权益查询;**只读**,不写任何表(含不写 `sys_user.customer_tier`)。"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def entitlements_for(self, context: RequestContext) -> dict[str, Any]:
"""端点入口:先鉴权,再按客户资产判定层级并展开权益。
⚠️ 权限检查放在 **Service 层**(项目惯例:26 个 service 都这么做,
见 `AuthorizationService.require`)—— `trading.py` 的 §T 端点目前**没有**
这一步,只有测评门槛,属既有缺口,不在本次改动范围内。
"""
await AuthorizationService.require(context, PERMISSION_READ_SELF)
total_asset = await self._total_asset(int(context.user_id))
return await self.entitlements(total_asset=total_asset)
async def _total_asset(self, customer_id: int) -> Decimal | None:
"""读客户画像的资产快照。
基线把 `fin_customer_profile.total_asset` 定为「风控研判所用资产快照」,
是系统里唯一的资产口径;客户未开户(无画像行)时返回 None,
由 `resolve_tier` 判为无层级,而不是抛错 —— "还没开户"不是异常。
"""
row = await self.session.scalar(
select(FundCustomerProfile.total_asset).where(
FundCustomerProfile.customer_id == customer_id
)
)
return row
async def entitlements(self, *, total_asset: Decimal | None) -> dict[str, Any]:
"""返回客户当前层级与应享权益。
`total_asset` 由调用方从 `fin_customer_profile` 读入并传入,
避免本服务再查一次客户主表(也在测试里便于给定资产直接断言分层)。
"""
spec = resolve_tier(total_asset)
if spec is None:
return {
"tier": None,
"tier_label": HEADLINE,
"min_total_asset": None,
"total_asset": self._amount(total_asset),
"next_tier": self._next_tier(None),
"benefits": [],
}
codes = tier_chain(spec)
rows = list(await self.session.scalars(
select(CustomerBenefit)
.where(
CustomerBenefit.customer_tier.in_(codes),
CustomerBenefit.status == "active",
)
.order_by(CustomerBenefit.display_order, CustomerBenefit.id)
))
return {
"tier": spec.code,
"tier_label": spec.label,
"min_total_asset": self._amount(spec.min_total_asset),
"total_asset": self._amount(total_asset),
"next_tier": self._next_tier(spec),
"benefits": [self._view(row) for row in rows],
}
@staticmethod
def _next_tier(spec: TierSpec | None) -> dict[str, Any] | None:
"""下一层级与还差多少 —— 前端可直接渲染"再投 X 元升级"。
已是最高的私行返回 None。
"""
if spec is None:
target = TIERS[-1] # 金卡
else:
higher = [t for t in TIERS if t.min_total_asset > spec.min_total_asset]
if not higher:
return None
target = min(higher, key=lambda t: t.min_total_asset)
return {
"tier": target.code,
"tier_label": target.label,
"min_total_asset": CustomerBenefitService._amount(target.min_total_asset),
}
@staticmethod
def _amount(value: Decimal | int | float | None) -> str | None:
"""金额统一用字符串输出(与项目其他接口一致,避免浮点误差)。"""
if value is None:
return None
return str(Decimal(str(value)).quantize(Decimal("0.01")))
@staticmethod
def _view(row: CustomerBenefit) -> dict[str, Any]:
spec = _spec(row.customer_tier)
return {
"benefit_code": row.benefit_code,
# 权益所属层级(用于前端按层级分组;累积展开后可能低于客户自身层级)
"tier": row.customer_tier,
"tier_label": spec.label if spec else None,
"category": row.category,
"name": row.name,
"description": row.description,
}