# P1-12 散客 memberId 取值实证 > 调研时间:2026-05-04 > 测试库:`test_etl_feiqiu`(TEST_DB_DSN) > Neo 反馈:"先调研后端 / 数据库:实际可能是 0 或 NULL 或 -1,进行校验" ## 一、测试库实际值分布 ### 1.1 dwd 层(原始结算单) ```sql 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(会员维表) ```sql 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 层(汇总表) ```sql -- 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 助教-客户表已正确过滤散客。 ```sql -- 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`: ```sql 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`: ```python 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: ```python 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`: ```python 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`: ```ts if (memberId <= 0) { wx.showToast({ title: '散客无详情可查看', icon: 'none' }) return } ``` `apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.ts:235`: ```ts 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.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,文档跟上"** — 三层约定就闭环。 ## 六、附:风险点与边界 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)