Files
Neo-ZQYY/docs/_overview/04c-feedback/P2-6-and-P2-9-design.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

17 KiB

P2-6 答疑 + P2-9 功能设计

日期:2026-05-04 / 触发:Neo 在 04c 反馈 状态:仅设计 + 待 Neo 审稿,实施延后到 Wave 5 作者:子代理(基于代码现状调研)


P2-6 — 答疑:是否改百炼 APP1 的系统 Prompt

一、当前应用 1 prompt 状态

代码层面调研结论:

  1. apps/backend/app/ai/prompts/ 下只有 app2~app8 的 prompt 模板,没有 app1_*.py
  2. APP1 的系统 prompt 完全在百炼控制台云端配置,后端只通过 app_id 调用(参见 apps/backend/app/ai/dashscope_client.py:69-178DashScopeClient.call_app_stream)。
  3. APP1 的输出形态:纯文本 SSE 流(xcx_chat.pychat_stream 端点),前端按 token 渲染,流末尾发 done 事件。没有任何结构化字段输出
  4. biz.ai_conversations.title 字段已存在(varchar(200),见 db/zqyy_app/schemas/biz.sql:60),但当前只读不写chat_service.py 的列表查询 SELECT title 会读,创建 _create_session 与 SSE 落库流程都没有对它赋值。
  5. chat_service.generate_title() 已经有"自定义标题 > 客户姓名 > 首条消息前 20 字 > '新对话'"的回退链(chat_service.py:494-522),所以当前 title 列表展示用的是回退链,不是 AI 摘要

二、修改方案

是否需要改百炼 APP1 prompt:N(不改)

理由

  1. 流式纯文本 + 末尾插结构化字段会破坏 SSE 体验:APP1 是逐 token 流式输出。在末尾再让模型多吐一段 <title>关于王昕的消费</title> 会让用户看到原始 XML/JSON 标记一闪而过,或需要前端再加解析层 — 复杂度不值。
  2. 百炼控制台的 prompt 是"配置在云端"的资产:每次调整都要进百炼控制台手工改,不在 git 仓库的版本管理内,改动不可追溯,违背 NeoZQYY"一切逻辑改动可追溯"原则。
  3. 生成 title 是低价值场景:title 只用于"对话历史列表"的辨识度,不参与业务逻辑。用全功率 APP1 模型 + 工具调用做摘要是杀鸡用牛刀。
  4. 回退链已经够用:generate_title 已能用客户姓名 + 首条消息前 20 字给出可辨识的标题。优化空间是"AI 让标题更精炼",但代价不应该是改 APP1 主对话 prompt。

推荐替代方案(三选一)

方案 描述 成本 推荐度
R1(推荐) 首轮对话结束后,后端异步qwen-turbo 直调一次"摘要 prompt"(类似 APP6 备注分析的轻量调用),16 字内,失败回退到现有 generate_title 低(qwen-turbo 价格远低于 APP1) ★★★★★
R2 直接用现有 chat_service.generate_title 回退链,不上 AI 摘要,把 title 列变成"客户姓名 / 首句截断"二选一 零额外成本 ★★★★(若 Neo 觉得"够用",优先选这条)
R3 借用 APP6(备注分析)的结构化输出能力做摘要 中,APP6 prompt 要扩展支持"摘要"模式 ★★(改动面比 R1 大,无收益)

推荐:R1 + R2 组合(默认 R2,可观察后升级 R1)

  • Wave 5 先落 R2:首轮 user message 落库时,同步 UPDATE ai_conversations SET title = LEFT(content, 16)(纯 SQL,零外部调用)。这一步可以解决"列表里全是空 title"的问题,且与现有 generate_title 回退链完全兼容。
  • 后续观察:如果产品反馈"前 16 字太机械"(例如用户首句是"你好,帮我看看"),再上 R1。R1 的实现可直接复用 DashScopeClient.call_app(app_id=qwen-turbo, prompt="给下面对话写 ≤16 字标题: ...") 模式,加进 background task 即可。
  • 不动 APP1 的百炼系统 prompt

给 Neo 的最终回答

不改 APP1 prompt。 P2-6 选项 A 描述里那句"由应用 1 首轮自动摘要生成"是策略层措辞,落到工程上更稳妥的实现是后端首轮自己写 title(纯 SQL 截断或异步轻量 AI 调用),与 APP1 主对话流解耦。


P2-9 — tenant-admin 无权限页编辑入口 功能设计

