# 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-178` 的 `DashScopeClient.call_app_stream`)。
3. APP1 的输出形态:**纯文本 SSE 流**(`xcx_chat.py` 的 `chat_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 自己填"对外昵称、联系方式、说明文字"。

### 一、数据模型

#### 表结构

```sql
-- 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

```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

```python
# 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` 替换:

```xml
<!-- 改造前 -->
<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>
```

4. 兜底:接口失败 / 未配置时,`contactInfo = { displayName: '门店管理员' }`。
5. 同时检查 `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。
