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

252 lines
9.8 KiB
Markdown

# 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)