背景与目标

现状:apps/miniprogram/miniprogram/pages/no-permission/no-permission.wxml:54 硬编码 <text class="reason-footer-value">厉超</text>。 Neo 反馈选项 A,但不直接显示 site_admin 的真实姓名(隐私 / 角色边界),改为在 tenant-admin 后台单独提供一个"编辑无权限页显示信息"的管理入口,由 site_admin / tenant_admin 自己填"对外昵称、联系方式、说明文字"。

一、数据模型

表结构

-- db/zqyy_app/migrations/2026-05-XX__create_site_contact_info.sql
CREATE TABLE biz.site_contact_info (
    site_id            bigint        PRIMARY KEY REFERENCES biz.sites(id),
    display_name       varchar(50)   NOT NULL DEFAULT '门店管理员',  -- 对外显示昵称(非真名)
    phone              varchar(20),                                    -- 可选联系电话
    wechat_id          varchar(50),                                    -- 可选微信号
    notes              varchar(200),                                   -- 自定义说明文字(覆盖默认"如有疑问请联系管理员")
    updated_by         bigint        REFERENCES auth.tenant_admins(id),
    updated_at         timestamptz   NOT NULL DEFAULT now()
);

COMMENT ON TABLE biz.site_contact_info IS '门店对外联系信息(用于小程序无权限页等公开场景)';
COMMENT ON COLUMN biz.site_contact_info.display_name IS '对外显示昵称,默认"门店管理员",不应填真实姓名';
COMMENT ON COLUMN biz.site_contact_info.notes IS '自定义说明文字,空则用默认文案';

设计要点

  1. 表名定为 biz.site_contact_info:与 biz.sites 直接关联,语义上是门店对外联络配置,不属于 auth schema。
  2. PK 直接用 site_id:每个门店只有一行,不需要独立自增 id;减少 JOIN。
  3. 不存 tenant_admin_id 关联:因为门店管理员可能轮换,且这里显示的是"门店身份",不是"具体某个人"
  4. display_name 默认 "门店管理员":迁移时所有现存 site 都 INSERT 一条默认记录,即便 tenant_admin 还没来配置,小程序也能显示通用文案。
  5. 不加 RLS:这张表本质是公开信息(小程序未登录页要读),走应用层授权即可;tenant-admin 改自己门店通过 API 层 WHERE site_id = ANY(:managed_site_ids) 过滤。

默认值迁移 SQL

INSERT INTO biz.site_contact_info (site_id, display_name)
SELECT id, '门店管理员'
FROM biz.sites
WHERE id NOT IN (SELECT site_id FROM biz.site_contact_info);

二、后端 API 设计

端点清单

方法 路径 aud 说明
GET /api/tenant/site-contact?siteId={id} tenant-admin 取自己管辖门店的配置;不传 siteId 取首个
PATCH /api/tenant/site-contact tenant-admin 编辑配置
GET /api/xcx/site-contact?siteId={id} miniapp(允许 limited token) 小程序无权限页读取

权限矩阵

角色 GET 自己门店 GET 任意门店 PATCH 自己门店 PATCH 任意门店
site_admin OK OK
tenant_admin(管多个 site) OK(白名单内) 拒(白名单外) OK(白名单内) 拒(白名单外)
小程序 limited token OK(只能读自己 apply 时填的 site)
小程序 approved token OK(自己绑定的 site)

权限校验通过 auth.tenant_admins.managed_site_ids 白名单实现。

Pydantic schema

# apps/backend/app/schemas/site_contact.py
from pydantic import Field
from app.schemas.base import CamelModel

class SiteContactInfo(CamelModel):
    site_id: int
    display_name: str = Field(..., max_length=50)
    phone: str | None = Field(None, max_length=20)
    wechat_id: str | None = Field(None, max_length=50)
    notes: str | None = Field(None, max_length=200)
    updated_at: str  # ISO 8601

class SiteContactUpdateRequest(CamelModel):
    site_id: int
    display_name: str = Field(..., min_length=1, max_length=50)
    phone: str | None = Field(None, max_length=20, pattern=r"^[\d\-\+\s]*$")
    wechat_id: str | None = Field(None, max_length=50)
    notes: str | None = Field(None, max_length=200)

