Files
Neo-ZQYY/docs/_overview/04b-feedback/P1-12-scattered-memberid.md
Neo 509cf43284 chore(docs): Wave 0 调研产出 + P0/P1/P2 反馈调研
建立项目级标杆文档 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 残留, 已修 commit 17f045a)
- P0-5 致命 2 (JWT aud 缺失, 已修 commit 17f045a)
- P0-6 clearAllTasks 守卫 (Wave 3)
- P0-8 DBViewer 黑名单漏 (已修 commit 17f045a)
- 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
2026-05-04 07:38:28 +08:00

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_id int 0 会员 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)拦截,不跳转

前端两套并存:

  1. 新约定(优先):用后端返回的 isScattered: bool 字段
  2. 旧兜底:点击跳转时再 memberId <= 0 二次判断(防御性)

apps/miniprogram/README.md:158-164:

后端判定规则:member_id <= 0 时在响应字段上标记 isScattered = true...散客条目不提供跳转到 customer-detail 的入口。

三、推荐统一约定

基于实证 + 现状,强烈推荐:member_id = 0 表示散客

理由:

  1. 飞球上游 API 就规定 0 = 散客,从源头一致
  2. ETL DWD 落库后 0 占 84.6%(27742/32789),无 NULL,无负数,实证最稳定
  3. DWS 会员维度表全部 NOT NULL,散客本就不入,不存在二义性
  4. DWS 2 张 ML 表已 DEFAULT 0,语义已物化
  5. NULL 语义混淆:NULL 通常表示"字段不适用 / 未知",散客是明确语义,不应是 NULL
  6. 负数(-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.mdapps/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,文档跟上" — 三层约定就闭环。

六、附:风险点与边界

  1. 飞球 API 偶发 NULL:虽然测试库无 NULL,但生产环境若飞球某次返 null,DWD 落库会保留 NULL,业务判断仍要兼容(OR IS NULL)
  2. ML 配单表 DEFAULT 0:若误把"未配单"和"散客配单"都记为 0,会出现语义混淆。需在 ML 任务中明确 member_id = 0 仅指散客,不能用作"配单失败占位"
  3. dim_member.member_id NOT NULL:维表无散客占位行,后端 JOIN 时若用 INNER JOIN 会丢散客行,必须用 LEFT JOIN(已检查 fdw_queries.py 多处使用 LEFT JOIN)