Files
group_fqcd_jr/tools/seed_profile_demo.py
T

262 lines
13 KiB
Python
Raw 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.
"""画像模块演示/测试数据种子(5 条,三张表都写)。
用法:
.\\.venv\\Scripts\\python.exe tools\\seed_profile_demo.py
## 为什么是"5 条 + 边界覆盖"
用户 2026-09-10 决定:**画像虚拟数据先做 5 条**,用于画像读取、适当性判定与后续演示。
5 条不是随机凑数,而是覆盖**风险等级与测评有效期的边界**:
| customer_id | 画像 | 等级 | 测评有效期 | 覆盖目的 |
|---|---|---|---|---|
| 9101 | 企业主 / 高净值 | C5 | 有效 | 最高风险等级 |
| 9102 | 中学教师 / 中产 | C3 | 有效 | 中间等级(R3 产品可买、R5 不可) |
| 9103 | 退休职工 | C1 | 有效 | 最低风险承受能力 |
| 9104 | 互联网产品经理 / 年轻白领 | C2 | 有效 | 新手 + 低资产 |
| 9001 | 既有测试客户 | C1 | **已过期 45 天** | **失败关闭**:过期测评不得放行 |
## 采用"底座版"画像字段(不是老师需求文档的四维加权版)
用户已裁定:画像字段与采集规则**按底座做**。老师版要求**新增 7 个字段**,
而项目铁律禁止改已有字段定义 —— 故只能走底座既有的 16 字段。
来源:`docs/superpowers/analysis/2026-09-10-范围重定位与画像讨论记录.md`。
## 两个已实测的技术坑(实现时必须遵守)
1. **`profile_snapshots.current_customer_id` 不是生成列**,而是带唯一键
`uk_profile_snapshot_current` 的普通列 —— `is_current=1` 时**必须显式写入**且等于 `customer_id`,
否则同一个 NULL 会在第二条记录上撞唯一键。(与 `agent_reply_template.active_key` 那个**真生成列**不同。)
2. `uk_profile_snapshot_version (customer_id, version)`:版本号按客户唯一,本脚本统一写 `version=1`。
## 幂等性
重复执行先删除**本脚本所造客户**(9101-9104 + 9001)在这三张表里的数据再重建。
**不删 `interaction_audit`**(审计留痕不可删),也**不动其它客户**。
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import uuid4
from sqlalchemy import bindparam, text as sql
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.infrastructure.db import SessionFactory # noqa: E402
CUSTOMER_ROLE_ID = 9001
#: 5 条虚拟画像。`valid_days` 为负表示测评**已过期**(用于验证失败关闭)。
PROFILES: tuple[dict[str, object], ...] = (
{
"user_id": 9101, "user_no": "T-PRF-01", "username": "profile_c5",
"real_name": "陈宏远", "birth_year": 1972, "occupation": "企业主",
"investor_type": "C5", "valid_days": 300,
"horizon": "long_term", "assets": ["equity_fund", "private_equity", "structured"],
"frequency": "high", "total_asset": "12800000.00", "behavior": 88,
"risk_tags": ["aggressive", "high_net_worth", "experienced"],
"tier": "diamond", "score": 92,
},
{
"user_id": 9102, "user_no": "T-PRF-02", "username": "profile_c3",
"real_name": "李静怡", "birth_year": 1985, "occupation": "中学教师",
"investor_type": "C3", "valid_days": 200,
"horizon": "medium_term", "assets": ["bond_fund", "money_fund", "bank_wm"],
"frequency": "medium", "total_asset": "860000.00", "behavior": 61,
"risk_tags": ["balanced", "stable_income"],
"tier": "platinum", "score": 63,
},
{
"user_id": 9103, "user_no": "T-PRF-03", "username": "profile_c1",
"real_name": "王秀兰", "birth_year": 1958, "occupation": "退休职工",
"investor_type": "C1", "valid_days": 120,
"horizon": "short_term", "assets": ["money_fund", "bank_wm"],
"frequency": "low", "total_asset": "420000.00", "behavior": 33,
"risk_tags": ["conservative", "retired", "capital_preservation"],
"tier": "gold", "score": 31,
},
{
"user_id": 9104, "user_no": "T-PRF-04", "username": "profile_c2",
"real_name": "张一鸣", "birth_year": 1996, "occupation": "互联网产品经理",
"investor_type": "C2", "valid_days": 180,
"horizon": "medium_term", "assets": ["money_fund", "index_fund"],
"frequency": "medium", "total_asset": "150000.00", "behavior": 47,
"risk_tags": ["cautious", "young_professional", "new_investor"],
"tier": "gold", "score": 42,
},
{
# 关键边界:测评**已过期**。用于验证画像读取与适配置性的失败关闭。
"user_id": 9001, "user_no": None, "username": None,
"real_name": "测试客户", "birth_year": 1990, "occupation": "软件工程师",
"investor_type": "C1", "valid_days": -45,
"horizon": "short_term", "assets": ["money_fund"],
"frequency": "low", "total_asset": "60000.00", "behavior": 22,
"risk_tags": ["expired_assessment", "conservative"],
"tier": "gold", "score": 35,
},
)
PROFILE_TABLE = "fin_customer_profile"
ASSESSMENT_TABLE = "fin_risk_assessment"
SNAPSHOT_TABLE = "profile_snapshots"
async def _ensure_customer(session: object, spec: dict[str, object], now: datetime) -> None:
"""为新造客户建 `sys_user` 行与 customer 角色(9001 是既有账号,跳过)。"""
if spec["user_no"] is None:
return
exists = await session.scalar( # type: ignore[attr-defined]
sql("SELECT id FROM sys_user WHERE id = :i"), {"i": spec["user_id"]}
)
if exists:
return
await session.execute( # type: ignore[attr-defined]
sql("""
INSERT INTO sys_user (id, user_no, username, password_hash, user_type,
professional_investor_status, fund_account_status, status, created_at, updated_at)
VALUES (:id, :no, :name, :pwd, 'customer', 'none', 'opened', '正常', :now, :now)
"""),
{"id": spec["user_id"], "no": spec["user_no"], "name": spec["username"],
"pwd": hashlib.sha256(f"profile-{spec['user_id']}".encode()).hexdigest(), "now": now},
)
await session.execute( # type: ignore[attr-defined]
# 列名是 `assigned_at`(不是 `created_at`)——按现库 DDL 口径写入。
sql("INSERT INTO sys_user_role (user_id, role_id, assigned_at) VALUES (:u, :r, :now)"),
{"u": spec["user_id"], "r": CUSTOMER_ROLE_ID, "now": now},
)
async def _fetch_count(session: object, table: str, ids: list[int]) -> int:
"""按 customer_id 集合计数(`IN` 的绑定值必须是**元组**,asyncmy 不接受 list)。"""
return int(await session.scalar( # type: ignore[attr-defined]
sql(f"SELECT COUNT(*) FROM {table} WHERE customer_id IN :ids").bindparams(
bindparam("ids", value=tuple(ids), expanding=True)
)
))
async def seed() -> list[int]:
"""幂等写入 5 条画像数据,返回涉及的 customer_id 列表。"""
now = datetime.now(UTC).replace(tzinfo=None)
ids = [int(spec["user_id"]) for spec in PROFILES]
id_tuple = tuple(ids)
async with SessionFactory() as session:
for table in (SNAPSHOT_TABLE, ASSESSMENT_TABLE, PROFILE_TABLE):
await session.execute(
sql(f"DELETE FROM {table} WHERE customer_id IN :ids").bindparams(
bindparam("ids", value=id_tuple, expanding=True)
)
)
for spec in PROFILES:
cid = int(spec["user_id"])
await _ensure_customer(session, spec, now)
valid_until = now + timedelta(days=int(spec["valid_days"]))
await session.execute(sql(f"""
INSERT INTO {PROFILE_TABLE} (customer_id, trade_account, real_name, birth_date,
occupation, mobile_masked, investor_type, investment_horizon,
preferred_asset_class, trading_frequency, last_active_at, total_asset,
behavior_score, risk_tags, opened_at, updated_at)
VALUES (:cid, :acct, :name, :birth, :occ, :mobile, :itype, :horizon,
:assets, :freq, :last_active, :total, :behavior, :tags, :opened, :now)
"""), {
"cid": cid, "acct": f"TA{cid}0001", "name": spec["real_name"],
"birth": f"{spec['birth_year']}-06-15", "occ": spec["occupation"],
"mobile": f"138****{cid % 10000:04d}", "itype": spec["investor_type"],
"horizon": spec["horizon"], "assets": json.dumps(spec["assets"]),
"freq": spec["frequency"],
"last_active": now - timedelta(days=3), "total": spec["total_asset"],
"behavior": spec["behavior"],
"tags": json.dumps(spec["risk_tags"], ensure_ascii=False),
"opened": now - timedelta(days=800), "now": now,
})
# `fin_risk_assessment.id` **没有 AUTO_INCREMENT**(实测 AUTO_INCREMENT=None),
# 必须显式给值;用 99xxx 段,避开任何自增序列。
await session.execute(sql(f"""
INSERT INTO {ASSESSMENT_TABLE} (id, customer_id, questionnaire_version, answers,
total_score, investor_type, assessed_at, valid_until, created_at)
VALUES (:aid, :cid, 'v2026.1', :answers, :score, :itype, :assessed, :valid, :now)
"""), {
"aid": 99000 + cid % 1000, "cid": cid,
"answers": json.dumps({"q1": "A", "q2": "B", "q3": "C"}, ensure_ascii=False),
"score": spec["score"], "itype": spec["investor_type"],
"assessed": valid_until - timedelta(days=365), "valid": valid_until, "now": now,
})
snapshot = {
"investor_type": spec["investor_type"],
"investment_horizon": spec["horizon"],
"preferred_asset_class": spec["assets"],
"trading_frequency": spec["frequency"],
"total_asset": spec["total_asset"],
"behavior_score": spec["behavior"],
"risk_tags": spec["risk_tags"],
"customer_tier": spec["tier"],
"assessment_valid_until": valid_until.isoformat(),
"assessment_expired": valid_until <= now,
}
body = json.dumps(snapshot, ensure_ascii=False, sort_keys=True)
await session.execute(sql(f"""
INSERT INTO {SNAPSHOT_TABLE} (profile_uuid, customer_id, version, snapshot,
generation_basis, snapshot_hash, is_current, current_customer_id,
generated_at, created_at, updated_at)
VALUES (:uuid, :cid, 1, :snap, :basis, :hash, 1, :cid, :now, :now, :now)
"""), {
"uuid": str(uuid4()), "cid": cid, "snap": body,
# `current_customer_id` **必须显式写入**(不是生成列,见模块 docstring 坑 1)。
"basis": json.dumps(
{"sources": [ASSESSMENT_TABLE, PROFILE_TABLE], "seed": "profile_demo"},
ensure_ascii=False),
"hash": hashlib.sha256(body.encode()).hexdigest(), "now": now,
})
await session.commit()
return ids
async def verify(ids: list[int]) -> None:
"""自校验:三张表各有 5 条、每客户恰好一条 is_current=1、过期边界确实存在。"""
id_tuple = tuple(ids)
async with SessionFactory() as session:
for table in (PROFILE_TABLE, ASSESSMENT_TABLE, SNAPSHOT_TABLE):
n = await _fetch_count(session, table, ids)
print(f" {table:<24} {n} 行(期望 5)")
dup = (await session.execute(sql(f"""
SELECT customer_id, COUNT(*) AS n FROM {SNAPSHOT_TABLE}
WHERE customer_id IN :ids AND is_current = 1
GROUP BY customer_id HAVING n <> 1
""").bindparams(bindparam("ids", value=id_tuple, expanding=True)))).mappings().all()
print(f" is_current=1 非唯一的客户: {[dict(d) for d in dup] or '无'}")
expired = (await session.execute(sql(f"""
SELECT customer_id FROM {ASSESSMENT_TABLE}
WHERE customer_id IN :ids AND valid_until <= NOW()
""").bindparams(bindparam("ids", value=id_tuple, expanding=True)))).scalars().all()
print(f" 测评已过期的客户: {sorted(expired)}(期望 [9001])")
levels = (await session.execute(sql(f"""
SELECT investor_type, COUNT(*) AS n FROM {PROFILE_TABLE}
WHERE customer_id IN :ids GROUP BY investor_type ORDER BY investor_type
""").bindparams(bindparam("ids", value=id_tuple, expanding=True)))).mappings().all()
print(f" 风险等级分布: {[(r['investor_type'], r['n']) for r in levels]}")
async def main() -> None:
ids = await seed()
print(f"已写入 {len(ids)} 条画像数据: {ids}")
await verify(ids)
if __name__ == "__main__":
asyncio.run(main())