校验规则

  • display_name:必填,1-50 字符,禁止包含全字符姓名常见字段(可加简单黑名单:厉超 等明显的真名 — 实施时若 Neo 想软提示,可改为"前端给提示,后端不强制")。
  • phone:选填,允许数字 / - / + / 空格,长度 ≤ 20。
  • notes:选填,≤ 200 字符。
  • 写入时 updated_by 取 JWT 中 tenant_admin_id

三、tenant-admin 前端页面设计

路径与菜单位置

  • 路径:/site-contact
  • 菜单:在现有侧边栏 用户审核 / 用户管理 / Excel 上传 / 维客线索管理 / 店铺管理员 之后增加 门店联络信息,图标用 IdcardOutlined
  • 权限:tenant_admin 看到所有管辖门店的下拉切换;site_admin 只看自己门店(下拉禁用)。

页面结构(ASCII 草图)

┌─────────────────────────────────────────────────────────┐
│  门店联络信息                                            │
│  用于小程序"无权限页/账号被禁页"对外展示。              │
│  请勿填写真实姓名,推荐使用"店长" / "客服" 等通用称呼。  │
├─────────────────────────────────────────────────────────┤
│                                                          │
│  门店:  [朗朗桌球(总店) ▾]   ← tenant_admin 可切换    │
│                                                          │
│  对外昵称*  [店长_______________]    1-50 字            │
│  联系电话    [13800000000_______]    可选,数字+-空格    │
│  微信号      [_____________________]  可选              │
│  自定义说明  [如有疑问请扫码加微信___________________]  │
│                                                  0/200  │
│                                                          │
│  最近修改:tenant_admin_neo / 2026-05-04 14:32:11         │
│                                                          │
│  ┌──────────────┐    ┌──────────────┐                   │
│  │  恢复默认     │    │  保 存        │                   │
│  └──────────────┘    └──────────────┘                   │
│                                                          │
│  ─── 预览 ────────────────────────────────────────────── │
│  小程序"无权限页"将显示:                                │
│  ┌─────────────────────────┐                            │
│  │ 请联系管理员             │                            │
│  │ 店长                     │                            │
│  │ ☎ 138-0000-0000         │                            │
│  │ 如有疑问请扫码加微信     │                            │
│  └─────────────────────────┘                            │
└─────────────────────────────────────────────────────────┘

实现要点

  • 表单组件:Form.useForm() + Ant Design Input / Input.TextArea
  • 实时预览:右下角同步渲染小程序的卡片样式(用 React 组件还原)。
  • 保存按钮:loading 态 + 成功 toast。
  • "恢复默认":一键填回 display_name='门店管理员',清空其他字段。

四、小程序 no-permission 改造

改造点

  1. no-permission.ts _checkStatus 调用完 /api/xcx/me 后,串行再请求 /api/xcx/site-contact?siteId={当前用户 site_id}
  2. 拿到的 displayName / phone / wechatId / notes 写入 data.contactInfo
  3. no-permission.wxml:51-54 替换:
<!-- 改造前 -->
<view class="reason-footer">
  <text class="reason-footer-label">请联系管理员</text>
  <text class="reason-footer-value">厉超</text>
</view>

<!-- 改造后 -->
<view class="reason-footer">
  <text class="reason-footer-label">请联系管理员</text>
  <text class="reason-footer-value">{{contactInfo.displayName || '门店管理员'}}</text>
  <text class="reason-footer-extra" wx:if="{{contactInfo.phone}}">☎ {{contactInfo.phone}}</text>
  <text class="reason-footer-extra" wx:if="{{contactInfo.wechatId}}">微信:{{contactInfo.wechatId}}</text>
  <text class="reason-footer-notes" wx:if="{{contactInfo.notes}}">{{contactInfo.notes}}</text>
</view>
  1. 兜底:接口失败 / 未配置时,contactInfo = { displayName: '门店管理员' }
  2. 同时检查 disabled 状态下的 contact-hint(no-permission.wxml:58-61 "如有疑问,请联系管理员"),如有 notes 则用 notes 覆盖。

站点 ID 来源

小程序 limited token 也需能解出 siteId。当前 auth.user_applications 在用户 apply 时已记 site_id,后端 /api/xcx/site-contact 读 token 中 site_id(若 approved)或 latestApplication.site_id(若 rejected/disabled)。设计时不要让前端传 siteId(防伪造),由后端从 token / 申请记录推断。

