建立项目级标杆文档 docs/_overview/ 作为产品全景索引, 解决"PRD 零碎、文档膨胀、跨子系统调研无入口"的问题。 主要内容: - 00-index 总索引 + 维护协议 + 与 CLAUDE.md 关系 - 01-product-overview 产品全景脑图(6 角色 / 6 子系统 / 数据流 / 7 业务概念 / 8+1 AI 矩阵 / 22 术语) - 02a-miniprogram-page-matrix 小程序 21 页业务指纹 - 02b-adminweb-page-matrix admin-web 19 路由业务指纹 - 03-test-spec 测试规范 (L1-L5 分层 + 走查模板 + 75-95 case 估算) - 04-doc-conflicts 39 条冲突索引(P0×8 / P1×13 / P2×13 + 5 子项) - 04a/b/c-conflicts-*-detail 业务故事卡(7 字段:关联/逻辑/影响/选项/判定) - 05-orphan-pages-cleanup admin-web 6 孤儿页面处置(1 归档 + 4 保留) - WAVES-MASTER-PLAN.md 全 Wave 主计划(0-5,共 22-32 工作日) - WAVE-1-KICKOFF.md Wave 1 实施 kickoff - GLOBAL-DECISION-DASHBOARD.md 全局决策仪表板 反馈调研产物: - 04a-feedback/ P0 两轮反馈(8+8 项决策 + D-1/2/3 + F-1/2 子代理产出) - 04b-feedback/ P1 两轮反馈(13+1+5 项 + E-1/2/3/4 + G-1/2 子代理产出) - 04c-feedback/ P2 反馈(13 项 + 5 子项 + H-1/2/3 子代理产出) - NEO-DECISIONS-LOG 累积决策记录 关键追加发现 8 处 D Bug(原蓝本 0): - P0-3 看板沙箱接入(Wave 1 W1-T1) - P0-5 致命 1 (4 处 fdw_etl 残留, 已修 commit17f045a) - P0-5 致命 2 (JWT aud 缺失, 已修 commit17f045a) - P0-6 clearAllTasks 守卫 (Wave 3) - P0-8 DBViewer 黑名单漏 (已修 commit17f045a) - P1-3 task-detail 跳转传 task_id 而非 customer_id - P2-7 board-finance 隐式 null - 2 个独立 Bug (page_context.created_at + ClueCategory 字典) 参考: docs/_overview/00-index.md
9.8 KiB
P1-12 散客 memberId 取值实证
调研时间:2026-05-04 测试库:
test_etl_feiqiu(TEST_DB_DSN) Neo 反馈:"先调研后端 / 数据库:实际可能是 0 或 NULL 或 -1,进行校验"
一、测试库实际值分布
1.1 dwd 层(原始结算单)
SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE member_id IS NULL) AS null_cnt,
COUNT(*) FILTER (WHERE member_id = 0) AS zero_cnt,
COUNT(*) FILTER (WHERE member_id < 0) AS neg_cnt,
COUNT(*) FILTER (WHERE member_id > 0) AS pos_cnt
FROM dwd.dwd_settlement_head;
| total | null_cnt | zero_cnt | neg_cnt | pos_cnt |
|---|---|---|---|---|
| 32789 | 0 | 27742 | 0 | 5047 |
结论:dwd_settlement_head 中散客全部是 member_id = 0,无 NULL,无负数。
1.2 dwd.dim_member(会员维表)
SELECT member_id FROM dwd.dim_member WHERE member_id IS NULL OR member_id <= 0 LIMIT 10;
-- 结果:0 行
dim_member.member_id 列约束 NOT NULL,且无 0 / 负数行。dim_member 表中不存在散客占位行。
1.3 dws 层(汇总表)
-- dws_assistant_customer_stats (散客不入此表)
SELECT COUNT(*), MIN(member_id), MAX(member_id),
COUNT(*) FILTER (WHERE member_id <= 0) AS scattered
FROM dws.dws_assistant_customer_stats;
| total | min | max | scattered |
|---|---|---|---|
| 182 | 2799207067109125 | 3180349199961029 | 0 |
证实 DWS 助教-客户表已正确过滤散客。
-- dws_order_summary 全订单汇总
SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE member_id IS NULL) AS null_cnt,
COUNT(*) FILTER (WHERE member_id = 0) AS zero_cnt,
COUNT(*) FILTER (WHERE member_id > 0) AS pos_cnt
FROM dws.dws_order_summary;
| total | null_cnt | zero_cnt | pos_cnt |
|---|---|---|---|
| 32789 | 0 | 27742 | 5047 |
dws_order_summary 与 dwd_settlement_head 一致(NULL=0,zero=27742,pos=5047)。
1.4 列约束扫描(dwd + dws)
| schema | table | column | nullable | default |
|---|---|---|---|---|
| dwd | dim_member | member_id | NO | (无) |
| dwd | dim_member_ex | member_id | NO | (无) |
| dwd | dwd_recharge_order | member_id | YES | (无) |
| dwd | dwd_refund | member_id | YES | (无) |
| dwd | dwd_settlement_head | member_id | YES | (无) |
| dwd | dwd_table_fee_log | member_id | YES | (无) |
| dws | dws_assistant_customer_stats | member_id | NO | (无) |
| dws | dws_member_assistant_intimacy | member_id | NO | (无) |
| dws | dws_member_assistant_relation_index | member_id | NO | (无) |
| dws | dws_member_consumption_summary | member_id | NO | (无) |
| dws | dws_member_visit_detail | member_id | NO | (无) |
| dws | dws_ml_manual_order_alloc | member_id | NO | DEFAULT 0 |
| dws | dws_ml_manual_order_source | member_id | NO | DEFAULT 0 |
| dws | dws_order_summary | member_id | YES | (无) |
关键观察:
- DWD 大多数表
nullable=YES,但实际数据全是 0,无 NULL - DWS 会员维度表全部
NOT NULL(因为散客不入) - DWS 中 2 张 ML 配单表显式
DEFAULT 0,即"散客 = 0"已物化为列默认值 - 没有任何表用
-1或负数表示散客
二、各层判散客逻辑现状
2.1 上游飞球 API 文档(权威源)
apps/etl/connectors/feiqiu/docs/api-reference/summary/table_fee_transactions.md:92:
member_idint0会员 ID。0= 散客/非会员。非 0 时对应会员档案表的id
飞球 API 端就规定:散客 = 0。
2.2 ETL DWD 层
apps/etl/connectors/feiqiu/CLAUDE.md:57:
散客:
member_id ≤ 0,不计入会员统计(但计入助教业绩)
apps/etl/connectors/feiqiu/tasks/dws/finance_base_task.py:51:
COUNT(CASE WHEN member_id = 0 OR member_id IS NULL THEN 1 END) AS guest_order_count
ETL 实测口径:= 0 OR IS NULL,采用宽容判断。
apps/etl/connectors/feiqiu/tasks/dws/base_dws_task.py:1403-1416 定义 is_guest() 工具:
散客处理:member_id=0 的客户是散客,不进入客户维度统计
ETL 主口径:= 0 等价于"散客",同时容忍 NULL。
2.3 后端 Python 服务层
apps/backend/app/services/performance_service.py:88:
mid_for_name = rec.get("member_id")
is_scattered = not mid_for_name or mid_for_name <= 0
if is_scattered:
customer_name = "散客待转换会员"
apps/backend/app/services/coach_service.py:442 与 line 500:
is_scattered = not mid or mid <= 0
apps/backend/app/services/fdw_queries.py:2148:
排除散客(member_id <= 0)
后端统一口径:not mid or mid <= 0(等价于 NULL / 0 / 负数)。
2.4 后端 API 契约
apps/backend/app/schemas/xcx_coaches.py:76, 92 + xcx_performance.py:24:
is_scattered: bool = False # 散客标识,前端据此置灰名称
apps/backend/docs/API-REFERENCE.md:480, 621-622:
isScattered:散客标记(member_id ≤ 0时为true),前端据此将客户姓名置灰
API 已采用"扁平布尔字段"模式,前端不再自己判断 memberId。
2.5 小程序前端
apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.ts:419,464,478:
if (memberId <= 0) {
wx.showToast({ title: '散客无详情可查看', icon: 'none' })
return
}
apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.ts:235:
wx.showToast({ title: '散客无详情可查看', icon: 'none' })
apps/miniprogram/miniprogram/utils/avatar-color.ts:60-67:
空字符串 / "0" / 负数字符串 → 'default'(灰色,未知客户/散客)
apps/miniprogram/miniprogram/pages/performance/performance.ts:251:
任务 C1: 散客/未知客户(memberId <= 0)拦截,不跳转
前端两套并存:
- 新约定(优先):用后端返回的
isScattered: bool字段 - 旧兜底:点击跳转时再
memberId <= 0二次判断(防御性)
apps/miniprogram/README.md:158-164:
后端判定规则:
member_id <= 0时在响应字段上标记isScattered = true...散客条目不提供跳转到customer-detail的入口。
三、推荐统一约定
基于实证 + 现状,强烈推荐:member_id = 0 表示散客。
理由:
- 飞球上游 API 就规定
0= 散客,从源头一致 - ETL DWD 落库后
0占 84.6%(27742/32789),无 NULL,无负数,实证最稳定 - DWS 会员维度表全部
NOT NULL,散客本就不入,不存在二义性 - DWS 2 张 ML 表已
DEFAULT 0,语义已物化 - NULL 语义混淆:NULL 通常表示"字段不适用 / 未知",散客是明确语义,不应是 NULL
- 负数(-1)无任何使用证据,引入会破坏现有
member_id <= 0判断习惯
建议口径:
- ETL 写入:固定写
0,不允许写 NULL(可加 NOT NULL 约束 + DEFAULT 0,但需评估上游影响) - 业务判断:
is_scattered = (member_id IS NULL OR member_id <= 0)(防御性容忍 NULL,但实测无 NULL) - API 返回:用
isScattered: bool扁平字段,前端不再判断
四、各层调整建议
4.1 ETL DWD-DOC 写入约定
文件:apps/etl/connectors/feiqiu/CLAUDE.md + docs/etl_tasks/dws_tasks.md
新增条款(强制):
散客 member_id 约定:
- 飞球 API 端
member_id = 0表示散客- DWD 写入时直接保留
0,不转换为 NULL,不映射为 -1- 判断逻辑统一用
member_id IS NULL OR member_id <= 0(容忍上游异常)- DWD-DOC 12 条强制规则中明确:
member_id列保留原始0,不允许任何替换
是否需要 schema 变更:
- 选项 A(零工作):保持现状,nullable=YES,但事实上无 NULL,文档规范化即可
- 选项 B(可选加强):DWD 表
member_id改为 NOT NULL DEFAULT 0,需要 schema 变更 + 兼容性测试
推荐选项 A(零工作 + 文档规范化),因为现状无问题,不需要破坏性变更。
4.2 后端 API 层约定
已经做对了(无需改动):
- 所有面向小程序的接口都返回
isScattered: bool扁平字段 - 后端统一用
not mid or mid <= 0判断 - API-REFERENCE.md 已明文规定
建议补充:
- 在
apps/backend/CLAUDE.md或apps/backend/docs/CONVENTIONS.md新增"散客契约"段:后端 API 一律返回
isScattered: bool,前端不应再自行判断memberId <= 0,旧的memberId <= 0判断逻辑应逐步替换为读isScattered。
4.3 小程序前端约定
问题:前端目前两套机制并存:
- 部分组件读
isScattered(WXML) - 部分页面 ts 仍用
memberId <= 0(coach-detail / coach-service-records / performance)
建议:
- 新代码禁止写
memberId <= 0,统一用record.isScattered - 旧代码逐步替换(非紧急,功能等价)
- 在
apps/miniprogram/README.md散客章节加一句:判断口径以isScattered为准,不要从memberId反推
五、给 Neo 的决策清单
| 决策点 | 选项 | 推荐 | 理由 |
|---|---|---|---|
| 散客 ID 真值 | NULL / 0 / -1 / <=0 |
0 |
飞球上游约定 + 实测 27742 行无 NULL/负数 |
| ETL DWD schema 变更 | 不变 / NOT NULL DEFAULT 0 | 不变 | 现状已稳定,改 schema 影响面大 |
| 后端 API 字段 | isScattered: bool(已实施) |
保持 | 前端不再自行判断 |
| 前端旧代码 | 立即重构 / 渐进替换 | 渐进替换 | 功能等价,无紧急 bug |
| 文档规范化 | 必须 | 必须 | DWD-DOC 12 条 + 后端 CONVENTIONS + miniprogram README 各加一段 |
Neo 只需拍板一句:"散客统一记 0,前端读 isScattered,不变 schema,文档跟上" — 三层约定就闭环。
六、附:风险点与边界
- 飞球 API 偶发 NULL:虽然测试库无 NULL,但生产环境若飞球某次返
null,DWD 落库会保留 NULL,业务判断仍要兼容(OR IS NULL) - ML 配单表 DEFAULT 0:若误把"未配单"和"散客配单"都记为 0,会出现语义混淆。需在 ML 任务中明确
member_id = 0仅指散客,不能用作"配单失败占位" - dim_member.member_id NOT NULL:维表无散客占位行,后端 JOIN 时若用 INNER JOIN 会丢散客行,必须用 LEFT JOIN(已检查 fdw_queries.py 多处使用 LEFT JOIN)