五、默认值 / 迁移策略

  1. 建表 + 默认数据:迁移脚本同步 INSERT 所有现存 biz.sites 行,display_name='门店管理员',其他字段 NULL。
  2. 朗朗桌球(总店):不在迁移里写"店长"/"厉超",保持默认 '门店管理员',让 Neo 上线后手工进 tenant-admin 配置(避免误把真名写进迁移)。
  3. 新建门店:在 biz.sites 新建门店的服务里(若有)或加 trigger,自动 INSERT 一行默认 site_contact_info。简化方案:每次小程序读取 API 时若行不存在,后端自动 INSERT 默认值再返回。

六、测试规范

后端 unit(apps/backend/tests/unit/)

  • test_site_contact_schema.py:Pydantic 校验
    • display_name 空字符串 / 超 50 字 / 含 emoji 的边界
    • phone 含中文 / 字母 → 拒
    • notes 超 200 字 → 拒
  • test_site_contact_service.py:服务层
    • 行不存在时自动返回默认值
    • 更新时 updated_by / updated_at 自动写入

后端 integration(apps/backend/tests/integration/,连测试库)

  • test_site_contact_api.py
    • tenant_admin A 改自己门店 → 200
    • tenant_admin A 改非管辖门店 → 403
    • site_admin 改非自己门店 → 403
    • 小程序 limited token 读自己 apply 的 site → 200
    • 小程序 token 读他人 site → 403
    • 跨 tenant_admin 的 site_id 隔离(白名单 ANY 校验)

tenant-admin e2e(Playwright,apps/tenant-admin/tests/e2e/)

  • site-contact.spec.ts
    • 登录 tenant_admin,跳到 /site-contact,看到门店切换下拉
    • display_name 为 "店长" → 保存 → toast → 重载页面 → 字段保留
    • 改 phone 为非法格式 → 表单校验失败,无法提交
    • 切换到非管辖门店 → 接口 403,前端显示"无权限"

小程序 e2e(微信开发者工具,apps/miniprogram/tests/e2e/)

  • no-permission.spec.ts(参考 P0-7 已有 e2e 模式)
    • mock /api/xcx/me 返回 status='disabled'
    • mock /api/xcx/site-contact 返回 {displayName:'店长', phone:'13800000000'}
    • 断言 wxml 渲染包含 "店长" 和 "13800000000",不包含 "厉超"
    • mock 接口 500 → 兜底显示 "门店管理员"

七、Wave 分配建议

任务 Wave 说明
数据模型 + 迁移 SQL Wave 5 与 P10 tenant-admin 账号体系收尾联动
后端 API(3 个端点 + Pydantic + 权限) Wave 5 依赖数据模型
tenant-admin 前端页面 Wave 5 依赖后端 API
小程序 no-permission 改造 Wave 5 依赖 /api/xcx/site-contact
全部测试 跟随各自实施 unit + integration + e2e 并行
文档同步(docs/database/ + docs/_overview/02b-adminweb-page-matrix.md + 02a-miniprogram-page-matrix.md) Wave 5 表 + API + 页面入口三处同步

预估工作量(单人):后端 0.5 天 / tenant-admin 0.5 天 / 小程序 0.2 天 / 测试 0.5 天 ≈ 1.7 天

八、给 Neo 的设计审稿提问

  1. 表名:推荐 biz.site_contact_info。是否同意?或倾向 biz.site_public_contact / biz.site_display_info?
  2. display_name 真名黑名单:是否需要后端硬拦"厉超 / 真实姓名"?推荐前端弹提示但后端不强制(避免遗漏 / 误伤)。
  3. 小程序读取时机:no-permission onLoad 时拉一次就够,还是 onShow 每次都拉(允许 tenant_admin 改了之后用户立即看到)?推荐 onShow
  4. menu 菜单文字:推荐 门店联络信息,可选 小程序展示信息 / 对外展示设置,Neo 选哪个?
  5. 新建门店时是否自动 INSERT 默认行:推荐"读取时若不存在则自动 INSERT 默认值"(零侵入)。或"biz.sites INSERT 时加 trigger"(复杂)。Neo 选哪种?
  6. 是否要审计这张表的修改:每次 PATCH 是否要落 audit_log 表?当前 NeoZQYY 没有通用审计表设计,推荐先不加,只靠 updated_by / updated_at 字段。
  7. R2 vs R1(P2-6)的取舍:Neo 是否同意 Wave 5 先做 R2(纯 SQL 取首句 16 字),后续观察后再考虑 R1(qwen-turbo 异步摘要)?

等 Neo 审稿确认 → 进 Wave 5 实施 → 实施时按本文档分阶段提 PR。