feat: chat integration, tenant admin spec, backend chat service, miniprogram updates, DEMO moved to tmp, XCX-TEST removed, migrations & docs

This commit is contained in:
Neo
2026-03-20 09:02:10 +08:00
parent 3d2e5f8165
commit beb88d5bea
388 changed files with 6436 additions and 25458 deletions

View File

@@ -125,7 +125,6 @@ WHERE site_id = current_setting('app.current_site_id')::bigint;
| `app.v_dws_member_visit_detail` | `dws.dws_member_visit_detail` | | `app.v_dws_member_visit_detail` | `dws.dws_member_visit_detail` |
| `app.v_dws_member_winback_index` | `dws.dws_member_winback_index` | | `app.v_dws_member_winback_index` | `dws.dws_member_winback_index` |
| `app.v_dws_member_newconv_index` | `dws.dws_member_newconv_index` | | `app.v_dws_member_newconv_index` | `dws.dws_member_newconv_index` |
| `app.v_dws_member_recall_index` | `dws.dws_member_recall_index` |
| `app.v_dws_member_assistant_relation_index` | `dws.dws_member_assistant_relation_index` | | `app.v_dws_member_assistant_relation_index` | `dws.dws_member_assistant_relation_index` |
| `app.v_dws_member_assistant_intimacy` | `dws.dws_member_assistant_intimacy` | | `app.v_dws_member_assistant_intimacy` | `dws.dws_member_assistant_intimacy` |
| `app.v_dws_assistant_daily_detail` | `dws.dws_assistant_daily_detail` | | `app.v_dws_assistant_daily_detail` | `dws.dws_assistant_daily_detail` |

View File

@@ -60,7 +60,7 @@
- [x] 5. Checkpoint — ETL 层验证 - [x] 5. Checkpoint — ETL 层验证
- Ensure all tests pass, ask the user if questions arise. - Ensure all tests pass, ask the user if questions arise.
- [ ] 6. 数据库视图层同步 - [x] 6. 数据库视图层同步
- [x] 6.1 RLS 视图重建 `app.v_dws_finance_recharge_summary` - [x] 6.1 RLS 视图重建 `app.v_dws_finance_recharge_summary`
- 创建迁移脚本 `db/zqyy_app/migrations/` - 创建迁移脚本 `db/zqyy_app/migrations/`
- `CREATE OR REPLACE VIEW` 包含全部 6 个新字段 - `CREATE OR REPLACE VIEW` 包含全部 6 个新字段

View File

@@ -141,7 +141,7 @@ async def get_finance_board(time, area, compare, site_id) -> dict:
| `get_all_assistants` | `v_dim_assistant` | BOARD-1 助教列表 | | `get_all_assistants` | `v_dim_assistant` | BOARD-1 助教列表 |
| `get_salary_calc_batch` | `v_dws_assistant_salary_calc` | BOARD-1 批量绩效(基于已有 `get_salary_calc` SQL 模式扩展为批量查询) | | `get_salary_calc_batch` | `v_dws_assistant_salary_calc` | BOARD-1 批量绩效(基于已有 `get_salary_calc` SQL 模式扩展为批量查询) |
| `get_top_customers_for_coaches` | `v_dws_member_assistant_relation_index` + `v_dim_member` | BOARD-1 Top 客户(基于已有 `get_relation_index` 扩展为按助教批量查询) | | `get_top_customers_for_coaches` | `v_dws_member_assistant_relation_index` + `v_dim_member` | BOARD-1 Top 客户(基于已有 `get_relation_index` 扩展为按助教批量查询) |
| `get_coach_sv_data` | `v_dws_assistant_monthly_summary` | BOARD-1 sv 维度(助教月度储值汇总,已按助教预聚合 | | `get_coach_sv_data` | `v_dws_assistant_recharge_commission` | BOARD-1 sv 维度(助教储值提成明细,取 recharge_amount + commission_amount |
| `get_customer_board_recall` | `v_dws_member_winback_index` + `v_dim_member` | BOARD-2 recall 维度ETL 已计算 WBI 指数) | | `get_customer_board_recall` | `v_dws_member_winback_index` + `v_dim_member` | BOARD-2 recall 维度ETL 已计算 WBI 指数) |
| `get_customer_board_potential` | `v_dws_member_spending_power_index` | BOARD-2 potential 维度ETL 已计算 SPI 指数) | | `get_customer_board_potential` | `v_dws_member_spending_power_index` | BOARD-2 potential 维度ETL 已计算 SPI 指数) |
| `get_customer_board_balance` | `v_dim_member_card_account` + `v_dim_member` | BOARD-2 balance 维度 | | `get_customer_board_balance` | `v_dim_member_card_account` + `v_dim_member` | BOARD-2 balance 维度 |
@@ -576,7 +576,7 @@ def get_finance_overview(conn, site_id, start_date, end_date):
⚠️ 已有函数复用说明: ⚠️ 已有函数复用说明:
- `get_salary_calc_batch` 基于已有 `get_salary_calc()` 的 SQL 模式,扩展为 `WHERE assistant_id = ANY(%s)` 批量查询 - `get_salary_calc_batch` 基于已有 `get_salary_calc()` 的 SQL 模式,扩展为 `WHERE assistant_id = ANY(%s)` 批量查询
- `get_top_customers_for_coaches` 基于已有 `get_relation_index()` 的 SQL 模式,扩展为按助教维度批量查询 + JOIN v_dim_member - `get_top_customers_for_coaches` 基于已有 `get_relation_index()` 的 SQL 模式,扩展为按助教维度批量查询 + JOIN v_dim_member
- `get_coach_sv_data` 使用 `v_dws_assistant_monthly_summary`(已按助教预聚合),无需从 `v_dws_member_consumption_summary` 手动聚合 - `get_coach_sv_data` 使用 `v_dws_assistant_recharge_commission`(助教储值提成明细表,取 recharge_amount + commission_amount`v_dws_assistant_monthly_summary` 口径错误M15 修复)
- BOARD-2 recall/potential/recent/recharge 维度使用 ETL 已计算好的专用指数视图,避免在后端重复计算 - BOARD-2 recall/potential/recent/recharge 维度使用 ETL 已计算好的专用指数视图,避免在后端重复计算
关键 SQL 模式: 关键 SQL 模式:

View File

@@ -0,0 +1 @@
{"specId": "9eccc890-b6c3-41a3-8ba1-bb2f0e09f653", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -0,0 +1,704 @@
# 技术设计文档 — RNS1.4CHAT 对齐与联调收尾
## 概述
RNS1.4 是 RNS1 系列的收尾 spec覆盖三大块工作
1. **CHAT 模块路径迁移与功能补全**T4-1 ~ T4-3将现有 `/api/ai/*` 路由迁移到 `/api/xcx/chat/*`,实现 CHAT-1/2/3/4 四个端点,支持 referenceCard 和多入口参数路由
2. **FDW 端到端验证**T4-4验证 `test_zqyy_app``test_etl_feiqiu` 链路上所有 FDW 视图可访问、性能达标、索引完备
3. **全量前后端联调**T4-513 个页面移除 mock 数据,连接真实后端,修复 notes 触底加载和 customer-service-records 按月请求
### 设计原则
- **复用优先**:现有 `biz.ai_conversations` / `biz.ai_messages` 表结构通过 DDL 迁移扩展,新增 CHAT 所需字段(`customer_id``title``last_message``reference_card`),不新建表
- **契约驱动**:所有端点严格遵循 `API-contract.md` 中 CHAT-1/2/3/4 的定义
- **权限一致**:所有 CHAT 端点使用 `require_approved()` 依赖,与 RNS1.1-1.3 保持一致
- **DWD-DOC 强制规则**referenceCard 中涉及金额使用 `items_sum` 口径,会员信息通过 `member_id` JOIN `dim_member`
### 依赖关系
```mermaid
graph LR
RNS10[RNS1.0<br/>基础设施] --> RNS14[RNS1.4<br/>CHAT + 联调]
RNS11[RNS1.1<br/>任务/绩效] --> RNS14
RNS12[RNS1.2<br/>客户/助教] --> RNS14
RNS13[RNS1.3<br/>三看板] --> RNS14
style RNS14 fill:#f9f,stroke:#333
```
## 架构
### 整体架构
```mermaid
graph TB
subgraph "微信小程序 (apps/miniprogram/)"
FE_CHAT[pages/chat/chat.ts]
FE_HIST[pages/chat-history/chat-history.ts]
FE_API[services/api.ts]
FE_REQ[utils/request.ts]
FE_CHAT --> FE_API
FE_HIST --> FE_API
FE_API --> FE_REQ
end
subgraph "FastAPI 后端 (apps/backend/app/)"
MW[ResponseWrapperMiddleware<br/>SSE 自动跳过]
ROUTER[routers/xcx_chat.py<br/>CHAT-1/2/3/4]
SCHEMA[schemas/xcx_chat.py<br/>Pydantic CamelModel]
SVC[services/chat_service.py<br/>对话业务逻辑]
AI_SVC[ai/conversation_service.py<br/>AI 调用 + 持久化]
BAILIAN[ai/bailian_client.py<br/>百炼 API]
FDW_Q[services/fdw_queries.py<br/>FDW 查询]
DB[database.py]
MW --> ROUTER
ROUTER --> SCHEMA
ROUTER --> SVC
SVC --> AI_SVC
SVC --> FDW_Q
AI_SVC --> BAILIAN
SVC --> DB
end
subgraph "数据库"
APP_DB[(zqyy_app<br/>biz.ai_conversations<br/>biz.ai_messages)]
ETL_DB[(etl_feiqiu via FDW<br/>fdw_etl.v_dim_member<br/>fdw_etl.v_dws_member_*)]
end
FE_REQ -->|HTTP JSON / SSE| MW
DB --> APP_DB
FDW_Q --> ETL_DB
style ROUTER fill:#f9f,stroke:#333
style SVC fill:#f9f,stroke:#333
```
### 请求-响应流程SSE 流式)
```mermaid
sequenceDiagram
participant MP as 小程序 chat.ts
participant MW as ResponseWrapper
participant R as xcx_chat.py
participant S as chat_service.py
participant AI as BailianClient
participant DB as zqyy_app
MP->>MW: POST /api/xcx/chat/stream<br/>{chatId, content}
MW->>R: 透传SSE 跳过包装)
R->>R: require_approved() + 验证 chatId 归属
R->>S: stream_chat(chatId, content, user)
S->>DB: INSERT user message → ai_messages
S->>AI: 流式调用百炼 API
loop 逐 token
AI-->>S: token 片段
S-->>R: SSEEvent(type=message, token=...)
R-->>MW: data: {"token": "..."}
MW-->>MP: 透传 SSE 事件
end
S->>DB: INSERT AI reply → ai_messages
S->>DB: UPDATE ai_conversations.last_message
S-->>R: SSEEvent(type=done, messageId, createdAt)
R-->>MW: data: {"messageId": "...", "createdAt": "..."}
MW-->>MP: 透传 done 事件
```
## 组件与接口
### 组件 1xcx_chat 路由模块(路径迁移 + CHAT-1/2/3/4
**位置**`apps/backend/app/routers/xcx_chat.py`(新文件,替代 `xcx_ai_chat.py`
**职责**:将现有 `/api/ai/*` 路由迁移到 `/api/xcx/chat/*`,并实现 CHAT-1/2/3/4 四个端点。
**迁移策略**
- 新建 `xcx_chat.py`prefix 为 `/api/xcx/chat`
-`xcx_ai_chat.py` 迁移 SSE 流式对话、历史列表、消息查询三个端点
-`main.py` 中替换路由注册:移除 `xcx_ai_chat.router`,注册 `xcx_chat.router`
- 删除 `xcx_ai_chat.py`(不保留旧路径兼容)
**端点定义**
```python
router = APIRouter(prefix="/api/xcx/chat", tags=["小程序 CHAT"])
# CHAT-1: 对话历史列表
@router.get("/history")
async def list_chat_history(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user: CurrentUser = Depends(require_approved()),
) -> ChatHistoryResponse: ...
# CHAT-2a: 通过 chatId 查询消息
@router.get("/{chat_id}/messages")
async def get_chat_messages(
chat_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
user: CurrentUser = Depends(require_approved()),
) -> ChatMessagesResponse: ...
# CHAT-2b: 通过上下文查询消息(自动查找/创建对话)
@router.get("/messages")
async def get_chat_messages_by_context(
context_type: str = Query(..., alias="contextType"), # task / customer / coach
context_id: str = Query(..., alias="contextId"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
user: CurrentUser = Depends(require_approved()),
) -> ChatMessagesResponse: ...
# CHAT-3: 发送消息(同步回复)
@router.post("/{chat_id}/messages")
async def send_message(
chat_id: int,
body: SendMessageRequest,
user: CurrentUser = Depends(require_approved()),
) -> SendMessageResponse: ...
# CHAT-4: SSE 流式端点
@router.post("/stream")
async def chat_stream(
body: ChatStreamRequest,
user: CurrentUser = Depends(require_approved()),
) -> StreamingResponse: ...
```
### 组件 2chat_service 业务逻辑层
**位置**`apps/backend/app/services/chat_service.py`(新文件)
**职责**:封装 CHAT 模块的核心业务逻辑包括对话管理、消息持久化、referenceCard 组装。
**关键方法**
```python
class ChatService:
"""CHAT 模块业务逻辑。"""
def get_chat_history(
self, user_id: int, site_id: int, page: int, page_size: int
) -> tuple[list[dict], int]:
"""CHAT-1: 查询对话历史列表,返回 (items, total)。"""
def get_or_create_session(
self, user_id: int, site_id: int,
context_type: str, context_id: str | None
) -> int:
"""按入口上下文查找或创建对话,返回 chat_id。
复用规则:
- context_type='task': 同一 taskId 始终复用(无时限)
- context_type='customer'/'coach': 最后消息 ≤ 3 天复用,> 3 天新建
- context_type='general': 始终新建
"""
def get_messages(
self, chat_id: int, user_id: int, site_id: int,
page: int, page_size: int
) -> tuple[list[dict], int, int]:
"""CHAT-2: 查询消息列表,返回 (messages, total, chat_id)。
验证 chat_id 归属当前用户。"""
def send_message_sync(
self, chat_id: int, content: str, user_id: int, site_id: int
) -> dict:
"""CHAT-3: 发送消息并获取同步 AI 回复。
1. 验证 chatId 归属
2. 存入用户消息
3. 调用 AI 获取回复
4. 存入 AI 回复
5. 更新 session 的 last_message / last_message_at
6. AI 失败时返回错误提示消息HTTP 200"""
def build_reference_card(
self, customer_id: int, site_id: int
) -> dict | None:
"""组装 referenceCard从 FDW 查询客户关键指标。
遵循 DWD-DOC 规则:金额用 items_sum会员信息通过 member_id JOIN dim_member。"""
def generate_title(self, session: dict) -> str:
"""生成对话标题:自定义标题 > 上下文名称 > 首条消息前20字。"""
```
**referenceCard 组装逻辑**
```python
def build_reference_card(self, customer_id: int, site_id: int) -> dict | None:
"""从 FDW 查询客户指标,组装为 referenceCard 结构。"""
# 1. 通过 member_id JOIN fdw_etl.v_dim_member 获取客户姓名
# 2. 通过 fdw_etl.v_dws_member_consumption_summary 获取:
# - 余额balance
# - 近30天消费items_sum 口径,非 consume_money
# - 到店次数
# 3. 组装为 referenceCard 结构
return {
"type": "customer",
"title": f"{member_name} — 消费概览",
"summary": f"余额 ¥{balance}近30天消费 ¥{consume_30d}",
"data": {
"余额": f"¥{balance}",
"近30天消费": f"¥{consume_30d}",
"到店次数": f"{visit_count}",
}
}
```
### 组件 3Pydantic SchemaCamelModel
**位置**`apps/backend/app/schemas/xcx_chat.py`(新文件)
**职责**:定义 CHAT 模块所有请求/响应的 Pydantic schema继承 `CamelModel` 统一 camelCase 输出。
```python
from app.schemas.base import CamelModel
class ChatHistoryItem(CamelModel):
id: int
title: str
customer_name: str | None = None
last_message: str | None = None
timestamp: str # ISO 8601最后消息时间
unread_count: int = 0
class ChatHistoryResponse(CamelModel):
items: list[ChatHistoryItem]
total: int
page: int
page_size: int
class ReferenceCard(CamelModel):
type: str # 'customer' | 'record'
title: str
summary: str
data: dict[str, str] # 键值对详情
class ChatMessageItem(CamelModel):
id: int
role: str # 'user' | 'assistant'
content: str
created_at: str # ISO 8601统一字段名替代 timestamp / created_at
reference_card: ReferenceCard | None = None
class ChatMessagesResponse(CamelModel):
chat_id: int
items: list[ChatMessageItem]
total: int
page: int
page_size: int
class SendMessageRequest(CamelModel):
content: str
class SendMessageResponse(CamelModel):
user_message: MessageBrief
ai_reply: MessageBrief
class MessageBrief(CamelModel):
id: int
content: str
created_at: str
class ChatStreamRequest(CamelModel):
chat_id: int
content: str
```
### 组件 4前端 chat 页面改造
**位置**`apps/miniprogram/miniprogram/pages/chat/chat.ts`
**改造要点**
1. **多入口参数路由**GAP-49/50
```typescript
onLoad(options) {
if (options.historyId) {
// 从 chat-history 跳转:直接用 historyId 作为 chatId
this.chatId = options.historyId
this.loadMessages(this.chatId)
} else if (options.taskId) {
// 从 task-detail 跳转:同一 taskId 始终复用同一对话
this.loadMessagesByContext('task', options.taskId)
} else if (options.customerId) {
// 从 customer-detail 跳转3 天内复用,超过 3 天新建
this.loadMessagesByContext('customer', options.customerId)
} else if (options.coachId) {
// 从 coach-detail 跳转3 天内复用,超过 3 天新建
this.loadMessagesByContext('coach', options.coachId)
} else {
// 无参数:始终新建通用对话
this.loadMessagesByContext('general', '')
}
}
```
2. **SSE 替换 mock 流式输出**
- 移除 `simulateStreamOutput()` 调用和 `mockAIReplies`
- 使用 `wx.request` + `enableChunked: true`(微信基础库 2.20.2+)接收 SSE
- 备选方案:轮询 CHAT-3 同步端点(不支持 chunked 的低版本基础库)
3. **referenceCard 渲染**
- 消息列表中检测 `referenceCard` 字段,已有 `toDataList()` 和 WXML 模板,无需大改
- 确保从真实 API 返回的 `referenceCard` 结构与 mock 一致
### 组件 5前端 chat-history 页面改造
**位置**`apps/miniprogram/miniprogram/pages/chat-history/chat-history.ts`
**改造要点**
- 移除 `mockChatHistory` 导入
- 调用 `fetchChatHistory()` 获取真实数据
- 响应字段映射:后端返回 `timestamp`ISO 8601→ 前端 `formatRelativeTime()` 处理
### 组件 6前端 services/api.ts CHAT 模块对接
**位置**`apps/miniprogram/miniprogram/services/api.ts`
**改造要点**
- `fetchChatHistory()`:调用 `GET /api/xcx/chat/history`
- `fetchChatMessages()`:调用 `GET /api/xcx/chat/{chatId}/messages`
- `fetchChatMessagesByContext(contextType, contextId)`:新增,调用 `GET /api/xcx/chat/messages?contextType={type}&contextId={id}`
- `sendChatMessage()`:调用 `POST /api/xcx/chat/{chatId}/messages`
- 移除所有 CHAT 相关 mock 数据导入
- `USE_REAL_API` 开关对 CHAT 模块设为 `true`
### 组件 7FDW 验证脚本
**位置**`scripts/ops/verify_fdw_e2e.py`(新文件)
**职责**:一次性验证脚本,检查 `test_zqyy_app` → `test_etl_feiqiu` FDW 链路。
**验证项**
1. 所有 `fdw_etl.*` 视图可访问SELECT 1 FROM ... LIMIT 1
2. 带典型过滤条件的查询响应时间 < 3s
3. 关键索引存在检查(`chat_sessions` 的 `(assistant_id, customer_id)` 等)
4. 结果输出为结构化报告JSON失败项标注需 DBA 介入
### 组件 8联调修复 — notes 触底加载 & customer-service-records 按月请求
**notes 页面**`pages/notes/notes.ts`
- 实现 `onReachBottom()` 生命周期函数
- 维护 `page` 状态,触底时 `page++` 调用 `fetchNotes({ page, pageSize })`
- 追加数据到已有列表,`hasMore === false` 时停止加载
**customer-service-records 页面**`pages/customer-service-records/customer-service-records.ts`
- 月份切换时调用 `fetchCustomerRecords({ customerId, year, month })`
- 清空已有列表 → 显示 loading → 渲染新数据
- 首次加载默认当前月份
## 数据模型
### 表结构变更:扩展 `biz.ai_conversations`
现有 `biz.ai_conversations` 表需新增字段以支持 CHAT API 契约:
```sql
-- 迁移脚本:扩展 ai_conversations 支持 CHAT 模块
ALTER TABLE biz.ai_conversations
ADD COLUMN IF NOT EXISTS context_type varchar(20), -- 关联上下文类型task / customer / coach / general
ADD COLUMN IF NOT EXISTS context_id varchar(50), -- 关联上下文 IDtaskId / customerId / coachId
ADD COLUMN IF NOT EXISTS title varchar(200), -- 对话标题
ADD COLUMN IF NOT EXISTS last_message text, -- 最后一条消息摘要
ADD COLUMN IF NOT EXISTS last_message_at timestamptz; -- 最后消息时间
COMMENT ON COLUMN biz.ai_conversations.context_type IS '对话关联上下文类型task任务/ customer客户/ coach助教/ general通用';
COMMENT ON COLUMN biz.ai_conversations.context_id IS '关联上下文 IDtask 入口为 taskIdcustomer 入口为 customerIdcoach 入口为 coachIdgeneral 为 NULL';
COMMENT ON COLUMN biz.ai_conversations.title IS '对话标题:自定义 > 上下文名称 > 首条消息前20字';
COMMENT ON COLUMN biz.ai_conversations.last_message IS '最后一条消息内容摘要截断至100字';
COMMENT ON COLUMN biz.ai_conversations.last_message_at IS '最后消息时间,用于历史列表排序和对话复用时限判断';
```
**新增索引**
```sql
-- 上下文对话查找(按 context_type + context_id 查找可复用对话)
CREATE INDEX idx_ai_conv_context
ON biz.ai_conversations (user_id, site_id, context_type, context_id, last_message_at DESC NULLS LAST)
WHERE context_type IS NOT NULL;
-- 历史列表排序优化CHAT-1: 按 last_message_at 倒序)
CREATE INDEX idx_ai_conv_last_msg
ON biz.ai_conversations (user_id, site_id, last_message_at DESC NULLS LAST);
```
### 对话复用规则
不同入口的对话创建/复用策略:
| 入口 | context_type | context_id | 复用规则 |
|------|-------------|-----------|---------|
| task-detail | `task` | taskId | **始终复用**:同一 taskId 归为同一个对话,无时限 |
| customer-detail | `customer` | customerId | **3 天时限**:最后一条消息 ≤ 3 天则复用,> 3 天则新建 |
| coach-detail | `coach` | coachId | **3 天时限**:最后一条消息 ≤ 3 天则复用,> 3 天则新建 |
| chat-history | — | — | **直接打开**:用 historyId 作为 chatId 加载已有对话 |
| 无参数AI 按钮等) | `general` | NULL | **始终新建**:每次创建新对话 |
复用查找 SQL 模式:
```sql
-- task 入口:始终复用(无时限)
SELECT id FROM biz.ai_conversations
WHERE user_id = :user_id AND site_id = :site_id
AND context_type = 'task' AND context_id = :task_id
ORDER BY created_at DESC LIMIT 1;
-- customer / coach 入口3 天时限复用
SELECT id FROM biz.ai_conversations
WHERE user_id = :user_id AND site_id = :site_id
AND context_type = :type AND context_id = :context_id
AND last_message_at > NOW() - INTERVAL '3 days'
ORDER BY last_message_at DESC LIMIT 1;
```
### 表结构变更:扩展 `biz.ai_messages`
```sql
-- 迁移脚本:扩展 ai_messages 支持 referenceCard
ALTER TABLE biz.ai_messages
ADD COLUMN IF NOT EXISTS reference_card jsonb; -- 引用卡片 JSON
COMMENT ON COLUMN biz.ai_messages.reference_card IS 'referenceCard JSON{type, title, summary, data}';
```
### 变更后完整表结构
#### biz.ai_conversations扩展后
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | bigint PK | 对话 ID即 chatId |
| `user_id` | varchar(50) | 用户 ID助教 |
| `nickname` | varchar(100) | 用户昵称 |
| `app_id` | varchar(30) | AI 应用 IDCHAT 模块固定为 `app1_chat` |
| `site_id` | bigint | 门店 ID |
| `source_page` | varchar(100) | 来源页面 |
| `source_context` | jsonb | 来源上下文 |
| `created_at` | timestamptz | 创建时间 |
| `customer_id` | — | **已移除** — 改用 `context_type` + `context_id` 通用方案 |
| `context_type` | varchar(20) | **新增** — 对话关联上下文类型task/customer/coach/general |
| `context_id` | varchar(50) | **新增** — 关联上下文 IDtaskId/customerId/coachId |
| `title` | varchar(200) | **新增** — 对话标题 |
| `last_message` | text | **新增** — 最后消息摘要 |
| `last_message_at` | timestamptz | **新增** — 最后消息时间 |
#### biz.ai_messages扩展后
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | bigint PK | 消息 ID |
| `conversation_id` | bigint FK | 对话 ID |
| `role` | varchar(10) | 角色:`user` / `assistant` |
| `content` | text | 消息内容 |
| `tokens_used` | integer | token 消耗量 |
| `created_at` | timestamptz | 创建时间 |
| `reference_card` | jsonb | **新增** — 引用卡片 |
### 字段映射:数据库 → API 响应
| 数据库字段 | API 响应字段camelCase | 说明 |
|-----------|------------------------|------|
| `ai_conversations.id` | `id` / `chatId` | 对话 ID |
| `ai_conversations.title` | `title` | 对话标题 |
| `ai_conversations.last_message` | `lastMessage` | 最后消息摘要 |
| `ai_conversations.last_message_at` | `timestamp` | CHAT-1 历史列表时间 |
| `ai_conversations.context_id` → 当 `context_type=customer` 时 JOIN `v_dim_member` | `customerName` | 客户姓名(仅 context_type=customer 时有值) |
| `ai_messages.id` | `id` | 消息 ID |
| `ai_messages.role` | `role` | 消息角色 |
| `ai_messages.content` | `content` | 消息内容 |
| `ai_messages.created_at` | `createdAt` | 统一时间字段名 |
| `ai_messages.reference_card` | `referenceCard` | 引用卡片 JSON |
### referenceCard JSON 结构
```json
{
"type": "customer",
"title": "张伟 — 消费概览",
"summary": "余额 ¥5,200近30天消费 ¥2,380",
"data": {
"余额": "¥5,200",
"近30天消费": "¥2,380",
"到店次数": "8次",
"最近到店": "3天前"
}
}
```
### DWD-DOC 强制规则在数据模型中的体现
| 规则 | 影响范围 | 实施方式 |
|------|---------|---------|
| `items_sum` 口径 | referenceCard 中"近30天消费" | SQL 使用 `items_sum` 字段,禁用 `consume_money` |
| 助教费用拆分 | referenceCard 中如涉及助教费用 | 使用 `assistant_pd_money` + `assistant_cx_money` |
| 会员信息 JOIN | referenceCard 中客户姓名 | 通过 `member_id` JOIN `fdw_etl.v_dim_member``scd2_is_current=1`),禁用结算单冗余字段 |
## 正确性属性
*属性Property是一个在系统所有合法执行路径上都应成立的特征或行为——本质上是对"系统应该做什么"的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
### Property 1: 路由迁移完整性
*For any* CHAT 端点路径(`/history`、`/{chatId}/messages`、`/messages`、`/{chatId}/messages` POST、`/stream`),以 `/api/xcx/chat` 为前缀的请求应返回非 404 响应(需认证),而以 `/api/ai` 为前缀的对应旧路径应返回 404。
**Validates: Requirements 1.1, 1.2**
### Property 2: CHAT API 响应结构完整性
*For any* CHAT-1 历史列表项,响应必须包含 `id`、`title`、`lastMessage`、`timestamp` 字段;*For any* CHAT-2 消息项,响应必须包含 `id`、`role`、`content`、`createdAt` 字段;*For any* CHAT-3 发送消息响应,必须包含 `userMessage` 和 `aiReply`,各含 `id`、`content`、`createdAt` 字段。
**Validates: Requirements 2.2, 3.3, 5.3**
### Property 3: 列表排序不变量
*For any* CHAT-1 返回的对话历史列表,相邻两项的 `timestamp` 应满足前项 ≥ 后项(按时间倒序);*For any* CHAT-2 返回的消息列表,相邻两项的 `createdAt` 应满足前项 ≤ 后项(按时间正序)。
**Validates: Requirements 2.3, 3.5**
### Property 4: 对话标题生成优先级
*For any* 对话记录,标题生成应遵循优先级链:若 `title` 字段非空则使用 `title`;否则若 `customer_id` 关联的客户姓名非空则使用客户姓名;否则使用首条消息内容的前 20 个字符。生成结果应始终为非空字符串。
**Validates: Requirements 2.4**
### Property 5: 权限控制与数据隔离
*For any* CHAT 端点CHAT-1/2/3/4未通过审核的用户status ≠ approved应收到 HTTP 403 响应;*For any* 已认证用户请求的对话数据,返回的所有对话记录的 `user_id` 应等于当前用户 ID*For any* 不属于当前用户的 `chatId`CHAT-3 和 CHAT-4 应返回 HTTP 403。
**Validates: Requirements 2.6, 5.6, 11.1, 11.2, 11.3**
### Property 6: 对话复用规则正确性
*For any* `context_type='task'` 的入口,同一 `(user_id, site_id, context_id)` 多次调用 `get_or_create_session` 应始终返回同一个 `chatId`*For any* `context_type='customer'` 或 `context_type='coach'` 的入口,若最后消息时间 ≤ 3 天则返回已有 `chatId`,若 > 3 天则返回新的 `chatId`*For any* `context_type='general'` 的入口,每次调用应返回不同的 `chatId`。
**Validates: Requirements 3.8, 3.9, 3.10**
### Property 7: referenceCard 持久化 Round Trip
*For any* 合法的 referenceCard JSON 对象(包含 `type`、`title`、`summary`、`data` 字段),存入 `ai_messages.reference_card` 后再读取,应得到与原始对象结构等价的 JSON。
**Validates: Requirements 4.1, 4.3**
### Property 8: 消息持久化与会话元数据更新
*For any* 通过 CHAT-3 或 CHAT-4 发送的消息,用户消息和 AI 回复均应被持久化到 `ai_messages` 表;发送后对应 `ai_conversations` 记录的 `last_message` 应更新为最新消息内容,`last_message_at` 应更新为最新消息时间。
**Validates: Requirements 5.2, 5.4, 6.3, 6.4**
### Property 9: SSE 事件类型有效性
*For any* CHAT-4 SSE 流中的事件,其 `event` 字段应为 `message`、`done`、`error` 三者之一;`message` 事件的 `data` 应包含 `token` 字段;`done` 事件的 `data` 应包含 `messageId` 和 `createdAt` 字段;`error` 事件的 `data` 应包含 `message` 字段。
**Validates: Requirements 6.2**
## 错误处理
### 后端错误处理
所有 CHAT 端点的错误响应遵循 RNS1.0 全局异常处理器格式:`{ code: <HTTP状态码>, message: <错误详情> }`。
| 场景 | HTTP 状态码 | 响应 | 处理方式 |
|------|-----------|------|---------|
| 未认证(无 token / token 过期) | 401 | `{ code: 401, message: "无效的令牌" }` | `get_current_user` 依赖抛出 |
| 未通过审核status ≠ approved | 403 | `{ code: 403, message: "用户未通过审核,无法访问此资源" }` | `require_approved()` 依赖抛出 |
| chatId 不属于当前用户 | 403 | `{ code: 403, message: "无权访问此对话" }` | `chat_service` 验证后抛出 |
| chatId 不存在 | 404 | `{ code: 404, message: "对话不存在" }` | `chat_service` 查询后抛出 |
| 消息内容为空 | 422 | `{ code: 422, message: "消息内容不能为空" }` | 路由层 Pydantic 验证 |
| AI 服务调用失败CHAT-3 | 200 | `aiReply.content = "抱歉AI 助手暂时无法回复,请稍后重试"` | 用户消息仍保存AI 回复为错误提示 |
| AI 服务调用失败CHAT-4 SSE | SSE error 事件 | `event: error\ndata: {"message": "AI 服务暂时不可用"}` | 流中发送 error 事件后关闭 |
| 数据库连接失败 | 500 | `{ code: 500, message: "Internal Server Error" }` | 全局 `unhandled_exception_handler` |
| FDW 查询失败referenceCard | 静默降级 | referenceCard 返回 `null` | 不影响消息本身,仅 referenceCard 缺失 |
### CHAT-3 AI 失败降级策略
```python
async def send_message_sync(self, chat_id, content, user_id, site_id):
# 1. 存入用户消息(无论 AI 是否成功)
user_msg_id = self._save_message(chat_id, "user", content)
# 2. 调用 AI
try:
ai_reply = await self._call_ai(content, chat_id)
except Exception as e:
logger.error("AI 服务调用失败: %s", e)
ai_reply = "抱歉AI 助手暂时无法回复,请稍后重试"
# 3. 存入 AI 回复(包括错误提示)
ai_msg_id = self._save_message(chat_id, "assistant", ai_reply)
# 4. 更新 session 元数据
self._update_session_metadata(chat_id, ai_reply)
# 5. HTTP 200 返回(不抛异常)
return { "userMessage": {...}, "aiReply": {...} }
```
### 前端错误处理
| 场景 | 处理方式 |
|------|---------|
| CHAT API 返回 401 | 跳转登录页(`request()` 全局拦截) |
| CHAT API 返回 403 | Toast 提示"权限不足" |
| CHAT API 返回 404 | Toast 提示"对话不存在" |
| CHAT API 返回 500 | Toast 提示"服务暂时不可用" |
| SSE 连接中断 | 停止流式显示,显示"连接中断"提示,允许重试 |
| 网络超时 | `wx.request` fail 回调,显示网络错误提示 |
## 测试策略
### 双轨测试方法
RNS1.4 采用属性测试Property-Based Testing+ 单元测试Unit Testing双轨并行
- **属性测试**验证对话管理、消息持久化、权限控制、referenceCard round trip 等通用规则
- **单元测试**验证具体端点行为、边界条件、AI 失败降级等
### 属性测试配置
- **测试库**[Hypothesis](https://hypothesis.readthedocs.io/)Python项目已使用
- **测试位置**`tests/` 目录Monorepo 级属性测试)
- **最小迭代次数**:每个属性测试 100 次(`@settings(max_examples=100)`
- **标签格式**:每个测试函数的 docstring 中标注 `Feature: rns1-chat-integration, Property {N}: {property_text}`
### 属性测试清单
| Property | 测试函数 | 生成器 | 验证逻辑 |
|----------|---------|--------|---------|
| P3: 列表排序不变量 | `test_chat_list_ordering` | `st.lists(st.datetimes())` 生成随机时间戳列表 | 对话列表按时间倒序,消息列表按时间正序 |
| P4: 标题生成优先级 | `test_title_generation_priority` | `st.fixed_dictionaries({"title": st.one_of(st.none(), st.text(min_size=1)), "customer_name": st.one_of(st.none(), st.text(min_size=1)), "first_message": st.text(min_size=1)})` | 标题遵循优先级链,结果非空 |
| P6: customerId 幂等性 | `test_customer_id_get_or_create_idempotent` | `st.integers(min_value=1)` 生成随机 user_id 和 customer_id | 多次调用返回同一 chatId |
| P7: referenceCard Round Trip | `test_reference_card_roundtrip` | `st.fixed_dictionaries({"type": st.sampled_from(["customer", "record"]), "title": st.text(min_size=1), "summary": st.text(), "data": st.dictionaries(st.text(min_size=1), st.text())})` | JSON 序列化→存储→读取→反序列化等于原始对象 |
| P8: 消息持久化 | `test_message_persistence_after_send` | `st.text(min_size=1, max_size=500)` 生成随机消息内容 | 发送后 ai_messages 包含用户消息和 AI 回复session 元数据已更新 |
| P9: SSE 事件类型 | `test_sse_event_type_validity` | `st.sampled_from(["message", "done", "error"])` + 对应 data 结构 | 事件类型为三者之一data 结构符合定义 |
### 单元测试清单
| 测试目标 | 测试文件 | 关键用例 |
|---------|---------|---------|
| 路由迁移P1 | `apps/backend/tests/unit/test_xcx_chat_routes.py` | `/api/xcx/chat/history` 返回 200`/api/ai/conversations` 返回 404 |
| 响应结构P2 | `apps/backend/tests/unit/test_xcx_chat_schema.py` | ChatHistoryItem / ChatMessageItem / SendMessageResponse 序列化验证 |
| 权限控制P5 | `apps/backend/tests/unit/test_xcx_chat_auth.py` | 未审核用户 403chatId 不属于当前用户 403 |
| AI 失败降级edge case | `apps/backend/tests/unit/test_xcx_chat_ai_fallback.py` | AI 超时时返回错误提示消息HTTP 200 |
| SSE 跳过包装 | 已由 RNS1.0 测试覆盖 | `text/event-stream` 不经过 ResponseWrapper |
| FDW 验证 | `scripts/ops/verify_fdw_e2e.py` | 一次性运行,输出验证报告 |
| 联调验证 | 手动测试 | 13 页面逐一验证真实数据渲染 |
### 测试执行命令
```bash
# 属性测试Hypothesis
cd C:\NeoZQYY && pytest tests/ -v -k "rns1_chat"
# 单元测试
cd apps/backend && pytest tests/unit/ -v -k "xcx_chat"
# FDW 验证脚本
cd C:\NeoZQYY && uv run python scripts/ops/verify_fdw_e2e.py
```

View File

@@ -0,0 +1,201 @@
# 需求文档 — RNS1.4CHAT 对齐与联调收尾
## 简介
RNS1.4 是 NS1 小程序后端 API 补全项目的最后一个子 spec负责 CHAT 模块路径迁移和功能补全、FDW 端到端验证、以及全量前后端联调。本 spec 依赖 RNS1.0-1.3 全部完成,是整个 RNS1 系列的收尾阶段,确保 13 个页面全部连接真实后端运行,无 mock 数据残留。
### 依赖
- RNS1.0(基础设施与契约重写)— 全局响应包装中间件、camelCase 转换、重写后的 API 契约
- RNS1.1(任务与绩效接口)— TASK-1/2、PERF-1/2、PIN 接口已实现
- RNS1.2(客户与助教接口)— CUST-1/2、COACH-1 接口已实现
- RNS1.3(三看板接口)— BOARD-1/2/3、CONFIG-1 接口已实现
- 后端已有 `xcx_ai.py`(现有 `/api/ai/*` 路由,需迁移)
- 前端已有 chat.ts 和 chat-history.ts 页面P5.2 交付),当前使用 mock 数据和模拟流式输出
### 来源文档
- `docs/prd/Neo_Specs/RNS1-split-plan.md` — 拆分计划主文档
- `docs/miniprogram-dev/API-contract.md` — API 契约RNS1.0 T0-5 重写后版本)
- `docs/prd/Neo_Specs/storyboard-walkthrough-assistant-view.md` — 助教视角走查报告GAP-45~51
## 术语表
- **Backend**FastAPI 后端应用,位于 `apps/backend/`
- **Miniprogram**:微信小程序前端应用,位于 `apps/miniprogram/`
- **CHAT_1_API**:对话历史列表接口 `GET /api/xcx/chat/history`,返回分页的对话列表
- **CHAT_2_API**:对话消息接口 `GET /api/xcx/chat/{chatId}/messages``GET /api/xcx/chat/messages?customerId={customerId}`,返回指定对话的消息列表
- **CHAT_3_API**:发送消息接口 `POST /api/xcx/chat/{chatId}/messages`,发送用户消息并获取 AI 同步回复
- **CHAT_4_SSE**SSE 流式端点 `POST /api/xcx/chat/stream`,通过 Server-Sent Events 逐 token 返回 AI 回复
- **SSE**Server-Sent Events服务端推送事件协议用于 AI 流式回复的逐 token 输出
- **referenceCard**:引用卡片,消息中附带的结构化上下文数据(类型/标题/摘要/键值对),用于展示客户概览等信息
- **FDW**PostgreSQL Foreign Data Wrapper用于从业务库 `zqyy_app` 访问 ETL 库 `etl_feiqiu` 的数据
- **chat_sessions**:业务库 `zqyy_app` 中的对话会话表,存储对话元数据
- **chat_messages**:业务库 `zqyy_app` 中的消息表,存储对话消息内容
- **Response_Wrapper**RNS1.0 实现的全局响应包装中间件,`text/event-stream` 响应自动跳过包装
- **items_sum**DWD-DOC 强制使用的消费金额口径
- **联调**:前后端联合调试,验证所有页面使用真实后端数据正常运行
## 需求
### 需求 1CHAT 路径迁移T4-1
**用户故事:** 作为前后端开发者,我希望 CHAT 模块的 API 路径从 `/api/ai/*` 统一迁移到 `/api/xcx/chat/*`,以便与其他小程序接口保持一致的路径命名规范。
#### 验收标准
1. THE Backend SHALL 将现有 `/api/ai/*` 路由全部迁移到 `/api/xcx/chat/*` 路径下,包括同步端点和 SSE 流式端点
2. THE Backend SHALL 在迁移后移除原 `/api/ai/*` 路径,不保留旧路径的兼容映射
3. THE Backend SHALL 将迁移后的路由注册到 `xcx_chat` router或等效命名与其他 `xcx_*` 路由模块保持一致的组织结构
4. THE Miniprogram SHALL 更新 `services/api.ts` 中所有 CHAT 相关的 API 调用路径,从 `/api/ai/*` 改为 `/api/xcx/chat/*`
5. WHEN CHAT_4_SSE 端点迁移到 `/api/xcx/chat/stream`THE Response_Wrapper SHALL 继续对 `text/event-stream` 响应跳过包装RNS1.0 已实现的行为不受路径变更影响)
### 需求 2实现 CHAT-1 对话历史列表T4-2 历史列表部分)
**用户故事:** 作为助教,我希望在对话历史页面看到所有对话记录(含对话标题和最后消息摘要),以便快速找到并继续之前的对话。
#### 验收标准
1. THE CHAT_1_API SHALL 实现 `GET /api/xcx/chat/history` 端点,接受 `page`(默认 1`pageSize`(默认 20查询参数返回分页的对话历史列表
2. THE CHAT_1_API SHALL 为每条对话记录返回以下字段:`id`(对话 ID`title`(对话标题)、`customerName`(关联客户姓名,可选)、`lastMessage`(最后一条消息摘要)、`timestamp`最后消息时间ISO 8601 格式)、`unreadCount`(未读消息数)
3. THE CHAT_1_API SHALL 从 `zqyy_app.chat_sessions` 查询对话列表,按最后消息时间倒序排列
4. THE CHAT_1_API SHALL 为 `title` 字段生成对话标题:优先使用对话会话中存储的自定义标题,若无自定义标题则使用关联客户姓名,若均无则使用首条消息内容的前 20 个字符作为标题
5. THE CHAT_1_API SHALL 返回标准分页字段:`total`(总记录数)、`page`(当前页码)、`pageSize`(每页条数)
6. THE CHAT_1_API SHALL 通过当前登录用户的身份过滤对话列表,确保每位助教只能看到自己的对话记录
### 需求 3实现 CHAT-2 对话消息查看T4-2 消息查看部分)
**用户故事:** 作为助教,我希望查看指定对话的消息列表,以便回顾与 AI 助手的对话内容。
#### 验收标准
1. THE CHAT_2_API SHALL 实现两个等效的消息查询端点:`GET /api/xcx/chat/{chatId}/messages`(通过对话 ID 查询)和 `GET /api/xcx/chat/messages?contextType={type}&contextId={id}`(通过上下文类型和 ID 查询)
2. THE CHAT_2_API SHALL 接受 `page`(默认 1`pageSize`(默认 50查询参数返回分页的消息列表
3. THE CHAT_2_API SHALL 为每条消息返回以下字段:`id`(消息 ID`role``user``assistant`)、`content`(消息内容)、`createdAt`创建时间ISO 8601 格式)、`referenceCard`(引用卡片,可选)
4. THE CHAT_2_API SHALL 统一使用 `createdAt` 作为消息时间字段名(替代前端使用的 `timestamp` 和旧契约的 `created_at`,遵循 camelCase 规范)
5. THE CHAT_2_API SHALL 从 `zqyy_app.chat_messages` 查询消息列表,按 `createdAt` 正序排列(最早的消息在前)
6. THE CHAT_2_API SHALL 在响应中返回 `chatId` 字段,供前端后续发送消息时使用(尤其是通过上下文入口时,前端需要获取对应的 `chatId`
7. THE CHAT_2_API SHALL 返回标准分页字段:`total`(总记录数)、`page`(当前页码)、`pageSize`(每页条数)
#### 3.2 上下文对话复用规则
8. WHEN 通过 `contextType``contextId` 查询参数调用消息端点时THE CHAT_2_API SHALL 按以下规则查找或创建对话:
- `contextType='task'`:查找同一用户、同一 `contextId`taskId的已有对话找到则复用无时限找不到则新建
- `contextType='customer'``contextType='coach'`:查找同一用户、同一 `contextId` 的已有对话,若最后消息时间 ≤ 3 天则复用,> 3 天或不存在则新建
- `contextType='general'`:始终新建对话
9. THE CHAT_2_API SHALL 在 `ai_conversations` 中记录 `context_type``context_id` 字段,用于后续对话查找
10. THE CHAT_2_API SHALL 确保对话复用查找基于 `(user_id, site_id, context_type, context_id)` 组合,不同用户的对话互不影响
### 需求 4CHAT referenceCard 支持T4-3
**用户故事:** 作为助教,我希望在与 AI 助手对话时,消息中能附带客户概览卡片(含余额、消费、到店频次等键值对数据),以便在对话上下文中快速查看客户关键信息。
#### 验收标准
1. THE CHAT_2_API SHALL 为消息返回可选的 `referenceCard` 字段,结构包含:`type`(引用类型,`customer``record` 枚举)、`title`(卡片标题,如 `"张伟 — 消费概览"`)、`summary`(摘要文字)、`data``Record<string, string>` 键值对详情,如 `{ "近30天消费": "¥2,380", "到店次数": "8次" }`
2. WHEN AI 助手回复消息涉及特定客户时THE Backend SHALL 从 FDW 查询该客户的关键指标(余额、近期消费、到店频次等),组装为 `referenceCard` 附加到 AI 回复消息中
3. THE Backend SHALL 将 `referenceCard` 数据持久化存储到 `chat_messages` 表中(作为 JSON 字段),以便历史消息查看时仍能展示引用卡片
4. THE Miniprogram SHALL 在 chat 页面的消息列表中,检测消息的 `referenceCard` 字段,若存在则渲染为结构化卡片组件(标题 + 摘要 + 键值对列表)
#### 4.2 多入口参数路由GAP-50
5. THE Miniprogram SHALL 在 chat 页面的 `onLoad(options)` 中实现多入口参数路由逻辑,按以下优先级处理入口参数:
-`options.historyId` 存在(从 chat-history 跳转),使用 `historyId` 作为 `chatId` 直接加载历史消息
-`options.taskId` 存在(从 task-detail 跳转),使用 `contextType=task` + `contextId=taskId` 调用 CHAT_2_API由后端查找同一任务的已有对话始终复用无时限
-`options.customerId` 存在(从 customer-detail 跳转),使用 `contextType=customer` + `contextId=customerId` 调用 CHAT_2_API由后端按 3 天时限判断复用或新建
-`options.coachId` 存在(从 coach-detail 跳转),使用 `contextType=coach` + `contextId=coachId` 调用 CHAT_2_API由后端按 3 天时限判断复用或新建
6. WHEN 通过上下文入口进入对话后THE Miniprogram SHALL 将后端返回的 `chatId` 缓存到页面 data 中,后续发送消息和 SSE 流式请求均使用该 `chatId`
7. IF chat 页面未收到任何入口参数(`historyId`/`taskId`/`customerId`/`coachId` 均为空THEN THE Miniprogram SHALL 使用 `contextType=general` 调用 CHAT_2_API 创建一个通用对话
### 需求 5CHAT-3 发送消息T4-2 发送部分)
**用户故事:** 作为助教,我希望在对话页面发送消息后能立即收到 AI 的同步回复,以便在不支持 SSE 的场景下也能正常使用 AI 助手。
#### 验收标准
1. THE CHAT_3_API SHALL 实现 `POST /api/xcx/chat/{chatId}/messages` 端点,接受请求体 `{ content: string }`
2. THE CHAT_3_API SHALL 将用户消息存入 `chat_messages` 表,调用 AI 服务获取回复,将 AI 回复也存入 `chat_messages`
3. THE CHAT_3_API SHALL 返回包含用户消息和 AI 回复的响应:`userMessage`(含 `id`/`content`/`createdAt`)和 `aiReply`(含 `id`/`content`/`createdAt`
4. THE CHAT_3_API SHALL 在发送消息后更新 `chat_sessions` 表的 `lastMessage` 和最后消息时间字段
5. IF AI 服务调用失败或超时THEN THE CHAT_3_API SHALL 仍保存用户消息,并返回 AI 回复为错误提示消息(如 `{ content: "抱歉AI 助手暂时无法回复,请稍后重试" }`HTTP 状态码保持 200
6. THE CHAT_3_API SHALL 验证请求的 `chatId` 属于当前登录助教,不属于时返回 HTTP 403
### 需求 6CHAT-4 SSE 流式端点T4-1 SSE 部分)
**用户故事:** 作为助教,我希望 AI 助手的回复能以流式方式逐字显示,以便获得更自然的对话体验,减少等待感。
#### 验收标准
1. THE CHAT_4_SSE SHALL 实现 `POST /api/xcx/chat/stream` 端点,接受请求体 `{ chatId: string, content: string }`,响应内容类型为 `text/event-stream`
2. THE CHAT_4_SSE SHALL 发送以下三种 SSE 事件类型:
- `event: message` — 逐 token 输出,`data``{"token": "<文本片段>"}`
- `event: done` — 流结束,`data``{"messageId": "<完整消息ID>", "createdAt": "<ISO 8601>"}`
- `event: error` — 错误,`data``{"message": "<错误描述>"}`
3. THE CHAT_4_SSE SHALL 在流开始前将用户消息存入 `chat_messages` 表,在流结束后将完整的 AI 回复存入 `chat_messages`
4. THE CHAT_4_SSE SHALL 在流结束后更新 `chat_sessions` 表的 `lastMessage` 和最后消息时间字段
5. THE Response_Wrapper SHALL 对 `text/event-stream` 响应跳过全局包装,直接透传 SSE 事件流RNS1.0 已实现)
6. THE CHAT_4_SSE SHALL 验证请求的 `chatId` 属于当前登录助教,不属于时返回 HTTP 403此时响应为普通 JSON 错误,非 SSE
7. THE Miniprogram SHALL 将 chat 页面现有的 `simulateStreamOutput()`(模拟逐字输出)替换为真实的 SSE 连接,通过 `wx.request` 或兼容方案接收 `text/event-stream` 响应
### 需求 7FDW 端到端验证T4-4
**用户故事:** 作为后端开发者,我希望验证所有 FDW 查询在测试环境链路(`test_zqyy_app``test_etl_feiqiu`)上正常工作,以便确保 RNS1.1-1.3 实现的所有接口在真实数据链路上不会因 FDW 连接、权限或性能问题而失败。
#### 验收标准
1. THE Backend SHALL 验证所有 `fdw_etl.*` 视图在 `test_zqyy_app` 数据库中可正常访问,包括但不限于:`v_dws_assistant_salary_calc``v_dwd_assistant_service_log``v_dim_member``v_dim_assistant``v_dws_member_consumption_summary``v_dws_member_assistant_relation_index``v_dws_finance_*` 系列视图
2. THE Backend SHALL 验证每个 FDW 视图的查询响应时间在可接受范围内(单次查询不超过 3 秒),对超时的查询记录慢查询日志并评估是否需要添加索引
3. THE Backend SHALL 验证 FDW 查询在带有典型过滤条件(如 `assistant_id``member_id`、日期范围)时能正确返回数据,且结果集与直接查询 `test_etl_feiqiu` 的结果一致
4. IF 某个 FDW 视图不存在或权限不足THEN THE Backend SHALL 记录具体的错误信息(视图名、错误类型),并在验证报告中标注需要 DBA 介入修复
5. THE Backend SHALL 检查 FDW 链路上的关键索引是否存在:`chat_sessions` 表的 `(assistant_id, customer_id)` 索引、`chat_messages` 表的 `(session_id, created_at)` 索引
### 需求 8前端联调修复 — notes 页触底加载T4-5 F11
**用户故事:** 作为助教,我希望在备注列表页面滚动到底部时自动加载更多备注,以便查看全部备注记录而不需要手动翻页。
#### 验收标准
1. THE Miniprogram SHALL 在 notes 页面实现 `onReachBottom()` 生命周期函数,当用户滚动到页面底部时自动请求下一页备注数据
2. WHEN 触底加载触发时THE Miniprogram SHALL 将 `page` 参数加 1调用 `fetchNotes({ page, pageSize })` 接口,将返回的备注追加到已有列表末尾
3. WHEN 后端返回的 `hasMore``false` 或返回的备注数量小于 `pageSize`THE Miniprogram SHALL 停止触底加载,显示"没有更多了"提示
4. THE Miniprogram SHALL 在触底加载过程中显示加载状态指示器,防止重复触发请求
### 需求 9前端联调修复 — customer-service-records 按月请求T4-5 F10
**用户故事:** 作为助教,我希望客户服务记录页面在切换月份时向后端请求对应月份的数据,以便在数据量大时页面仍能快速响应,而不是全量加载后本地过滤。
#### 验收标准
1. THE Miniprogram SHALL 修改 customer-service-records 页面的月份切换逻辑,从当前的"全量加载后本地过滤"改为"按月请求 API"
2. WHEN 用户切换月份时THE Miniprogram SHALL 使用新的 `year`/`month` 参数调用 `fetchCustomerRecords({ customerId, year, month })` 接口,加载对应月份的服务记录
3. THE Miniprogram SHALL 在月份切换时清空已有记录列表,显示加载状态,待新数据返回后渲染
4. THE Miniprogram SHALL 在首次加载时默认请求当前月份的数据,而非全量数据
5. THE Backend SHALL 确保 CUST-2 接口支持 `year``month` 查询参数仅返回指定月份的服务记录RNS1.2 T2-4 已实现按月查询能力)
### 需求 10全量前后端联调T4-5 联调部分)
**用户故事:** 作为开发团队,我们希望 13 个小程序页面全部连接真实后端运行,无 mock 数据残留,以便确认整个应用在真实数据环境下功能完整、交互正常。
#### 验收标准
1. THE Miniprogram SHALL 移除所有页面中的内联 mock 数据和 mock 数据导入(`import { mockXxx } from '../../utils/mock-data'`),全部替换为真实 API 调用
2. THE Miniprogram SHALL 确保以下 13 个页面均能使用真实后端数据正常渲染:`task-list``task-detail``notes``performance``performance-records``customer-detail``customer-service-records``coach-detail``board-coach``board-customer``board-finance``chat-history``chat`
3. WHEN 某个页面的 API 调用失败时THE Miniprogram SHALL 显示友好的错误提示(如 Toast 或空状态占位),不出现白屏或未捕获异常
4. THE Miniprogram SHALL 验证所有页面间的跳转参数传递正确RNS1.0 T0-6 已修复的跨页面参数),确保目标页面能正确加载对应数据
5. THE Miniprogram SHALL 验证 chat 页面从 4 个入口task-detail、customer-detail、coach-detail、chat-history进入时均能正确关联上下文并加载对应对话
6. IF 联调过程中发现新的 Bug 或数据不一致问题THEN THE Miniprogram 和 Backend SHALL 在本 spec 范围内修复,修复内容记录到联调问题清单中
### 需求 11全局约束与权限控制
**用户故事:** 作为系统管理员,我希望所有 CHAT 接口遵循统一的权限控制和数据隔离规则,以确保每位助教只能访问自己的对话数据。
#### 验收标准
1. THE Backend SHALL 对所有 RNS1.4 CHAT 接口CHAT-1、CHAT-2、CHAT-3、CHAT-4执行 `require_approved()` 权限检查,确保用户状态为 `approved`
2. THE Backend SHALL 通过当前登录用户的身份信息过滤对话数据,确保每位助教只能访问自己创建的或与自己关联的对话
3. IF 当前用户未通过审核(状态非 `approved`THEN THE Backend SHALL 返回 HTTP 403 `{ code: 403, message: "用户未通过审核,无法访问此资源" }`
4. THE Backend SHALL 对所有 CHAT 接口的响应字段名统一使用 camelCase 格式(与 RNS1.0 的 CamelCase_Converter 一致)
5. THE Backend SHALL 确保 CHAT 模块的错误响应格式与全局异常处理器一致:`{ code: <HTTP状态码>, message: <错误详情> }`
6. WHEN CHAT 模块查询 FDW 数据(如为 referenceCard 获取客户指标THE Backend SHALL 遵循 DWD-DOC 强制规则:金额使用 `items_sum` 口径,会员信息通过 `member_id` JOIN `dim_member` 获取

View File

@@ -0,0 +1,311 @@
# 实施计划RNS1.4 CHAT 对齐与联调收尾
## 概述
按照设计文档的 8 个组件将实施拆分为DDL 迁移 → 后端 Schema/Service/Router → FDW 验证 → 前端改造 → 联调收尾。每个任务增量构建确保无孤立代码。属性测试Hypothesis和单元测试作为可选子任务紧跟实现步骤。
## 任务
- [x] 1. DDL 迁移:扩展 ai_conversations 和 ai_messages 表
- [x] 1.1 创建 DDL 迁移脚本 `db/zqyy_app/migrations/2026-03-20__rns14_chat_module_extend.sql`
- ALTER TABLE `biz.ai_conversations` 新增 `context_type varchar(20)``context_id varchar(50)``title varchar(200)``last_message text``last_message_at timestamptz` 五个字段
- ALTER TABLE `biz.ai_messages` 新增 `reference_card` jsonb 字段
- 创建索引 `idx_ai_conv_context` ON `(user_id, site_id, context_type, context_id, last_message_at DESC NULLS LAST) WHERE context_type IS NOT NULL`
- 创建排序索引 `idx_ai_conv_last_msg` ON `(user_id, site_id, last_message_at DESC NULLS LAST)`
- 添加 COMMENT ON COLUMN 注释
- _需求: R2.3, R3.8, R3.10, R4.3, R7.5_
- [x] 2. 后端 Pydantic Schema 定义(组件 3
- [x] 2.1 创建 `apps/backend/app/schemas/xcx_chat.py`
- 继承 `CamelModel` 基类,定义 `ChatHistoryItem``ChatHistoryResponse``ReferenceCard``ChatMessageItem``ChatMessagesResponse``SendMessageRequest``SendMessageResponse``MessageBrief``ChatStreamRequest`
- 字段类型和可选性严格遵循设计文档组件 3 定义
- _需求: R2.2, R3.3, R4.1, R5.1, R5.3, R6.1, R11.4_
- [x] 2.2 编写 Schema 序列化单元测试 `apps/backend/tests/unit/test_xcx_chat_schema.py`
- 验证 ChatHistoryItem / ChatMessageItem / SendMessageResponse 的 camelCase 序列化输出
- 验证 ReferenceCard 可选字段为 None 时不报错
- _需求: R2.2, R3.3, R5.3, R11.4_
- [x] 3. 后端 chat_service 业务逻辑层(组件 2
- [x] 3.1 创建 `apps/backend/app/services/chat_service.py`
- 实现 `ChatService` 类,包含 `get_chat_history``get_or_create_session``get_messages``send_message_sync``build_reference_card``generate_title` 方法
- `get_chat_history`:查询 `biz.ai_conversations`,按 `last_message_at` 倒序JOIN `v_dim_member` 获取 `customerName`,分页返回
- `get_or_create_session`:按 `(user_id, site_id, context_type, context_id)` 查找或创建对话。复用规则task 入口始终复用无时限customer/coach 入口 ≤ 3 天复用、> 3 天新建general 入口始终新建
- `get_messages`:查询 `biz.ai_messages`,按 `created_at` 正序,验证 chatId 归属当前用户
- `send_message_sync`:存入用户消息 → 调用 AI → 存入 AI 回复 → 更新 session 元数据AI 失败时返回错误提示消息HTTP 200
- ⚠️ **P5 PRD 合规**:对话落库必须遵循 `docs/prd/specs/P5-miniapp-ai-integration.md` 数据写入规则:
- `app_id` 固定为 `app1_chat`
- 用户消息发送时即写入 `ai_messages`role=user
- 流式完成后完整 assistant 回复写入 `ai_messages`role=assistant`tokens_used`
- 首条消息为页面上下文 JSON`current_time`/`source_page`/`page_context`/`screen_content`
- `get_or_create_session` 仅用于 task/customer/coach 入口的对话复用task 无时限customer/coach 3 天时限general 入口始终新建(保持 P5 PRD 兼容)
- `build_reference_card`:从 FDW 查询客户指标(`items_sum` 口径),组装 referenceCard JSON
- `generate_title`:自定义标题 > 客户姓名 > 首条消息前 20 字
- _需求: R2.1-R2.6, R3.1-R3.10, R4.1-R4.3, R5.1-R5.6, R11.2, R11.6_
- [x] 3.2 编写属性测试:标题生成优先级
- **Property 4: 对话标题生成优先级**
- 使用 Hypothesis `st.fixed_dictionaries` 生成随机 title/customer_name/first_message 组合
- 验证:有 title 用 title否则用 customer_name否则用首条消息前 20 字,结果始终非空
- **验证: 需求 R2.4**
- [x] 3.3 编写属性测试:对话复用规则正确性
- **Property 6: 对话复用规则正确性**
- 使用 Hypothesis 生成随机 context_type/context_id/last_message_at 组合
- 验证task 入口同一 context_id 始终返回同一 chatIdcustomer/coach 入口 ≤ 3 天复用、> 3 天新建general 入口每次返回不同 chatId
- **验证: 需求 R3.8, R3.9, R3.10**
- [x] 3.4 编写属性测试referenceCard Round Trip
- **Property 7: referenceCard 持久化 Round Trip**
- 使用 Hypothesis 生成随机 referenceCard JSONtype/title/summary/data
- 验证JSON 序列化→存储→读取→反序列化等于原始对象
- **验证: 需求 R4.1, R4.3**
- [x] 3.5 编写属性测试:消息持久化与会话元数据更新
- **Property 8: 消息持久化与会话元数据更新**
- 使用 Hypothesis `st.text(min_size=1, max_size=500)` 生成随机消息内容
- 验证:发送后 ai_messages 包含用户消息和 AI 回复session 的 last_message 和 last_message_at 已更新
- **验证: 需求 R5.2, R5.4, R6.3, R6.4**
- [x] 3.6 编写单元测试AI 失败降级
- 测试文件 `apps/backend/tests/unit/test_xcx_chat_ai_fallback.py`
- 验证 AI 服务超时/异常时用户消息仍保存AI 回复为错误提示消息HTTP 200
- _需求: R5.5_
- [x] 4. 后端路由迁移与 CHAT-1/2/3/4 端点实现(组件 1
- [x] 4.1 创建 `apps/backend/app/routers/xcx_chat.py`,实现 CHAT-1/2/3/4 五个端点
- `GET /history` — CHAT-1 对话历史列表,调用 `chat_service.get_chat_history`
- `GET /{chat_id}/messages` — CHAT-2a 通过 chatId 查询消息
- `GET /messages?contextType=&contextId=` — CHAT-2b 通过上下文查询消息(按复用规则自动查找/创建对话)
- `POST /{chat_id}/messages` — CHAT-3 发送消息(同步回复)
- `POST /stream` — CHAT-4 SSE 流式端点,返回 `StreamingResponse(media_type="text/event-stream")`
- 所有端点使用 `Depends(require_approved())` 权限检查
- chatId 归属验证CHAT-3/4 不属于当前用户返回 HTTP 403
- _需求: R1.1, R1.3, R2.1, R3.1, R5.1, R6.1, R11.1, R11.2_
- [x] 4.2 在 `apps/backend/app/main.py` 中注册 `xcx_chat.router`,移除 `xcx_ai_chat.router`
- 删除 `xcx_ai_chat.py` 文件(不保留旧路径兼容)
- _需求: R1.2, R1.3_
- [x] 4.3 编写属性测试SSE 事件类型有效性
- **Property 9: SSE 事件类型有效性**
- 使用 Hypothesis `st.sampled_from(["message", "done", "error"])` + 对应 data 结构
- 验证事件类型为三者之一data 结构符合定义message→token, done→messageId+createdAt, error→message
- **验证: 需求 R6.2**
- [x] 4.4 编写属性测试:列表排序不变量
- **Property 3: 列表排序不变量**
- 使用 Hypothesis `st.lists(st.datetimes())` 生成随机时间戳列表
- 验证CHAT-1 对话列表按时间倒序CHAT-2 消息列表按时间正序
- **验证: 需求 R2.3, R3.5**
- [x] 4.5 编写单元测试:路由迁移与权限控制
- 测试文件 `apps/backend/tests/unit/test_xcx_chat_routes.py`
- 验证 `/api/xcx/chat/history` 返回 200需认证`/api/ai/conversations` 返回 404
- 验证未审核用户收到 403chatId 不属于当前用户收到 403
- **Property 1: 路由迁移完整性** / **Property 5: 权限控制与数据隔离**
- **验证: 需求 R1.1, R1.2, R5.6, R6.6, R11.1, R11.3**
- [x] 5. 检查点 — 后端实现验证
- 确保所有后端测试通过ask the user if questions arise.
- [x] 6. FDW 端到端验证脚本(组件 7
- [x] 6.1 创建 `scripts/ops/verify_fdw_e2e.py`
- 验证所有 `fdw_etl.*` 视图在 `test_zqyy_app` 中可访问SELECT 1 FROM ... LIMIT 1
- 验证带典型过滤条件assistant_id、member_id、日期范围的查询响应时间 < 3s
- 检查关键索引存在:`chat_sessions(assistant_id, customer_id)``chat_messages(session_id, created_at)`
- 输出结构化 JSON 报告,失败项标注需 DBA 介入
- 使用 `load_dotenv` 加载根 `.env`,连接 `test_zqyy_app`(遵循 testing-env.md 规范)
- _需求: R7.1, R7.2, R7.3, R7.4, R7.5_
- [x] 7. 前端 services/api.ts CHAT 模块对接(组件 6
- [x] 7.1 修改 `apps/miniprogram/miniprogram/services/api.ts`
- `fetchChatHistory()`:调用 `GET /api/xcx/chat/history`
- `fetchChatMessages(chatId)`:调用 `GET /api/xcx/chat/{chatId}/messages`
- 新增 `fetchChatMessagesByContext(contextType, contextId)`:调用 `GET /api/xcx/chat/messages?contextType={type}&contextId={id}`
- `sendChatMessage(chatId, content)`:调用 `POST /api/xcx/chat/{chatId}/messages`
- 移除所有 CHAT 相关 mock 数据导入,`USE_REAL_API` 对 CHAT 模块设为 `true`
- _需求: R1.4, R10.1_
- [x] 8. 前端 chat 页面改造(组件 4
- [x] 8.1 修改 `apps/miniprogram/miniprogram/pages/chat/chat.ts` 实现多入口参数路由
- `onLoad(options)` 中按优先级处理:`historyId``taskId``customerId``coachId` → 无参数(通用对话)
- `historyId` 入口:直接用作 chatId 加载历史消息
- `taskId` 入口:调用 `fetchChatMessagesByContext('task', taskId)`,同一 taskId 始终复用同一对话(无时限)
- `customerId` 入口:调用 `fetchChatMessagesByContext('customer', customerId)`,≤ 3 天复用、> 3 天新建
- `coachId` 入口:调用 `fetchChatMessagesByContext('coach', coachId)`,≤ 3 天复用、> 3 天新建
- 无参数入口:调用 `fetchChatMessagesByContext('general', '')`,始终新建
- _需求: R4.5, R4.6, R4.7_
- [x] 8.2 修改 chat.ts 将 `simulateStreamOutput()` 替换为真实 SSE 连接
- 使用 `wx.request` + `enableChunked: true` 接收 `POST /api/xcx/chat/stream` 的 SSE 响应
- 解析 `event: message`(逐 token 追加)、`event: done`(流结束)、`event: error`(错误处理)
- 移除 `simulateStreamOutput()``mockAIReplies` 相关代码
- SSE 连接中断时显示"连接中断"提示,允许重试
- _需求: R6.7, R10.1_
- [x] 8.3 确保 chat 页面 referenceCard 渲染与真实 API 数据兼容
- 验证 `toDataList()` 和 WXML 模板能正确渲染后端返回的 referenceCard 结构
- _需求: R4.4_
- [x] 9. 前端 chat-history 页面改造(组件 5
- [x] 9.1 修改 `apps/miniprogram/miniprogram/pages/chat-history/chat-history.ts`
- 移除 `mockChatHistory` 导入,调用 `fetchChatHistory()` 获取真实数据
- 响应字段映射:后端 `timestamp`ISO 8601`formatRelativeTime()` 处理
- 点击对话项跳转 chat 页面时传递 `historyId` 参数
- _需求: R2.1, R10.1, R10.2_
- [x] 10. 前端联调修复(组件 8
- [x] 10.1 修改 notes 页面实现触底加载
-`apps/miniprogram/miniprogram/pages/notes/notes.ts` 实现 `onReachBottom()` 生命周期函数
- 维护 `page` 状态,触底时 `page++` 调用 `fetchNotes({ page, pageSize })`
- 追加数据到已有列表,`hasMore === false` 时停止加载并显示"没有更多了"
- 加载过程中显示加载状态指示器,防止重复触发
- _需求: R8.1, R8.2, R8.3, R8.4_
- [x] 10.2 修改 customer-service-records 页面实现按月请求
-`apps/miniprogram/miniprogram/pages/customer-service-records/customer-service-records.ts` 修改月份切换逻辑
- 月份切换时调用 `fetchCustomerRecords({ customerId, year, month })`,清空列表 → loading → 渲染
- 首次加载默认当前月份数据
- _需求: R9.1, R9.2, R9.3, R9.4_
- [x] 11. 检查点 — 前端改造验证
- 确保所有前端改造完成chat 页面 4 个入口task-detail、customer-detail、coach-detail、chat-history均能正确进入并加载对话。ask the user if questions arise.
- [x] 12. Mock 数据移除与全量联调
- [x] 12.1 移除所有页面中的 mock 数据残留
- 搜索并移除所有 `import { mockXxx } from '../../utils/mock-data'` 引用
- 确保 13 个页面task-list、task-detail、notes、performance、performance-records、customer-detail、customer-service-records、coach-detail、board-coach、board-customer、board-finance、chat-history、chat均使用真实 API
- _需求: R10.1, R10.2_
- [x] 12.2 验证全量联调
- 确保所有页面 API 调用失败时显示友好错误提示Toast 或空状态占位),不出现白屏
- 验证页面间跳转参数传递正确RNS1.0 T0-6 已修复的跨页面参数)
- 验证 chat 页面从 4 个入口进入时均能正确关联上下文
- _需求: R10.2, R10.3, R10.4, R10.5_
- [x] 13. 全链路端到端测试(真实 AI 接口)
- [x] 13.1 后端全链路测试:使用真实百炼 API 验证 CHAT-3同步和 CHAT-4SSE 流式)
- 启动后端服务连接 `test_zqyy_app`,使用真实测试用户 token
- 调用 `POST /api/xcx/chat/{chatId}/messages` 发送真实消息,验证:
- AI 回复内容质量(非乱码、语义相关、中文正常)
- 用户消息和 AI 回复均已持久化到 `biz.ai_messages`
- `biz.ai_conversations``last_message``last_message_at` 已更新
- `tokens_used` 字段已记录
- 调用 `POST /api/xcx/chat/stream` 验证 SSE 流式返回:
- 逐 token 事件格式正确(`event: message``event: done`
- 完整回复拼接后语义通顺
- 流结束后消息已落库
- 手动评估 AI 返回内容质量(至少 3 轮对话),记录评估结果
- _需求: R5.2, R5.4, R6.2, R6.3, R6.4, AC1, AC9_
- [x] 13.2 前端→后端→AI→数据库全链路验证
- 在微信开发者工具中启动小程序,连接本地后端
- 从 4 个入口task-detail、customer-detail、coach-detail、chat-history进入 chat 页面
- 每个入口发送至少 1 条消息,验证:
- SSE 流式逐字显示正常
- 消息发送后页面状态正确loading → 显示回复)
- 返回 chat-history 页面能看到刚才的对话记录
- referenceCard 在有客户关联时正确渲染(如有数据)
- 验证错误场景:网络断开时的提示、空消息拦截
- _需求: R6.7, R10.5, R4.4_
- [x] 13.3 AI 对话落库合规性验证(对照 P5 PRD + 用户确认的复用规则)
- 验证对话复用规则task 入口同一 taskId 始终复用customer/coach 入口 ≤ 3 天复用、> 3 天新建general 入口始终新建
- 验证首条消息格式:应用 1 的首条消息应为页面上下文 JSON`source_page`/`page_context`/`screen_content`/`current_time`),对照 P5 PRD 应用 1 Prompt 数据结构
- 验证 `app_id` 字段CHAT 模块对话的 `app_id` 应为 `app1_chat`
- 验证 `ai_messages.role` 值:仅 `user`/`assistant`/`system` 三种CHECK 约束)
- 验证 `tokens_used` 记录AI 回复消息应记录 token 消耗量
- 验证 `source_page``source_context`:从不同入口进入时应正确记录来源页面和上下文
- 验证 `context_type``context_id`:不同入口写入正确的上下文类型和 ID
- _需求: AC9, P5-PRD 数据写入规则_
- [x] 14. DDL 迁移合并到主 DDL 基线
- [x] 14.1 执行迁移脚本到 `test_zqyy_app`
- 运行 `db/zqyy_app/migrations/2026-03-20__rns14_chat_module_extend.sql`(任务 1 创建的脚本)
- 验证新字段和索引已正确创建(使用 BD 手册中的验证 SQL
- _需求: R2.3, R3.8, R3.10, R4.3, R7.5_
- [x] 14.2 合并到主 DDL 基线 `docs/database/ddl/zqyy_app__biz.sql`
-`biz.ai_conversations` 表定义中追加 5 个新字段:`context_type varchar(20)``context_id varchar(50)``title varchar(200)``last_message text``last_message_at timestamptz`
-`biz.ai_messages` 表定义中追加 1 个新字段:`reference_card jsonb`
- 在索引区追加 2 个新索引:`idx_ai_conv_context`条件索引WHERE context_type IS NOT NULL`idx_ai_conv_last_msg`
- 更新文件头部的"生成日期"注释
- _需求: DDL 基线同步_
- [x] 15. 文档更新落地
- [x] 15.1 更新 BD 手册 `docs/database/BD_Manual_ai_tables.md`
-`biz.ai_conversations` 字段明细中追加 5 个新字段context_type/context_id/title/last_message/last_message_at
-`biz.ai_messages` 字段明细中追加 reference_card 字段
- 在约束与索引表中追加 2 个新索引idx_ai_conv_context、idx_ai_conv_last_msg
- 更新兼容性影响:标注 RNS1.4 CHAT 模块依赖新字段
- 更新验证 SQL追加新字段和索引的验证查询
- 更新回滚策略:追加 DROP INDEX 和 ALTER TABLE DROP COLUMN
- _规范: db-docs.md 强制要求_
- [x] 15.2 更新 API 契约 `docs/miniprogram-dev/API-contract.md`
- CHAT 部分路径从 `/api/ai/*` 更新为 `/api/xcx/chat/*`
- 补充 CHAT-1历史列表、CHAT-2a/2b消息查询含 customerId 参数、CHAT-3发送消息端点定义
- 补充 referenceCard 结构定义
- 补充 SSE 事件类型定义message/done/error
- _需求: R1.1, R1.4_
- [x] 15.3 更新后端 API 参考 `apps/backend/docs/API-REFERENCE.md`
- 新增 `xcx_chat` 路由模块文档5 个端点)
- 移除 `xcx_ai_chat` 路由模块文档(已删除)
- _需求: R1.2, R1.3_
- [x] 15.4 更新后端 README `apps/backend/README.md`
- 路由模块摘要中:移除 `xcx_ai_chat`,新增 `xcx_chat`CHAT-1/2/3/4
- 服务层中:新增 `chat_service.py` 说明
- _需求: R1.3_
- [x] 15.5 更新文档地图 `docs/DOCUMENTATION-MAP.md`
- 在 3.1 FastAPI 后端部分新增 RNS1.4 模块xcx_chat.py、chat_service.py、xcx_chat schema
- 在 5.6 Spec 文件表中新增 `rns1-chat-integration` 条目
- 更新"最后更新"日期
- _规范: doc-map.md 归档规则_
- [x] 15.6 更新 RNS1 拆分计划 `docs/prd/Neo_Specs/RNS1-split-plan.md`
- 标注 RNS1.4 状态为"实施中"或"已完成"(视进度)
- _需求: 项目追踪_
- [x] 16. 最终检查点 — 全量验证
- 确保所有测试通过13 个页面均连接真实后端运行,无 mock 数据残留
- 确保 DDL 迁移已合并到主基线BD 手册已同步更新
- 确保 API 契约、后端 README、文档地图均已更新
- 确保 AI 对话落库符合 P5 PRD 规范
- ask the user if questions arise.
## 备注
- 标记 `*` 的子任务为可选,可跳过以加速 MVP 交付
- 每个任务引用了具体的需求编号R1-R11以确保可追溯性
- 属性测试验证通用正确性属性Property 1-9单元测试验证具体边界条件
- 检查点任务确保增量验证,避免问题累积
- 后端使用 PythonFastAPI + Pydantic前端使用 TypeScript微信小程序
### PRD 合规注意事项
- **P5 PRD 数据写入规则**`docs/prd/specs/P5-miniapp-ai-integration.md`
- 流式返回完成后,完整 assistant 回复写入 `ai_messages`role=assistant
- 用户消息在发送时即写入role=user
- 所有 AI 调用记录写入 `ai_conversations` + `ai_messages`(含 tokens_used 统计)
- 首条消息为页面上下文 JSON`source_page`/`page_context`/`screen_content`/`current_time`
- `app_id` 固定为 `app1_chat`
- **对话复用规则**(用户已确认,覆盖 P5 PRD 的"始终新建"规则):
- `task` 入口:同一 taskId 始终复用同一对话(无时限)
- `customer` / `coach` 入口:最后消息 ≤ 3 天复用,> 3 天新建
- `general` 入口(无参数):始终新建
- `chat-history` 入口:直接打开已有对话(传 historyId
### 文档更新清单
| 文档 | 更新内容 | 任务 |
|------|---------|------|
| `docs/database/ddl/zqyy_app__biz.sql` | 合并 5+1 新字段、2 新索引 | 14.2 |
| `docs/database/BD_Manual_ai_tables.md` | 新字段context_type/context_id/title/last_message/last_message_at/reference_card/索引/兼容性/回滚/验证 SQL | 15.1 |
| `docs/miniprogram-dev/API-contract.md` | CHAT 路径迁移 + 新端点定义 | 15.2 |
| `apps/backend/docs/API-REFERENCE.md` | xcx_chat 路由模块文档 | 15.3 |
| `apps/backend/README.md` | 路由模块 + 服务层更新 | 15.4 |
| `docs/DOCUMENTATION-MAP.md` | RNS1.4 模块 + spec 条目 | 15.5 |
| `docs/prd/Neo_Specs/RNS1-split-plan.md` | RNS1.4 状态更新 | 15.6 |

View File

@@ -0,0 +1 @@
{"specId": "9eccc890-b6c3-41a3-8ba1-bb2f0e09f653", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -0,0 +1,623 @@
# 技术设计文档租户管理后台tenant-admin-web
## 概述
本设计为 NS4 租户管理后台提供完整的技术方案。系统面向租户管理员Tenant_Admin提供用户审核与管理、Excel 数据上传4 种模板)、维客线索管理三大功能模块。
前端为独立 React 应用(`apps/tenant-admin/`),后端复用现有 FastAPI 服务(`apps/backend/`)新增 4 个路由模块,所有端点注册在 `/api/tenant/` 前缀下。认证体系与小程序完全隔离,使用独立的 `auth.tenant_admins` 表、用户名+密码登录、JWT `aud=tenant-admin`
### 关键设计决策
| 决策 | 选择 | 理由 |
|------|------|------|
| 认证表 | 独立 `auth.tenant_admins` | 与小程序 `auth.users`(微信登录)完全隔离,避免 user_type 判断复杂度 |
| JWT 隔离 | `aud` 字段区分 | 复用现有 `jose` + `bcrypt` 基础设施,仅扩展 payload |
| Excel 写入策略 | staging 表 + ETL 同步(方案 B | 数据经 ETL 标准流程,一致性有保障;助教奖罚直接写 biz |
| FDW 多店铺查询 | 逐 site_id 查询合并 | RLS 视图要求 `SET LOCAL app.current_site_id`,单次只能设一个 |
| 前端技术栈 | React + Vite + Ant Design | 与 `apps/admin-web/` 一致,降低维护成本 |
| 响应格式 | `{ code: 0, data }` | 复用现有 `ResponseWrapperMiddleware`,前端统一解包 |
---
## 架构
### 高层架构图
```mermaid
graph TB
subgraph "前端 apps/tenant-admin/"
FE[React + Vite + Ant Design]
API_LAYER[services/api.ts<br/>JWT 自动附加/刷新]
end
subgraph "后端 apps/backend/"
subgraph "路由层 /api/tenant/*"
R_AUTH[tenant_auth.py<br/>登录/刷新]
R_USERS[tenant_users.py<br/>审核+管理]
R_EXCEL[tenant_excel.py<br/>上传/校验/冲突]
R_CLUES[tenant_clues.py<br/>线索管理]
end
AUTH_DEP[require_tenant_admin<br/>认证依赖注入]
MIDDLEWARE[ResponseWrapperMiddleware<br/>全局响应包装]
end
subgraph "数据层"
APP_DB[(zqyy_app<br/>auth + biz + public)]
ETL_DB[(etl_feiqiu<br/>fdw_etl 视图)]
end
FE --> API_LAYER
API_LAYER -->|HTTP| MIDDLEWARE
MIDDLEWARE --> R_AUTH
MIDDLEWARE --> R_USERS
MIDDLEWARE --> R_EXCEL
MIDDLEWARE --> R_CLUES
R_USERS --> AUTH_DEP
R_EXCEL --> AUTH_DEP
R_CLUES --> AUTH_DEP
R_AUTH -->|登录无需认证| APP_DB
AUTH_DEP --> APP_DB
R_USERS -->|FDW 人员匹配| ETL_DB
R_EXCEL -->|FDW 助教匹配| ETL_DB
R_CLUES -->|FDW 客户搜索| ETL_DB
```
### 认证流程
```mermaid
sequenceDiagram
participant FE as Tenant Admin Web
participant BE as Backend API
participant DB as auth.tenant_admins
FE->>BE: POST /api/tenant/auth/login {username, password}
BE->>DB: SELECT password_hash WHERE username=? AND is_active=true
alt 凭据有效
BE->>BE: bcrypt.checkpw() 验证
BE->>BE: 签发 JWT (aud=tenant-admin, sub=admin_id, managed_site_ids)
BE-->>FE: {access_token, refresh_token}
else 凭据无效
BE-->>FE: 401 用户名或密码错误
else 账号禁用
BE-->>FE: 403 账号已被禁用
end
Note over FE,BE: 后续请求
FE->>BE: GET /api/tenant/* (Authorization: Bearer <token>)
BE->>BE: require_tenant_admin() 验证 aud=tenant-admin
BE->>BE: 提取 managed_site_ids附加数据隔离条件
```
### Excel 上传流程
```mermaid
flowchart TD
A[选择模板类型 + 上传 Excel] --> B[后端解析 + 格式校验]
B --> C{校验结果}
C -->|格式错误| D[返回错误行号/列名/描述<br/>前端标红展示]
D --> A
C -->|校验通过| E[人员匹配校验<br/>仅 salary_adj / recharge_commission]
E --> F[冲突检测<br/>按主键规则匹配]
F --> G{有冲突?}
G -->|是| H[返回 diff 数据<br/>前端展示 diff 表格]
H --> I[用户逐行选择<br/>替换/保留]
G -->|否| J[标记为待写入]
I --> K[确认写入]
J --> K
K --> L{写入目标}
L -->|助教奖罚| M[biz.salary_adjustments]
L -->|财务支出| N[biz.stg_finance_expense]
L -->|团购收入| O[biz.stg_platform_income]
L -->|充值归属| P[biz.stg_recharge_commission]
K --> Q[更新 excel_upload_log<br/>status=confirmed]
```
---
## 组件与接口
### 后端路由模块
#### 1. `tenant_auth.py` — 认证路由
| 端点 | 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|------|
| 登录 | POST | `/api/tenant/auth/login` | 无 | 用户名+密码 → JWT 令牌对 |
| 刷新 | POST | `/api/tenant/auth/refresh` | 无 | refresh_token → 新令牌对 |
#### 2. `tenant_users.py` — 用户审核 + 管理路由
| 端点 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 申请列表 | GET | `/api/tenant/applications` | status 筛选 + 分页 |
| 关联建议 | GET | `/api/tenant/applications/{id}/match-suggestions` | FDW 助教/员工匹配 |
| 审核通过 | POST | `/api/tenant/applications/{id}/approve` | 分配角色 + 绑定 |
| 审核拒绝 | POST | `/api/tenant/applications/{id}/reject` | 填写拒绝原因 |
| 用户列表 | GET | `/api/tenant/users` | 角色筛选 + 搜索 + 分页 |
| 编辑用户 | PATCH | `/api/tenant/users/{id}` | 修改角色/门店/状态 |
| 修改绑定 | PUT | `/api/tenant/users/{id}/binding` | 更新助教/员工绑定 |
#### 3. `tenant_excel.py` — Excel 上传路由
| 端点 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 上传解析 | POST | `/api/tenant/excel/upload` | multipart/form-data校验+冲突检测 |
| 确认写入 | POST | `/api/tenant/excel/confirm` | upload_id + resolutions[] |
| 上传记录 | GET | `/api/tenant/excel/logs` | 历史记录列表 + 分页 |
| 模板下载 | GET | `/api/tenant/excel/template/{type}` | 下载空白 Excel 模板 |
#### 4. `tenant_clues.py` — 维客线索路由
| 端点 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 客户搜索 | GET | `/api/tenant/customers/search` | keyword + site_id 筛选 |
| 线索列表 | GET | `/api/tenant/customers/{member_id}/clues` | 该客户全部线索 |
| 修改线索 | PATCH | `/api/tenant/clues/{id}` | category + summary + detail |
| 删除线索 | DELETE | `/api/tenant/clues/{id}` | 物理删除 |
| 隐藏/显示 | PATCH | `/api/tenant/clues/{id}/visibility` | is_hidden 切换 |
### 认证依赖注入
```python
@dataclass(frozen=True)
class CurrentTenantAdmin:
"""从 JWT 解析出的租户管理员上下文。"""
admin_id: int
tenant_id: int
managed_site_ids: list[int]
display_name: str | None = None
async def require_tenant_admin(
credentials: HTTPAuthorizationCredentials = Depends(_bearer_scheme),
) -> CurrentTenantAdmin:
"""
验证 JWT aud=tenant-admin提取管理员信息。
拒绝小程序 JWTaud 不匹配)。
"""
payload = decode_access_token(token) # 复用现有解码
if payload.get("aud") != "tenant-admin":
raise HTTPException(401, "令牌类型不匹配")
return CurrentTenantAdmin(
admin_id=int(payload["sub"]),
tenant_id=payload["tenant_id"],
managed_site_ids=payload["managed_site_ids"],
)
```
数据隔离通过 `managed_site_ids` 实现:
```python
def site_filter_clause(admin: CurrentTenantAdmin) -> tuple[str, tuple]:
"""生成 site_id IN (...) SQL 片段。"""
placeholders = ",".join(["%s"] * len(admin.managed_site_ids))
return f"site_id IN ({placeholders})", tuple(admin.managed_site_ids)
```
### 前端组件结构
```
apps/tenant-admin/src/
├── pages/
│ ├── Login/ # 登录页
│ ├── UserApproval/ # 用户审核(申请列表 + 关联建议 + 审核操作)
│ ├── UserManagement/ # 用户管理(列表 + 编辑 + 绑定)
│ ├── ExcelUpload/ # Excel 上传(模板选择 + 上传 + 校验 + diff + 确认)
│ └── RetentionClues/ # 维客线索(客户搜索 + 线索列表 + 编辑/删除/隐藏)
├── components/
│ ├── SiteSelector/ # 门店筛选器(页面顶部)
│ ├── DiffTable/ # 冲突 diff 交互表格
│ └── ClueEditor/ # 线索编辑表单
├── services/
│ └── api.ts # API 调用封装JWT 自动附加/刷新)
├── hooks/
│ └── useAuth.ts # 认证状态管理
├── utils/
│ └── format.ts # 格式化工具(手机号脱敏等)
└── App.tsx # 路由配置 + 侧边栏布局
```
### API 调用层设计
复用 `apps/admin-web/src/api/client.ts` 的模式:
- axios 实例,`baseURL: "/api/tenant"`
- 请求拦截器:从 localStorage 读取 access_token 附加 Authorization header
- 响应拦截器401 时用 refresh_token 刷新,刷新失败重定向 `/login`
- 并发刷新保护:多个 401 只触发一次 refresh其余排队
- 响应解包:`{ code: 0, data }` → 提取 `data`
### 响应格式约定
成功响应(由 `ResponseWrapperMiddleware` 自动包装):
```json
{ "code": 0, "data": { ... } }
```
错误响应(由异常处理器格式化):
```json
{ "code": 401, "message": "用户名或密码错误" }
```
分页响应:
```json
{
"code": 0,
"data": {
"items": [...],
"total": 100,
"page": 1,
"pageSize": 20
}
}
```
Pydantic 模型使用 `alias_generator=to_camel` 实现 snake_case → camelCase 转换。
---
## 数据模型
### 新建表
#### 1. `auth.tenant_admins` — 租户管理员表
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PK | 自增主键 |
| username | VARCHAR(50) | UNIQUE NOT NULL | 登录用户名 |
| password_hash | VARCHAR(255) | NOT NULL | bcrypt 哈希 |
| display_name | VARCHAR(100) | — | 显示名称 |
| tenant_id | BIGINT | NOT NULL | 所属租户 |
| managed_site_ids | BIGINT[] | NOT NULL | 管辖门店 ID 列表 |
| is_active | BOOLEAN | DEFAULT true | 账号状态 |
| created_by | BIGINT | — | 创建者Operator ID |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
| last_login_at | TIMESTAMPTZ | — | 最后登录时间 |
索引:`idx_tenant_admin_tenant ON (tenant_id)`
#### 2. `biz.excel_upload_log` — Excel 上传记录表
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PK | 自增主键 |
| site_id | BIGINT | NOT NULL | 门店 ID |
| upload_type | VARCHAR(30) | CHECK IN (expense, platform_income, salary_adj, recharge_commission) | 模板类型 |
| file_name | VARCHAR(255) | NOT NULL | 原始文件名 |
| uploaded_by | BIGINT | NOT NULL | 上传人(管理员 ID |
| row_count | INTEGER | DEFAULT 0 | 数据行数 |
| conflict_count | INTEGER | DEFAULT 0 | 冲突行数 |
| resolved_count | INTEGER | DEFAULT 0 | 已解决冲突数 |
| status | VARCHAR(20) | CHECK IN (pending, confirmed, failed) | 批次状态 |
| error_detail | JSONB | — | 错误详情 |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 上传时间 |
| confirmed_at | TIMESTAMPTZ | — | 确认时间 |
索引:`idx_excel_log_site ON (site_id, created_at DESC)`
#### 3. `biz.salary_adjustments` — 助教奖罚明细表
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PK | 自增主键 |
| site_id | BIGINT | NOT NULL | 门店 ID |
| assistant_id | BIGINT | — | 匹配到的助教 ID可空 |
| assistant_name | VARCHAR(100) | NOT NULL | 助教姓名 |
| assistant_number | VARCHAR(50) | NOT NULL | 助教编号 |
| salary_month | VARCHAR(7) | NOT NULL | 月份 YYYY-MM |
| adjustment_type | VARCHAR(20) | CHECK IN (deduction, bonus) | 类型 |
| amount | NUMERIC(12,2) | CHECK > 0 | 金额 |
| reason | VARCHAR(200) | NOT NULL | 原因 |
| upload_batch_id | BIGINT | FK → excel_upload_log | 上传批次 |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
| created_by | BIGINT | — | 上传人 |
索引:`(site_id, salary_month)`, `(assistant_id, salary_month)`
#### 4. `biz.stg_finance_expense` — 财务支出暂存表
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PK | 自增主键 |
| site_id | BIGINT | NOT NULL | 门店 ID |
| expense_month | VARCHAR(7) | NOT NULL | 月份 |
| category | VARCHAR(50) | NOT NULL | 支出类别8 值枚举) |
| amount | NUMERIC(12,2) | NOT NULL | 金额 |
| remark | TEXT | — | 备注 |
| upload_batch_id | BIGINT | FK → excel_upload_log | 上传批次 |
| synced_at | TIMESTAMPTZ | — | ETL 同步时间NULL=未同步) |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
#### 5. `biz.stg_platform_income` — 团购收入暂存表
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PK | 自增主键 |
| site_id | BIGINT | NOT NULL | 门店 ID |
| income_month | VARCHAR(7) | NOT NULL | 月份 |
| platform_name | VARCHAR(100) | NOT NULL | 平台名称 |
| amount | NUMERIC(12,2) | NOT NULL | 收入金额 |
| remark | TEXT | — | 备注 |
| upload_batch_id | BIGINT | FK → excel_upload_log | 上传批次 |
| synced_at | TIMESTAMPTZ | — | ETL 同步时间 |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
#### 6. `biz.stg_recharge_commission` — 充值业绩归属暂存表
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PK | 自增主键 |
| site_id | BIGINT | NOT NULL | 门店 ID |
| recharge_date | DATE | NOT NULL | 充值日期 |
| member_name | VARCHAR(100) | NOT NULL | 会员名称 |
| recharge_amount | NUMERIC(12,2) | NOT NULL | 充值金额 |
| assigned_assistant | VARCHAR(100) | NOT NULL | 归属助教 |
| reward_amount | NUMERIC(12,2) | NOT NULL | 奖励金额 |
| upload_batch_id | BIGINT | FK → excel_upload_log | 上传批次 |
| synced_at | TIMESTAMPTZ | — | ETL 同步时间 |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
### 表结构变更
#### `public.member_retention_clue` — 新增 `is_hidden` 列
```sql
ALTER TABLE public.member_retention_clue
ADD COLUMN is_hidden BOOLEAN NOT NULL DEFAULT false;
COMMENT ON COLUMN public.member_retention_clue.is_hidden
IS '是否隐藏true=管理后台保留但小程序不展示)';
```
已有数据通过 `DEFAULT false` 保证兼容。小程序端线索查询需增加 `WHERE is_hidden = false`
### 复用的现有表auth Schema
| 表 | 用途 |
|---|------|
| `auth.users` | 小程序用户主表(审核时更新 status |
| `auth.user_applications` | 用户入驻申请(审核列表数据源) |
| `auth.site_code_mapping` | 球房编号 → site_id 映射 |
| `auth.roles` | 角色定义coach/staff/site_admin/tenant_admin |
| `auth.user_site_roles` | 用户-门店-角色关联(审核通过时写入) |
| `auth.user_assistant_binding` | 用户-助教/员工绑定(审核通过时写入) |
### FDW 数据源ETL 库只读)
| 视图 | 用途 | 查询场景 |
|------|------|---------|
| `fdw_etl.v_dim_assistant` | 助教维度表 | 用户审核关联建议、Excel 人员匹配 |
| `fdw_etl.v_dim_staff` + `v_dim_staff_ex` | 员工维度表 | 用户审核关联建议、Excel 人员匹配 |
| `fdw_etl.v_dim_member` | 会员维度表 | 维客线索客户搜索DQ-6 规则) |
多店铺查询策略:逐 `site_id` 调用 `get_etl_readonly_connection(site_id)` 查询后合并结果。
### Pydantic Schema 设计
```python
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
class TenantBaseModel(BaseModel):
"""租户管理后台基础模型,统一驼峰命名。"""
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class PaginatedResponse(TenantBaseModel, Generic[T]):
items: list[T]
total: int
page: int
page_size: int
```
---
## 正确性属性Correctness Properties
*属性Property是在系统所有有效执行中都应成立的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格说明与机器可验证正确性保证之间的桥梁。*
### Property 1: 认证正确性
*For any* 用户名和密码组合,如果该组合对应一个活跃的 `auth.tenant_admins` 记录且密码哈希匹配,则登录应返回包含 `aud=tenant-admin` 的有效 JWT 令牌对;如果用户名不存在或密码不匹配,则应返回 401 且错误消息不区分是用户名还是密码错误。
**Validates: Requirements 1.1, 1.2**
### Property 2: JWT 隔离与端点保护
*For any* JWT 令牌和任意 `/api/tenant/*` 受保护端点,当且仅当令牌有效且 `aud=tenant-admin` 时请求应被允许;`aud` 不匹配(如小程序 JWT `aud=xcx`)、令牌过期或令牌无效时应返回 401。
**Validates: Requirements 1.4, 1.6, 17.3**
### Property 3: 数据隔离
*For any* 租户管理员和任意数据查询端点,返回结果中每条记录的 `site_id` 必须属于该管理员的 `managed_site_ids` 集合;尝试访问不在 `managed_site_ids` 范围内的数据应返回 403。
**Validates: Requirements 2.1, 2.2, 2.3**
### Property 4: 审核通过多表一致性
*For any* 状态为 `pending` 的用户申请,执行审核通过操作后,以下条件应同时成立:`auth.users.status = 'approved'``auth.user_site_roles` 中存在对应角色记录、`auth.user_assistant_binding` 中存在绑定记录(如提供了 assistant_id/staff_id`auth.user_applications.status = 'approved'` 且审核人和审核时间已填写。
**Validates: Requirements 3.4**
### Property 5: 审核拒绝状态更新
*For any* 状态为 `pending` 的用户申请和任意非空拒绝原因字符串,执行审核拒绝操作后,`auth.user_applications.status` 应为 `rejected``review_note` 应等于提交的拒绝原因,`reviewed_at` 应非空。
**Validates: Requirements 3.5**
### Property 6: 申请关联匹配
*For any* 用户申请中的手机号和球房编号,关联匹配逻辑应:先通过 `site_code_mapping` 解析 `site_id`,然后在 `v_dim_assistant``scd2_is_current=1`)和 `v_dim_staff` + `v_dim_staff_ex` 中按 phone 匹配,返回的候选列表应仅包含 phone 匹配的记录。
**Validates: Requirements 3.3**
### Property 7: 用户管理编辑
*For any* 已通过审核的用户和任意有效的编辑参数角色、site_id 在管辖范围内、状态),编辑操作后用户记录应反映新值;修改绑定关系后 `user_assistant_binding` 应更新为新的 `assistant_id`/`staff_id`
**Validates: Requirements 4.2, 4.3, 4.4**
### Property 8: Excel 格式校验
*For any* 模板类型和任意数据行集合,校验器应:对符合模板列定义的行标记为通过,对不符合的行返回包含行号、列名和错误描述的错误信息;校验全部通过时应创建 `excel_upload_log` 记录status=pending通过行数 + 警告行数 + 错误行数 = 总行数。
**Validates: Requirements 5.1, 5.2, 5.3, 5.4, 6.5**
### Property 9: 人员匹配校验
*For any* 助教姓名+编号组合和给定的助教/员工数据集,匹配逻辑应:优先在 `v_dim_assistant`nickname + assistant_number中匹配未匹配时再查 `v_dim_staff` + `v_dim_staff_ex`name + staff_number匹配成功时填充 `assistant_id`,匹配失败时标记为 warning 且不阻断流程。
**Validates: Requirements 6.1, 6.2, 6.3, 6.4**
### Property 10: 冲突检测
*For any* 模板类型、上传数据集和已有数据集,冲突检测应:按模板主键规则(如财务支出的"月份+支出类别")识别主键重复的行,对冲突行返回逐字段 diff旧值 vs 新值),非冲突行标记为待写入。
**Validates: Requirements 7.1, 7.2**
### Property 11: 数据写入 round-trip
*For any* 确认写入的数据批次和冲突解决方案,写入后从目标表读取的数据应与提交的数据一致(选择"替换"的行使用新值,选择"保留"的行保持旧值);`excel_upload_log` 状态应更新为 `confirmed`,实际写入行数应正确记录。
**Validates: Requirements 7.4, 7.5, 8.1, 8.2**
### Property 12: 客户搜索
*For any* 搜索关键词和管辖门店范围,搜索结果应满足:每条结果的 `nickname` 包含关键词(模糊匹配)或 `mobile` 等于关键词(精确匹配),且 `site_id` 在管辖范围内;指定门店筛选时结果应仅包含该门店的客户。
**Validates: Requirements 9.1, 9.3**
### Property 13: 线索编辑校验
*For any* 有效的编辑参数category 在 6 值枚举内、summary 非空且 ≤200 字符),编辑操作后线索记录应反映新值;对于无效的 category 或空/超长 summary应拒绝操作。
**Validates: Requirements 11.1, 11.2**
### Property 14: 线索删除
*For any* 存在且在管辖门店范围内的线索,执行删除操作后,该线索应不可通过任何查询接口获取(物理删除)。
**Validates: Requirements 12.2**
### Property 15: 线索隐藏/显示 round-trip
*For any* 线索,将 `is_hidden` 设为 `true` 后,小程序端查询(`WHERE is_hidden = false`)不应返回该线索,但管理后台查询应返回;再将 `is_hidden` 设回 `false` 后,小程序端查询应恢复返回该线索。
**Validates: Requirements 13.1, 13.2, 13.3, 15.3**
### Property 16: 响应格式一致性
*For any* `/api/tenant/*` 端点的成功响应,响应体应符合 `{ code: 0, data: ... }` 格式;错误响应应符合 `{ code: <status_code>, message: <string> }` 格式;分页响应的 `data` 应包含 `items``total``page``pageSize` 字段。
**Validates: Requirements 17.4**
---
## 错误处理
### 认证错误
| 场景 | HTTP 状态码 | 错误消息 | 处理方式 |
|------|------------|---------|---------|
| 用户名或密码错误 | 401 | "用户名或密码错误" | 统一消息,不区分 |
| 账号已禁用 | 403 | "账号已被禁用" | 检查 `is_active` |
| JWT 过期 | 401 | "令牌已过期" | 前端自动刷新 |
| JWT 无效/aud 不匹配 | 401 | "无效的令牌" | 重定向登录页 |
| 刷新令牌过期 | 401 | "刷新令牌已过期" | 清除令牌,重定向登录页 |
### 数据隔离错误
| 场景 | HTTP 状态码 | 错误消息 |
|------|------------|---------|
| 访问非管辖门店数据 | 403 | "无权访问该门店数据" |
| 修改 site_id 超出管辖范围 | 403 | "目标门店不在管辖范围内" |
### 业务逻辑错误
| 场景 | HTTP 状态码 | 错误消息 |
|------|------------|---------|
| 审核非 pending 状态的申请 | 409 | "该申请已被处理" |
| 线索 ID 不存在 | 404 | "线索不存在" |
| 线索不在管辖范围 | 404 | "线索不存在" |
| category 枚举值无效 | 422 | "无效的线索大类" |
| summary 为空或超长 | 422 | "摘要不能为空且不超过200字符" |
### Excel 上传错误
| 场景 | HTTP 状态码 | 错误消息 |
|------|------------|---------|
| 文件格式非 .xlsx/.xls | 400 | "请上传有效的 Excel 文件" |
| 格式校验失败 | 200 | 返回错误行详情(不阻断,前端标红) |
| 人员匹配失败 | 200 | 标记为 warning不阻断前端黄色高亮 |
| 写入数据库失败 | 500 | 回滚整批log status=failed记录 error_detail |
### 数据库事务策略
- Excel 写入:整批次在单个事务中执行,任何行写入失败则回滚全部
- 审核通过多表写入在单个事务中执行users + user_site_roles + user_assistant_binding + user_applications
- 线索操作:单条记录操作,无需跨表事务
---
## 测试策略
### 双轨测试方法
本项目采用单元测试 + 属性测试的双轨策略:
- **单元测试**:验证具体示例、边界条件和错误处理
- **属性测试**:验证跨所有输入的通用属性
两者互补:单元测试捕获具体 bug属性测试验证通用正确性。
### 属性测试配置
- **库**Python 后端使用 `hypothesis`(项目已有 `.hypothesis/` 目录);前端使用 `fast-check`
- **迭代次数**:每个属性测试最少 100 次迭代
- **标签格式**`Feature: tenant-admin-web, Property {number}: {property_text}`
- **每个正确性属性对应一个属性测试**
### 单元测试覆盖
| 模块 | 测试重点 |
|------|---------|
| `tenant_auth.py` | 登录成功/失败、禁用账号、JWT 签发/验证、刷新令牌 |
| `tenant_users.py` | 申请列表筛选、审核通过/拒绝、非 pending 状态拒绝409、用户编辑、绑定修改 |
| `tenant_excel.py` | 4 种模板格式校验、人员匹配、冲突检测、写入回滚、模板下载 |
| `tenant_clues.py` | 客户搜索、线索 CRUD、隐藏/显示、枚举校验、越权访问 |
| 前端 `services/api.ts` | JWT 自动附加、401 刷新、并发刷新保护 |
### 属性测试覆盖
| Property | 测试描述 | 生成器 |
|----------|---------|--------|
| P1 | 认证正确性 | 随机用户名+密码 |
| P2 | JWT 隔离 | 随机 JWT payload不同 aud |
| P3 | 数据隔离 | 随机 managed_site_ids + 随机数据 |
| P4 | 审核通过一致性 | 随机 pending 申请 + 角色/绑定参数 |
| P5 | 审核拒绝 | 随机 pending 申请 + 随机拒绝原因 |
| P6 | 关联匹配 | 随机手机号 + 随机助教/员工数据集 |
| P7 | 用户编辑 | 随机用户 + 随机编辑参数 |
| P8 | Excel 校验 | 随机模板类型 + 随机数据行(含有效/无效) |
| P9 | 人员匹配 | 随机姓名+编号 + 随机助教/员工数据集 |
| P10 | 冲突检测 | 随机上传数据 + 随机已有数据 |
| P11 | 写入 round-trip | 随机数据 + 随机冲突解决方案 |
| P12 | 客户搜索 | 随机关键词 + 随机客户数据集 |
| P13 | 线索编辑 | 随机 category + 随机 summary |
| P14 | 线索删除 | 随机线索 ID |
| P15 | 隐藏/显示 round-trip | 随机线索 + 隐藏→显示→验证 |
| P16 | 响应格式 | 随机端点调用 |
### 边界条件测试(单元测试覆盖)
- 禁用账号登录1.3)→ 403
- 非 pending 申请审核3.6)→ 409
- 越权修改 site_id4.5)→ 403
- 无效 Excel 文件格式5.5)→ 400
- 写入数据库失败回滚8.3)→ log status=failed
- 线索 ID 不存在/越权11.3, 12.3, 13.4)→ 404

View File

@@ -0,0 +1,241 @@
# 需求文档租户管理后台tenant-admin-web
## 简介
构建独立的租户管理 Web 应用(`apps/tenant-admin/`面向租户管理员提供用户审核与管理、Excel 数据上传4 种模板)、维客线索管理三大功能模块。前端采用 React + Vite + Ant Design`apps/admin-web/` 同技术栈),后端复用 `apps/backend/` FastAPI 新增 4 个路由模块。认证体系与小程序完全隔离,使用独立的 `auth.tenant_admins` 表、用户名+密码登录、不同 JWT audience。所有数据查询附加 `site_id IN (管辖列表)` 条件实现多门店数据隔离。
## 术语表
- **Tenant_Admin_Web**:租户管理后台前端应用,部署在 `apps/tenant-admin/`
- **Backend_API**FastAPI 后端服务,部署在 `apps/backend/`,为 Tenant_Admin_Web 提供 RESTful API
- **Tenant_Admin**:租户管理员,由系统管理后台 Operator 创建的账号,使用用户名+密码登录租户管理后台
- **Operator**:系统管理后台操作员,在 `apps/admin-web/` 中管理租户管理员账号
- **Site**:门店,通过 `site_id` 标识,是多门店数据隔离的基本单位
- **Site_Code**:球房编号,用户申请时输入的门店标识码,通过 `auth.site_code_mapping` 映射到 `site_id`
- **User_Application**:用户入驻申请,小程序用户提交的申请记录,存储在 `auth.user_applications`
- **Retention_Clue**:维客线索,助教为会员记录的销售/维护线索,存储在 `public.member_retention_clue`
- **Staging_Table**暂存表Excel 上传数据先写入业务库暂存表,再由 ETL 同步到 DWS 层
- **Upload_Batch**:上传批次,一次 Excel 上传操作的记录,存储在 `biz.excel_upload_log`
- **Salary_Adjustment**:助教奖罚明细,通过 Excel 上传写入 `biz.salary_adjustments`
- **Managed_Site_Ids**管辖门店列表Tenant_Admin 被授权管理的 `site_id` 数组
## 需求
### 需求 1租户管理员认证
**用户故事:** 作为 Tenant_Admin我希望通过用户名和密码登录租户管理后台以便安全地执行用户审核、数据上传等管理操作。
#### 验收标准
1. WHEN Tenant_Admin 提交有效的用户名和密码, THE Backend_API SHALL 验证凭据bcrypt 哈希比对),签发 JWT 访问令牌audience 为 `tenant-admin`,与小程序 `xcx` 隔离),并返回访问令牌和刷新令牌
2. WHEN Tenant_Admin 提交无效的用户名或密码, THE Backend_API SHALL 返回 401 状态码和错误描述,不泄露具体是用户名还是密码错误
3. IF Tenant_Admin 账号状态为禁用(`is_active = false`, THEN THE Backend_API SHALL 返回 403 状态码并提示账号已被禁用
4. WHILE Tenant_Admin 持有有效的 JWT 令牌, THE Backend_API SHALL 允许访问 `/api/tenant/*` 路径下的受保护端点
5. WHEN JWT 访问令牌过期, THE Tenant_Admin_Web SHALL 使用刷新令牌自动获取新的访问令牌WHEN 刷新令牌也过期, THE Tenant_Admin_Web SHALL 将 Tenant_Admin 重定向到登录页面
6. THE Backend_API SHALL 通过 JWT 中的 `aud` 字段区分租户管理员和小程序用户,使用独立的认证依赖注入(`require_tenant_admin()`),拒绝小程序 JWT 访问租户管理端点
### 需求 2数据隔离与权限控制
**用户故事:** 作为 Tenant_Admin我希望只能查看和操作自己管辖门店的数据以确保多门店之间的数据安全隔离。
#### 验收标准
1. THE Backend_API SHALL 在所有租户管理端点的数据查询中附加 `site_id IN (managed_site_ids)` 条件,其中 `managed_site_ids` 从当前 Tenant_Admin 的 JWT 令牌或 `auth.tenant_admins` 记录中获取
2. WHEN Tenant_Admin 的 `managed_site_ids` 包含多个 site_id, THE Backend_API SHALL 支持跨门店聚合查询(合并多个 site_id 的结果)
3. IF Tenant_Admin 尝试访问不在其 `managed_site_ids` 范围内的数据, THEN THE Backend_API SHALL 返回 403 状态码
4. THE Tenant_Admin_Web SHALL 在页面顶部提供门店筛选器,允许 Tenant_Admin 在管辖范围内切换或选择门店
### 需求 3用户申请审核
**用户故事:** 作为 Tenant_Admin我希望查看和审核小程序用户的入驻申请以便控制哪些用户可以使用系统。
#### 验收标准
1. WHEN Tenant_Admin 打开用户审核页面, THE Tenant_Admin_Web SHALL 从 Backend_API 获取管辖门店范围内的用户申请列表,支持按状态筛选(全部 / 待审核 pending / 已通过 approved / 已拒绝 rejected支持分页
2. THE Backend_API SHALL 返回每条申请的以下信息申请人昵称、手机号、球房编号site_code、申请角色文本、员工编号、申请时间、当前状态
3. WHEN Tenant_Admin 查看某条待审核申请的关联建议, THE Backend_API SHALL 根据申请中的球房编号通过 `auth.site_code_mapping` 查得 `site_id`,再并行匹配 `fdw_etl.v_dim_assistant`phone 匹配,`scd2_is_current=1`)和 `fdw_etl.v_dim_staff` + `v_dim_staff_ex`phone 匹配),返回匹配建议列表
4. WHEN Tenant_Admin 审核通过一条申请, THE Backend_API SHALL 接受角色(助教/管理者/员工)、关联助教 ID可选、关联员工 ID可选执行以下操作更新 `auth.users.status = 'approved'`、写入 `auth.user_site_roles`(分配角色)、写入 `auth.user_assistant_binding`(关联助教/员工,含 staff_id、更新 `auth.user_applications.status = 'approved'` 及审核人和审核时间
5. WHEN Tenant_Admin 审核拒绝一条申请, THE Backend_API SHALL 接受拒绝原因(必填),更新 `auth.user_applications.status = 'rejected'``review_note` 和审核时间
6. IF 申请状态不是 pending, THEN THE Backend_API SHALL 拒绝审核操作并返回 409 状态码
### 需求 4用户管理
**用户故事:** 作为 Tenant_Admin我希望管理已通过审核的用户信息包括修改角色、店铺归属和助教绑定关系以便维护用户数据的准确性。
#### 验收标准
1. WHEN Tenant_Admin 打开用户管理页面, THE Tenant_Admin_Web SHALL 展示管辖门店范围内已通过审核的用户列表,包含姓名、角色、关联助教姓名、所属门店、账号状态,支持按角色筛选和关键词搜索,支持分页
2. WHEN Tenant_Admin 编辑用户信息, THE Backend_API SHALL 允许修改以下字段:角色(助教/管理者/员工)、所属门店(`site_id`,限管辖范围内)、账号状态(启用/禁用)
3. WHEN Tenant_Admin 修改用户的助教/员工绑定关系, THE Backend_API SHALL 更新 `auth.user_assistant_binding` 记录,接受新的 `assistant_id` 和/或 `staff_id`
4. WHEN Tenant_Admin 禁用某用户账号, THE Backend_API SHALL 将 `auth.users.status` 设为 `disabled`,该用户后续登录小程序时 SHALL 被拒绝
5. IF Tenant_Admin 尝试将用户的 site_id 修改为不在其管辖范围内的值, THEN THE Backend_API SHALL 返回 403 状态码
### 需求 5Excel 上传 — 文件解析与格式校验
**用户故事:** 作为 Tenant_Admin我希望上传 Excel 文件并获得即时的格式校验反馈,以便在数据写入前发现和修正错误。
#### 验收标准
1. WHEN Tenant_Admin 选择模板类型并上传 Excel 文件, THE Backend_API SHALL 解析文件内容,按对应模板的列定义进行格式校验,并返回校验结果
2. THE Backend_API SHALL 支持 4 种模板类型的格式校验:
- 财务支出expense月份YYYY-MM不超过当前月、支出类别枚举 8 值:房租/水电/物业/食品饮料进货/耗材/报销/固定人员工资/其他费用)、金额(> 0精度 2 位小数)、备注(可选,最长 500 字符)
- 团购收入platform_income月份YYYY-MM、平台名称非空、收入金额> 0、备注可选最长 500 字符)
- 助教奖罚salary_adj月份YYYY-MM、助教姓名非空、助教编号非空、类型枚举扣款/奖金)、金额(> 0、原因非空最长 200 字符)
- 充值业绩归属recharge_commission充值日期YYYY-MM-DD、会员名称非空、充值金额> 0、归属助教非空、奖励金额≥ 0
3. WHEN 校验发现格式错误行, THE Backend_API SHALL 返回错误行号、列名和具体错误描述Tenant_Admin_Web SHALL 标红展示错误行
4. WHEN 校验全部通过, THE Backend_API SHALL 创建 `biz.excel_upload_log` 记录(状态为 pending返回 upload_id 和解析后的数据预览
5. IF 上传文件不是有效的 Excel 格式(.xlsx/.xls, THEN THE Backend_API SHALL 返回 400 状态码并提示文件格式错误
### 需求 6Excel 上传 — 人员匹配校验
**用户故事:** 作为 Tenant_Admin我希望上传助教奖罚和充值业绩归属数据时系统能自动校验助教信息的准确性以减少数据录入错误。
#### 验收标准
1. WHEN 上传助教奖罚salary_adj或充值业绩归属recharge_commission模板时, THE Backend_API SHALL 对每行数据中的助教姓名+助教编号执行匹配校验
2. THE Backend_API SHALL 按以下顺序匹配:先查 `fdw_etl.v_dim_assistant`nickname + assistant_number`scd2_is_current=1`),如不匹配再查 `fdw_etl.v_dim_staff` + `v_dim_staff_ex`name + staff_number
3. WHEN 匹配成功, THE Backend_API SHALL 在返回数据中填充 `assistant_id`
4. WHEN 匹配失败, THE Backend_API SHALL 标记该行为校验警告warning不阻断上传流程但在前端以黄色高亮提示 Tenant_Admin 确认
5. THE Tenant_Admin_Web SHALL 在校验结果页面汇总展示:通过行数、警告行数、错误行数
### 需求 7Excel 上传 — 冲突检测与解决
**用户故事:** 作为 Tenant_Admin我希望在数据写入前看到与已有数据的冲突情况并能逐行选择处理方式以避免误覆盖历史数据。
#### 验收标准
1. WHEN 格式校验通过后, THE Backend_API SHALL 按各模板的主键规则检测冲突:
- 财务支出:月份 + 支出类别
- 团购收入:月份 + 平台名称
- 助教奖罚:月份 + 助教姓名 + 助教编号 + 类型 + 原因
- 充值业绩归属:充值日期 + 会员名称 + 归属助教
2. WHEN 检测到冲突行(主键已存在), THE Backend_API SHALL 返回 diff 数据(旧值 vs 新值,按字段逐一对比)
3. THE Tenant_Admin_Web SHALL 展示 diff 交互表格,每行显示字段名、旧值、新值和操作选项(替换/保留),支持"全部替换"和"全部保留"快捷操作
4. WHEN Tenant_Admin 确认冲突解决方案并提交, THE Backend_API SHALL 接受 upload_id 和每行的解决方案resolutions 数组),按选择执行写入
5. WHEN 无冲突行, THE Backend_API SHALL 直接标记为"待写入"Tenant_Admin 确认后写入
### 需求 8Excel 上传 — 数据写入与记录
**用户故事:** 作为 Tenant_Admin我希望确认后的数据能正确写入目标表并保留上传历史记录以便追溯。
#### 验收标准
1. WHEN Tenant_Admin 确认写入, THE Backend_API SHALL 将数据写入对应目标表:
- 助教奖罚 → `biz.salary_adjustments`(直接写入业务库)
- 财务支出 → `biz.stg_finance_expense`staging 表,由 ETL 同步到 DWS
- 团购收入 → `biz.stg_platform_income`staging 表)
- 充值业绩归属 → `biz.stg_recharge_commission`staging 表)
2. THE Backend_API SHALL 在写入完成后更新 `biz.excel_upload_log` 记录:状态设为 `confirmed`、记录实际写入行数、冲突解决数、确认时间
3. IF 写入过程中发生数据库错误, THEN THE Backend_API SHALL 回滚整个批次的写入,将 `biz.excel_upload_log` 状态设为 `failed`,记录错误详情到 `error_detail` 字段
4. WHEN Tenant_Admin 查看上传记录页面, THE Backend_API SHALL 返回管辖门店范围内的历史上传记录列表,包含模板类型、文件名、上传人、上传时间、行数、冲突数、状态,支持分页
5. WHEN Tenant_Admin 下载空白模板, THE Backend_API SHALL 返回对应模板类型的 Excel 文件(含表头和格式说明)
### 需求 9维客线索 — 客户搜索
**用户故事:** 作为 Tenant_Admin我希望通过客户姓名或手机号搜索客户以便查看和管理其维客线索。
#### 验收标准
1. WHEN Tenant_Admin 输入搜索关键词, THE Backend_API SHALL 在管辖门店范围内搜索客户,匹配 `fdw_etl.v_dim_member``nickname`(模糊匹配)或 `mobile`精确匹配返回客户列表member_id、姓名、手机号脱敏、所属门店
2. THE Backend_API SHALL 通过 `member_id` JOIN `fdw_etl.v_dim_member``scd2_is_current=1`获取客户姓名和手机号不使用结算单上的冗余字段DQ-6 规则)
3. WHEN Tenant_Admin 选择门店筛选条件, THE Backend_API SHALL 在搜索结果中仅返回该门店的客户
4. IF 搜索结果为空, THEN THE Tenant_Admin_Web SHALL 展示"未找到匹配客户"的提示
### 需求 10维客线索 — 线索列表与展示
**用户故事:** 作为 Tenant_Admin我希望查看某客户的全部维客线索以便了解客户画像和历史记录。
#### 验收标准
1. WHEN Tenant_Admin 选择某客户, THE Backend_API SHALL 返回该客户在管辖门店范围内的全部维客线索列表,包含:线索 ID、大类标签category、摘要summary、详情detail、提供人recorded_by_name、来源sourcemanual / ai_consumption / ai_note、记录时间recorded_at、隐藏状态is_hidden
2. THE Tenant_Admin_Web SHALL 按大类标签分组展示线索,支持按来源和隐藏状态筛选
3. THE Tenant_Admin_Web SHALL 对已隐藏的线索以灰色或删除线样式区分展示
### 需求 11维客线索 — 编辑
**用户故事:** 作为 Tenant_Admin我希望修改维客线索的标签、摘要和详情以便纠正错误或补充信息。
#### 验收标准
1. WHEN Tenant_Admin 编辑某条线索, THE Backend_API SHALL 接受修改后的 category枚举 6 值:客户基础/消费习惯/玩法偏好/促销偏好/社交关系/重要反馈、summary非空最长 200 字符、detail可选
2. THE Backend_API SHALL 验证 category 值在枚举范围内summary 非空且不超过长度限制
3. IF 线索 ID 不存在或不在管辖门店范围内, THEN THE Backend_API SHALL 返回 404 状态码
### 需求 12维客线索 — 删除
**用户故事:** 作为 Tenant_Admin我希望删除错误或无效的维客线索以保持数据整洁。
#### 验收标准
1. WHEN Tenant_Admin 请求删除某条线索, THE Tenant_Admin_Web SHALL 弹出二次确认对话框,明确提示"删除后不可恢复"
2. WHEN Tenant_Admin 确认删除, THE Backend_API SHALL 对该线索执行物理删除(`DELETE FROM public.member_retention_clue WHERE id = ?`
3. IF 线索 ID 不存在或不在管辖门店范围内, THEN THE Backend_API SHALL 返回 404 状态码
### 需求 13维客线索 — 隐藏与显示
**用户故事:** 作为 Tenant_Admin我希望隐藏某些线索使其不在小程序端展示同时保留在管理后台可见以便灵活控制线索的可见性。
#### 验收标准
1. WHEN Tenant_Admin 切换某条线索的隐藏状态, THE Backend_API SHALL 更新 `public.member_retention_clue.is_hidden` 字段true=隐藏false=显示)
2. WHILE 线索的 `is_hidden = true`, THE Backend_API SHALL 确保小程序端查询线索时通过 `WHERE is_hidden = false` 条件过滤该线索
3. THE Tenant_Admin_Web SHALL 允许 Tenant_Admin 将已隐藏的线索恢复为显示状态(`is_hidden = false`
4. IF 线索 ID 不存在或不在管辖门店范围内, THEN THE Backend_API SHALL 返回 404 状态码
### 需求 14管理后台 — 租户管理员账号管理
**用户故事:** 作为 Operator我希望在系统管理后台admin-web中创建、编辑和管理租户管理员账号以便控制谁可以登录租户管理后台。
#### 验收标准
1. WHEN Operator 打开租户管理员管理页面, THE admin-web SHALL 展示所有租户管理员列表,包含用户名、显示名称、管辖门店、账号状态(启用/禁用)、创建时间、最后登录时间,支持分页和关键词搜索
2. WHEN Operator 创建新的租户管理员, THE Backend_API SHALL 接受用户名唯一、初始密码、显示名称、tenant_id、managed_site_ids门店列表将密码 bcrypt 哈希后写入 `auth.tenant_admins`,记录 `created_by` 为当前 Operator 的 user_id
3. IF 用户名已存在, THEN THE Backend_API SHALL 返回 409 状态码并提示"用户名已存在"
4. WHEN Operator 编辑租户管理员信息, THE Backend_API SHALL 允许修改以下字段显示名称、managed_site_ids、is_active启用/禁用)
5. WHEN Operator 重置租户管理员密码, THE Backend_API SHALL 接受新密码bcrypt 哈希后更新 `auth.tenant_admins.password_hash`
6. IF Operator 禁用某租户管理员is_active=false, THEN 该管理员后续登录租户管理后台时 SHALL 被拒绝(返回 403
7. THE Backend_API SHALL 要求 Operator 具有 site_admin 或 tenant_admin 角色才能访问租户管理员管理端点
### 需求 15数据库变更 — 新建表
**用户故事:** 作为开发者,我需要创建 NS4 所需的新数据库表,以支撑租户管理后台的全部功能。
#### 验收标准
1. THE 迁移脚本 SHALL 在 `auth` Schema 中创建 `tenant_admins`包含字段idBIGSERIAL PK、usernameVARCHAR(50) UNIQUE NOT NULL、password_hashVARCHAR(255) NOT NULL、display_nameVARCHAR(100)、tenant_idBIGINT NOT NULL、managed_site_idsBIGINT[] NOT NULL、is_activeBOOLEAN DEFAULT true、created_byBIGINT、created_atTIMESTAMPTZ DEFAULT NOW()、last_login_atTIMESTAMPTZ并创建 tenant_id 索引
2. THE 迁移脚本 SHALL 在 `biz` Schema 中创建 `salary_adjustments`包含字段idBIGSERIAL PK、site_idBIGINT NOT NULL、assistant_idBIGINT 可空、assistant_nameVARCHAR(100) NOT NULL、assistant_numberVARCHAR(50) NOT NULL、salary_monthVARCHAR(7) NOT NULL、adjustment_typeCHECK IN deduction/bonus、amountNUMERIC(12,2) > 0、reasonVARCHAR(200) NOT NULL、upload_batch_idFK → excel_upload_log、created_at、created_by并创建 (site_id, salary_month) 和 (assistant_id, salary_month) 索引
3. THE 迁移脚本 SHALL 在 `biz` Schema 中创建 `excel_upload_log`包含字段idBIGSERIAL PK、site_idBIGINT NOT NULL、upload_typeCHECK IN expense/platform_income/salary_adj/recharge_commission、file_nameVARCHAR(255) NOT NULL、uploaded_byBIGINT NOT NULL、row_countINTEGER DEFAULT 0、conflict_countINTEGER DEFAULT 0、resolved_countINTEGER DEFAULT 0、statusCHECK IN pending/confirmed/failed、error_detailJSONB、created_at、confirmed_at并创建 (site_id, created_at DESC) 索引
4. THE 迁移脚本 SHALL 在 `biz` Schema 中创建 3 张 staging 表:`stg_finance_expense``stg_platform_income``stg_recharge_commission`,各表包含 site_id、业务字段、upload_batch_idFK、synced_atTIMESTAMPTZ 可空NULL 表示未同步、created_at
### 需求 16数据库变更 — 表结构修改
**用户故事:** 作为开发者,我需要为现有表添加 NS4 所需的字段,以支持维客线索隐藏功能。
#### 验收标准
1. THE 迁移脚本 SHALL 为 `public.member_retention_clue` 表添加 `is_hidden`BOOLEAN NOT NULL DEFAULT false并添加列注释说明"是否隐藏true=管理后台保留但小程序不展示)"
2. THE 迁移脚本 SHALL 确保已有数据的 `is_hidden` 值为 false通过 DEFAULT 约束保证)
3. THE 小程序端现有的线索查询 API SHALL 在查询条件中增加 `WHERE is_hidden = false`,确保隐藏线索不在小程序端展示
### 需求 17前端应用骨架
**用户故事:** 作为开发者,我需要搭建租户管理后台的前端项目骨架,以便后续功能页面的开发。
#### 验收标准
1. THE 前端项目 SHALL 在 `apps/tenant-admin/` 目录下创建,使用 React + Vite + Ant Design 技术栈,包含 `package.json``vite.config.ts``tsconfig.json` 等配置文件
2. THE Tenant_Admin_Web SHALL 提供侧边栏导航包含四个功能模块入口用户审核、用户管理、Excel 上传、维客线索管理
3. THE Tenant_Admin_Web SHALL 实现登录页面,未认证时自动重定向到登录页
4. THE Tenant_Admin_Web SHALL 封装统一的 API 调用层(`services/api.ts`),处理 JWT 令牌的自动附加、刷新和过期重定向
5. THE Tenant_Admin_Web SHALL 使用与 Backend_API 一致的响应格式(`{ code: 0, data: ... }`Pydantic 使用 `alias_generator=to_camel` 实现驼峰命名转换
### 需求 18后端路由模块
**用户故事:** 作为开发者,我需要在 FastAPI 后端中创建租户管理专用的路由模块,以提供 NS4 所需的全部 API 端点。
#### 验收标准
1. THE Backend_API SHALL 在 `apps/backend/app/routers/` 下创建 4 个路由文件:`tenant_auth.py`(登录/JWT 签发/鉴权)、`tenant_users.py`(用户审核+用户管理)、`tenant_excel.py`Excel 上传/校验/冲突处理)、`tenant_clues.py`(维客线索管理)
2. THE Backend_API SHALL 将所有租户管理端点注册在 `/api/tenant/` 路径前缀下,与小程序端点(`/api/xcx/`)和管理后台端点(`/api/admin/`)隔离
3. THE Backend_API SHALL 为所有 `/api/tenant/*` 端点(登录接口除外)添加 `require_tenant_admin()` 认证依赖,验证 JWT 的 `aud` 字段为 `tenant-admin`
4. THE Backend_API SHALL 遵循现有的 API 响应格式约定:成功返回 `{ code: 0, data: ... }`,失败返回 `{ code: number, message: string }`,分页返回 `{ items: T[], total: number, page: number, pageSize: number }`

View File

@@ -0,0 +1,409 @@
# 实施计划租户管理后台tenant-admin-web
## 概述
按照设计文档将实施拆分为DDL 迁移 → 后端认证模块 → 后端路由模块 → 前端项目骨架 → 前端页面实现 → 联调收尾。每个任务增量构建确保无孤立代码。属性测试Hypothesis / fast-check和单元测试作为可选子任务紧跟实现步骤。
后端使用 PythonFastAPI + Pydantic前端使用 TypeScriptReact + Vite + Ant Design
## 任务
- [ ] 1. DDL 迁移:新建表 + 表结构变更
- [ ] 1.1 创建 DDL 迁移脚本 `db/zqyy_app/migrations/2026-03-xx__ns4_tenant_admin_tables.sql`
-`auth` Schema 创建 `tenant_admins`id, username, password_hash, display_name, tenant_id, managed_site_ids, is_active, created_by, created_at, last_login_at
- 创建索引 `idx_tenant_admin_tenant ON auth.tenant_admins(tenant_id)`
-`biz` Schema 创建 `excel_upload_log`id, site_id, upload_type, file_name, uploaded_by, row_count, conflict_count, resolved_count, status, error_detail, created_at, confirmed_at
- 创建索引 `idx_excel_log_site ON biz.excel_upload_log(site_id, created_at DESC)`
-`biz` Schema 创建 `salary_adjustments`id, site_id, assistant_id, assistant_name, assistant_number, salary_month, adjustment_type, amount, reason, upload_batch_id FK, created_at, created_by
- 创建索引 `(site_id, salary_month)``(assistant_id, salary_month)`
-`biz` Schema 创建 3 张 staging 表:`stg_finance_expense``stg_platform_income``stg_recharge_commission`,各含 site_id、业务字段、upload_batch_id FK、synced_at、created_at
- _需求: 14.1, 14.2, 14.3, 14.4_
- [ ] 1.2 创建 DDL 迁移脚本 `db/zqyy_app/migrations/2026-03-xx__ns4_member_clue_is_hidden.sql`
- ALTER TABLE `public.member_retention_clue` ADD COLUMN `is_hidden` BOOLEAN NOT NULL DEFAULT false
- 添加 COMMENT ON COLUMN 注释:"是否隐藏true=管理后台保留但小程序不展示)"
- _需求: 15.1, 15.2_
- [ ] 1.3 更新小程序端线索查询增加 `WHERE is_hidden = false` 条件
- 搜索现有线索查询 SQL在 WHERE 子句中追加 `AND is_hidden = false`
- _需求: 15.3_
- [ ] 2. 后端认证模块auth.tenant_admins + JWT + 依赖注入)
- [ ] 2.1 创建 `apps/backend/app/auth/tenant_admins.py`
- 定义 `CurrentTenantAdmin` dataclassadmin_id, tenant_id, managed_site_ids, display_name
- 实现 `require_tenant_admin()` 依赖注入:验证 JWT `aud=tenant-admin`,提取管理员信息
- 实现 `site_filter_clause()` 工具函数:生成 `site_id IN (...)` SQL 片段
- 实现 `verify_site_access()` 工具函数:校验 site_id 是否在 managed_site_ids 内,否则抛 403
- _需求: 1.4, 1.6, 2.1, 2.3, 17.3_
- [ ]* 2.2 编写属性测试JWT 隔离与端点保护
- **Property 2: JWT 隔离与端点保护**
- 使用 Hypothesis 生成随机 JWT payload不同 aud 值tenant-admin / xcx / 无效值)
- 验证:仅 `aud=tenant-admin``require_tenant_admin()` 返回成功,其余返回 401
- **验证: 需求 1.4, 1.6, 17.3**
- [ ]* 2.3 编写属性测试:数据隔离
- **Property 3: 数据隔离**
- 使用 Hypothesis 生成随机 managed_site_ids 集合和随机 site_id
- 验证:`verify_site_access()` 仅当 site_id ∈ managed_site_ids 时通过,否则抛 403
- **验证: 需求 2.1, 2.2, 2.3**
- [ ] 3. 后端路由tenant_auth.py登录/刷新)
- [ ] 3.1 创建 `apps/backend/app/routers/tenant_auth.py`
- 实现 `POST /api/tenant/auth/login`:接受 username + password查询 `auth.tenant_admins`bcrypt 验证,签发 JWTaud=tenant-admin, sub=admin_id, tenant_id, managed_site_ids返回 access_token + refresh_token
- 实现 `POST /api/tenant/auth/refresh`:验证 refresh_token签发新令牌对
- 登录成功时更新 `last_login_at`
- 账号禁用is_active=false返回 403
- 凭据无效返回 401错误消息不区分用户名/密码
- _需求: 1.1, 1.2, 1.3, 1.5_
- [ ] 3.2 在 `apps/backend/app/main.py` 中注册 tenant_auth router路径前缀 `/api/tenant/auth`
- _需求: 17.2_
- [ ]* 3.3 编写属性测试:认证正确性
- **Property 1: 认证正确性**
- 使用 Hypothesis 生成随机用户名+密码组合
- 验证:有效凭据返回含 `aud=tenant-admin` 的 JWT无效凭据返回 401 且错误消息统一
- **验证: 需求 1.1, 1.2**
- [ ]* 3.4 编写单元测试:登录边界条件
- 测试文件 `apps/backend/tests/unit/test_tenant_auth.py`
- 验证:禁用账号登录返回 403、用户名不存在返回 401、密码错误返回 401、JWT 过期处理、refresh_token 刷新
- _需求: 1.1, 1.2, 1.3, 1.5_
- [ ] 4. 检查点 — 认证模块验证
- 确保认证模块所有测试通过ask the user if questions arise.
- [ ] 5. 后端路由tenant_users.py用户审核 + 用户管理)
- [ ] 5.1 创建 Pydantic Schema `apps/backend/app/schemas/tenant_users.py`
- 继承 `TenantBaseModel`alias_generator=to_camel定义
- `ApplicationListItem`(昵称、手机号、球房编号、申请角色、员工编号、申请时间、状态)
- `MatchSuggestion`assistant_id/staff_id、姓名、编号、来源表
- `ApproveRequest`role、assistant_id 可选、staff_id 可选)
- `RejectRequest`reason 必填)
- `UserListItem`(姓名、角色、关联助教姓名、所属门店、账号状态)
- `UserEditRequest`role 可选、site_id 可选、status 可选)
- `UserBindingRequest`assistant_id 可选、staff_id 可选)
- _需求: 3.2, 4.1_
- [ ] 5.2 创建 `apps/backend/app/routers/tenant_users.py`
- `GET /api/tenant/applications`申请列表status 筛选 + 分页,附加 site_id IN 条件
- `GET /api/tenant/applications/{id}/match-suggestions`:通过 site_code_mapping 查 site_id并行匹配 v_dim_assistantphone, scd2_is_current=1和 v_dim_staff + v_dim_staff_exphone
- `POST /api/tenant/applications/{id}/approve`:事务内执行:更新 users.status='approved' → 写入 user_site_roles → 写入 user_assistant_binding → 更新 user_applications.status='approved' + 审核人 + 审核时间
- `POST /api/tenant/applications/{id}/reject`:更新 user_applications.status='rejected' + review_note + reviewed_at
- 非 pending 状态审核返回 409
- `GET /api/tenant/users`:已通过审核用户列表,角色筛选 + 关键词搜索 + 分页
- `PATCH /api/tenant/users/{id}`:编辑角色/门店/状态site_id 超出管辖范围返回 403
- `PUT /api/tenant/users/{id}/binding`:更新 user_assistant_binding
- 禁用用户时设 users.status='disabled'
- _需求: 3.1-3.6, 4.1-4.5_
- [ ] 5.3 在 `apps/backend/app/main.py` 中注册 tenant_users router路径前缀 `/api/tenant`
- _需求: 17.2_
- [ ]* 5.4 编写属性测试:审核通过多表一致性
- **Property 4: 审核通过多表一致性**
- 使用 Hypothesis 生成随机 pending 申请 + 角色/绑定参数
- 验证:审核通过后 users.status='approved'、user_site_roles 存在记录、user_assistant_binding 存在记录(如提供 assistant_id、user_applications.status='approved' 且审核人和时间已填写
- **验证: 需求 3.4**
- [ ]* 5.5 编写属性测试:审核拒绝状态更新
- **Property 5: 审核拒绝状态更新**
- 使用 Hypothesis 生成随机 pending 申请 + 随机拒绝原因字符串
- 验证:拒绝后 user_applications.status='rejected'、review_note 等于提交的原因、reviewed_at 非空
- **验证: 需求 3.5**
- [ ]* 5.6 编写属性测试:申请关联匹配
- **Property 6: 申请关联匹配**
- 使用 Hypothesis 生成随机手机号 + 随机助教/员工数据集
- 验证:匹配结果仅包含 phone 匹配的记录,优先 v_dim_assistant 再 v_dim_staff
- **验证: 需求 3.3**
- [ ]* 5.7 编写属性测试:用户管理编辑
- **Property 7: 用户管理编辑**
- 使用 Hypothesis 生成随机编辑参数角色、site_id 在管辖范围内、状态)
- 验证编辑后用户记录反映新值site_id 超出管辖范围时返回 403
- **验证: 需求 4.2, 4.3, 4.4**
- [ ]* 5.8 编写单元测试:用户审核与管理边界条件
- 测试文件 `apps/backend/tests/unit/test_tenant_users.py`
- 验证:非 pending 状态审核返回 409、越权修改 site_id 返回 403、禁用用户后 status='disabled'
- _需求: 3.6, 4.4, 4.5_
- [ ] 6. 后端路由tenant_excel.pyExcel 上传/校验/冲突/写入)
- [ ] 6.1 创建 Pydantic Schema `apps/backend/app/schemas/tenant_excel.py`
- 定义 4 种模板的行数据模型:`ExpenseRow``PlatformIncomeRow``SalaryAdjRow``RechargeCommissionRow`
- 定义校验结果模型:`ValidationResult`errors[], warnings[], passed_rows[], upload_id
- 定义冲突模型:`ConflictDiff`row_index, field_diffs[{field, old_value, new_value}]
- 定义确认请求:`ConfirmRequest`upload_id, resolutions[{row_index, action: replace/keep}]
- 定义上传记录模型:`UploadLogItem`(模板类型、文件名、上传人、时间、行数、冲突数、状态)
- _需求: 5.2, 7.2, 8.4_
- [ ] 6.2 创建 `apps/backend/app/routers/tenant_excel.py`
- `POST /api/tenant/excel/upload`multipart/form-data 接收文件 + upload_type 参数
- 校验文件格式(.xlsx/.xls非法返回 400
- 按模板类型执行格式校验(月份格式、枚举值、金额精度、非空等)
- salary_adj / recharge_commission 模板执行人员匹配校验v_dim_assistant → v_dim_staff 降级匹配)
- 格式校验通过后执行冲突检测(按模板主键规则匹配已有数据)
- 创建 excel_upload_log 记录status=pending返回 upload_id + 校验结果 + 冲突 diff
- `POST /api/tenant/excel/confirm`:接受 upload_id + resolutions[]
- 单事务写入目标表salary_adj → biz.salary_adjustments其余 → staging 表)
- 替换行执行 UPDATE新增行执行 INSERT
- 写入失败回滚整批log status=failed记录 error_detail
- 写入成功更新 log status=confirmed + 实际行数 + 冲突解决数 + confirmed_at
- `GET /api/tenant/excel/logs`:上传记录列表,分页,附加 site_id IN 条件
- `GET /api/tenant/excel/template/{type}`:返回空白 Excel 模板文件(含表头和格式说明)
- _需求: 5.1-5.5, 6.1-6.5, 7.1-7.5, 8.1-8.5_
- [ ] 6.3 在 `apps/backend/app/main.py` 中注册 tenant_excel router路径前缀 `/api/tenant/excel`
- _需求: 17.2_
- [ ]* 6.4 编写属性测试Excel 格式校验
- **Property 8: Excel 格式校验**
- 使用 Hypothesis 生成随机模板类型 + 随机数据行(含有效/无效混合)
- 验证:符合模板定义的行标记通过,不符合的行返回行号+列名+错误描述;通过行数 + 警告行数 + 错误行数 = 总行数
- **验证: 需求 5.1, 5.2, 5.3, 5.4, 6.5**
- [ ]* 6.5 编写属性测试:人员匹配校验
- **Property 9: 人员匹配校验**
- 使用 Hypothesis 生成随机助教姓名+编号 + 随机助教/员工数据集
- 验证:优先 v_dim_assistant 匹配,未匹配再查 v_dim_staff匹配成功填充 assistant_id失败标记 warning 不阻断
- **验证: 需求 6.1, 6.2, 6.3, 6.4**
- [ ]* 6.6 编写属性测试:冲突检测
- **Property 10: 冲突检测**
- 使用 Hypothesis 生成随机上传数据 + 随机已有数据
- 验证:按模板主键规则识别冲突行,冲突行返回逐字段 diff非冲突行标记待写入
- **验证: 需求 7.1, 7.2**
- [ ]* 6.7 编写属性测试:数据写入 round-trip
- **Property 11: 数据写入 round-trip**
- 使用 Hypothesis 生成随机数据 + 随机冲突解决方案replace/keep
- 验证写入后从目标表读取的数据与提交一致replace 用新值keep 保持旧值log status=confirmed行数正确
- **验证: 需求 7.4, 7.5, 8.1, 8.2**
- [ ]* 6.8 编写单元测试Excel 上传边界条件
- 测试文件 `apps/backend/tests/unit/test_tenant_excel.py`
- 验证:无效文件格式返回 400、写入失败回滚log status=failed、模板下载返回正确文件
- _需求: 5.5, 8.3, 8.5_
- [ ] 7. 后端路由tenant_clues.py维客线索管理
- [ ] 7.1 创建 Pydantic Schema `apps/backend/app/schemas/tenant_clues.py`
- 定义 `CustomerSearchItem`member_id、姓名、手机号脱敏、所属门店
- 定义 `ClueListItem`id、category、summary、detail、recorded_by_name、source、recorded_at、is_hidden
- 定义 `ClueEditRequest`category 枚举 6 值、summary 非空 ≤200 字符、detail 可选)
- 定义 `ClueVisibilityRequest`is_hidden: bool
- _需求: 9.1, 10.1, 11.1_
- [ ] 7.2 创建 `apps/backend/app/routers/tenant_clues.py`
- `GET /api/tenant/customers/search`keyword + site_id 参数,在管辖门店范围内搜索 v_dim_membernickname 模糊匹配 OR mobile 精确匹配scd2_is_current=1手机号脱敏返回
- `GET /api/tenant/customers/{member_id}/clues`:返回该客户在管辖门店范围内的全部线索,支持 source 和 is_hidden 筛选
- `PATCH /api/tenant/clues/{id}`:编辑线索 category/summary/detail校验 category 枚举和 summary 长度
- `DELETE /api/tenant/clues/{id}`物理删除DELETE FROM线索不存在或不在管辖范围返回 404
- `PATCH /api/tenant/clues/{id}/visibility`:切换 is_hidden 状态
- 所有线索操作先校验线索 site_id 是否在管辖范围内,不在则返回 404
- _需求: 9.1-9.4, 10.1, 11.1-11.3, 12.2-12.3, 13.1-13.4_
- [ ] 7.3 在 `apps/backend/app/main.py` 中注册 tenant_clues router路径前缀 `/api/tenant`
- _需求: 17.2_
- [ ]* 7.4 编写属性测试:客户搜索
- **Property 12: 客户搜索**
- 使用 Hypothesis 生成随机关键词 + 随机客户数据集
- 验证:结果中 nickname 包含关键词(模糊)或 mobile 等于关键词(精确),且 site_id 在管辖范围内
- **验证: 需求 9.1, 9.3**
- [ ]* 7.5 编写属性测试:线索编辑校验
- **Property 13: 线索编辑校验**
- 使用 Hypothesis 生成随机 category含有效/无效值)+ 随机 summary含空/超长)
- 验证:有效参数编辑成功且记录反映新值;无效 category 或空/超长 summary 被拒绝
- **验证: 需求 11.1, 11.2**
- [ ]* 7.6 编写属性测试:线索删除
- **Property 14: 线索删除**
- 使用 Hypothesis 生成随机线索 ID
- 验证:删除后该线索不可通过任何查询获取(物理删除)
- **验证: 需求 12.2**
- [ ]* 7.7 编写属性测试:线索隐藏/显示 round-trip
- **Property 15: 线索隐藏/显示 round-trip**
- 使用 Hypothesis 生成随机线索
- 验证:设 is_hidden=true 后小程序端查询WHERE is_hidden=false不返回该线索管理后台查询返回设回 false 后小程序端恢复返回
- **验证: 需求 13.1, 13.2, 13.3, 15.3**
- [ ]* 7.8 编写单元测试:维客线索边界条件
- 测试文件 `apps/backend/tests/unit/test_tenant_clues.py`
- 验证:线索不存在返回 404、越权访问返回 404、无效 category 返回 422、空 summary 返回 422
- _需求: 11.2, 11.3, 12.3, 13.4_
- [ ] 8. 检查点 — 后端路由模块验证
- 确保所有后端路由模块测试通过4 个路由文件均已注册到 main.pyask the user if questions arise.
- [ ] 9. 前端项目骨架apps/tenant-admin/
- [ ] 9.1 创建 `apps/tenant-admin/` 项目结构
- 初始化 `package.json`React + Vite + Ant Design + axios 依赖)
- 创建 `vite.config.ts`proxy 配置 `/api/tenant` → 后端地址)
- 创建 `tsconfig.json`
- 创建 `index.html` 入口
- _需求: 16.1_
- [ ] 9.2 创建 `apps/tenant-admin/src/services/api.ts` — API 调用封装
- axios 实例baseURL: `/api/tenant`
- 请求拦截器:从 localStorage 读取 access_token 附加 Authorization header
- 响应拦截器401 时用 refresh_token 刷新,刷新失败重定向 `/login`
- 并发刷新保护:多个 401 只触发一次 refresh其余排队
- 响应解包:`{ code: 0, data }` → 提取 `data`
- _需求: 16.4, 16.5_
- [ ] 9.3 创建 `apps/tenant-admin/src/hooks/useAuth.ts` — 认证状态管理
- 登录/登出方法、token 存储、用户信息display_name, managed_site_ids
- _需求: 1.5, 16.3_
- [ ] 9.4 创建 `apps/tenant-admin/src/App.tsx` — 路由配置 + 侧边栏布局
- react-router-dom 路由配置
- Ant Design Layout + Sider 侧边栏导航用户审核、用户管理、Excel 上传、维客线索管理)
- 未认证时重定向到 `/login`
- _需求: 16.2, 16.3_
- [ ] 9.5 创建 `apps/tenant-admin/src/components/SiteSelector/` — 门店筛选器组件
- 页面顶部门店选择器,数据源为当前管理员的 managed_site_ids
- 支持多选/全选
- _需求: 2.4_
- [ ]* 9.6 编写属性测试:响应格式一致性(前端 fast-check
- **Property 16: 响应格式一致性**
- 使用 fast-check 生成随机 API 响应数据
- 验证:成功响应符合 `{ code: 0, data }` 格式;错误响应符合 `{ code: number, message: string }` 格式;分页响应 data 包含 items/total/page/pageSize
- **验证: 需求 17.4**
- [ ] 10. 前端页面:登录页
- [ ] 10.1 创建 `apps/tenant-admin/src/pages/Login/index.tsx`
- 用户名 + 密码表单Ant Design Form + Input + Button
- 调用 `POST /api/tenant/auth/login`,成功后存储 token 并跳转首页
- 错误提示401 显示"用户名或密码错误"、403 显示"账号已被禁用"
- _需求: 1.1, 1.2, 1.3, 16.3_
- [ ] 11. 前端页面:用户审核
- [ ] 11.1 创建 `apps/tenant-admin/src/pages/UserApproval/index.tsx`
- 申请列表表格Ant Design Table支持状态筛选全部/待审核/已通过/已拒绝)+ 分页
- 展示字段:昵称、手机号、球房编号、申请角色、员工编号、申请时间、状态
- 待审核行提供"审核"操作按钮
- _需求: 3.1, 3.2_
- [ ] 11.2 实现审核操作交互
- 点击"审核"打开 Modal展示关联建议列表调用 match-suggestions API
- 通过操作:选择角色 + 关联助教/员工 → 调用 approve API
- 拒绝操作:填写拒绝原因(必填)→ 调用 reject API
- 操作成功后刷新列表
- _需求: 3.3, 3.4, 3.5_
- [ ] 12. 前端页面:用户管理
- [ ] 12.1 创建 `apps/tenant-admin/src/pages/UserManagement/index.tsx`
- 用户列表表格,支持角色筛选 + 关键词搜索 + 分页
- 展示字段:姓名、角色、关联助教姓名、所属门店、账号状态
- 每行提供"编辑"和"绑定"操作按钮
- _需求: 4.1_
- [ ] 12.2 实现编辑与绑定交互
- 编辑 Modal修改角色Select、所属门店Select限管辖范围、账号状态Switch
- 绑定 Modal修改关联助教 ID / 员工 ID
- 禁用用户时二次确认
- _需求: 4.2, 4.3, 4.4_
- [ ] 13. 前端页面Excel 上传
- [ ] 13.1 创建 `apps/tenant-admin/src/pages/ExcelUpload/index.tsx`
- 模板类型选择Radio/Select财务支出/团购收入/助教奖罚/充值业绩归属)
- 模板下载按钮(调用 template/{type} API
- 文件上传组件Ant Design Upload限 .xlsx/.xls
- 上传后展示校验结果:错误行标红、警告行黄色高亮
- 汇总展示:通过行数、警告行数、错误行数
- _需求: 5.1, 5.3, 6.4, 6.5, 8.5_
- [ ] 13.2 创建 `apps/tenant-admin/src/components/DiffTable/index.tsx` — 冲突 diff 交互表格
- 每行显示:字段名、旧值、新值、操作选项(替换/保留 Radio
- 支持"全部替换"和"全部保留"快捷操作按钮
- 确认按钮提交 resolutions 数组
- _需求: 7.3, 7.4_
- [ ] 13.3 实现确认写入与上传记录
- 无冲突时直接展示"待写入"确认按钮
- 有冲突时展示 DiffTable用户选择后确认
- 调用 confirm API 写入,成功/失败提示
- 上传记录 Tab展示历史上传记录列表模板类型、文件名、上传人、时间、行数、冲突数、状态分页
- _需求: 7.5, 8.1, 8.2, 8.4_
- [ ] 14. 前端页面:维客线索管理
- [ ] 14.1 创建 `apps/tenant-admin/src/pages/RetentionClues/index.tsx`
- 客户搜索栏Input.Search支持姓名模糊/手机号精确搜索
- 门店筛选器(复用 SiteSelector 组件)
- 搜索结果列表member_id、姓名、手机号脱敏、所属门店
- 搜索结果为空时展示"未找到匹配客户"提示
- _需求: 9.1, 9.3, 9.4_
- [ ] 14.2 实现线索列表与操作
- 选择客户后展示该客户全部线索,按大类标签分组
- 支持按来源和隐藏状态筛选
- 已隐藏线索以灰色/删除线样式区分
- 每条线索提供:编辑、删除、隐藏/显示操作按钮
- _需求: 10.1, 10.2, 10.3_
- [ ] 14.3 创建 `apps/tenant-admin/src/components/ClueEditor/index.tsx` — 线索编辑表单
- category Select6 值枚举:客户基础/消费习惯/玩法偏好/促销偏好/社交关系/重要反馈)
- summary Input必填最长 200 字符)
- detail TextArea可选
- _需求: 11.1_
- [ ] 14.4 实现删除与隐藏/显示交互
- 删除二次确认对话框Modal.confirm明确提示"删除后不可恢复"
- 隐藏/显示Switch 切换,即时调用 visibility API
- _需求: 12.1, 12.2, 13.1, 13.3_
- [ ] 15. 检查点 — 前端页面验证
- 确保所有前端页面组件渲染正常API 调用层工作正确ask the user if questions arise.
- [ ] 16. 前后端联调与集成
- [ ] 16.1 前端所有页面对接后端真实 API
- 登录页 → tenant_auth 登录/刷新
- 用户审核页 → tenant_users 申请列表/关联建议/审核
- 用户管理页 → tenant_users 用户列表/编辑/绑定
- Excel 上传页 → tenant_excel 上传/校验/冲突/确认/记录/模板下载
- 维客线索页 → tenant_clues 客户搜索/线索列表/编辑/删除/隐藏
- 验证所有页面 API 调用失败时显示友好错误提示,不出现白屏
- _需求: 1.1-1.6, 3.1-3.6, 4.1-4.5, 5.1-5.5, 9.1-9.4, 10.1-10.3, 11.1-11.3, 12.1-12.3, 13.1-13.4_
- [ ] 16.2 DDL 迁移合并到主 DDL 基线
- 执行迁移脚本到 `test_zqyy_app`
- 合并新表定义到 `docs/database/ddl/` 对应基线文件
- _需求: 14.1-14.4, 15.1-15.2_
- [ ] 17. 文档更新
- [ ] 17.1 创建/更新 BD 手册
- 新增 `docs/database/BD_Manual_tenant_admin_tables.md`tenant_admins、excel_upload_log、salary_adjustments、3 张 staging 表的字段明细、约束、索引、验证 SQL
- 更新 `docs/database/BD_Manual_retention_clue.md`(如存在):追加 is_hidden 字段说明
- _规范: db-docs.md_
- [ ] 17.2 更新后端 API 参考文档
-`apps/backend/docs/API-REFERENCE.md` 新增 4 个 tenant 路由模块文档
- 更新 `apps/backend/README.md` 路由模块摘要
- _需求: 17.1-17.4_
- [ ] 17.3 更新文档地图
-`docs/DOCUMENTATION-MAP.md` 新增 NS4 模块条目tenant-admin-web spec、BD 手册、前端项目)
- _规范: doc-map.md_
- [ ] 18. 最终检查点 — 全量验证
- 确保所有后端测试通过(单元测试 + 属性测试)
- 确保前端所有页面连接真实后端运行正常
- 确保 DDL 迁移已合并到主基线BD 手册已同步更新
- 确保 API 文档、后端 README、文档地图均已更新
- ask the user if questions arise.
## 备注
- 标记 `*` 的子任务为可选,可跳过以加速 MVP 交付
- 每个任务引用了具体的需求编号以确保可追溯性
- 属性测试覆盖 16 个正确性属性Property 1-16单元测试覆盖具体边界条件
- 检查点任务确保增量验证,避免问题累积
- 后端使用 PythonFastAPI + Pydantic + Hypothesis前端使用 TypeScriptReact + Vite + Ant Design + fast-check
- 会员信息一律通过 `member_id` JOIN `v_dim_member``scd2_is_current=1`不使用结算单冗余字段DQ-6 规则)
- 多店铺 FDW 查询采用逐 site_id 查询后合并结果的策略

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,66 @@
{ {
"audit_required": false, "audit_required": true,
"db_docs_required": false, "db_docs_required": true,
"reasons": [], "reasons": [
"changed_files": [], "dir:backend",
"change_fingerprint": "", "dir:miniprogram",
"marked_at": "2026-03-20T03:26:08.198013+08:00", "dir:db",
"db-schema-change",
"root-file"
],
"changed_files": [
"apps/DEMO-miniprogram/.gitignore",
"apps/DEMO-miniprogram/.gitkeep",
"apps/DEMO-miniprogram/README.md",
"apps/DEMO-miniprogram/doc/ABANDON_MODAL_COMPONENT.md",
"apps/DEMO-miniprogram/doc/KEYBOARD_INTERACTION_FIX.md",
"apps/DEMO-miniprogram/doc/TASK_ABANDON_IMPROVEMENTS.md",
"apps/DEMO-miniprogram/doc/TASK_ABANDON_QUICK_REFERENCE.md",
"apps/DEMO-miniprogram/doc/progress-bar-animation.md",
"apps/DEMO-miniprogram/doc/useless/ABANDON_MODAL_COMPONENT.md",
"apps/DEMO-miniprogram/doc/useless/KEYBOARD_INTERACTION_FIX.md",
"apps/DEMO-miniprogram/doc/useless/TASK_ABANDON_IMPROVEMENTS.md",
"apps/DEMO-miniprogram/doc/useless/TASK_ABANDON_QUICK_REFERENCE.md",
"apps/DEMO-miniprogram/doc/useless/progress-bar-animation.md",
"apps/DEMO-miniprogram/i18n/base.json",
"apps/DEMO-miniprogram/jest.config.js",
"apps/DEMO-miniprogram/miniprogram/app.json",
"apps/DEMO-miniprogram/miniprogram/app.miniapp.json",
"apps/DEMO-miniprogram/miniprogram/app.ts",
"apps/DEMO-miniprogram/miniprogram/app.wxss",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/ai-robot-sm.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/ai-robot-title.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/arrow-left.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/chart.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/chat-gray.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/chat.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/check-bold.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/check-circle.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/clock.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/forbidden.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/help-circle.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/icon-ai-float.png",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/icon-ai-inline.png",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/info-circle.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/info-error.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/info-warning.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/logout.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/tab-board-active.png",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/tab-board.png",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/tab-my-active.png",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/tab-my.png",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/tab-task-active.png",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/tab-task.png",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/task.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/_archived/wechat.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/ai-robot-badge.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/ai-robot-inline.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/ai-robot.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/ball-black.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/ball-gray.svg",
"apps/DEMO-miniprogram/miniprogram/assets/icons/feature-ai.svg"
],
"change_fingerprint": "c347e0fb24548a8427f63a65a48dbf7df0b4a734",
"marked_at": "2026-03-20T09:01:30.178895+08:00",
"last_reminded_at": null "last_reminded_at": null
} }

View File

@@ -1,41 +1,13 @@
{ {
"needs_check": true, "needs_check": false,
"scanned_at": "2026-03-20T03:26:03.443110+08:00", "scanned_at": "2026-03-20T08:32:06.937993+08:00",
"new_migration_sql": [ "new_migration_sql": [],
"db/zqyy_app/migrations/2026-03-20_rebuild_rls_view_gift_breakdown.sql", "new_or_modified_sql": [],
"db/zqyy_app/migrations/2026-03-20_refresh_fdw_finance_recharge_summary.sql" "code_without_docs": [],
],
"new_or_modified_sql": [
"db/zqyy_app/migrations/2026-03-20_rebuild_rls_view_gift_breakdown.sql",
"db/zqyy_app/migrations/2026-03-20_refresh_fdw_finance_recharge_summary.sql",
"docs/database/ddl/etl_feiqiu__app.sql",
"scripts/ops/verify_gift_card_breakdown.sql"
],
"code_without_docs": [
{
"file": "apps/backend/app/services/fdw_queries.py",
"expected_docs": [
"apps/backend/docs/API-REFERENCE.md",
"apps/backend/README.md"
]
},
{
"file": "apps/etl/connectors/feiqiu/tasks/dws/finance_recharge_task.py",
"expected_docs": [
"apps/etl/connectors/feiqiu/docs/etl_tasks/"
]
},
{
"file": "apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts",
"expected_docs": [
"apps/miniprogram/README.md"
]
}
],
"new_files": [], "new_files": [],
"has_bd_manual": false, "has_bd_manual": false,
"has_audit_record": false, "has_audit_record": false,
"has_ddl_baseline": true, "has_ddl_baseline": false,
"api_changed": false, "api_changed": false,
"openapi_spec_stale": false "openapi_spec_stale": false
} }

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
{ {
"prompt_id": "P20260320-032608", "prompt_id": "P20260320-090130",
"at": "2026-03-20T03:26:08.198013+08:00" "at": "2026-03-20T09:01:30.178895+08:00"
} }

View File

@@ -36,7 +36,7 @@ DWSData Warehouse Summary层从 DWD 明细层按业务维度聚合计算
| 任务代码 | 目标表 | 粒度 | 核心指标 | | 任务代码 | 目标表 | 粒度 | 核心指标 |
|----------|--------|------|----------| |----------|--------|------|----------|
| `DWS_FINANCE_DAILY` | `dws_finance_daily_summary` | 日期 | 发生额、优惠合计、确认收入、现金流入/流出/净变动、卡消费、充值统计 | | `DWS_FINANCE_DAILY` | `dws_finance_daily_summary` | 日期 | 发生额、优惠合计、确认收入、现金流入/流出/净变动、卡消费、充值统计 |
| `DWS_FINANCE_RECHARGE` | `dws_finance_recharge_summary` | 日期 | 充值笔数/总额、首充/续充拆分、去重会员数、全店卡余额快照 | | `DWS_FINANCE_RECHARGE` | `dws_finance_recharge_summary` | 日期 | 充值笔数/总额、首充/续充拆分、去重会员数、全店卡余额快照、赠送卡按卡类型拆分(酒水卡/台费卡/抵用券 × 余额+新增) |
| `DWS_FINANCE_INCOME_STRUCTURE` | `dws_finance_income_structure` | 日期+收入类型 | 按收入类型(台费/商品/助教基础课/附加课)和区域分析 | | `DWS_FINANCE_INCOME_STRUCTURE` | `dws_finance_income_structure` | 日期+收入类型 | 按收入类型(台费/商品/助教基础课/附加课)和区域分析 |
| `DWS_FINANCE_DISCOUNT_DETAIL` | `dws_finance_discount_detail` | 日期+折扣类型 | 折扣类型拆分GROUPBUY/VIP/ROUNDING/GIFT_CARD_*/BIG_CUSTOMER/OTHER | | `DWS_FINANCE_DISCOUNT_DETAIL` | `dws_finance_discount_detail` | 日期+折扣类型 | 折扣类型拆分GROUPBUY/VIP/ROUNDING/GIFT_CARD_*/BIG_CUSTOMER/OTHER |
@@ -241,7 +241,7 @@ DWSData Warehouse Summary层从 DWD 明细层按业务维度聚合计算
`dws_assistant_daily_detail``dws_assistant_monthly_summary``dws_assistant_customer_stats``dws_assistant_salary_calc``dws_assistant_finance_analysis``dws_assistant_order_contribution``dws_member_consumption_summary``dws_member_visit_detail``dws_finance_daily_summary``dws_finance_recharge_summary``dws_finance_income_structure``dws_finance_discount_detail``dws_goods_stock_daily_summary``dws_goods_stock_weekly_summary``dws_goods_stock_monthly_summary``dws_order_summary` `dws_assistant_daily_detail``dws_assistant_monthly_summary``dws_assistant_customer_stats``dws_assistant_salary_calc``dws_assistant_finance_analysis``dws_assistant_order_contribution``dws_member_consumption_summary``dws_member_visit_detail``dws_finance_daily_summary``dws_finance_recharge_summary``dws_finance_income_structure``dws_finance_discount_detail``dws_goods_stock_daily_summary``dws_goods_stock_weekly_summary``dws_goods_stock_monthly_summary``dws_order_summary`
### 指数表 ### 指数表
`dws_member_winback_index``dws_member_newconv_index``dws_member_recall_index``dws_member_assistant_relation_index``dws_member_assistant_intimacy``dws_member_spending_power_index``dws_index_percentile_history` `dws_member_winback_index``dws_member_newconv_index``dws_member_assistant_relation_index``dws_member_assistant_intimacy``dws_member_spending_power_index``dws_index_percentile_history`
### 其他表 ### 其他表
`dws_platform_settlement``dws_ml_manual_order_source``dws_ml_manual_order_alloc``dws_assistant_recharge_commission``dws_assistant_project_tag``dws_member_project_tag` `dws_platform_settlement``dws_ml_manual_order_source``dws_ml_manual_order_alloc``dws_assistant_recharge_commission``dws_assistant_project_tag``dws_member_project_tag`

View File

@@ -38,6 +38,7 @@ NeoZQYY Monorepo — 面向台球门店业务的全栈数据平台。多门店
| `ods.assistant_cancellation_records` | ODS 表 | 2026-02-22 | 不再需要独立链路 | | `ods.assistant_cancellation_records` | ODS 表 | 2026-02-22 | 不再需要独立链路 |
| `ODS_ASSISTANT_ABOLISH` / `ASSISTANT_ABOLISH` | ETL/调度任务 | 2026-02-22 | 无 | | `ODS_ASSISTANT_ABOLISH` / `ASSISTANT_ABOLISH` | ETL/调度任务 | 2026-02-22 | 无 |
| `BILLIARD_VIP` | cfg_area_category 分类代码 | 2026-03-07 | V1-V4 归入 `BILLIARD`V5 归入 `SNOOKER` | | `BILLIARD_VIP` | cfg_area_category 分类代码 | 2026-03-07 | V1-V4 归入 `BILLIARD`V5 归入 `SNOOKER` |
| `dws_member_recall_index` / `v_dws_member_recall_index` | DWS 表 + RLS 视图 | 2026-03-20 | `dws_member_winback_index`WBI+ `dws_member_newconv_index`NCI |
所有 `_archived/` 目录存放已废弃内容,除非用户明确要求,禁止读取或参考。 所有 `_archived/` 目录存放已废弃内容,除非用户明确要求,禁止读取或参考。

View File

@@ -1,11 +0,0 @@
{
"ios": {
"name": "桌球运营助手"
},
"android": {
"name": "桌球运营助手"
},
"common": {
"name": "桌球运营助手"
}
}

View File

@@ -1,14 +0,0 @@
{
"pages": [
"pages/index/index",
"pages/logs/logs"
],
"window": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "Weixin",
"navigationBarBackgroundColor": "#ffffff"
},
"style": "v2",
"componentFramework": "glass-easel",
"lazyCodeLoading": "requiredComponents"
}

View File

@@ -1,5 +0,0 @@
{
"adapteByMiniprogram": {
"userName": "gh_521029c3a9c7"
}
}

View File

@@ -1,18 +0,0 @@
// app.ts
App<IAppOption>({
globalData: {},
onLaunch() {
// 展示本地存储能力
const logs = wx.getStorageSync('logs') || []
logs.unshift(Date.now())
wx.setStorageSync('logs', logs)
// 登录
wx.login({
success: res => {
console.log(res.code)
// 发送 res.code 到后台换取 openId, sessionKey, unionId
},
})
},
})

View File

@@ -1,10 +0,0 @@
/**app.wxss**/
.container {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: 200rpx 0;
box-sizing: border-box;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 806 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 778 B

View File

@@ -1,4 +0,0 @@
{
"usingComponents": {
}
}

View File

@@ -1,54 +0,0 @@
// index.ts
// 获取应用实例
const app = getApp<IAppOption>()
const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
Component({
data: {
motto: 'Hello World',
userInfo: {
avatarUrl: defaultAvatarUrl,
nickName: '',
},
hasUserInfo: false,
canIUseGetUserProfile: wx.canIUse('getUserProfile'),
canIUseNicknameComp: wx.canIUse('input.type.nickname'),
},
methods: {
// 事件处理函数
bindViewTap() {
wx.navigateTo({
url: '../logs/logs',
})
},
onChooseAvatar(e: any) {
const { avatarUrl } = e.detail
const { nickName } = this.data.userInfo
this.setData({
"userInfo.avatarUrl": avatarUrl,
hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl,
})
},
onInputChange(e: any) {
const nickName = e.detail.value
const { avatarUrl } = this.data.userInfo
this.setData({
"userInfo.nickName": nickName,
hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl,
})
},
getUserProfile() {
// 推荐使用wx.getUserProfile获取用户信息开发者每次通过该接口获取用户个人信息均需用户确认开发者妥善保管用户快速填写的头像昵称避免重复弹窗
wx.getUserProfile({
desc: '展示用户信息', // 声明获取用户个人信息后的用途,后续会展示在弹窗中,请谨慎填写
success: (res) => {
console.log(res)
this.setData({
userInfo: res.userInfo,
hasUserInfo: true
})
}
})
},
},
})

View File

@@ -1,27 +0,0 @@
<!--index.wxml-->
<scroll-view class="scrollarea" scroll-y type="list">
<view class="container">
<view class="userinfo">
<block wx:if="{{canIUseNicknameComp && !hasUserInfo}}">
<button class="avatar-wrapper" open-type="chooseAvatar" bind:chooseavatar="onChooseAvatar">
<image class="avatar" src="{{userInfo.avatarUrl}}"></image>
</button>
<view class="nickname-wrapper">
<text class="nickname-label">😀昵称</text>
<input type="nickname" class="nickname-input" placeholder="请输入昵称" bind:change="onInputChange" />
</view>
</block>
<block wx:elif="{{!hasUserInfo}}">
<button wx:if="{{canIUseGetUserProfile}}" bindtap="getUserProfile"> 获取头像昵称 </button>
<view wx:else> 请使用2.10.4及以上版本基础库 </view>
</block>
<block wx:else>
<image bindtap="bindViewTap" class="userinfo-avatar" src="{{userInfo.avatarUrl}}" mode="cover"></image>
<text class="userinfo-nickname">{{userInfo.nickName}}</text>
</block>
</view>
<view class="usermotto">
<text class="user-motto">{{motto}}</text>
</view>
</view>
</scroll-view>

View File

@@ -1,62 +0,0 @@
/**index.wxss**/
page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scrollarea {
flex: 1;
overflow-y: hidden;
}
.userinfo {
display: flex;
flex-direction: column;
align-items: center;
color: #aaa;
width: 80%;
}
.userinfo-avatar {
overflow: hidden;
width: 128rpx;
height: 128rpx;
margin: 20rpx;
border-radius: 50%;
}
.usermotto {
margin-top: 200px;
}
.avatar-wrapper {
padding: 0;
width: 56px !important;
border-radius: 8px;
margin-top: 40px;
margin-bottom: 40px;
}
.avatar {
display: block;
width: 56px;
height: 56px;
}
.nickname-wrapper {
display: flex;
width: 100%;
padding: 16px;
box-sizing: border-box;
border-top: .5px solid rgba(0, 0, 0, 0.1);
border-bottom: .5px solid rgba(0, 0, 0, 0.1);
color: black;
}
.nickname-label {
width: 105px;
}
.nickname-input {
flex: 1;
}

View File

@@ -1,19 +0,0 @@
export const formatTime = (date: Date) => {
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
const hour = date.getHours()
const minute = date.getMinutes()
const second = date.getSeconds()
return (
[year, month, day].map(formatNumber).join('/') +
' ' +
[hour, minute, second].map(formatNumber).join(':')
)
}
const formatNumber = (n: number) => {
const s = n.toString()
return s[1] ? s : '0' + s
}

View File

@@ -1,15 +0,0 @@
{
"name": "miniprogram-ts-quickstart",
"version": "1.0.0",
"description": "",
"scripts": {
},
"keywords": [],
"author": "",
"license": "",
"dependencies": {
},
"devDependencies": {
"miniprogram-api-typings": "^2.8.3-1"
}
}

View File

@@ -1,53 +0,0 @@
{
"description": "项目配置文件",
"miniprogramRoot": "miniprogram/",
"compileType": "miniprogram",
"setting": {
"useCompilerPlugins": [
"typescript"
],
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"coverView": false,
"postcss": false,
"minified": false,
"enhance": false,
"showShadowRootInWxmlPanel": false,
"packNpmRelationList": [],
"ignoreUploadUnusedFiles": false,
"compileHotReLoad": false,
"skylineRenderEnable": true,
"condition": false,
"es6": false,
"compileWorklet": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"packNpmManually": false,
"minifyWXSS": false,
"minifyWXML": false,
"localPlugins": false,
"swc": false,
"disableSWC": true,
"disableUseStrict": false
},
"simulatorType": "wechat",
"simulatorPluginLibVersion": {
"wxext14566970e7e9f62": "3.6.5-41"
},
"condition": {},
"srcMiniprogramRoot": "miniprogram/",
"editorSetting": {
"tabIndent": "insertSpaces",
"tabSize": 2
},
"libVersion": "trial",
"packOptions": {
"ignore": [],
"include": []
},
"appid": "wx7c07793d82732921",
"projectArchitecture": "multiPlatform"
}

View File

@@ -1,68 +0,0 @@
{
"miniVersion": "v2",
"name": "%name%",
"version": "0.0.1",
"versionCode": 100,
"i18nFilePath": "i18n",
"mini-ohos": {
"sdkVersion": "0.5.1"
},
"mini-android": {
"resourcePath": "miniapp/android/nativeResources",
"sdkVersion": "1.6.24",
"toolkitVersion": "0.11.0",
"useExtendedSdk": {
"media": false,
"bluetooth": false,
"network": false,
"scanner": false,
"xweb": false
},
"icons": {
"hdpi": "",
"xhdpi": "",
"xxhdpi": "",
"xxxhdpi": ""
},
"splashscreen": {
"hdpi": "",
"xhdpi": "",
"xxhdpi": ""
},
"enableVConsole": "open",
"privacy": {
"enable": true
}
},
"mini-ios": {
"sdkVersion": "1.6.28",
"toolkitVersion": "0.0.9",
"useExtendedSdk": {
"WeAppOpenFuns": true,
"WeAppNetwork": false,
"WeAppBluetooth": false,
"WeAppMedia": false,
"WeAppLBS": false,
"WeAppOthers": false
},
"enableVConsole": "open",
"icons": {
"mainIcon120": "",
"mainIcon180": "",
"spotlightIcon80": "",
"spotlightIcon120": "",
"settingsIcon58": "",
"settingsIcon87": "",
"notificationIcon40": "",
"notificationIcon60": "",
"appStore1024": ""
},
"splashScreen": {
"customImage": ""
},
"privacy": {
"enable": false
},
"enableOpenUrlNavigate": true
}
}

View File

@@ -1,22 +0,0 @@
{
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
"projectname": "XCX-TEST",
"setting": {
"compileHotReLoad": false,
"urlCheck": true,
"coverView": false,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"useApiHook": true,
"showShadowRootInWxmlPanel": false,
"useStaticServer": false,
"useLanDebug": false,
"showES6CompileOption": false,
"bigPackageSizeSupport": false,
"checkInvalidKey": true,
"ignoreDevUnusedFiles": true
},
"libVersion": "3.14.3"
}

View File

@@ -1,30 +0,0 @@
{
"compilerOptions": {
"strictNullChecks": true,
"noImplicitAny": true,
"module": "CommonJS",
"target": "ES2020",
"allowJs": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"noImplicitThis": true,
"noImplicitReturns": true,
"alwaysStrict": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"strict": true,
"strictPropertyInitialization": true,
"lib": ["ES2020"],
"typeRoots": [
"./typings"
]
},
"include": [
"./**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -1,8 +0,0 @@
/// <reference path="./types/index.d.ts" />
interface IAppOption {
globalData: {
userInfo?: WechatMiniprogram.UserInfo,
}
userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback,
}

View File

@@ -1 +0,0 @@
/// <reference path="./wx/index.d.ts" />

View File

@@ -1,74 +0,0 @@
/*! *****************************************************************************
Copyright (c) 2021 Tencent, Inc. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
***************************************************************************** */
/// <reference path="./lib.wx.app.d.ts" />
/// <reference path="./lib.wx.page.d.ts" />
/// <reference path="./lib.wx.api.d.ts" />
/// <reference path="./lib.wx.cloud.d.ts" />
/// <reference path="./lib.wx.component.d.ts" />
/// <reference path="./lib.wx.behavior.d.ts" />
/// <reference path="./lib.wx.event.d.ts" />
declare namespace WechatMiniprogram {
type IAnyObject = Record<string, any>
type Optional<F> = F extends (arg: infer P) => infer R ? (arg?: P) => R : F
type OptionalInterface<T> = { [K in keyof T]: Optional<T[K]> }
interface AsyncMethodOptionLike {
success?: (...args: any[]) => void
}
type PromisifySuccessResult<
P,
T extends AsyncMethodOptionLike
> = P extends { success: any }
? void
: P extends { fail: any }
? void
: P extends { complete: any }
? void
: Promise<Parameters<Exclude<T['success'], undefined>>[0]>
}
declare const console: WechatMiniprogram.Console
declare const wx: WechatMiniprogram.Wx
/** 引入模块。返回模块通过 `module.exports` 或 `exports` 暴露的接口。 */
declare function require(
/** 需要引入模块文件相对于当前文件的相对路径,或 npm 模块名,或 npm 模块路径。不支持绝对路径 */
module: string
): any
/** 引入插件。返回插件通过 `main` 暴露的接口。 */
declare function requirePlugin(
/** 需要引入的插件的 alias */
module: string
): any
/** 插件引入当前使用者小程序。返回使用者小程序通过 [插件配置中 `export` 暴露的接口](https://developers.weixin.qq.com/miniprogram/dev/framework/plugin/using.html#%E5%AF%BC%E5%87%BA%E5%88%B0%E6%8F%92%E4%BB%B6)。
*
* 该接口只在插件中存在
*
* 最低基础库: `2.11.1` */
declare function requireMiniProgram(): any
/** 当前模块对象 */
declare let module: {
/** 模块向外暴露的对象,使用 `require` 引用该模块时可以获取 */
exports: any
}
/** `module.exports` 的引用 */
declare let exports: any

File diff suppressed because it is too large Load Diff

View File

@@ -1,270 +0,0 @@
/*! *****************************************************************************
Copyright (c) 2021 Tencent, Inc. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
***************************************************************************** */
declare namespace WechatMiniprogram.App {
interface ReferrerInfo {
/** 来源小程序或公众号或App的 appId
*
* 以下场景支持返回 referrerInfo.appId
* - 1020公众号 profile 页相关小程序列表): appId
* - 1035公众号自定义菜单来源公众号 appId
* - 1036App 分享消息卡片):来源应用 appId
* - 1037小程序打开小程序来源小程序 appId
* - 1038从另一个小程序返回来源小程序 appId
* - 1043公众号模板消息来源公众号 appId
*/
appId: string
/** 来源小程序传过来的数据scene=1037或1038时支持 */
extraData?: any
}
type SceneValues =
| 1001
| 1005
| 1006
| 1007
| 1008
| 1011
| 1012
| 1013
| 1014
| 1017
| 1019
| 1020
| 1023
| 1024
| 1025
| 1026
| 1027
| 1028
| 1029
| 1030
| 1031
| 1032
| 1034
| 1035
| 1036
| 1037
| 1038
| 1039
| 1042
| 1043
| 1044
| 1045
| 1046
| 1047
| 1048
| 1049
| 1052
| 1053
| 1056
| 1057
| 1058
| 1059
| 1064
| 1067
| 1069
| 1071
| 1072
| 1073
| 1074
| 1077
| 1078
| 1079
| 1081
| 1082
| 1084
| 1089
| 1090
| 1091
| 1092
| 1095
| 1096
| 1097
| 1099
| 1102
| 1124
| 1125
| 1126
| 1129
interface LaunchShowOption {
/** 打开小程序的路径 */
path: string
/** 打开小程序的query */
query: IAnyObject
/** 打开小程序的场景值
* - 1001发现栏小程序主入口「最近使用」列表基础库2.2.4版本起包含「我的小程序」列表)
* - 1005微信首页顶部搜索框的搜索结果页
* - 1006发现栏小程序主入口搜索框的搜索结果页
* - 1007单人聊天会话中的小程序消息卡片
* - 1008群聊会话中的小程序消息卡片
* - 1011扫描二维码
* - 1012长按图片识别二维码
* - 1013扫描手机相册中选取的二维码
* - 1014小程序模板消息
* - 1017前往小程序体验版的入口页
* - 1019微信钱包微信客户端7.0.0版本改为支付入口)
* - 1020公众号 profile 页相关小程序列表
* - 1023安卓系统桌面图标
* - 1024小程序 profile 页
* - 1025扫描一维码
* - 1026发现栏小程序主入口「附近的小程序」列表
* - 1027微信首页顶部搜索框搜索结果页「使用过的小程序」列表
* - 1028我的卡包
* - 1029小程序中的卡券详情页
* - 1030自动化测试下打开小程序
* - 1031长按图片识别一维码
* - 1032扫描手机相册中选取的一维码
* - 1034微信支付完成页
* - 1035公众号自定义菜单
* - 1036App 分享消息卡片
* - 1037小程序打开小程序
* - 1038从另一个小程序返回
* - 1039摇电视
* - 1042添加好友搜索框的搜索结果页
* - 1043公众号模板消息
* - 1044带 shareTicket 的小程序消息卡片 [详情](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/share.html)
* - 1045朋友圈广告
* - 1046朋友圈广告详情页
* - 1047扫描小程序码
* - 1048长按图片识别小程序码
* - 1049扫描手机相册中选取的小程序码
* - 1052卡券的适用门店列表
* - 1053搜一搜的结果页
* - 1056聊天顶部音乐播放器右上角菜单
* - 1057钱包中的银行卡详情页
* - 1058公众号文章
* - 1059体验版小程序绑定邀请页
* - 1064微信首页连Wi-Fi状态栏
* - 1067公众号文章广告
* - 1069移动应用
* - 1071钱包中的银行卡列表页
* - 1072二维码收款页面
* - 1073客服消息列表下发的小程序消息卡片
* - 1074公众号会话下发的小程序消息卡片
* - 1077摇周边
* - 1078微信连Wi-Fi成功提示页
* - 1079微信游戏中心
* - 1081客服消息下发的文字链
* - 1082公众号会话下发的文字链
* - 1084朋友圈广告原生页
* - 1089微信聊天主界面下拉「最近使用」栏基础库2.2.4版本起包含「我的小程序」栏)
* - 1090长按小程序右上角菜单唤出最近使用历史
* - 1091公众号文章商品卡片
* - 1092城市服务入口
* - 1095小程序广告组件
* - 1096聊天记录
* - 1097微信支付签约页
* - 1099页面内嵌插件
* - 1102公众号 profile 页服务预览
* - 1124扫“一物一码”打开小程序
* - 1125长按图片识别“一物一码”
* - 1126扫描手机相册中选取的“一物一码”
* - 1129微信爬虫访问 [详情](https://developers.weixin.qq.com/miniprogram/dev/reference/configuration/sitemap.html)
*/
scene: SceneValues
/** shareTicket详见 [获取更多转发信息]((转发#获取更多转发信息)) */
shareTicket: string
/** 当场景为由从另一个小程序或公众号或App打开时返回此字段 */
referrerInfo?: ReferrerInfo
}
interface PageNotFoundOption {
/** 不存在页面的路径 */
path: string
/** 打开不存在页面的 query */
query: IAnyObject
/** 是否本次启动的首个页面(例如从分享等入口进来,首个页面是开发者配置的分享页面) */
isEntryPage: boolean
}
interface Option {
/** 生命周期回调—监听小程序初始化
*
* 小程序初始化完成时触发,全局只触发一次。
*/
onLaunch(options: LaunchShowOption): void
/** 生命周期回调—监听小程序显示
*
* 小程序启动,或从后台进入前台显示时
*/
onShow(options: LaunchShowOption): void
/** 生命周期回调—监听小程序隐藏
*
* 小程序从前台进入后台时
*/
onHide(): void
/** 错误监听函数
*
* 小程序发生脚本错误,或者 api
*/
onError(/** 错误信息,包含堆栈 */ error: string): void
/** 页面不存在监听函数
*
* 小程序要打开的页面不存在时触发,会带上页面信息回调该函数
*
* **注意:**
* 1. 如果开发者没有添加 `onPageNotFound` 监听,当跳转页面不存在时,将推入微信客户端原生的页面不存在提示页面。
* 2. 如果 `onPageNotFound` 回调中又重定向到另一个不存在的页面,将推入微信客户端原生的页面不存在提示页面,并且不再回调 `onPageNotFound`。
*
* 最低基础库: 1.9.90
*/
onPageNotFound(options: PageNotFoundOption): void
/**
* 小程序有未处理的 Promise 拒绝时触发。也可以使用 [wx.onUnhandledRejection](https://developers.weixin.qq.com/miniprogram/dev/api/base/app/app-event/wx.onUnhandledRejection.html) 绑定监听。注意事项请参考 [wx.onUnhandledRejection](https://developers.weixin.qq.com/miniprogram/dev/api/base/app/app-event/wx.onUnhandledRejection.html)。
* **参数**:与 [wx.onUnhandledRejection](https://developers.weixin.qq.com/miniprogram/dev/api/base/app/app-event/wx.onUnhandledRejection.html) 一致
*/
onUnhandledRejection: OnUnhandledRejectionCallback
/**
* 系统切换主题时触发。也可以使用 wx.onThemeChange 绑定监听。
*
* 最低基础库: 2.11.0
*/
onThemeChange: OnThemeChangeCallback
}
type Instance<T extends IAnyObject> = Option & T
type Options<T extends IAnyObject> = Partial<Option> &
T &
ThisType<Instance<T>>
type TrivialInstance = Instance<IAnyObject>
interface Constructor {
<T extends IAnyObject>(options: Options<T>): void
}
interface GetAppOption {
/** 在 `App` 未定义时返回默认实现。当App被调用时默认实现中定义的属性会被覆盖合并到App中。一般用于独立分包
*
* 最低基础库: 2.2.4
*/
allowDefault?: boolean
}
interface GetApp {
<T = IAnyObject>(opts?: GetAppOption): Instance<T>
}
}
declare let App: WechatMiniprogram.App.Constructor
declare let getApp: WechatMiniprogram.App.GetApp

View File

@@ -1,68 +0,0 @@
/*! *****************************************************************************
Copyright (c) 2021 Tencent, Inc. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
***************************************************************************** */
declare namespace WechatMiniprogram.Behavior {
type BehaviorIdentifier = string
type Instance<
TData extends DataOption,
TProperty extends PropertyOption,
TMethod extends MethodOption,
TCustomInstanceProperty extends IAnyObject = Record<string, never>
> = Component.Instance<TData, TProperty, TMethod, TCustomInstanceProperty>
type TrivialInstance = Instance<IAnyObject, IAnyObject, IAnyObject>
type TrivialOption = Options<IAnyObject, IAnyObject, IAnyObject>
type Options<
TData extends DataOption,
TProperty extends PropertyOption,
TMethod extends MethodOption,
TCustomInstanceProperty extends IAnyObject = Record<string, never>
> = Partial<Data<TData>> &
Partial<Property<TProperty>> &
Partial<Method<TMethod>> &
Partial<OtherOption> &
Partial<Lifetimes> &
ThisType<Instance<TData, TProperty, TMethod, TCustomInstanceProperty>>
interface Constructor {
<
TData extends DataOption,
TProperty extends PropertyOption,
TMethod extends MethodOption,
TCustomInstanceProperty extends IAnyObject = Record<string, never>
>(
options: Options<TData, TProperty, TMethod, TCustomInstanceProperty>
): BehaviorIdentifier
}
type DataOption = Component.DataOption
type PropertyOption = Component.PropertyOption
type MethodOption = Component.MethodOption
type Data<D extends DataOption> = Component.Data<D>
type Property<P extends PropertyOption> = Component.Property<P>
type Method<M extends MethodOption> = Component.Method<M>
type DefinitionFilter = Component.DefinitionFilter
type Lifetimes = Component.Lifetimes
type OtherOption = Omit<Component.OtherOption, 'options'>
}
/** 注册一个 `behavior`,接受一个 `Object` 类型的参数。*/
declare let Behavior: WechatMiniprogram.Behavior.Constructor

View File

@@ -1,924 +0,0 @@
/*! *****************************************************************************
Copyright (c) 2021 Tencent, Inc. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
***************************************************************************** */
interface IAPIError {
errMsg: string
}
interface IAPIParam<T = any> {
config?: ICloudConfig
success?: (res: T) => void
fail?: (err: IAPIError) => void
complete?: (val: T | IAPIError) => void
}
interface IAPISuccessParam {
errMsg: string
}
type IAPICompleteParam = IAPISuccessParam | IAPIError
type IAPIFunction<T, P extends IAPIParam<T>> = (param?: P) => Promise<T>
interface IInitCloudConfig {
env?:
| string
| {
database?: string
functions?: string
storage?: string
}
traceUser?: boolean
}
interface ICloudConfig {
env?: string
traceUser?: boolean
}
interface IICloudAPI {
init: (config?: IInitCloudConfig) => void
[api: string]: AnyFunction | IAPIFunction<any, any>
}
interface ICloudService {
name: string
getAPIs: () => { [name: string]: IAPIFunction<any, any> }
}
interface ICloudServices {
[serviceName: string]: ICloudService
}
interface ICloudMetaData {
session_id: string
}
declare class InternalSymbol {}
interface AnyObject {
[x: string]: any
}
type AnyArray = any[]
type AnyFunction = (...args: any[]) => any
/**
* extend wx with cloud
*/
interface WxCloud {
init: (config?: ICloudConfig) => void
callFunction(param: OQ<ICloud.CallFunctionParam>): void
callFunction(
param: RQ<ICloud.CallFunctionParam>
): Promise<ICloud.CallFunctionResult>
uploadFile(param: OQ<ICloud.UploadFileParam>): WechatMiniprogram.UploadTask
uploadFile(
param: RQ<ICloud.UploadFileParam>
): Promise<ICloud.UploadFileResult>
downloadFile(
param: OQ<ICloud.DownloadFileParam>
): WechatMiniprogram.DownloadTask
downloadFile(
param: RQ<ICloud.DownloadFileParam>
): Promise<ICloud.DownloadFileResult>
getTempFileURL(param: OQ<ICloud.GetTempFileURLParam>): void
getTempFileURL(
param: RQ<ICloud.GetTempFileURLParam>
): Promise<ICloud.GetTempFileURLResult>
deleteFile(param: OQ<ICloud.DeleteFileParam>): void
deleteFile(
param: RQ<ICloud.DeleteFileParam>
): Promise<ICloud.DeleteFileResult>
database: (config?: ICloudConfig) => DB.Database
CloudID: ICloud.ICloudIDConstructor
CDN: ICloud.ICDNConstructor
}
declare namespace ICloud {
interface ICloudAPIParam<T = any> extends IAPIParam<T> {
config?: ICloudConfig
}
// === API: callFunction ===
type CallFunctionData = AnyObject
interface CallFunctionResult extends IAPISuccessParam {
result: AnyObject | string | undefined
}
interface CallFunctionParam extends ICloudAPIParam<CallFunctionResult> {
name: string
data?: CallFunctionData
slow?: boolean
}
// === end ===
// === API: uploadFile ===
interface UploadFileResult extends IAPISuccessParam {
fileID: string
statusCode: number
}
interface UploadFileParam extends ICloudAPIParam<UploadFileResult> {
cloudPath: string
filePath: string
header?: AnyObject
}
// === end ===
// === API: downloadFile ===
interface DownloadFileResult extends IAPISuccessParam {
tempFilePath: string
statusCode: number
}
interface DownloadFileParam extends ICloudAPIParam<DownloadFileResult> {
fileID: string
cloudPath?: string
}
// === end ===
// === API: getTempFileURL ===
interface GetTempFileURLResult extends IAPISuccessParam {
fileList: GetTempFileURLResultItem[]
}
interface GetTempFileURLResultItem {
fileID: string
tempFileURL: string
maxAge: number
status: number
errMsg: string
}
interface GetTempFileURLParam extends ICloudAPIParam<GetTempFileURLResult> {
fileList: string[]
}
// === end ===
// === API: deleteFile ===
interface DeleteFileResult extends IAPISuccessParam {
fileList: DeleteFileResultItem[]
}
interface DeleteFileResultItem {
fileID: string
status: number
errMsg: string
}
interface DeleteFileParam extends ICloudAPIParam<DeleteFileResult> {
fileList: string[]
}
// === end ===
// === API: CloudID ===
abstract class CloudID {
constructor(cloudID: string)
}
interface ICloudIDConstructor {
new (cloudId: string): CloudID
(cloudId: string): CloudID
}
// === end ===
// === API: CDN ===
abstract class CDN {
target: string | ArrayBuffer | ICDNFilePathSpec
constructor(target: string | ArrayBuffer | ICDNFilePathSpec)
}
interface ICDNFilePathSpec {
type: 'filePath'
filePath: string
}
interface ICDNConstructor {
new (options: string | ArrayBuffer | ICDNFilePathSpec): CDN
(options: string | ArrayBuffer | ICDNFilePathSpec): CDN
}
// === end ===
}
// === Database ===
declare namespace DB {
/**
* The class of all exposed cloud database instances
*/
class Database {
readonly config: ICloudConfig
readonly command: DatabaseCommand
readonly Geo: IGeo
readonly serverDate: () => ServerDate
readonly RegExp: IRegExpConstructor
private constructor()
collection(collectionName: string): CollectionReference
}
class CollectionReference extends Query {
readonly collectionName: string
private constructor(name: string, database: Database)
doc(docId: string | number): DocumentReference
add(options: OQ<IAddDocumentOptions>): void
add(options: RQ<IAddDocumentOptions>): Promise<IAddResult>
}
class DocumentReference {
private constructor(docId: string | number, database: Database)
field(object: Record<string, any>): this
get(options: OQ<IGetDocumentOptions>): void
get(options?: RQ<IGetDocumentOptions>): Promise<IQuerySingleResult>
set(options: OQ<ISetSingleDocumentOptions>): void
set(options?: RQ<ISetSingleDocumentOptions>): Promise<ISetResult>
update(options: OQ<IUpdateSingleDocumentOptions>): void
update(
options?: RQ<IUpdateSingleDocumentOptions>
): Promise<IUpdateResult>
remove(options: OQ<IRemoveSingleDocumentOptions>): void
remove(
options?: RQ<IRemoveSingleDocumentOptions>
): Promise<IRemoveResult>
watch(options: IWatchOptions): RealtimeListener
}
class RealtimeListener {
// "And Now His Watch Is Ended"
close: () => Promise<void>
}
class Query {
where(condition: IQueryCondition): Query
orderBy(fieldPath: string, order: string): Query
limit(max: number): Query
skip(offset: number): Query
field(object: Record<string, any>): Query
get(options: OQ<IGetDocumentOptions>): void
get(options?: RQ<IGetDocumentOptions>): Promise<IQueryResult>
count(options: OQ<ICountDocumentOptions>): void
count(options?: RQ<ICountDocumentOptions>): Promise<ICountResult>
watch(options: IWatchOptions): RealtimeListener
}
interface DatabaseCommand {
eq(val: any): DatabaseQueryCommand
neq(val: any): DatabaseQueryCommand
gt(val: any): DatabaseQueryCommand
gte(val: any): DatabaseQueryCommand
lt(val: any): DatabaseQueryCommand
lte(val: any): DatabaseQueryCommand
in(val: any[]): DatabaseQueryCommand
nin(val: any[]): DatabaseQueryCommand
geoNear(options: IGeoNearCommandOptions): DatabaseQueryCommand
geoWithin(options: IGeoWithinCommandOptions): DatabaseQueryCommand
geoIntersects(
options: IGeoIntersectsCommandOptions
): DatabaseQueryCommand
and(
...expressions: Array<DatabaseLogicCommand | IQueryCondition>
): DatabaseLogicCommand
or(
...expressions: Array<DatabaseLogicCommand | IQueryCondition>
): DatabaseLogicCommand
nor(
...expressions: Array<DatabaseLogicCommand | IQueryCondition>
): DatabaseLogicCommand
not(expression: DatabaseLogicCommand): DatabaseLogicCommand
exists(val: boolean): DatabaseQueryCommand
mod(divisor: number, remainder: number): DatabaseQueryCommand
all(val: any[]): DatabaseQueryCommand
elemMatch(val: any): DatabaseQueryCommand
size(val: number): DatabaseQueryCommand
set(val: any): DatabaseUpdateCommand
remove(): DatabaseUpdateCommand
inc(val: number): DatabaseUpdateCommand
mul(val: number): DatabaseUpdateCommand
min(val: number): DatabaseUpdateCommand
max(val: number): DatabaseUpdateCommand
rename(val: string): DatabaseUpdateCommand
bit(val: number): DatabaseUpdateCommand
push(...values: any[]): DatabaseUpdateCommand
pop(): DatabaseUpdateCommand
shift(): DatabaseUpdateCommand
unshift(...values: any[]): DatabaseUpdateCommand
addToSet(val: any): DatabaseUpdateCommand
pull(val: any): DatabaseUpdateCommand
pullAll(val: any): DatabaseUpdateCommand
project: {
slice(val: number | [number, number]): DatabaseProjectionCommand
}
aggregate: {
__safe_props__?: Set<string>
abs(val: any): DatabaseAggregateCommand
add(val: any): DatabaseAggregateCommand
addToSet(val: any): DatabaseAggregateCommand
allElementsTrue(val: any): DatabaseAggregateCommand
and(val: any): DatabaseAggregateCommand
anyElementTrue(val: any): DatabaseAggregateCommand
arrayElemAt(val: any): DatabaseAggregateCommand
arrayToObject(val: any): DatabaseAggregateCommand
avg(val: any): DatabaseAggregateCommand
ceil(val: any): DatabaseAggregateCommand
cmp(val: any): DatabaseAggregateCommand
concat(val: any): DatabaseAggregateCommand
concatArrays(val: any): DatabaseAggregateCommand
cond(val: any): DatabaseAggregateCommand
convert(val: any): DatabaseAggregateCommand
dateFromParts(val: any): DatabaseAggregateCommand
dateToParts(val: any): DatabaseAggregateCommand
dateFromString(val: any): DatabaseAggregateCommand
dateToString(val: any): DatabaseAggregateCommand
dayOfMonth(val: any): DatabaseAggregateCommand
dayOfWeek(val: any): DatabaseAggregateCommand
dayOfYear(val: any): DatabaseAggregateCommand
divide(val: any): DatabaseAggregateCommand
eq(val: any): DatabaseAggregateCommand
exp(val: any): DatabaseAggregateCommand
filter(val: any): DatabaseAggregateCommand
first(val: any): DatabaseAggregateCommand
floor(val: any): DatabaseAggregateCommand
gt(val: any): DatabaseAggregateCommand
gte(val: any): DatabaseAggregateCommand
hour(val: any): DatabaseAggregateCommand
ifNull(val: any): DatabaseAggregateCommand
in(val: any): DatabaseAggregateCommand
indexOfArray(val: any): DatabaseAggregateCommand
indexOfBytes(val: any): DatabaseAggregateCommand
indexOfCP(val: any): DatabaseAggregateCommand
isArray(val: any): DatabaseAggregateCommand
isoDayOfWeek(val: any): DatabaseAggregateCommand
isoWeek(val: any): DatabaseAggregateCommand
isoWeekYear(val: any): DatabaseAggregateCommand
last(val: any): DatabaseAggregateCommand
let(val: any): DatabaseAggregateCommand
literal(val: any): DatabaseAggregateCommand
ln(val: any): DatabaseAggregateCommand
log(val: any): DatabaseAggregateCommand
log10(val: any): DatabaseAggregateCommand
lt(val: any): DatabaseAggregateCommand
lte(val: any): DatabaseAggregateCommand
ltrim(val: any): DatabaseAggregateCommand
map(val: any): DatabaseAggregateCommand
max(val: any): DatabaseAggregateCommand
mergeObjects(val: any): DatabaseAggregateCommand
meta(val: any): DatabaseAggregateCommand
min(val: any): DatabaseAggregateCommand
millisecond(val: any): DatabaseAggregateCommand
minute(val: any): DatabaseAggregateCommand
mod(val: any): DatabaseAggregateCommand
month(val: any): DatabaseAggregateCommand
multiply(val: any): DatabaseAggregateCommand
neq(val: any): DatabaseAggregateCommand
not(val: any): DatabaseAggregateCommand
objectToArray(val: any): DatabaseAggregateCommand
or(val: any): DatabaseAggregateCommand
pow(val: any): DatabaseAggregateCommand
push(val: any): DatabaseAggregateCommand
range(val: any): DatabaseAggregateCommand
reduce(val: any): DatabaseAggregateCommand
reverseArray(val: any): DatabaseAggregateCommand
rtrim(val: any): DatabaseAggregateCommand
second(val: any): DatabaseAggregateCommand
setDifference(val: any): DatabaseAggregateCommand
setEquals(val: any): DatabaseAggregateCommand
setIntersection(val: any): DatabaseAggregateCommand
setIsSubset(val: any): DatabaseAggregateCommand
setUnion(val: any): DatabaseAggregateCommand
size(val: any): DatabaseAggregateCommand
slice(val: any): DatabaseAggregateCommand
split(val: any): DatabaseAggregateCommand
sqrt(val: any): DatabaseAggregateCommand
stdDevPop(val: any): DatabaseAggregateCommand
stdDevSamp(val: any): DatabaseAggregateCommand
strcasecmp(val: any): DatabaseAggregateCommand
strLenBytes(val: any): DatabaseAggregateCommand
strLenCP(val: any): DatabaseAggregateCommand
substr(val: any): DatabaseAggregateCommand
substrBytes(val: any): DatabaseAggregateCommand
substrCP(val: any): DatabaseAggregateCommand
subtract(val: any): DatabaseAggregateCommand
sum(val: any): DatabaseAggregateCommand
switch(val: any): DatabaseAggregateCommand
toBool(val: any): DatabaseAggregateCommand
toDate(val: any): DatabaseAggregateCommand
toDecimal(val: any): DatabaseAggregateCommand
toDouble(val: any): DatabaseAggregateCommand
toInt(val: any): DatabaseAggregateCommand
toLong(val: any): DatabaseAggregateCommand
toObjectId(val: any): DatabaseAggregateCommand
toString(val: any): DatabaseAggregateCommand
toLower(val: any): DatabaseAggregateCommand
toUpper(val: any): DatabaseAggregateCommand
trim(val: any): DatabaseAggregateCommand
trunc(val: any): DatabaseAggregateCommand
type(val: any): DatabaseAggregateCommand
week(val: any): DatabaseAggregateCommand
year(val: any): DatabaseAggregateCommand
zip(val: any): DatabaseAggregateCommand
}
}
class DatabaseAggregateCommand {}
enum LOGIC_COMMANDS_LITERAL {
AND = 'and',
OR = 'or',
NOT = 'not',
NOR = 'nor'
}
class DatabaseLogicCommand {
and(...expressions: DatabaseLogicCommand[]): DatabaseLogicCommand
or(...expressions: DatabaseLogicCommand[]): DatabaseLogicCommand
nor(...expressions: DatabaseLogicCommand[]): DatabaseLogicCommand
not(expression: DatabaseLogicCommand): DatabaseLogicCommand
}
enum QUERY_COMMANDS_LITERAL {
// comparison
EQ = 'eq',
NEQ = 'neq',
GT = 'gt',
GTE = 'gte',
LT = 'lt',
LTE = 'lte',
IN = 'in',
NIN = 'nin',
// geo
GEO_NEAR = 'geoNear',
GEO_WITHIN = 'geoWithin',
GEO_INTERSECTS = 'geoIntersects',
// element
EXISTS = 'exists',
// evaluation
MOD = 'mod',
// array
ALL = 'all',
ELEM_MATCH = 'elemMatch',
SIZE = 'size'
}
class DatabaseQueryCommand extends DatabaseLogicCommand {
eq(val: any): DatabaseLogicCommand
neq(val: any): DatabaseLogicCommand
gt(val: any): DatabaseLogicCommand
gte(val: any): DatabaseLogicCommand
lt(val: any): DatabaseLogicCommand
lte(val: any): DatabaseLogicCommand
in(val: any[]): DatabaseLogicCommand
nin(val: any[]): DatabaseLogicCommand
exists(val: boolean): DatabaseLogicCommand
mod(divisor: number, remainder: number): DatabaseLogicCommand
all(val: any[]): DatabaseLogicCommand
elemMatch(val: any): DatabaseLogicCommand
size(val: number): DatabaseLogicCommand
geoNear(options: IGeoNearCommandOptions): DatabaseLogicCommand
geoWithin(options: IGeoWithinCommandOptions): DatabaseLogicCommand
geoIntersects(
options: IGeoIntersectsCommandOptions
): DatabaseLogicCommand
}
enum PROJECTION_COMMANDS_LITERAL {
SLICE = 'slice'
}
class DatabaseProjectionCommand {}
enum UPDATE_COMMANDS_LITERAL {
// field
SET = 'set',
REMOVE = 'remove',
INC = 'inc',
MUL = 'mul',
MIN = 'min',
MAX = 'max',
RENAME = 'rename',
// bitwise
BIT = 'bit',
// array
PUSH = 'push',
POP = 'pop',
SHIFT = 'shift',
UNSHIFT = 'unshift',
ADD_TO_SET = 'addToSet',
PULL = 'pull',
PULL_ALL = 'pullAll'
}
class DatabaseUpdateCommand {}
class Batch {}
/**
* A contract that all API provider must adhere to
*/
class APIBaseContract<
PromiseReturn,
CallbackReturn,
Param extends IAPIParam,
Context = any
> {
getContext(param: Param): Context
/**
* In case of callback-style invocation, this function will be called
*/
getCallbackReturn(param: Param, context: Context): CallbackReturn
getFinalParam<T extends Param>(param: Param, context: Context): T
run<T extends Param>(param: T): Promise<PromiseReturn>
}
interface IGeoPointConstructor {
new (longitude: number, latitide: number): GeoPoint
new (geojson: IGeoJSONPoint): GeoPoint
(longitude: number, latitide: number): GeoPoint
(geojson: IGeoJSONPoint): GeoPoint
}
interface IGeoMultiPointConstructor {
new (points: GeoPoint[] | IGeoJSONMultiPoint): GeoMultiPoint
(points: GeoPoint[] | IGeoJSONMultiPoint): GeoMultiPoint
}
interface IGeoLineStringConstructor {
new (points: GeoPoint[] | IGeoJSONLineString): GeoLineString
(points: GeoPoint[] | IGeoJSONLineString): GeoLineString
}
interface IGeoMultiLineStringConstructor {
new (
lineStrings: GeoLineString[] | IGeoJSONMultiLineString
): GeoMultiLineString
(
lineStrings: GeoLineString[] | IGeoJSONMultiLineString
): GeoMultiLineString
}
interface IGeoPolygonConstructor {
new (lineStrings: GeoLineString[] | IGeoJSONPolygon): GeoPolygon
(lineStrings: GeoLineString[] | IGeoJSONPolygon): GeoPolygon
}
interface IGeoMultiPolygonConstructor {
new (polygons: GeoPolygon[] | IGeoJSONMultiPolygon): GeoMultiPolygon
(polygons: GeoPolygon[] | IGeoJSONMultiPolygon): GeoMultiPolygon
}
interface IGeo {
Point: IGeoPointConstructor
MultiPoint: IGeoMultiPointConstructor
LineString: IGeoLineStringConstructor
MultiLineString: IGeoMultiLineStringConstructor
Polygon: IGeoPolygonConstructor
MultiPolygon: IGeoMultiPolygonConstructor
}
interface IGeoJSONPoint {
type: 'Point'
coordinates: [number, number]
}
interface IGeoJSONMultiPoint {
type: 'MultiPoint'
coordinates: Array<[number, number]>
}
interface IGeoJSONLineString {
type: 'LineString'
coordinates: Array<[number, number]>
}
interface IGeoJSONMultiLineString {
type: 'MultiLineString'
coordinates: Array<Array<[number, number]>>
}
interface IGeoJSONPolygon {
type: 'Polygon'
coordinates: Array<Array<[number, number]>>
}
interface IGeoJSONMultiPolygon {
type: 'MultiPolygon'
coordinates: Array<Array<Array<[number, number]>>>
}
type IGeoJSONObject =
| IGeoJSONPoint
| IGeoJSONMultiPoint
| IGeoJSONLineString
| IGeoJSONMultiLineString
| IGeoJSONPolygon
| IGeoJSONMultiPolygon
abstract class GeoPoint {
longitude: number
latitude: number
constructor(longitude: number, latitude: number)
toJSON(): Record<string, any>
toString(): string
}
abstract class GeoMultiPoint {
points: GeoPoint[]
constructor(points: GeoPoint[])
toJSON(): IGeoJSONMultiPoint
toString(): string
}
abstract class GeoLineString {
points: GeoPoint[]
constructor(points: GeoPoint[])
toJSON(): IGeoJSONLineString
toString(): string
}
abstract class GeoMultiLineString {
lines: GeoLineString[]
constructor(lines: GeoLineString[])
toJSON(): IGeoJSONMultiLineString
toString(): string
}
abstract class GeoPolygon {
lines: GeoLineString[]
constructor(lines: GeoLineString[])
toJSON(): IGeoJSONPolygon
toString(): string
}
abstract class GeoMultiPolygon {
polygons: GeoPolygon[]
constructor(polygons: GeoPolygon[])
toJSON(): IGeoJSONMultiPolygon
toString(): string
}
type GeoInstance =
| GeoPoint
| GeoMultiPoint
| GeoLineString
| GeoMultiLineString
| GeoPolygon
| GeoMultiPolygon
interface IGeoNearCommandOptions {
geometry: GeoPoint
maxDistance?: number
minDistance?: number
}
interface IGeoWithinCommandOptions {
geometry: GeoPolygon | GeoMultiPolygon
}
interface IGeoIntersectsCommandOptions {
geometry:
| GeoPoint
| GeoMultiPoint
| GeoLineString
| GeoMultiLineString
| GeoPolygon
| GeoMultiPolygon
}
interface IServerDateOptions {
offset: number
}
abstract class ServerDate {
readonly options: IServerDateOptions
constructor(options?: IServerDateOptions)
}
interface IRegExpOptions {
regexp: string
options?: string
}
interface IRegExpConstructor {
new (options: IRegExpOptions): RegExp
(options: IRegExpOptions): RegExp
}
abstract class RegExp {
readonly regexp: string
readonly options: string
constructor(options: IRegExpOptions)
}
type DocumentId = string | number
interface IDocumentData {
_id?: DocumentId
[key: string]: any
}
type IDBAPIParam = IAPIParam
interface IAddDocumentOptions extends IDBAPIParam {
data: IDocumentData
}
type IGetDocumentOptions = IDBAPIParam
type ICountDocumentOptions = IDBAPIParam
interface IUpdateDocumentOptions extends IDBAPIParam {
data: IUpdateCondition
}
interface IUpdateSingleDocumentOptions extends IDBAPIParam {
data: IUpdateCondition
}
interface ISetDocumentOptions extends IDBAPIParam {
data: IUpdateCondition
}
interface ISetSingleDocumentOptions extends IDBAPIParam {
data: IUpdateCondition
}
interface IRemoveDocumentOptions extends IDBAPIParam {
query: IQueryCondition
}
type IRemoveSingleDocumentOptions = IDBAPIParam
interface IWatchOptions {
// server realtime data init & change event
onChange: (snapshot: ISnapshot) => void
// error while connecting / listening
onError: (error: any) => void
}
interface ISnapshot {
id: number
docChanges: ISingleDBEvent[]
docs: Record<string, any>
type?: SnapshotType
}
type SnapshotType = 'init'
interface ISingleDBEvent {
id: number
dataType: DataType
queueType: QueueType
docId: string
doc: Record<string, any>
updatedFields?: Record<string, any>
removedFields?: string[]
}
type DataType = 'init' | 'update' | 'replace' | 'add' | 'remove' | 'limit'
type QueueType = 'init' | 'enqueue' | 'dequeue' | 'update'
interface IQueryCondition {
[key: string]: any
}
type IStringQueryCondition = string
interface IQueryResult extends IAPISuccessParam {
data: IDocumentData[]
}
interface IQuerySingleResult extends IAPISuccessParam {
data: IDocumentData
}
interface IUpdateCondition {
[key: string]: any
}
type IStringUpdateCondition = string
interface IAddResult extends IAPISuccessParam {
_id: DocumentId
}
interface IUpdateResult extends IAPISuccessParam {
stats: {
updated: number
// created: number,
}
}
interface ISetResult extends IAPISuccessParam {
_id: DocumentId
stats: {
updated: number
created: number
}
}
interface IRemoveResult extends IAPISuccessParam {
stats: {
removed: number
}
}
interface ICountResult extends IAPISuccessParam {
total: number
}
}
type Optional<T> = { [K in keyof T]+?: T[K] }
type OQ<
T extends Optional<
Record<'complete' | 'success' | 'fail', (...args: any[]) => any>
>
> =
| (RQ<T> & Required<Pick<T, 'success'>>)
| (RQ<T> & Required<Pick<T, 'fail'>>)
| (RQ<T> & Required<Pick<T, 'complete'>>)
| (RQ<T> & Required<Pick<T, 'success' | 'fail'>>)
| (RQ<T> & Required<Pick<T, 'success' | 'complete'>>)
| (RQ<T> & Required<Pick<T, 'fail' | 'complete'>>)
| (RQ<T> & Required<Pick<T, 'fail' | 'complete' | 'success'>>)
type RQ<
T extends Optional<
Record<'complete' | 'success' | 'fail', (...args: any[]) => any>
>
> = Pick<T, Exclude<keyof T, 'complete' | 'success' | 'fail'>>

View File

@@ -1,636 +0,0 @@
/*! *****************************************************************************
Copyright (c) 2021 Tencent, Inc. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
***************************************************************************** */
declare namespace WechatMiniprogram.Component {
type Instance<
TData extends DataOption,
TProperty extends PropertyOption,
TMethod extends Partial<MethodOption>,
TCustomInstanceProperty extends IAnyObject = {},
TIsPage extends boolean = false
> = InstanceProperties &
InstanceMethods<TData> &
TMethod &
(TIsPage extends true ? Page.ILifetime : {}) &
TCustomInstanceProperty & {
/** 组件数据,**包括内部数据和属性值** */
data: TData & PropertyOptionToData<TProperty>
/** 组件数据,**包括内部数据和属性值**(与 `data` 一致) */
properties: TData & PropertyOptionToData<TProperty>
}
type TrivialInstance = Instance<
IAnyObject,
IAnyObject,
IAnyObject,
IAnyObject
>
type TrivialOption = Options<IAnyObject, IAnyObject, IAnyObject, IAnyObject>
type Options<
TData extends DataOption,
TProperty extends PropertyOption,
TMethod extends MethodOption,
TCustomInstanceProperty extends IAnyObject = {},
TIsPage extends boolean = false
> = Partial<Data<TData>> &
Partial<Property<TProperty>> &
Partial<Method<TMethod, TIsPage>> &
Partial<OtherOption> &
Partial<Lifetimes> &
ThisType<
Instance<
TData,
TProperty,
TMethod,
TCustomInstanceProperty,
TIsPage
>
>
interface Constructor {
<
TData extends DataOption,
TProperty extends PropertyOption,
TMethod extends MethodOption,
TCustomInstanceProperty extends IAnyObject = {},
TIsPage extends boolean = false
>(
options: Options<
TData,
TProperty,
TMethod,
TCustomInstanceProperty,
TIsPage
>
): string
}
type DataOption = Record<string, any>
type PropertyOption = Record<string, AllProperty>
type MethodOption = Record<string, Function>
interface Data<D extends DataOption> {
/** 组件的内部数据,和 `properties` 一同用于组件的模板渲染 */
data?: D
}
interface Property<P extends PropertyOption> {
/** 组件的对外属性,是属性名到属性设置的映射表 */
properties: P
}
interface Method<M extends MethodOption, TIsPage extends boolean = false> {
/** 组件的方法,包括事件响应函数和任意的自定义方法,关于事件响应函数的使用,参见 [组件间通信与事件](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/events.html) */
methods: M & (TIsPage extends true ? Partial<Page.ILifetime> : {})
}
type PropertyType =
| StringConstructor
| NumberConstructor
| BooleanConstructor
| ArrayConstructor
| ObjectConstructor
| null
type ValueType<T extends PropertyType> = T extends null
? any
: T extends StringConstructor
? string
: T extends NumberConstructor
? number
: T extends BooleanConstructor
? boolean
: T extends ArrayConstructor
? any[]
: T extends ObjectConstructor
? IAnyObject
: never
type FullProperty<T extends PropertyType> = {
/** 属性类型 */
type: T
/** 属性初始值 */
value?: ValueType<T>
/** 属性值被更改时的响应函数 */
observer?:
| string
| ((
newVal: ValueType<T>,
oldVal: ValueType<T>,
changedPath: Array<string | number>
) => void)
/** 属性的类型(可以指定多个) */
optionalTypes?: ShortProperty[]
}
type AllFullProperty =
| FullProperty<StringConstructor>
| FullProperty<NumberConstructor>
| FullProperty<BooleanConstructor>
| FullProperty<ArrayConstructor>
| FullProperty<ObjectConstructor>
| FullProperty<null>
type ShortProperty =
| StringConstructor
| NumberConstructor
| BooleanConstructor
| ArrayConstructor
| ObjectConstructor
| null
type AllProperty = AllFullProperty | ShortProperty
type PropertyToData<T extends AllProperty> = T extends ShortProperty
? ValueType<T>
: FullPropertyToData<Exclude<T, ShortProperty>>
type FullPropertyToData<T extends AllFullProperty> = ValueType<T['type']>
type PropertyOptionToData<P extends PropertyOption> = {
[name in keyof P]: PropertyToData<P[name]>
}
interface InstanceProperties {
/** 组件的文件路径 */
is: string
/** 节点id */
id: string
/** 节点dataset */
dataset: Record<string, string>
}
interface InstanceMethods<D extends DataOption> {
/** `setData` 函数用于将数据从逻辑层发送到视图层
*(异步),同时改变对应的 `this.data` 的值(同步)。
*
* **注意:**
*
* 1. **直接修改 this.data 而不调用 this.setData 是无法改变页面的状态的,还会造成数据不一致**。
* 1. 仅支持设置可 JSON 化的数据。
* 1. 单次设置的数据不能超过1024kB请尽量避免一次设置过多的数据。
* 1. 请不要把 data 中任何一项的 value 设为 `undefined` ,否则这一项将不被设置并可能遗留一些潜在问题。
*/
setData(
/** 这次要改变的数据
*
* 以 `key: value` 的形式表示,将 `this.data` 中的 `key` 对应的值改变成 `value`。
*
* 其中 `key` 可以以数据路径的形式给出,支持改变数组中的某一项或对象的某个属性,如 `array[2].message``a.b.c.d`,并且不需要在 this.data 中预先定义。
*/
data: Partial<D> & IAnyObject,
/** setData引起的界面更新渲染完毕后的回调函数最低基础库 `1.5.0` */
callback?: () => void
): void
/** 检查组件是否具有 `behavior` 检查时会递归检查被直接或间接引入的所有behavior */
hasBehavior(behavior: Behavior.BehaviorIdentifier): void
/** 触发事件,参见组件事件 */
triggerEvent<DetailType = any>(
name: string,
detail?: DetailType,
options?: TriggerEventOption
): void
/** 创建一个 SelectorQuery 对象,选择器选取范围为这个组件实例内 */
createSelectorQuery(): SelectorQuery
/** 创建一个 IntersectionObserver 对象,选择器选取范围为这个组件实例内 */
createIntersectionObserver(
options: CreateIntersectionObserverOption
): IntersectionObserver
/** 使用选择器选择组件实例节点,返回匹配到的第一个组件实例对象(会被 `wx://component-export` 影响) */
selectComponent(selector: string): TrivialInstance
/** 使用选择器选择组件实例节点,返回匹配到的全部组件实例对象组成的数组 */
selectAllComponents(selector: string): TrivialInstance[]
/**
* 选取当前组件节点所在的组件实例(即组件的引用者),返回它的组件实例对象(会被 `wx://component-export` 影响)
*
* 最低基础库版本:[`2.8.2`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
**/
selectOwnerComponent(): TrivialInstance
/** 获取这个关系所对应的所有关联节点,参见 组件间关系 */
getRelationNodes(relationKey: string): TrivialInstance[]
/**
* 立刻执行 callback ,其中的多个 setData 之间不会触发界面绘制(只有某些特殊场景中需要,如用于在不同组件同时 setData 时进行界面绘制同步)
*
* 最低基础库版本:[`2.4.0`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
**/
groupSetData(callback?: () => void): void
/**
* 返回当前页面的 custom-tab-bar 的组件实例
*
* 最低基础库版本:[`2.6.2`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
**/
getTabBar(): TrivialInstance
/**
* 返回页面标识符(一个字符串),可以用来判断几个自定义组件实例是不是在同一个页面内
*
* 最低基础库版本:[`2.7.1`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
**/
getPageId(): string
/**
* 执行关键帧动画,详见[动画](https://developers.weixin.qq.com/miniprogram/dev/framework/view/animation.html)
*
* 最低基础库版本:[`2.9.0`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
**/
animate(
selector: string,
keyFrames: KeyFrame[],
duration: number,
callback?: () => void
): void
/**
* 执行关键帧动画,详见[动画](https://developers.weixin.qq.com/miniprogram/dev/framework/view/animation.html)
*
* 最低基础库版本:[`2.9.0`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
**/
animate(
selector: string,
keyFrames: ScrollTimelineKeyframe[],
duration: number,
scrollTimeline: ScrollTimelineOption
): void
/**
* 清除关键帧动画,详见[动画](https://developers.weixin.qq.com/miniprogram/dev/framework/view/animation.html)
*
* 最低基础库版本:[`2.9.0`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
**/
clearAnimation(selector: string, callback: () => void): void
/**
* 清除关键帧动画,详见[动画](https://developers.weixin.qq.com/miniprogram/dev/framework/view/animation.html)
*
* 最低基础库版本:[`2.9.0`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
**/
clearAnimation(
selector: string,
options?: ClearAnimationOptions,
callback?: () => void
): void
getOpenerEventChannel(): EventChannel
}
interface ComponentOptions {
/**
* [启用多slot支持](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/wxml-wxss.html#组件wxml的slot)
*/
multipleSlots?: boolean
/**
* [组件样式隔离](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/wxml-wxss.html#组件样式隔离)
*/
addGlobalClass?: boolean
/**
* [组件样式隔离](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/wxml-wxss.html#组件样式隔离)
*/
styleIsolation?:
| 'isolated'
| 'apply-shared'
| 'shared'
| 'page-isolated'
| 'page-apply-shared'
| 'page-shared'
/**
* [纯数据字段](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/pure-data.html) 是一些不用于界面渲染的 data 字段,可以用于提升页面更新性能。从小程序基础库版本 2.8.2 开始支持。
*/
pureDataPattern?: RegExp
/**
* [虚拟化组件节点](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/wxml-wxss.html#%E8%99%9A%E6%8B%9F%E5%8C%96%E7%BB%84%E4%BB%B6%E8%8A%82%E7%82%B9) 使自定义组件内部的第一层节点由自定义组件本身完全决定。从小程序基础库版本 [`2.11.2`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html) 开始支持 */
virtualHost?: boolean
}
interface TriggerEventOption {
/** 事件是否冒泡
*
* 默认值: `false`
*/
bubbles?: boolean
/** 事件是否可以穿越组件边界为false时事件将只能在引用组件的节点树上触发不进入其他任何组件内部
*
* 默认值: `false`
*/
composed?: boolean
/** 事件是否拥有捕获阶段
*
* 默认值: `false`
*/
capturePhase?: boolean
}
interface RelationOption {
/** 目标组件的相对关系 */
type: 'parent' | 'child' | 'ancestor' | 'descendant'
/** 关系生命周期函数当关系被建立在页面节点树中时触发触发时机在组件attached生命周期之后 */
linked?(target: TrivialInstance): void
/** 关系生命周期函数当关系在页面节点树中发生改变时触发触发时机在组件moved生命周期之后 */
linkChanged?(target: TrivialInstance): void
/** 关系生命周期函数当关系脱离页面节点树时触发触发时机在组件detached生命周期之后 */
unlinked?(target: TrivialInstance): void
/** 如果这一项被设置则它表示关联的目标节点所应具有的behavior所有拥有这一behavior的组件节点都会被关联 */
target?: string
}
interface PageLifetimes {
/** 页面生命周期回调—监听页面显示
*
* 页面显示/切入前台时触发。
*/
show(): void
/** 页面生命周期回调—监听页面隐藏
*
* 页面隐藏/切入后台时触发。 如 `navigateTo` 或底部 `tab` 切换到其他页面,小程序切入后台等。
*/
hide(): void
/** 页面生命周期回调—监听页面尺寸变化
*
* 所在页面尺寸变化时执行
*/
resize(size: Page.IResizeOption): void
}
type DefinitionFilter = <T extends TrivialOption>(
/** 使用该 behavior 的 component/behavior 的定义对象 */
defFields: T,
/** 该 behavior 所使用的 behavior 的 definitionFilter 函数列表 */
definitionFilterArr?: DefinitionFilter[]
) => void
interface Lifetimes {
/** 组件生命周期声明对象,组件的生命周期:`created`、`attached`、`ready`、`moved`、`detached` 将收归到 `lifetimes` 字段内进行声明,原有声明方式仍旧有效,如同时存在两种声明方式,则 `lifetimes` 字段内声明方式优先级最高
*
* 最低基础库: `2.2.3` */
lifetimes: Partial<{
/**
* 在组件实例刚刚被创建时执行,注意此时不能调用 `setData`
*
* 最低基础库版本:[`1.6.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
created(): void
/**
* 在组件实例进入页面节点树时执行
*
* 最低基础库版本:[`1.6.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
attached(): void
/**
* 在组件在视图层布局完成后执行
*
* 最低基础库版本:[`1.6.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
ready(): void
/**
* 在组件实例被移动到节点树另一个位置时执行
*
* 最低基础库版本:[`1.6.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
moved(): void
/**
* 在组件实例被从页面节点树移除时执行
*
* 最低基础库版本:[`1.6.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
detached(): void
/**
* 每当组件方法抛出错误时执行
*
* 最低基础库版本:[`2.4.1`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
error(err: Error): void
}>
/**
* @deprecated 旧式的定义方式,基础库 `2.2.3` 起请在 lifetimes 中定义
*
* 在组件实例刚刚被创建时执行
*
* 最低基础库版本:[`1.6.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
created(): void
/**
* @deprecated 旧式的定义方式,基础库 `2.2.3` 起请在 lifetimes 中定义
*
* 在组件实例进入页面节点树时执行
*
* 最低基础库版本:[`1.6.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
attached(): void
/**
* @deprecated 旧式的定义方式,基础库 `2.2.3` 起请在 lifetimes 中定义
*
* 在组件在视图层布局完成后执行
*
* 最低基础库版本:[`1.6.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
ready(): void
/**
* @deprecated 旧式的定义方式,基础库 `2.2.3` 起请在 lifetimes 中定义
*
* 在组件实例被移动到节点树另一个位置时执行
*
* 最低基础库版本:[`1.6.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
moved(): void
/**
* @deprecated 旧式的定义方式,基础库 `2.2.3` 起请在 lifetimes 中定义
*
* 在组件实例被从页面节点树移除时执行
*
* 最低基础库版本:[`1.6.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
detached(): void
/**
* @deprecated 旧式的定义方式,基础库 `2.2.3` 起请在 lifetimes 中定义
*
* 每当组件方法抛出错误时执行
*
* 最低基础库版本:[`2.4.1`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
error(err: Error): void
}
interface OtherOption {
/** 类似于mixins和traits的组件间代码复用机制参见 [behaviors](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/behaviors.html) */
behaviors: Behavior.BehaviorIdentifier[]
/**
* 组件数据字段监听器,用于监听 properties 和 data 的变化,参见 [数据监听器](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/observer.html)
*
* 最低基础库版本:[`2.6.1`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
observers: Record<string, (...args: any[]) => any>
/** 组件间关系定义,参见 [组件间关系](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/lifetimes.html) */
relations: {
[componentName: string]: RelationOption
}
/** 组件接受的外部样式类,参见 [外部样式类](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/wxml-wxss.html) */
externalClasses?: string[]
/** 组件所在页面的生命周期声明对象,参见 [组件生命周期](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/lifetimes.html)
*
* 最低基础库版本: [`2.2.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html) */
pageLifetimes?: Partial<PageLifetimes>
/** 一些选项(文档中介绍相关特性时会涉及具体的选项设置,这里暂不列举) */
options: ComponentOptions
/** 定义段过滤器,用于自定义组件扩展,参见 [自定义组件扩展](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/extend.html)
*
* 最低基础库版本: [`2.2.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html) */
definitionFilter?: DefinitionFilter
/**
* 组件自定义导出,当使用 `behavior: wx://component-export` 时,这个定义段可以用于指定组件被 selectComponent 调用时的返回值,参见 [组件间通信与事件](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/events.html)
* 最低基础库版本: [`2.2.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html) */
export: () => IAnyObject
}
interface KeyFrame {
/** 关键帧的偏移,范围[0-1] */
offset?: number
/** 动画缓动函数 */
ease?: string
/** 基点位置,即 CSS transform-origin */
transformOrigin?: string
/** 背景颜色,即 CSS background-color */
backgroundColor?: string
/** 底边位置,即 CSS bottom */
bottom?: number | string
/** 高度,即 CSS height */
height?: number | string
/** 左边位置,即 CSS left */
left?: number | string
/** 宽度,即 CSS width */
width?: number | string
/** 不透明度,即 CSS opacity */
opacity?: number | string
/** 右边位置,即 CSS right */
right?: number | string
/** 顶边位置,即 CSS top */
top?: number | string
/** 变换矩阵,即 CSS transform matrix */
matrix?: number[]
/** 三维变换矩阵,即 CSS transform matrix3d */
matrix3d?: number[]
/** 旋转,即 CSS transform rotate */
rotate?: number
/** 三维旋转,即 CSS transform rotate3d */
rotate3d?: number[]
/** X 方向旋转,即 CSS transform rotateX */
rotateX?: number
/** Y 方向旋转,即 CSS transform rotateY */
rotateY?: number
/** Z 方向旋转,即 CSS transform rotateZ */
rotateZ?: number
/** 缩放,即 CSS transform scale */
scale?: number[]
/** 三维缩放,即 CSS transform scale3d */
scale3d?: number[]
/** X 方向缩放,即 CSS transform scaleX */
scaleX?: number
/** Y 方向缩放,即 CSS transform scaleY */
scaleY?: number
/** Z 方向缩放,即 CSS transform scaleZ */
scaleZ?: number
/** 倾斜,即 CSS transform skew */
skew?: number[]
/** X 方向倾斜,即 CSS transform skewX */
skewX?: number
/** Y 方向倾斜,即 CSS transform skewY */
skewY?: number
/** 位移,即 CSS transform translate */
translate?: Array<number | string>
/** 三维位移,即 CSS transform translate3d */
translate3d?: Array<number | string>
/** X 方向位移,即 CSS transform translateX */
translateX?: number | string
/** Y 方向位移,即 CSS transform translateY */
translateY?: number | string
/** Z 方向位移,即 CSS transform translateZ */
translateZ?: number | string
}
interface ClearAnimationOptions {
/** 基点位置,即 CSS transform-origin */
transformOrigin?: boolean
/** 背景颜色,即 CSS background-color */
backgroundColor?: boolean
/** 底边位置,即 CSS bottom */
bottom?: boolean
/** 高度,即 CSS height */
height?: boolean
/** 左边位置,即 CSS left */
left?: boolean
/** 宽度,即 CSS width */
width?: boolean
/** 不透明度,即 CSS opacity */
opacity?: boolean
/** 右边位置,即 CSS right */
right?: boolean
/** 顶边位置,即 CSS top */
top?: boolean
/** 变换矩阵,即 CSS transform matrix */
matrix?: boolean
/** 三维变换矩阵,即 CSS transform matrix3d */
matrix3d?: boolean
/** 旋转,即 CSS transform rotate */
rotate?: boolean
/** 三维旋转,即 CSS transform rotate3d */
rotate3d?: boolean
/** X 方向旋转,即 CSS transform rotateX */
rotateX?: boolean
/** Y 方向旋转,即 CSS transform rotateY */
rotateY?: boolean
/** Z 方向旋转,即 CSS transform rotateZ */
rotateZ?: boolean
/** 缩放,即 CSS transform scale */
scale?: boolean
/** 三维缩放,即 CSS transform scale3d */
scale3d?: boolean
/** X 方向缩放,即 CSS transform scaleX */
scaleX?: boolean
/** Y 方向缩放,即 CSS transform scaleY */
scaleY?: boolean
/** Z 方向缩放,即 CSS transform scaleZ */
scaleZ?: boolean
/** 倾斜,即 CSS transform skew */
skew?: boolean
/** X 方向倾斜,即 CSS transform skewX */
skewX?: boolean
/** Y 方向倾斜,即 CSS transform skewY */
skewY?: boolean
/** 位移,即 CSS transform translate */
translate?: boolean
/** 三维位移,即 CSS transform translate3d */
translate3d?: boolean
/** X 方向位移,即 CSS transform translateX */
translateX?: boolean
/** Y 方向位移,即 CSS transform translateY */
translateY?: boolean
/** Z 方向位移,即 CSS transform translateZ */
translateZ?: boolean
}
interface ScrollTimelineKeyframe {
composite?: 'replace' | 'add' | 'accumulate' | 'auto'
easing?: string
offset?: number | null
[property: string]: string | number | null | undefined
}
interface ScrollTimelineOption {
/** 指定滚动元素的选择器(只支持 scroll-view该元素滚动时会驱动动画的进度 */
scrollSource: string
/** 指定滚动的方向。有效值为 horizontal 或 vertical */
orientation?: string
/** 指定开始驱动动画进度的滚动偏移量,单位 px */
startScrollOffset: number
/** 指定停止驱动动画进度的滚动偏移量,单位 px */
endScrollOffset: number
/** 起始和结束的滚动范围映射的时间长度,该时间可用于与关键帧动画里的时间 (duration) 相匹配,单位 ms */
timeRange: number
}
}
/** Component构造器可用于定义组件调用Component构造器时可以指定组件的属性、数据、方法等。
*
* * 使用 `this.data` 可以获取内部数据和属性值,但不要直接修改它们,应使用 `setData` 修改。
* * 生命周期函数无法在组件方法中通过 `this` 访问到。
* * 属性名应避免以 data 开头,即不要命名成 `dataXyz` 这样的形式,因为在 WXML 中, `data-xyz=""` 会被作为节点 dataset 来处理,而不是组件属性。
* * 在一个组件的定义和使用时,组件的属性名和 data 字段相互间都不能冲突(尽管它们位于不同的定义段中)。
* * 从基础库 `2.0.9` 开始,对象类型的属性和 data 字段中可以包含函数类型的子字段,即可以通过对象类型的属性字段来传递函数。低于这一版本的基础库不支持这一特性。
* * `bug` : 对于 type 为 Object 或 Array 的属性,如果通过该组件自身的 `this.setData` 来改变属性值的一个子字段,则依旧会触发属性 observer ,且 observer 接收到的 `newVal` 是变化的那个子字段的值, `oldVal` 为空, `changedPath` 包含子字段的字段名相关信息。
*/
declare let Component: WechatMiniprogram.Component.Constructor

File diff suppressed because it is too large Load Diff

View File

@@ -1,259 +0,0 @@
/*! *****************************************************************************
Copyright (c) 2021 Tencent, Inc. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
***************************************************************************** */
declare namespace WechatMiniprogram.Page {
type Instance<
TData extends DataOption,
TCustom extends CustomOption
> = OptionalInterface<ILifetime> &
InstanceProperties &
InstanceMethods<TData> &
Data<TData> &
TCustom
type Options<
TData extends DataOption,
TCustom extends CustomOption
> = (TCustom & Partial<Data<TData>> & Partial<ILifetime>) &
ThisType<Instance<TData, TCustom>>
type TrivialInstance = Instance<IAnyObject, IAnyObject>
interface Constructor {
<TData extends DataOption, TCustom extends CustomOption>(
options: Options<TData, TCustom>
): void
}
interface ILifetime {
/** 生命周期回调—监听页面加载
*
* 页面加载时触发。一个页面只会调用一次,可以在 onLoad 的参数中获取打开当前页面路径中的参数。
*/
onLoad(
/** 打开当前页面路径中的参数 */
query: Record<string, string | undefined>
): void | Promise<void>
/** 生命周期回调—监听页面显示
*
* 页面显示/切入前台时触发。
*/
onShow(): void | Promise<void>
/** 生命周期回调—监听页面初次渲染完成
*
* 页面初次渲染完成时触发。一个页面只会调用一次,代表页面已经准备妥当,可以和视图层进行交互。
*
* 注意:对界面内容进行设置的 API 如`wx.setNavigationBarTitle`,请在`onReady`之后进行。
*/
onReady(): void | Promise<void>
/** 生命周期回调—监听页面隐藏
*
* 页面隐藏/切入后台时触发。 如 `navigateTo` 或底部 `tab` 切换到其他页面,小程序切入后台等。
*/
onHide(): void | Promise<void>
/** 生命周期回调—监听页面卸载
*
* 页面卸载时触发。如`redirectTo`或`navigateBack`到其他页面时。
*/
onUnload(): void | Promise<void>
/** 监听用户下拉动作
*
* 监听用户下拉刷新事件。
* - 需要在`app.json`的`window`选项中或页面配置中开启`enablePullDownRefresh`。
* - 可以通过`wx.startPullDownRefresh`触发下拉刷新,调用后触发下拉刷新动画,效果与用户手动下拉刷新一致。
* - 当处理完数据刷新后,`wx.stopPullDownRefresh`可以停止当前页面的下拉刷新。
*/
onPullDownRefresh(): void | Promise<void>
/** 页面上拉触底事件的处理函数
*
* 监听用户上拉触底事件。
* - 可以在`app.json`的`window`选项中或页面配置中设置触发距离`onReachBottomDistance`。
* - 在触发距离内滑动期间,本事件只会被触发一次。
*/
onReachBottom(): void | Promise<void>
/** 用户点击右上角转发
*
* 监听用户点击页面内转发按钮(`<button>` 组件 `open-type="share"`)或右上角菜单“转发”按钮的行为,并自定义转发内容。
*
* **注意:只有定义了此事件处理函数,右上角菜单才会显示“转发”按钮**
*
* 此事件需要 return 一个 Object用于自定义转发内容
*/
onShareAppMessage(
/** 分享发起来源参数 */
options: IShareAppMessageOption
): ICustomShareContent | void
/**
* 监听右上角菜单“分享到朋友圈”按钮的行为,并自定义分享内容
*
* 本接口为 Beta 版本,暂只在 Android 平台支持,详见 [分享到朋友圈 (Beta)](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/share-timeline.html)
*
* 基础库 2.11.3 开始支持,低版本需做兼容处理。
*/
onShareTimeline(): ICustomTimelineContent | void
/** 页面滚动触发事件的处理函数
*
* 监听用户滑动页面事件。
*/
onPageScroll(
/** 页面滚动参数 */
options: IPageScrollOption
): void | Promise<void>
/** 当前是 tab 页时,点击 tab 时触发,最低基础库: `1.9.0` */
onTabItemTap(
/** tab 点击参数 */
options: ITabItemTapOption
): void | Promise<void>
/** 窗口尺寸改变时触发,最低基础库:`2.4.0` */
onResize(
/** 窗口尺寸参数 */
options: IResizeOption
): void | Promise<void>
/**
* 监听用户点击右上角菜单“收藏”按钮的行为,并自定义收藏内容。
* 基础库 2.10.3,安卓 7.0.15 版本起支持iOS 暂不支持
*/
onAddToFavorites(options: IAddToFavoritesOption): IAddToFavoritesContent
}
interface InstanceProperties {
/** 页面的文件路径 */
is: string
/** 到当前页面的路径 */
route: string
/** 打开当前页面路径中的参数 */
options: Record<string, string | undefined>
}
type DataOption = Record<string, any>
type CustomOption = Record<string, any>
type InstanceMethods<D extends DataOption> = Component.InstanceMethods<D>
interface Data<D extends DataOption> {
/** 页面的初始数据
*
* `data` 是页面第一次渲染使用的**初始数据**。
*
* 页面加载时,`data` 将会以`JSON`字符串的形式由逻辑层传至渲染层,因此`data`中的数据必须是可以转成`JSON`的类型:字符串,数字,布尔值,对象,数组。
*
* 渲染层可以通过 `WXML` 对数据进行绑定。
*/
data: D
}
interface ICustomShareContent {
/** 转发标题。默认值:当前小程序名称 */
title?: string
/** 转发路径,必须是以 / 开头的完整路径。默认值:当前页面 path */
path?: string
/** 自定义图片路径可以是本地文件路径、代码包文件路径或者网络图片路径。支持PNG及JPG。显示图片长宽比是 5:4最低基础库 `1.5.0`。默认值:使用默认截图 */
imageUrl?: string
}
interface ICustomTimelineContent {
/** 自定义标题,即朋友圈列表页上显示的标题。默认值:当前小程序名称 */
title?: string
/** 自定义页面路径中携带的参数,如 `path?a=1&b=2` 的 “?” 后面部分 默认值:当前页面路径携带的参数 */
query?: string
/** 自定义图片路径,可以是本地文件路径、代码包文件路径或者网络图片路径。支持 PNG 及 JPG。显示图片长宽比是 1:1。默认值默认使用小程序 Logo*/
imageUrl?: string
}
interface IPageScrollOption {
/** 页面在垂直方向已滚动的距离单位px */
scrollTop: number
}
interface IShareAppMessageOption {
/** 转发事件来源。
*
* 可选值:
* - `button`:页面内转发按钮;
* - `menu`:右上角转发菜单。
*
* 最低基础库: `1.2.4`
*/
from: 'button' | 'menu' | string
/** 如果 `from` 值是 `button`,则 `target` 是触发这次转发事件的 `button`,否则为 `undefined`
*
* 最低基础库: `1.2.4` */
target: any
/** 页面中包含`<web-view>`组件时,返回当前`<web-view>`的url
*
* 最低基础库: `1.6.4`
*/
webViewUrl?: string
}
interface ITabItemTapOption {
/** 被点击tabItem的序号从0开始最低基础库 `1.9.0` */
index: string
/** 被点击tabItem的页面路径最低基础库 `1.9.0` */
pagePath: string
/** 被点击tabItem的按钮文字最低基础库 `1.9.0` */
text: string
}
interface IResizeOption {
size: {
/** 变化后的窗口宽度,单位 px */
windowWidth: number
/** 变化后的窗口高度,单位 px */
windowHeight: number
}
}
interface IAddToFavoritesOption {
/** 页面中包含web-view组件时返回当前web-view的url */
webviewUrl?: string
}
interface IAddToFavoritesContent {
/** 自定义标题,默认值:页面标题或账号名称 */
title?: string
/** 自定义图片,显示图片长宽比为 11默认值页面截图 */
imageUrl?: string
/** 自定义query字段默认值当前页面的query */
query?: string
}
interface GetCurrentPages {
(): Array<Instance<IAnyObject, IAnyObject>>
}
}
/**
* 注册小程序中的一个页面。接受一个 `Object` 类型参数,其指定页面的初始数据、生命周期回调、事件处理函数等。
*/
declare let Page: WechatMiniprogram.Page.Constructor
/**
* 获取当前页面栈。数组中第一个元素为首页,最后一个元素为当前页面。
* __注意__
* - __不要尝试修改页面栈会导致路由以及页面状态错误。__
* - 不要在 `App.onLaunch` 的时候调用 `getCurrentPages()`,此时 `page` 还没有生成。
*/
declare let getCurrentPages: WechatMiniprogram.Page.GetCurrentPages

View File

@@ -1,6 +1,6 @@
# apps/backend — FastAPI 后端 # apps/backend — FastAPI 后端
为微信小程序和管理后台提供 RESTful API连接 `zqyy_app` 业务数据库,通过 FDW 只读访问 ETL 数据 为微信小程序和管理后台提供 RESTful API连接 `zqyy_app` 业务数据库,直连 `etl_feiqiu` ETL 库只读访问 `app.v_*` RLS 视图
## 技术栈 ## 技术栈
@@ -115,6 +115,8 @@ JWT 令牌分两种:
| `/api/xcx/tasks` | `xcx_tasks.py` | 小程序任务列表/详情/置顶/放弃/恢复 | JWT | | `/api/xcx/tasks` | `xcx_tasks.py` | 小程序任务列表/详情/置顶/放弃/恢复 | JWT |
| `/api/xcx/notes` | `xcx_notes.py` | 小程序备注 CRUD | JWT | | `/api/xcx/notes` | `xcx_notes.py` | 小程序备注 CRUD | JWT |
| `/api/xcx/performance` | `xcx_performance.py` | 小程序绩效概览/明细 | JWT | | `/api/xcx/performance` | `xcx_performance.py` | 小程序绩效概览/明细 | JWT |
| `/api/xcx/config` | `xcx_config.py` | 小程序配置(项目类型筛选器等) | JWT |
| `/api/xcx/board` | `xcx_board.py` | 小程序三看板(助教/客户/财务) | JWT |
| `/api/admin/applications` | `admin_applications.py` | 管理端申请审核 | JWT | | `/api/admin/applications` | `admin_applications.py` | 管理端申请审核 | JWT |
| `/api/business-day` | `business_day.py` | 营业日配置查询 | JWT | | `/api/business-day` | `business_day.py` | 营业日配置查询 | JWT |
| `/api/tasks` | `tasks.py` | 任务注册表/Flow 定义/配置验证 | JWT | | `/api/tasks` | `tasks.py` | 任务注册表/Flow 定义/配置验证 | JWT |
@@ -126,6 +128,7 @@ JWT 令牌分两种:
| `/api/xcx-test` | `xcx_test.py` | MVP 全链路验证 | 无 | | `/api/xcx-test` | `xcx_test.py` | MVP 全链路验证 | 无 |
| `/api/wx-callback` | `wx_callback.py` | 微信消息推送回调 | 签名验证 | | `/api/wx-callback` | `wx_callback.py` | 微信消息推送回调 | 签名验证 |
| `/api/retention-clue` | `member_retention_clue.py` | 维客线索 CRUD | JWT | | `/api/retention-clue` | `member_retention_clue.py` | 维客线索 CRUD | JWT |
| `/api/xcx/chat` | `xcx_chat.py` | 小程序 CHAT 对话/消息/发送/SSE 流式 | JWT |
| `/api/ops` | `ops_panel.py` | 运维面板(服务启停/Git/系统信息) | 无 | | `/api/ops` | `ops_panel.py` | 运维面板(服务启停/Git/系统信息) | 无 |
| `/ws/logs` | `ws/logs.py` | WebSocket 实时日志推送 | — | | `/ws/logs` | `ws/logs.py` | WebSocket 实时日志推送 | — |
| `/health` | `main.py` | 健康检查 | 无 | | `/health` | `main.py` | 健康检查 | 无 |
@@ -149,12 +152,13 @@ JWT 令牌分两种:
| `task_manager.py` | 任务管理(置顶/放弃/状态变更) | | `task_manager.py` | 任务管理(置顶/放弃/状态变更) |
| `task_expiry.py` | 任务过期检查与处理 | | `task_expiry.py` | 任务过期检查与处理 |
| `task_manager.py` | 任务管理CRUD + 列表扩展 + 详情) | | `task_manager.py` | 任务管理CRUD + 列表扩展 + 详情) |
| `performance_service.py` | 绩效概览 + 明细(FDW 查询) | | `performance_service.py` | 绩效概览 + 明细(ETL 直连查询) |
| `note_service.py` | 备注服务CRUD + 星星评分) | | `note_service.py` | 备注服务CRUD + 星星评分) |
| `fdw_queries.py` | FDW 查询集中封装(门店隔离 + DWD-DOC 规则 | | `fdw_queries.py` | ETL 查询集中封装(直连 ETL 库 + 门店隔离 RLS |
| `note_reclassifier.py` | 备注重分类(召回完成后回填) | | `note_reclassifier.py` | 备注重分类(召回完成后回填) |
| `recall_detector.py` | 召回完成检测ETL 数据更新触发) | | `recall_detector.py` | 召回完成检测ETL 数据更新触发) |
| `trigger_scheduler.py` | 触发器调度器cron/interval/event | | `trigger_scheduler.py` | 触发器调度器cron/interval/event |
| `chat_service.py` | CHAT 模块业务逻辑(对话管理/消息持久化/referenceCard |
## 依赖 ## 依赖

View File

@@ -24,12 +24,12 @@ from app import config
# CHANGE 2026-02-26 | member_birthday 路由替换为 member_retention_clue维客线索重构 # CHANGE 2026-02-26 | member_birthday 路由替换为 member_retention_clue维客线索重构
# CHANGE 2026-02-26 | 新增 admin_applications 路由(管理端申请审核) # CHANGE 2026-02-26 | 新增 admin_applications 路由(管理端申请审核)
# CHANGE 2026-02-27 | 新增 xcx_tasks / xcx_notes 路由(小程序核心业务) # CHANGE 2026-02-27 | 新增 xcx_tasks / xcx_notes 路由(小程序核心业务)
# CHANGE 2026-03-09 | 新增 xcx_ai_chat 路由AI SSE 对话 + 历史对话) # CHANGE 2026-03-09 | 新增 xcx_ai_chat 路由AI SSE 对话 + 历史对话)→ 2026-03-20 迁移为 xcx_chat/api/xcx/chat/*
# CHANGE 2026-03-09 | 新增 xcx_ai_cache 路由AI 缓存查询) # CHANGE 2026-03-09 | 新增 xcx_ai_cache 路由AI 缓存查询)
# CHANGE 2026-03-18 | 新增 xcx_customers 路由CUST-1 客户详情、CUST-2 客户服务记录) # CHANGE 2026-03-18 | 新增 xcx_customers 路由CUST-1 客户详情、CUST-2 客户服务记录)
# CHANGE 2026-03-19 | 新增 xcx_coaches 路由COACH-1 助教详情) # CHANGE 2026-03-19 | 新增 xcx_coaches 路由COACH-1 助教详情)
# CHANGE 2026-03-19 | 新增 xcx_board / xcx_config 路由RNS1.3 三看板 + 技能类型配置) # CHANGE 2026-03-19 | 新增 xcx_board / xcx_config 路由RNS1.3 三看板 + 技能类型配置)
from app.routers import auth, execution, schedules, tasks, env_config, db_viewer, etl_status, xcx_test, wx_callback, member_retention_clue, ops_panel, xcx_auth, admin_applications, business_day, xcx_tasks, xcx_notes, xcx_ai_chat, xcx_ai_cache, xcx_performance, xcx_customers, xcx_coaches, xcx_board, xcx_config from app.routers import auth, execution, schedules, tasks, env_config, db_viewer, etl_status, xcx_test, wx_callback, member_retention_clue, ops_panel, xcx_auth, admin_applications, business_day, xcx_tasks, xcx_notes, xcx_chat, xcx_ai_cache, xcx_performance, xcx_customers, xcx_coaches, xcx_board, xcx_config
from app.services.scheduler import scheduler from app.services.scheduler import scheduler
from app.services.task_queue import task_queue from app.services.task_queue import task_queue
from app.ws.logs import ws_router from app.ws.logs import ws_router
@@ -139,7 +139,7 @@ app.include_router(admin_applications.router)
app.include_router(business_day.router) app.include_router(business_day.router)
app.include_router(xcx_tasks.router) app.include_router(xcx_tasks.router)
app.include_router(xcx_notes.router) app.include_router(xcx_notes.router)
app.include_router(xcx_ai_chat.router) app.include_router(xcx_chat.router)
app.include_router(xcx_ai_cache.router) app.include_router(xcx_ai_cache.router)
app.include_router(xcx_performance.router) app.include_router(xcx_performance.router)
app.include_router(xcx_customers.router) app.include_router(xcx_customers.router)

View File

@@ -1,223 +0,0 @@
# -*- coding: utf-8 -*-
"""
小程序 AI 对话路由 —— SSE 流式对话、历史对话列表、消息查询。
端点清单:
- POST /api/ai/chat/stream — SSE 流式对话
- GET /api/ai/conversations — 历史对话列表(分页)
- GET /api/ai/conversations/{conversation_id}/messages — 对话消息列表
"""
from __future__ import annotations
import json
import logging
import os
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from app.ai.bailian_client import BailianClient
from app.ai.conversation_service import ConversationService
from app.ai.apps.app1_chat import chat_stream
from app.ai.schemas import ChatStreamRequest, SSEEvent
from app.auth.dependencies import CurrentUser, get_current_user
from app.database import get_connection
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/ai", tags=["小程序 AI 对话"])
# ── 辅助:获取用户 nickname ──────────────────────────────────
def _get_user_nickname(user_id: int) -> str:
"""从 auth.users 查询用户 nickname查不到返回空字符串。"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT nickname FROM auth.users WHERE id = %s",
(user_id,),
)
row = cur.fetchone()
return row[0] if row and row[0] else ""
finally:
conn.close()
# ── 辅助:获取用户主要角色 ───────────────────────────────────
def _get_user_role_label(roles: list[str]) -> str:
"""从角色列表提取主要角色标签,用于 AI 上下文。"""
if "store_manager" in roles or "owner" in roles:
return "管理者"
if "assistant" in roles or "coach" in roles:
return "助教"
return "用户"
# ── 辅助:构建 BailianClient 实例 ────────────────────────────
def _get_bailian_client() -> BailianClient:
"""从环境变量构建 BailianClient缺失时报错。"""
api_key = os.environ.get("BAILIAN_API_KEY")
base_url = os.environ.get("BAILIAN_BASE_URL")
model = os.environ.get("BAILIAN_MODEL")
if not api_key or not base_url or not model:
raise RuntimeError(
"百炼 API 环境变量缺失,需要 BAILIAN_API_KEY、BAILIAN_BASE_URL、BAILIAN_MODEL"
)
return BailianClient(api_key=api_key, base_url=base_url, model=model)
# ── SSE 流式对话 ─────────────────────────────────────────────
@router.post("/chat/stream")
async def ai_chat_stream(
body: ChatStreamRequest,
user: CurrentUser = Depends(get_current_user),
):
"""SSE 流式对话端点。
接收用户消息,通过百炼 API 流式返回 AI 回复。
每个 SSE 事件格式data: {json}\n\n
事件类型chunk文本片段/ done完成/ error错误
"""
if not body.message or not body.message.strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="消息内容不能为空",
)
nickname = _get_user_nickname(user.user_id)
role_label = _get_user_role_label(user.roles)
bailian = _get_bailian_client()
conv_svc = ConversationService()
async def event_generator():
"""SSE 事件生成器,逐事件 yield data: {json}\n\n 格式。"""
try:
async for event in chat_stream(
message=body.message.strip(),
user_id=user.user_id,
nickname=nickname,
role=role_label,
site_id=user.site_id,
source_page=body.source_page,
page_context=body.page_context,
screen_content=body.screen_content,
bailian=bailian,
conv_svc=conv_svc,
):
yield f"data: {event.model_dump_json()}\n\n"
except Exception as e:
# 兜底:生成器内部异常也以 SSE error 事件返回
logger.error("SSE 生成器异常: %s", e, exc_info=True)
error_event = SSEEvent(type="error", message=str(e))
yield f"data: {error_event.model_dump_json()}\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # nginx 禁用缓冲
},
)
# ── 历史对话列表 ─────────────────────────────────────────────
@router.get("/conversations")
async def list_conversations(
page: int = 1,
page_size: int = 20,
user: CurrentUser = Depends(get_current_user),
):
"""查询当前用户的历史对话列表,按时间倒序,分页。"""
if page < 1:
page = 1
if page_size < 1 or page_size > 100:
page_size = 20
conv_svc = ConversationService()
conversations = conv_svc.get_conversations(
user_id=user.user_id,
site_id=user.site_id,
page=page,
page_size=page_size,
)
# 为每条对话附加首条消息预览
result = []
for conv in conversations:
item = {
"id": conv["id"],
"app_id": conv["app_id"],
"source_page": conv.get("source_page"),
"created_at": conv["created_at"],
"first_message_preview": None,
}
# 查询首条 user 消息作为预览
messages = conv_svc.get_messages(conv["id"])
for msg in messages:
if msg["role"] == "user":
content = msg["content"] or ""
item["first_message_preview"] = content[:50] if len(content) > 50 else content
break
result.append(item)
return result
# ── 对话消息列表 ─────────────────────────────────────────────
@router.get("/conversations/{conversation_id}/messages")
async def get_conversation_messages(
conversation_id: int,
user: CurrentUser = Depends(get_current_user),
):
"""查询指定对话的所有消息,按时间升序。
验证对话归属当前用户和 site_id防止越权访问。
"""
# 先验证对话归属
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id FROM biz.ai_conversations
WHERE id = %s AND user_id = %s AND site_id = %s
""",
(conversation_id, str(user.user_id), user.site_id),
)
if not cur.fetchone():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="对话不存在或无权访问",
)
finally:
conn.close()
conv_svc = ConversationService()
messages = conv_svc.get_messages(conversation_id)
return [
{
"id": msg["id"],
"role": msg["role"],
"content": msg["content"],
"tokens_used": msg.get("tokens_used"),
"created_at": msg["created_at"],
}
for msg in messages
]

View File

@@ -1,3 +1,7 @@
# AI_CHANGELOG
# - 2026-03-20 | Prompt: R3 项目类型筛选接口重建 | SkillFilterEnum/ProjectFilterEnum
# 默认值从 .all 改为 .ALL与新枚举值一致。
""" """
看板路由BOARD-1助教、BOARD-2客户、BOARD-3财务 看板路由BOARD-1助教、BOARD-2客户、BOARD-3财务
@@ -30,7 +34,7 @@ router = APIRouter(prefix="/api/xcx/board", tags=["xcx-board"])
@router.get("/coaches", response_model=CoachBoardResponse) @router.get("/coaches", response_model=CoachBoardResponse)
async def get_coach_board( async def get_coach_board(
sort: CoachSortEnum = Query(default=CoachSortEnum.perf_desc), sort: CoachSortEnum = Query(default=CoachSortEnum.perf_desc),
skill: SkillFilterEnum = Query(default=SkillFilterEnum.all), skill: SkillFilterEnum = Query(default=SkillFilterEnum.ALL),
time: BoardTimeEnum = Query(default=BoardTimeEnum.month), time: BoardTimeEnum = Query(default=BoardTimeEnum.month),
user: CurrentUser = Depends(require_permission("view_board_coach")), user: CurrentUser = Depends(require_permission("view_board_coach")),
): ):
@@ -44,7 +48,7 @@ async def get_coach_board(
@router.get("/customers", response_model=CustomerBoardResponse) @router.get("/customers", response_model=CustomerBoardResponse)
async def get_customer_board( async def get_customer_board(
dimension: CustomerDimensionEnum = Query(default=CustomerDimensionEnum.recall), dimension: CustomerDimensionEnum = Query(default=CustomerDimensionEnum.recall),
project: ProjectFilterEnum = Query(default=ProjectFilterEnum.all), project: ProjectFilterEnum = Query(default=ProjectFilterEnum.ALL),
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100), page_size: int = Query(default=20, ge=1, le=100),
user: CurrentUser = Depends(require_permission("view_board_customer")), user: CurrentUser = Depends(require_permission("view_board_customer")),

View File

@@ -0,0 +1,329 @@
# -*- coding: utf-8 -*-
"""
小程序 CHAT 路由 —— CHAT-1/2/3/4 端点。
替代原 xcx_ai_chat.py/api/ai/*),统一迁移到 /api/xcx/chat/* 路径。
端点清单:
- GET /api/xcx/chat/history — CHAT-1 对话历史列表
- GET /api/xcx/chat/{chat_id}/messages — CHAT-2a 通过 chatId 查询消息
- GET /api/xcx/chat/messages?contextType=&contextId= — CHAT-2b 通过上下文查询消息
- POST /api/xcx/chat/{chat_id}/messages — CHAT-3 发送消息(同步回复)
- POST /api/xcx/chat/stream — CHAT-4 SSE 流式端点
所有端点使用 require_approved() 权限检查。
"""
from __future__ import annotations
import json
import logging
import os
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from app.ai.bailian_client import BailianClient
from app.auth.dependencies import CurrentUser
from app.database import get_connection
from app.middleware.permission import require_approved
from app.schemas.xcx_chat import (
ChatHistoryItem,
ChatHistoryResponse,
ChatMessageItem,
ChatMessagesResponse,
ChatStreamRequest,
MessageBrief,
ReferenceCard,
SendMessageRequest,
SendMessageResponse,
)
from app.services.chat_service import ChatService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/xcx/chat", tags=["小程序 CHAT"])
# ── CHAT-1: 对话历史列表 ─────────────────────────────────────
@router.get("/history", response_model=ChatHistoryResponse)
async def list_chat_history(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user: CurrentUser = Depends(require_approved()),
) -> ChatHistoryResponse:
"""CHAT-1: 查询当前用户的对话历史列表,按最后消息时间倒序。"""
svc = ChatService()
items, total = svc.get_chat_history(
user_id=user.user_id,
site_id=user.site_id,
page=page,
page_size=page_size,
)
return ChatHistoryResponse(
items=[ChatHistoryItem(**item) for item in items],
total=total,
page=page,
page_size=page_size,
)
# ── CHAT-2b: 通过上下文查询消息 ─────────────────────────────
# ⚠️ 必须在 /{chat_id}/messages 之前注册,否则 "messages" 会被当作 chat_id 路径参数
@router.get("/messages", response_model=ChatMessagesResponse)
async def get_chat_messages_by_context(
context_type: str = Query(..., alias="contextType"),
context_id: str = Query(..., alias="contextId"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
user: CurrentUser = Depends(require_approved()),
) -> ChatMessagesResponse:
"""CHAT-2b: 通过上下文类型和 ID 查询消息(自动查找/创建对话)。"""
svc = ChatService()
# 按复用规则查找或创建对话
chat_id = svc.get_or_create_session(
user_id=user.user_id,
site_id=user.site_id,
context_type=context_type,
context_id=context_id if context_id else None,
)
messages, total, resolved_chat_id = svc.get_messages(
chat_id=chat_id,
user_id=user.user_id,
site_id=user.site_id,
page=page,
page_size=page_size,
)
return ChatMessagesResponse(
chat_id=resolved_chat_id,
items=[_to_message_item(m) for m in messages],
total=total,
page=page,
page_size=page_size,
)
# ── CHAT-2a: 通过 chatId 查询消息 ───────────────────────────
@router.get("/{chat_id}/messages", response_model=ChatMessagesResponse)
async def get_chat_messages(
chat_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
user: CurrentUser = Depends(require_approved()),
) -> ChatMessagesResponse:
"""CHAT-2a: 通过 chatId 查询对话消息列表,按 createdAt 正序。"""
svc = ChatService()
messages, total, resolved_chat_id = svc.get_messages(
chat_id=chat_id,
user_id=user.user_id,
site_id=user.site_id,
page=page,
page_size=page_size,
)
return ChatMessagesResponse(
chat_id=resolved_chat_id,
items=[_to_message_item(m) for m in messages],
total=total,
page=page,
page_size=page_size,
)
# ── CHAT-4: SSE 流式端点 ────────────────────────────────────
# ⚠️ 必须在 /{chat_id}/messages 之前注册,否则 "stream" 会被当作 chat_id 路径参数
@router.post("/stream")
async def chat_stream(
body: ChatStreamRequest,
user: CurrentUser = Depends(require_approved()),
) -> StreamingResponse:
"""CHAT-4: SSE 流式对话端点。
接收用户消息,通过百炼 API 流式返回 AI 回复。
SSE 事件类型message逐 token/ done完成/ error错误
chatId 归属验证:不属于当前用户返回 HTTP 403普通 JSON 错误,非 SSE
"""
if not body.content or not body.content.strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="消息内容不能为空",
)
svc = ChatService()
content = body.content.strip()
# 归属验证(在 SSE 流开始前完成,失败时返回普通 HTTP 错误)
svc._verify_ownership(body.chat_id, user.user_id, user.site_id)
# 存入用户消息P5 PRD 合规:发送时即写入)
user_msg_id, user_created_at = svc._save_message(body.chat_id, "user", content)
async def event_generator():
"""SSE 事件生成器。
事件格式:
- event: message\\ndata: {"token": "..."}\\n\\n
- event: done\\ndata: {"messageId": ..., "createdAt": "..."}\\n\\n
- event: error\\ndata: {"message": "..."}\\n\\n
"""
full_reply_parts: list[str] = []
try:
bailian = _get_bailian_client()
# 获取历史消息作为上下文
messages = _build_ai_messages(body.chat_id)
# 流式调用百炼 API
async for chunk in bailian.chat_stream(messages):
full_reply_parts.append(chunk)
yield f"event: message\ndata: {json.dumps({'token': chunk}, ensure_ascii=False)}\n\n"
# 流结束:拼接完整回复并持久化
full_reply = "".join(full_reply_parts)
estimated_tokens = len(full_reply)
ai_msg_id, ai_created_at = svc._save_message(
body.chat_id, "assistant", full_reply, tokens_used=estimated_tokens,
)
svc._update_session_metadata(body.chat_id, full_reply)
# 发送 done 事件
done_data = json.dumps(
{"messageId": ai_msg_id, "createdAt": ai_created_at},
ensure_ascii=False,
)
yield f"event: done\ndata: {done_data}\n\n"
except Exception as e:
logger.error("SSE 流式对话异常: %s", e, exc_info=True)
# 如果已有部分回复,仍然持久化
if full_reply_parts:
partial = "".join(full_reply_parts)
try:
svc._save_message(body.chat_id, "assistant", partial)
svc._update_session_metadata(body.chat_id, partial)
except Exception:
logger.error("持久化部分回复失败", exc_info=True)
error_data = json.dumps(
{"message": "AI 服务暂时不可用"},
ensure_ascii=False,
)
yield f"event: error\ndata: {error_data}\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# ── CHAT-3: 发送消息(同步回复) ─────────────────────────────
@router.post("/{chat_id}/messages", response_model=SendMessageResponse)
async def send_message(
chat_id: int,
body: SendMessageRequest,
user: CurrentUser = Depends(require_approved()),
) -> SendMessageResponse:
"""CHAT-3: 发送用户消息并获取同步 AI 回复。
chatId 归属验证:不属于当前用户返回 HTTP 403。
AI 失败时返回错误提示消息HTTP 200
"""
if not body.content or not body.content.strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="消息内容不能为空",
)
svc = ChatService()
result = await svc.send_message_sync(
chat_id=chat_id,
content=body.content.strip(),
user_id=user.user_id,
site_id=user.site_id,
)
return SendMessageResponse(
user_message=MessageBrief(**result["user_message"]),
ai_reply=MessageBrief(**result["ai_reply"]),
)
# ── 辅助函数 ─────────────────────────────────────────────────
def _to_message_item(msg: dict) -> ChatMessageItem:
"""将 chat_service 返回的消息 dict 转换为 ChatMessageItem。"""
ref_card = msg.get("reference_card")
reference_card = ReferenceCard(**ref_card) if ref_card and isinstance(ref_card, dict) else None
return ChatMessageItem(
id=msg["id"],
role=msg["role"],
content=msg["content"],
created_at=msg["created_at"],
reference_card=reference_card,
)
def _get_bailian_client() -> BailianClient:
"""从环境变量构建 BailianClient缺失时报错。"""
api_key = os.environ.get("BAILIAN_API_KEY")
base_url = os.environ.get("BAILIAN_BASE_URL")
model = os.environ.get("BAILIAN_MODEL")
if not api_key or not base_url or not model:
raise RuntimeError(
"百炼 API 环境变量缺失,需要 BAILIAN_API_KEY、BAILIAN_BASE_URL、BAILIAN_MODEL"
)
return BailianClient(api_key=api_key, base_url=base_url, model=model)
def _build_ai_messages(chat_id: int) -> list[dict]:
"""构建发送给 AI 的消息列表(含历史上下文)。"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT role, content FROM biz.ai_messages
WHERE conversation_id = %s
ORDER BY created_at ASC
""",
(chat_id,),
)
history = cur.fetchall()
finally:
conn.close()
messages: list[dict] = []
# 取最近 20 条
recent = history[-20:] if len(history) > 20 else history
for role, msg_content in recent:
messages.append({"role": role, "content": msg_content})
# 如果没有 system 消息,添加默认 system prompt
if not messages or messages[0]["role"] != "system":
system_prompt = {
"role": "system",
"content": json.dumps(
{"task": "你是台球门店的 AI 助手,根据用户的问题和当前页面上下文提供帮助。"},
ensure_ascii=False,
),
}
messages.insert(0, system_prompt)
return messages

View File

@@ -1,3 +1,8 @@
# AI_CHANGELOG
# - 2026-03-20 | Prompt: R3 项目类型筛选接口重建 | SkillFilterEnum 和 ProjectFilterEnum
# 枚举值从 all/chinese/snooker/mahjong/karaoke 改为 ALL/BILLIARD/SNOOKER/MAHJONG/KTV
# 与 dws.cfg_area_category.category_code 一致,消除前后端映射层。
"""三看板接口 Pydantic SchemaBOARD-1/2/3 请求参数枚举 + 响应模型)。""" """三看板接口 Pydantic SchemaBOARD-1/2/3 请求参数枚举 + 响应模型)。"""
from __future__ import annotations from __future__ import annotations
@@ -22,12 +27,14 @@ class CoachSortEnum(str, Enum):
class SkillFilterEnum(str, Enum): class SkillFilterEnum(str, Enum):
"""BOARD-1 技能筛选。""" """BOARD-1 技能筛选(值与 dws.cfg_area_category.category_code 一致)"""
all = "all" # CHANGE 2026-03-20 | R3 修复:枚举值从 chinese/snooker 等前端自定义值
chinese = "chinese" # 改为数据库 category_codeBILLIARD/SNOOKER/MAHJONG/KTV消除映射层。
snooker = "snooker" ALL = "ALL"
mahjong = "mahjong" BILLIARD = "BILLIARD"
karaoke = "karaoke" SNOOKER = "SNOOKER"
MAHJONG = "MAHJONG"
KTV = "KTV"
class BoardTimeEnum(str, Enum): class BoardTimeEnum(str, Enum):
@@ -53,12 +60,14 @@ class CustomerDimensionEnum(str, Enum):
class ProjectFilterEnum(str, Enum): class ProjectFilterEnum(str, Enum):
"""BOARD-2 项目筛选。""" """BOARD-2 项目筛选(值与 dws.cfg_area_category.category_code 一致)"""
all = "all" # CHANGE 2026-03-20 | R3 修复:枚举值从 chinese/snooker 等前端自定义值
chinese = "chinese" # 改为数据库 category_codeBILLIARD/SNOOKER/MAHJONG/KTV消除映射层。
snooker = "snooker" ALL = "ALL"
mahjong = "mahjong" BILLIARD = "BILLIARD"
karaoke = "karaoke" SNOOKER = "SNOOKER"
MAHJONG = "MAHJONG"
KTV = "KTV"
class FinanceTimeEnum(str, Enum): class FinanceTimeEnum(str, Enum):

View File

@@ -0,0 +1,106 @@
"""
小程序 CHAT 模块 Pydantic 模型。
覆盖对话历史列表、消息查看、发送消息、SSE 流式请求等场景。
"""
from __future__ import annotations
from app.schemas.base import CamelModel
# ---------------------------------------------------------------------------
# 对话历史CHAT-1
# ---------------------------------------------------------------------------
class ChatHistoryItem(CamelModel):
"""对话历史列表项。"""
id: int
title: str
customer_name: str | None = None
last_message: str | None = None
timestamp: str # ISO 8601最后消息时间
unread_count: int = 0
class ChatHistoryResponse(CamelModel):
"""CHAT-1 对话历史列表响应。"""
items: list[ChatHistoryItem]
total: int
page: int
page_size: int
# ---------------------------------------------------------------------------
# 消息查看CHAT-2
# ---------------------------------------------------------------------------
class ReferenceCard(CamelModel):
"""引用卡片,附加在 AI 回复消息中的结构化上下文数据。"""
type: str # 'customer' | 'record'
title: str
summary: str
data: dict[str, str] # 键值对详情
class ChatMessageItem(CamelModel):
"""对话消息项。"""
id: int
role: str # 'user' | 'assistant'
content: str
created_at: str # ISO 8601统一字段名
reference_card: ReferenceCard | None = None
class ChatMessagesResponse(CamelModel):
"""CHAT-2 对话消息列表响应。"""
chat_id: int
items: list[ChatMessageItem]
total: int
page: int
page_size: int
# ---------------------------------------------------------------------------
# 发送消息CHAT-3
# ---------------------------------------------------------------------------
class MessageBrief(CamelModel):
"""消息摘要(用于发送消息响应)。"""
id: int
content: str
created_at: str # ISO 8601
class SendMessageRequest(CamelModel):
"""CHAT-3 发送消息请求体。"""
content: str
class SendMessageResponse(CamelModel):
"""CHAT-3 发送消息响应(含用户消息和 AI 回复)。"""
user_message: MessageBrief
ai_reply: MessageBrief
# ---------------------------------------------------------------------------
# SSE 流式CHAT-4
# ---------------------------------------------------------------------------
class ChatStreamRequest(CamelModel):
"""CHAT-4 SSE 流式请求体。"""
chat_id: int
content: str

View File

@@ -1,3 +1,7 @@
# AI_CHANGELOG
# - 2026-03-20 | Prompt: M4 emoji 注释修复 | heart_emoji 注释从旧 3 级(❤️/💛/🤍)
# 改为 P6 权威定义的 4 级(💖/🧡/💛/💙),与 compute_heart_icon() 实际逻辑对齐。
from __future__ import annotations from __future__ import annotations
from app.schemas.base import CamelModel from app.schemas.base import CamelModel
@@ -43,7 +47,9 @@ class TopCustomer(CamelModel):
name: str name: str
initial: str initial: str
avatar_gradient: str avatar_gradient: str
heart_emoji: str # ❤️ / 💛 / 🤍 # CHANGE 2026-03-20 | M4 修复: emoji 注释与 P6 权威定义对齐4 级映射)
# intent: 注释应反映 compute_heart_icon() 的实际 4 级映射(💖🧡💛💙)
heart_emoji: str # 💖 / 🧡 / 💛 / 💙
score: str score: str
score_color: str score_color: str
service_count: int service_count: int

View File

@@ -1,4 +1,8 @@
"""CONFIG-1 技能类型响应 Schema。""" # AI_CHANGELOG
# - 2026-03-20 | Prompt: R3 项目类型筛选接口重建 | SkillTypeItem.key 注释从
# chinese/snooker 改为 BILLIARD/SNOOKERlabel 说明改为从 display_name 读取。
"""CONFIG-1 项目类型筛选器响应 Schema。"""
from __future__ import annotations from __future__ import annotations
@@ -6,7 +10,14 @@ from app.schemas.base import CamelModel
class SkillTypeItem(CamelModel): class SkillTypeItem(CamelModel):
key: str # chinese/snooker/mahjong/karaoke """项目类型筛选器选项。
label: str # 中文标签
emoji: str # 表情符号 key 值与 dws.cfg_area_category.category_code 一致
cls: str # 前端样式类 BILLIARD/SNOOKER/MAHJONG/KTV"不限"选项 key="ALL"
"""
# CHANGE 2026-03-20 | R3 修复key 从 chinese/snooker 改为 BILLIARD/SNOOKER
# label 从 display_name 读取(含 emojicls 保留但后端不再填充。
key: str # BILLIARD/SNOOKER/MAHJONG/KTV/ALL
label: str # display_name含 emoji如 "🎱 中式/追分"
emoji: str # short_name单 emoji如 "🎱"
cls: str # 前端样式类(预留,当前为空字符串)

View File

@@ -1,3 +1,7 @@
# AI_CHANGELOG
# - 2026-03-20 | Prompt: M4 emoji 注释修复 | FavoriteCoach.emoji 注释从旧 2 级(💖/💛)
# 改为 P6 权威定义的 4 级(💖/🧡/💛/💙),与 compute_heart_icon() 实际逻辑对齐。
from __future__ import annotations from __future__ import annotations
from app.schemas.base import CamelModel from app.schemas.base import CamelModel
@@ -28,7 +32,9 @@ class CoachTask(CamelModel):
metrics: list[MetricItem] = [] metrics: list[MetricItem] = []
class FavoriteCoach(CamelModel): class FavoriteCoach(CamelModel):
emoji: str # 💖 / 💛 # CHANGE 2026-03-20 | M4 修复: emoji 注释与 P6 权威定义对齐4 级映射)
# intent: 注释应反映 compute_heart_icon() 的实际 4 级映射(💖🧡💛💙)
emoji: str # 💖 / 🧡 / 💛 / 💙
name: str name: str
relation_index: str relation_index: str
index_color: str index_color: str

View File

@@ -0,0 +1,685 @@
"""
CHAT 模块业务逻辑层。
封装对话管理、消息持久化、referenceCard 组装、标题生成等核心逻辑。
路由层xcx_chat.py调用本服务完成 CHAT-1/2/3/4 端点的业务处理。
表依赖:
- biz.ai_conversations — 对话会话(含 context_type/context_id/title/last_message 扩展字段)
- biz.ai_messages — 消息记录(含 reference_card 扩展字段)
- fdw_etl.v_dim_member — 会员信息(通过 ETL 直连)
- fdw_etl.v_dws_member_consumption_summary / v_dwd_assistant_service_log — 消费指标
⚠️ P5 PRD 合规:
- app_id 固定为 'app1_chat'
- 用户消息发送时即写入 ai_messagesrole=user
- 流式完成后完整 assistant 回复写入 ai_messagesrole=assistant含 tokens_used
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime
from decimal import Decimal
from typing import Any
from fastapi import HTTPException, status
from app.ai.bailian_client import BailianClient
from app.database import get_connection
from app.services import fdw_queries
logger = logging.getLogger(__name__)
APP_ID = "app1_chat"
# 对话复用时限(天)
_REUSE_DAYS = 3
class ChatService:
"""CHAT 模块业务逻辑。"""
# ------------------------------------------------------------------
# CHAT-1: 对话历史列表
# ------------------------------------------------------------------
def get_chat_history(
self,
user_id: int,
site_id: int,
page: int,
page_size: int,
) -> tuple[list[dict], int]:
"""查询对话历史列表,返回 (items, total)。
按 last_message_at 倒序JOIN v_dim_member 获取 customerName。
仅返回 app_id='app1_chat' 的对话。
"""
offset = (page - 1) * page_size
conn = get_connection()
try:
with conn.cursor() as cur:
# 总数
cur.execute(
"""
SELECT COUNT(*)
FROM biz.ai_conversations
WHERE user_id = %s AND site_id = %s AND app_id = %s
""",
(str(user_id), site_id, APP_ID),
)
total = cur.fetchone()[0]
# 分页列表
cur.execute(
"""
SELECT id, title, context_type, context_id,
last_message, last_message_at, created_at
FROM biz.ai_conversations
WHERE user_id = %s AND site_id = %s AND app_id = %s
ORDER BY COALESCE(last_message_at, created_at) DESC
LIMIT %s OFFSET %s
""",
(str(user_id), site_id, APP_ID, page_size, offset),
)
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
finally:
conn.close()
# 组装结果,尝试获取 customerName
items: list[dict] = []
# 收集需要查询姓名的 customer context_id
customer_ids: list[int] = []
raw_items: list[dict] = []
for row in rows:
item = dict(zip(columns, row))
raw_items.append(item)
if item.get("context_type") == "customer" and item.get("context_id"):
try:
customer_ids.append(int(item["context_id"]))
except (ValueError, TypeError):
pass
# 批量查询客户姓名FDW 降级:查询失败返回空映射)
name_map: dict[int, str] = {}
if customer_ids:
try:
biz_conn = get_connection()
try:
info_map = fdw_queries.get_member_info(biz_conn, site_id, customer_ids)
for mid, info in info_map.items():
name_map[mid] = info.get("nickname") or ""
finally:
biz_conn.close()
except Exception:
logger.warning("查询客户姓名失败,降级为空", exc_info=True)
for item in raw_items:
customer_name: str | None = None
if item.get("context_type") == "customer" and item.get("context_id"):
try:
customer_name = name_map.get(int(item["context_id"]))
except (ValueError, TypeError):
pass
# 生成标题
title = self.generate_title(
title=item.get("title"),
customer_name=customer_name,
conversation_id=item["id"],
)
ts = item.get("last_message_at") or item.get("created_at")
items.append({
"id": item["id"],
"title": title,
"customer_name": customer_name,
"last_message": item.get("last_message"),
"timestamp": ts.isoformat() if isinstance(ts, datetime) else str(ts) if ts else "",
"unread_count": 0,
})
return items, total
# ------------------------------------------------------------------
# 对话复用 / 创建
# ------------------------------------------------------------------
def get_or_create_session(
self,
user_id: int,
site_id: int,
context_type: str,
context_id: str | None,
) -> int:
"""按入口上下文查找或创建对话,返回 chat_id。
复用规则:
- context_type='task': 同一 taskId 始终复用(无时限)
- context_type='customer'/'coach': 最后消息 ≤ 3 天复用,> 3 天新建
- context_type='general': 始终新建
"""
# general 入口始终新建
if context_type == "general":
return self._create_session(user_id, site_id, context_type, context_id)
conn = get_connection()
try:
with conn.cursor() as cur:
if context_type == "task":
# task 入口:始终复用(无时限)
cur.execute(
"""
SELECT id FROM biz.ai_conversations
WHERE user_id = %s AND site_id = %s
AND context_type = 'task' AND context_id = %s
ORDER BY created_at DESC LIMIT 1
""",
(str(user_id), site_id, context_id),
)
elif context_type in ("customer", "coach"):
# customer/coach 入口3 天时限复用
cur.execute(
"""
SELECT id FROM biz.ai_conversations
WHERE user_id = %s AND site_id = %s
AND context_type = %s AND context_id = %s
AND last_message_at > NOW() - INTERVAL '3 days'
ORDER BY last_message_at DESC LIMIT 1
""",
(str(user_id), site_id, context_type, context_id),
)
else:
# 未知类型,新建
return self._create_session(user_id, site_id, context_type, context_id)
row = cur.fetchone()
if row:
return row[0]
finally:
conn.close()
# 未找到可复用对话,新建
return self._create_session(user_id, site_id, context_type, context_id)
def _create_session(
self,
user_id: int,
site_id: int,
context_type: str,
context_id: str | None,
) -> int:
"""创建新对话记录,返回 conversation_id。"""
conn = get_connection()
try:
with conn.cursor() as cur:
# 查询用户昵称
cur.execute(
"SELECT nickname FROM auth.users WHERE id = %s",
(user_id,),
)
row = cur.fetchone()
nickname = row[0] if row and row[0] else ""
cur.execute(
"""
INSERT INTO biz.ai_conversations
(user_id, nickname, app_id, site_id, context_type, context_id)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""",
(str(user_id), nickname, APP_ID, site_id, context_type, context_id),
)
new_id = cur.fetchone()[0]
conn.commit()
return new_id
except Exception:
conn.rollback()
raise
finally:
conn.close()
# ------------------------------------------------------------------
# CHAT-2: 消息列表
# ------------------------------------------------------------------
def get_messages(
self,
chat_id: int,
user_id: int,
site_id: int,
page: int,
page_size: int,
) -> tuple[list[dict], int, int]:
"""查询消息列表,返回 (messages, total, chat_id)。
验证 chat_id 归属当前用户,按 created_at 正序。
"""
self._verify_ownership(chat_id, user_id, site_id)
offset = (page - 1) * page_size
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT COUNT(*) FROM biz.ai_messages WHERE conversation_id = %s",
(chat_id,),
)
total = cur.fetchone()[0]
cur.execute(
"""
SELECT id, role, content, created_at, reference_card
FROM biz.ai_messages
WHERE conversation_id = %s
ORDER BY created_at ASC
LIMIT %s OFFSET %s
""",
(chat_id, page_size, offset),
)
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
finally:
conn.close()
messages = []
for row in rows:
item = dict(zip(columns, row))
ref_card = item.get("reference_card")
# reference_card 可能是 dictpsycopg2 自动解析 jsonb或 str
if isinstance(ref_card, str):
try:
ref_card = json.loads(ref_card)
except (json.JSONDecodeError, TypeError):
ref_card = None
created_at = item["created_at"]
messages.append({
"id": item["id"],
"role": item["role"],
"content": item["content"],
"created_at": created_at.isoformat() if isinstance(created_at, datetime) else str(created_at),
"reference_card": ref_card,
})
return messages, total, chat_id
# ------------------------------------------------------------------
# CHAT-3: 发送消息(同步回复)
# ------------------------------------------------------------------
async def send_message_sync(
self,
chat_id: int,
content: str,
user_id: int,
site_id: int,
) -> dict:
"""发送消息并获取同步 AI 回复。
流程:
1. 验证 chatId 归属
2. 存入用户消息(立即写入)
3. 调用 AI 获取回复
4. 存入 AI 回复
5. 更新 session 的 last_message / last_message_at
6. AI 失败时返回错误提示消息HTTP 200
"""
self._verify_ownership(chat_id, user_id, site_id)
# 1. 立即存入用户消息P5 PRD 合规:发送时即写入)
user_msg_id, user_created_at = self._save_message(chat_id, "user", content)
# 2. 调用 AI
ai_reply_text: str
tokens_used: int | None = None
try:
ai_reply_text, tokens_used = await self._call_ai(chat_id, content, user_id, site_id)
except Exception as e:
logger.error("AI 服务调用失败: %s", e, exc_info=True)
ai_reply_text = "抱歉AI 助手暂时无法回复,请稍后重试"
# 3. 存入 AI 回复
ai_msg_id, ai_created_at = self._save_message(
chat_id, "assistant", ai_reply_text, tokens_used=tokens_used,
)
# 4. 更新 session 元数据
self._update_session_metadata(chat_id, ai_reply_text)
return {
"user_message": {
"id": user_msg_id,
"content": content,
"created_at": user_created_at,
},
"ai_reply": {
"id": ai_msg_id,
"content": ai_reply_text,
"created_at": ai_created_at,
},
}
# ------------------------------------------------------------------
# referenceCard 组装
# ------------------------------------------------------------------
def build_reference_card(
self,
customer_id: int,
site_id: int,
) -> dict | None:
"""从 FDW 查询客户关键指标,组装 referenceCard。
⚠️ DWD-DOC 规则:金额用 items_sum 口径ledger_amount
会员信息通过 member_id JOIN dim_memberscd2_is_current=1
FDW 查询失败时静默降级返回 None不影响消息本身
"""
try:
biz_conn = get_connection()
try:
# 客户姓名
info_map = fdw_queries.get_member_info(biz_conn, site_id, [customer_id])
if customer_id not in info_map:
return None
member_name = info_map[customer_id].get("nickname") or "未知客户"
# 余额
balance: Decimal | None = None
try:
balance_map = fdw_queries.get_member_balance(biz_conn, site_id, [customer_id])
balance = balance_map.get(customer_id)
except Exception:
logger.warning("referenceCard: 查询余额失败", exc_info=True)
# 近 30 天消费items_sum 口径)
consume_30d: Decimal | None = None
try:
consume_30d = self._get_consumption_30d(biz_conn, site_id, customer_id)
except Exception:
logger.warning("referenceCard: 查询近30天消费失败", exc_info=True)
# 近 30 天到店次数
visit_count: int | None = None
try:
visit_count = self._get_visit_count_30d(biz_conn, site_id, customer_id)
except Exception:
logger.warning("referenceCard: 查询到店次数失败", exc_info=True)
finally:
biz_conn.close()
# 格式化
balance_str = f"¥{balance:,.2f}" if balance is not None else ""
consume_str = f"¥{consume_30d:,.2f}" if consume_30d is not None else ""
visit_str = f"{visit_count}" if visit_count is not None else ""
return {
"type": "customer",
"title": f"{member_name} — 消费概览",
"summary": f"余额 {balance_str}近30天消费 {consume_str}",
"data": {
"余额": balance_str,
"近30天消费": consume_str,
"到店次数": visit_str,
},
}
except Exception:
logger.warning("referenceCard 组装失败,降级为 null", exc_info=True)
return None
# ------------------------------------------------------------------
# 标题生成
# ------------------------------------------------------------------
def generate_title(
self,
title: str | None = None,
customer_name: str | None = None,
conversation_id: int | None = None,
first_message: str | None = None,
) -> str:
"""生成对话标题:自定义标题 > 客户姓名 > 首条消息前 20 字。
结果始终非空。
"""
# 优先级 1自定义标题
if title and title.strip():
return title.strip()
# 优先级 2客户姓名
if customer_name and customer_name.strip():
return customer_name.strip()
# 优先级 3首条消息前 20 字
if first_message is None and conversation_id is not None:
first_message = self._get_first_message(conversation_id)
if first_message and first_message.strip():
text = first_message.strip()
return text[:20] if len(text) > 20 else text
return "新对话"
# ------------------------------------------------------------------
# 内部辅助方法
# ------------------------------------------------------------------
def _verify_ownership(self, chat_id: int, user_id: int, site_id: int) -> None:
"""验证对话归属当前用户,不属于时抛出 HTTP 403/404。"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT user_id FROM biz.ai_conversations
WHERE id = %s AND site_id = %s
""",
(chat_id, site_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="对话不存在",
)
if str(row[0]) != str(user_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权访问此对话",
)
finally:
conn.close()
def _save_message(
self,
conversation_id: int,
role: str,
content: str,
tokens_used: int | None = None,
reference_card: dict | None = None,
) -> tuple[int, str]:
"""写入消息记录,返回 (message_id, created_at ISO 字符串)。"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO biz.ai_messages
(conversation_id, role, content, tokens_used, reference_card)
VALUES (%s, %s, %s, %s, %s)
RETURNING id, created_at
""",
(
conversation_id,
role,
content,
tokens_used,
json.dumps(reference_card, ensure_ascii=False) if reference_card else None,
),
)
row = cur.fetchone()
conn.commit()
msg_id = row[0]
created_at = row[1]
return msg_id, created_at.isoformat() if isinstance(created_at, datetime) else str(created_at)
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _update_session_metadata(self, chat_id: int, last_message: str) -> None:
"""更新对话的 last_message 和 last_message_at。"""
# 截断至 100 字
truncated = last_message[:100] if len(last_message) > 100 else last_message
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE biz.ai_conversations
SET last_message = %s, last_message_at = NOW()
WHERE id = %s
""",
(truncated, chat_id),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _get_first_message(self, conversation_id: int) -> str | None:
"""查询对话的首条 user 消息内容。"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT content FROM biz.ai_messages
WHERE conversation_id = %s AND role = 'user'
ORDER BY created_at ASC LIMIT 1
""",
(conversation_id,),
)
row = cur.fetchone()
return row[0] if row else None
finally:
conn.close()
async def _call_ai(
self,
chat_id: int,
content: str,
user_id: int,
site_id: int,
) -> tuple[str, int | None]:
"""调用百炼 API 获取非流式回复,返回 (reply_text, tokens_used)。
构建历史消息上下文发送给 AI。
"""
bailian = _get_bailian_client()
# 获取历史消息作为上下文(最近 20 条)
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT role, content FROM biz.ai_messages
WHERE conversation_id = %s
ORDER BY created_at ASC
""",
(chat_id,),
)
history = cur.fetchall()
finally:
conn.close()
# 构建消息列表
messages: list[dict] = []
# 取最近 20 条(含刚写入的 user 消息)
recent = history[-20:] if len(history) > 20 else history
for role, msg_content in recent:
messages.append({"role": role, "content": msg_content})
# 如果没有 system 消息,添加默认 system prompt
if not messages or messages[0]["role"] != "system":
system_prompt = {
"role": "system",
"content": json.dumps(
{"task": "你是台球门店的 AI 助手,根据用户的问题和当前页面上下文提供帮助。"},
ensure_ascii=False,
),
}
messages.insert(0, system_prompt)
# 非流式调用chat_stream 用于 SSE这里用 chat_stream 收集完整回复)
full_parts: list[str] = []
async for chunk in bailian.chat_stream(messages):
full_parts.append(chunk)
reply = "".join(full_parts)
# 流式模式不返回 tokens_used按字符数估算
estimated_tokens = len(reply)
return reply, estimated_tokens
@staticmethod
def _get_consumption_30d(conn: Any, site_id: int, member_id: int) -> Decimal | None:
"""查询客户近 30 天消费金额items_sum 口径)。
⚠️ DWD-DOC 规则 1: 使用 ledger_amountitems_sum 口径),禁用 consume_money。
"""
with fdw_queries._fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT COALESCE(SUM(ledger_amount), 0)
FROM app.v_dwd_assistant_service_log
WHERE tenant_member_id = %s
AND is_delete = 0
AND create_time >= (CURRENT_DATE - INTERVAL '30 days')::timestamptz
""",
(member_id,),
)
row = cur.fetchone()
return Decimal(str(row[0])) if row and row[0] is not None else None
@staticmethod
def _get_visit_count_30d(conn: Any, site_id: int, member_id: int) -> int | None:
"""查询客户近 30 天到店次数。"""
with fdw_queries._fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT COUNT(DISTINCT create_time::date)
FROM app.v_dwd_assistant_service_log
WHERE tenant_member_id = %s
AND is_delete = 0
AND create_time >= (CURRENT_DATE - INTERVAL '30 days')::timestamptz
""",
(member_id,),
)
row = cur.fetchone()
return int(row[0]) if row and row[0] is not None else None
# ── 模块级辅助函数 ──────────────────────────────────────────────
def _get_bailian_client() -> BailianClient:
"""从环境变量构建 BailianClient缺失时报错。"""
api_key = os.environ.get("BAILIAN_API_KEY")
base_url = os.environ.get("BAILIAN_BASE_URL")
model = os.environ.get("BAILIAN_MODEL")
if not api_key or not base_url or not model:
raise RuntimeError(
"百炼 API 环境变量缺失,需要 BAILIAN_API_KEY、BAILIAN_BASE_URL、BAILIAN_MODEL"
)
return BailianClient(api_key=api_key, base_url=base_url, model=model)

View File

@@ -1,4 +1,9 @@
# AI_CHANGELOG # AI_CHANGELOG
# - 2026-03-20 | Prompt: R3 项目类型筛选接口重建 | get_skill_types() 从虚构的 v_cfg_skill_type
# 改为查询 app.v_cfg_area_category真实 RLS 视图),头部插入"不限"选项;
# get_all_assistants() 移除 _skill_to_category/_category_to_skill 映射字典,
# 改为 _valid_categories set 直接比较_project_filter_clause() 移除 _project_to_category
# 映射字典,直接用 category_code。
# - 2026-03-20 | Prompt: RNS1.3 FDW 列名修正 | 修正 17 处列名映射design.md 理想名 → 实际视图列名), # - 2026-03-20 | Prompt: RNS1.3 FDW 列名修正 | 修正 17 处列名映射design.md 理想名 → 实际视图列名),
# gift_rows 每个 cell 改为 GiftCell dict 避免 Pydantic 校验失败, # gift_rows 每个 cell 改为 GiftCell dict 避免 Pydantic 校验失败,
# v_dws_member_spending_power_index 降级为空列表skill_filter 暂不生效 # v_dws_member_spending_power_index 降级为空列表skill_filter 暂不生效
@@ -525,15 +530,22 @@ def get_relation_index(
return records return records
# AI_CHANGELOG
# - 2026-03-20: R1 修复 — 5 列(table_charge_money, goods_money, assistant_pd_money,
# assistant_cx_money, settle_type)从 sl(service_log) 改为 sh(settlement_head)
# 添加 LEFT JOIN v_dwd_settlement_headWHERE settle_type 引用也改为 sh。
# 原因:这些字段属于结算单头表,不在助教服务日志视图中。
# 验证MCP 端到端查询通过。
def get_consumption_records( def get_consumption_records(
conn: Any, site_id: int, member_id: int, limit: int, offset: int conn: Any, site_id: int, member_id: int, limit: int, offset: int
) -> list[dict]: ) -> list[dict]:
""" """
查询客户消费记录CUST-1 consumptionRecords 用)。 查询客户消费记录CUST-1 consumptionRecords 用)。
来源: app.v_dwd_assistant_service_log + v_dim_assistant。 来源: app.v_dwd_assistant_service_log + v_dwd_settlement_head + v_dim_assistant。
⚠️ DWD-DOC 规则 1: totalAmount 使用 ledger_amount。 ⚠️ DWD-DOC 规则 1: totalAmount 使用 ledger_amount(来自 service_log
⚠️ DWD-DOC 规则 2: coaches fee 使用 assistant_pd_money / assistant_cx_money。 ⚠️ DWD-DOC 规则 2: coaches fee 使用 assistant_pd_money / assistant_cx_money(来自 settlement_head
⚠️ 费用拆分字段table_charge_money, goods_money, settle_type来自 settlement_head。
⚠️ 废单排除: is_delete = 0。 ⚠️ 废单排除: is_delete = 0。
⚠️ 正向交易: settle_type IN (1, 3)。 ⚠️ 正向交易: settle_type IN (1, 3)。
⚠️ DQ-6: 助教姓名通过 v_dim_assistant 获取。 ⚠️ DQ-6: 助教姓名通过 v_dim_assistant 获取。
@@ -553,18 +565,25 @@ def get_consumption_records(
sl.site_assistant_id AS assistant_id, sl.site_assistant_id AS assistant_id,
COALESCE(da.real_name, da.nickname, '') AS assistant_name, COALESCE(da.real_name, da.nickname, '') AS assistant_name,
da.level AS assistant_level, da.level AS assistant_level,
sl.table_charge_money, sh.table_charge_money,
sl.goods_money, sh.goods_money,
sl.assistant_pd_money, sh.assistant_pd_money,
sl.assistant_cx_money, sh.assistant_cx_money,
sl.settle_type sh.settle_type
FROM app.v_dwd_assistant_service_log sl FROM app.v_dwd_assistant_service_log sl
LEFT JOIN app.v_dim_assistant da LEFT JOIN app.v_dim_assistant da
ON sl.site_assistant_id = da.assistant_id ON sl.site_assistant_id = da.assistant_id
AND da.scd2_is_current = 1 AND da.scd2_is_current = 1
LEFT JOIN app.v_dwd_settlement_head sh
ON sl.order_settle_id = sh.order_settle_id
-- CHANGE 2026-03-20 | R1 修复: 费用拆分字段来自 settlement_head 而非 service_log
-- intent: table_charge_money/goods_money/assistant_pd_money/assistant_cx_money/settle_type
-- 属于结算单头表(dwd_settlement_head),通过 order_settle_id 关联
-- assumption: 每条 service_log 对应一条 settlement_head1:1 或 1:0
-- verify: SELECT count(*) FROM v_dwd_assistant_service_log WHERE order_settle_id IS NULL
WHERE sl.tenant_member_id = %s WHERE sl.tenant_member_id = %s
AND sl.is_delete = 0 AND sl.is_delete = 0
AND sl.settle_type IN (1, 3) AND sh.settle_type IN (1, 3)
ORDER BY sl.create_time DESC ORDER BY sl.create_time DESC
LIMIT %s OFFSET %s LIMIT %s OFFSET %s
""", """,
@@ -963,23 +982,17 @@ def get_coach_service_records(
def get_all_assistants( def get_all_assistants(
conn: Any, site_id: int, skill_filter: str = "all" conn: Any, site_id: int, skill_filter: str = "ALL"
) -> list[dict]: ) -> list[dict]:
""" """
查询门店全部助教列表BOARD-1 用)。 查询门店全部助教列表BOARD-1 用)。
CHANGE 2026-03-19 | P1 修复:通过 LEFT JOIN v_dws_assistant_project_tag 获取技能标签, CHANGE 2026-03-20 | R3 修复:skill_filter 直接接收 category_code
支持 skill_filter 筛选chinese/snooker/mahjong/karaoke/all BILLIARD/SNOOKER/MAHJONG/KTV/ALL去掉 chinese→BILLIARD 映射层
category_code 映射BILLIARD→chinese, SNOOKER→snooker, MAHJONG→mahjong, KTV→karaoke。
""" """
# CHANGE 2026-03-19 | feiqiu-data-rules 规则 6: 等级名称从配置表动态读取 # CHANGE 2026-03-19 | feiqiu-data-rules 规则 6: 等级名称从配置表动态读取
_skill_to_category = { # CHANGE 2026-03-20 | R3 修复:去掉 _skill_to_category 映射,直接用 category_code
"chinese": "BILLIARD", _valid_categories = {"BILLIARD", "SNOOKER", "MAHJONG", "KTV"}
"snooker": "SNOOKER",
"mahjong": "MAHJONG",
"karaoke": "KTV",
}
_category_to_skill = {v: k for k, v in _skill_to_category.items()}
level_map = get_level_map(conn, site_id) level_map = get_level_map(conn, site_id)
records: list[dict] = [] records: list[dict] = []
@@ -987,7 +1000,7 @@ def get_all_assistants(
# 筛选条件:如果指定了技能,只返回被标记的助教 # 筛选条件:如果指定了技能,只返回被标记的助教
filter_clause = "" filter_clause = ""
params: tuple = () params: tuple = ()
if skill_filter != "all" and skill_filter in _skill_to_category: if skill_filter != "ALL" and skill_filter in _valid_categories:
filter_clause = """ filter_clause = """
AND da.assistant_id IN ( AND da.assistant_id IN (
SELECT apt.assistant_id SELECT apt.assistant_id
@@ -995,7 +1008,7 @@ def get_all_assistants(
WHERE apt.category_code = %s AND apt.is_tagged = true WHERE apt.category_code = %s AND apt.is_tagged = true
) )
""" """
params = (_skill_to_category[skill_filter],) params = (skill_filter,)
cur.execute( cur.execute(
f""" f"""
@@ -1015,11 +1028,11 @@ def get_all_assistants(
) )
for row in cur.fetchall(): for row in cur.fetchall():
skill_codes = row[3] if row[3] else [] skill_codes = row[3] if row[3] else []
skill_labels = [_category_to_skill.get(c, c) for c in skill_codes if c] # CHANGE 2026-03-20 | R3 修复:直接返回 category_code不再反向映射为旧值
records.append({ records.append({
"assistant_id": row[0], "assistant_id": row[0],
"name": row[1] or "", "name": row[1] or "",
"skill": ",".join(skill_labels) if skill_labels else "", "skill": ",".join(c for c in skill_codes if c) if skill_codes else "",
"level": level_map.get(row[2], "") if row[2] else "", "level": level_map.get(row[2], "") if row[2] else "",
}) })
return records return records
@@ -1190,19 +1203,13 @@ def _project_filter_clause(project: str) -> tuple[str, tuple]:
""" """
生成项目筛选 SQL 片段(用于 BOARD-2 会员维度查询)。 生成项目筛选 SQL 片段(用于 BOARD-2 会员维度查询)。
CHANGE 2026-03-19 | P1 修复:通过 v_dws_member_project_tag 子查询实现项目筛选。 CHANGE 2026-03-20 | R3 修复:project 参数直接接收 category_code
project 参数映射chinese→BILLIARD, snooker→SNOOKER, mahjong→MAHJONG, karaoke→KTV BILLIARD/SNOOKER/MAHJONG/KTV/ALL去掉 chinese→BILLIARD 映射层
返回 (sql_fragment, params)sql_fragment 以 AND 开头,可直接拼入 WHERE 子句。 返回 (sql_fragment, params)sql_fragment 以 AND 开头,可直接拼入 WHERE 子句。
""" """
_project_to_category = { _valid_categories = {"BILLIARD", "SNOOKER", "MAHJONG", "KTV"}
"chinese": "BILLIARD", if project == "ALL" or project not in _valid_categories:
"snooker": "SNOOKER",
"mahjong": "MAHJONG",
"karaoke": "KTV",
}
if project == "all" or project not in _project_to_category:
return "", () return "", ()
category_code = _project_to_category[project]
clause = """ clause = """
AND vd.member_id IN ( AND vd.member_id IN (
SELECT mpt.member_id SELECT mpt.member_id
@@ -1210,7 +1217,7 @@ def _project_filter_clause(project: str) -> tuple[str, tuple]:
WHERE mpt.category_code = %s AND mpt.is_tagged = true WHERE mpt.category_code = %s AND mpt.is_tagged = true
) )
""" """
return clause, (category_code,) return clause, (project,)
def get_customer_board_recall( def get_customer_board_recall(
@@ -2314,25 +2321,31 @@ def get_finance_coach_analysis(
def get_skill_types(conn: Any, site_id: int) -> list[dict]: def get_skill_types(conn: Any, site_id: int) -> list[dict]:
""" """
CONFIG-1: 查询技能类型配置。 CONFIG-1: 查询项目类型筛选器配置。
来源: ETL cfg 表app.v_cfg_skill_type 或类似配置视图)。 来源: app.v_cfg_area_category基于 dws.cfg_area_category 去重,
排除 SPECIAL/OTHER。返回列表头部插入"不限"选项。
查询失败时由调用方降级返回空数组。 查询失败时由调用方降级返回空数组。
""" """
# CHANGE 2026-03-20 | R3 修复:原查询虚构的 v_cfg_skill_type 视图不存在,
# 改为查询 v_cfg_area_category项目类型配置value 直接用 category_code
# BILLIARD/SNOOKER/MAHJONG/KTV前端枚举同步修改。
# 假设cfg_area_category 的 category_code 是稳定的业务标识,不会频繁变动。
with _fdw_context(conn, site_id) as cur: with _fdw_context(conn, site_id) as cur:
cur.execute( cur.execute(
""" """
SELECT skill_key, skill_label, emoji, css_cls SELECT category_code, display_name, short_name
FROM app.v_cfg_skill_type FROM app.v_cfg_area_category
ORDER BY sort_order ORDER BY sort_order
""" """
) )
items = [] # 头部插入"不限"选项(后端生成,不存数据库)
items: list[dict] = [{"key": "ALL", "label": "不限", "emoji": "", "cls": ""}]
for row in cur.fetchall(): for row in cur.fetchall():
items.append({ items.append({
"key": row[0] or "", "key": row[0] or "",
"label": row[1] or "", "label": row[1] or "",
"emoji": row[2] or "", "emoji": row[2] or "",
"cls": row[3] or "", "cls": "",
}) })
return items return items

View File

@@ -1,20 +1,24 @@
# AI_CHANGELOG
# - 2026-03-20 | Prompt: H2 FDW→直连ETL统一改造 | FDW 外部表fdw_etl.*)改为直连 ETL 库
# 查询 app.v_* RLS 视图。原因postgres_fdw 不传递 GUC 参数RLS 门店隔离失效。
# 使用 fdw_queries._fdw_context() 上下文管理器统一管理 ETL 连接。
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
人员匹配服务 —— 根据申请信息在 FDW 外部表中查找候选匹配。 人员匹配服务 —— 根据申请信息在 ETL 库 RLS 视图中查找候选匹配。
职责: 职责:
- find_candidates():根据 site_id + phone+ employee_number在助教表和员工表中查找匹配 - find_candidates():根据 site_id + phone+ employee_number在助教表和员工表中查找匹配
查询通过业务库的 fdw_etl Schema 访问 ETL 库的 RLS 视图 直连 ETL 库查询 app.v_* RLS 视图,通过 _fdw_context 设置 site_id 实现门店隔离
查询前需 SET LOCAL app.current_site_id 以启用门店隔离 ETL 库连接失败时优雅降级返回空列表
FDW 外部表可能不存在(测试库等场景),需优雅降级返回空列表。
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from app.database import get_connection from app.services.fdw_queries import _fdw_context
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -29,9 +33,9 @@ async def find_candidates(
查询逻辑: 查询逻辑:
1. 若 site_id 为 None跳过匹配返回空列表 1. 若 site_id 为 None跳过匹配返回空列表
2. 设置 app.current_site_id 进行 RLS 隔离 2. 设置 app.current_site_id 进行 RLS 隔离(直连 ETL 库)
3. fdw_etl.v_dim_assistant: WHERE mobile = phone 3. app.v_dim_assistant: WHERE mobile = phone
4. fdw_etl.v_dim_staff JOIN fdw_etl.v_dim_staff_ex: WHERE mobile = phone OR job_num = employee_number 4. app.v_dim_staff JOIN app.v_dim_staff_ex: WHERE mobile = phone OR job_num = employee_number
5. 合并结果返回统一候选列表 5. 合并结果返回统一候选列表
参数: 参数:
@@ -49,56 +53,46 @@ async def find_candidates(
candidates: list[dict] = [] candidates: list[dict] = []
conn = get_connection() # CHANGE 2026-03-20 | H2 FDW→直连ETL | 从业务库 fdw_etl.* 改为直连 ETL 库 app.v_*
# intent: 修复 RLS 门店隔离失效postgres_fdw 不传递 GUC 参数)
# assumptions: _fdw_context 内部管理 ETL 连接生命周期,无需外部 conn
try: try:
conn.autocommit = False with _fdw_context(None, site_id) as cur:
with conn.cursor() as cur:
# 设置 RLS 隔离FDW 会透传 session 变量到远端 ETL 库
cur.execute(
"SET LOCAL app.current_site_id = %s", (str(site_id),)
)
# 1. 查询助教匹配 # 1. 查询助教匹配
candidates.extend(_query_assistants(cur, phone)) candidates.extend(_query_assistants(cur, phone))
# 2. 查询员工匹配 # 2. 查询员工匹配
candidates.extend(_query_staff(cur, phone, employee_number)) candidates.extend(_query_staff(cur, phone, employee_number))
conn.commit()
except Exception: except Exception:
logger.warning( logger.warning(
"FDW 人员匹配查询失败 (site_id=%s, phone=%s),返回空列表", "ETL 人员匹配查询失败 (site_id=%s, phone=%s),返回空列表",
site_id, site_id,
phone, phone,
exc_info=True, exc_info=True,
) )
try:
conn.rollback()
except Exception:
pass
return [] return []
finally:
conn.close()
return candidates return candidates
def _query_assistants(cur, phone: str) -> list[dict]: def _query_assistants(cur, phone: str) -> list[dict]:
"""查询 fdw_etl.v_dim_assistant 中按 mobile 匹配的助教记录。""" """查询 app.v_dim_assistant 中按 mobile 匹配的助教记录(直连 ETL 库)"""
try: try:
# CHANGE 2026-03-20 | H2 | fdw_etl.v_dim_assistant → app.v_dim_assistant
# 列名映射: scd2_is_current 是 integer 类型1=当前),不是 boolean
cur.execute( cur.execute(
""" """
SELECT assistant_id, real_name, mobile SELECT assistant_id, real_name, mobile
FROM fdw_etl.v_dim_assistant FROM app.v_dim_assistant
WHERE mobile = %s WHERE mobile = %s
AND scd2_is_current = TRUE AND scd2_is_current = 1
""", """,
(phone,), (phone,),
) )
rows = cur.fetchall() rows = cur.fetchall()
except Exception: except Exception:
logger.warning( logger.warning(
"查询 fdw_etl.v_dim_assistant 失败,跳过助教匹配", "查询 app.v_dim_assistant 失败,跳过助教匹配",
exc_info=True, exc_info=True,
) )
return [] return []
@@ -119,20 +113,21 @@ def _query_staff(
cur, phone: str, employee_number: str | None cur, phone: str, employee_number: str | None
) -> list[dict]: ) -> list[dict]:
""" """
查询 fdw_etl.v_dim_staff JOIN fdw_etl.v_dim_staff_ex 查询 app.v_dim_staff JOIN app.v_dim_staff_ex(直连 ETL 库)
按 mobile 或 job_num 匹配的员工记录。 按 mobile 或 job_num 匹配的员工记录。
""" """
try: try:
# 构建 WHERE 条件mobile = phone或 job_num = employee_number # CHANGE 2026-03-20 | H2 | fdw_etl.v_dim_staff/v_dim_staff_ex → app.v_dim_staff/v_dim_staff_ex
# 列名映射: scd2_is_current 是 integer 类型1=当前),不是 boolean
if employee_number: if employee_number:
cur.execute( cur.execute(
""" """
SELECT s.staff_id, s.staff_name, s.mobile, ex.job_num SELECT s.staff_id, s.staff_name, s.mobile, ex.job_num
FROM fdw_etl.v_dim_staff s FROM app.v_dim_staff s
LEFT JOIN fdw_etl.v_dim_staff_ex ex LEFT JOIN app.v_dim_staff_ex ex
ON s.staff_id = ex.staff_id ON s.staff_id = ex.staff_id
AND ex.scd2_is_current = TRUE AND ex.scd2_is_current = 1
WHERE s.scd2_is_current = TRUE WHERE s.scd2_is_current = 1
AND (s.mobile = %s OR ex.job_num = %s) AND (s.mobile = %s OR ex.job_num = %s)
""", """,
(phone, employee_number), (phone, employee_number),
@@ -141,11 +136,11 @@ def _query_staff(
cur.execute( cur.execute(
""" """
SELECT s.staff_id, s.staff_name, s.mobile, ex.job_num SELECT s.staff_id, s.staff_name, s.mobile, ex.job_num
FROM fdw_etl.v_dim_staff s FROM app.v_dim_staff s
LEFT JOIN fdw_etl.v_dim_staff_ex ex LEFT JOIN app.v_dim_staff_ex ex
ON s.staff_id = ex.staff_id ON s.staff_id = ex.staff_id
AND ex.scd2_is_current = TRUE AND ex.scd2_is_current = 1
WHERE s.scd2_is_current = TRUE WHERE s.scd2_is_current = 1
AND s.mobile = %s AND s.mobile = %s
""", """,
(phone,), (phone,),
@@ -153,7 +148,7 @@ def _query_staff(
rows = cur.fetchall() rows = cur.fetchall()
except Exception: except Exception:
logger.warning( logger.warning(
"查询 fdw_etl.v_dim_staff 失败,跳过员工匹配", "查询 app.v_dim_staff 失败,跳过员工匹配",
exc_info=True, exc_info=True,
) )
return [] return []

View File

@@ -1,8 +1,12 @@
# AI_CHANGELOG
# - 2026-03-20 | Prompt: H2 FDW→直连ETL统一改造 | _process_site() 中 fdw_etl.v_dwd_assistant_service_log
# 改为直连 ETL 库查询 app.v_dwd_assistant_service_log。使用 fdw_queries._fdw_context()。
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
召回完成检测器Recall Completion Detector 召回完成检测器Recall Completion Detector
ETL 数据更新后,通过 FDW 读取助教服务记录, ETL 数据更新后,直连 ETL 库读取助教服务记录,
匹配活跃任务标记为 completed记录 completed_at 和 completed_task_type 快照, 匹配活跃任务标记为 completed记录 completed_at 和 completed_task_type 快照,
触发 recall_completed 事件通知备注回溯重分类器。 触发 recall_completed 事件通知备注回溯重分类器。
@@ -138,25 +142,26 @@ def _process_site(conn, site_id: int, last_run_at) -> int:
""" """
处理单个门店的召回完成检测。 处理单个门店的召回完成检测。
通过 FDW 读取新增服务记录,匹配 active 任务并标记 completed。 直连 ETL 库读取新增服务记录,匹配 active 任务并标记 completed。
返回本门店完成的任务数。 返回本门店完成的任务数。
""" """
completed = 0 completed = 0
# 通过 FDW 读取新增服务记录(需要 SET LOCAL 启用 RLS # CHANGE 2026-03-20 | H2 FDW→直连ETL | fdw_etl.v_dwd_assistant_service_log → app.v_dwd_assistant_service_log
with conn.cursor() as cur: # intent: 修复 RLS 门店隔离失效postgres_fdw 不传递 GUC 参数)
cur.execute("BEGIN") # assumptions: _fdw_context 内部管理 ETL 连接conn 仅用于后续业务库操作
cur.execute( from app.services.fdw_queries import _fdw_context
"SET LOCAL app.current_site_id = %s", (str(site_id),)
)
with _fdw_context(conn, site_id) as cur:
if last_run_at is not None: if last_run_at is not None:
# 列名映射: FDW 外部表 assistant_id/member_id/service_time
# → RLS 视图 site_assistant_id/tenant_member_id/create_time
cur.execute( cur.execute(
""" """
SELECT DISTINCT assistant_id, member_id, service_time SELECT DISTINCT site_assistant_id, tenant_member_id, create_time
FROM fdw_etl.v_dwd_assistant_service_log FROM app.v_dwd_assistant_service_log
WHERE service_time > %s WHERE create_time > %s
ORDER BY service_time ASC ORDER BY create_time ASC
""", """,
(last_run_at,), (last_run_at,),
) )
@@ -164,13 +169,12 @@ def _process_site(conn, site_id: int, last_run_at) -> int:
# 首次运行,读取所有服务记录 # 首次运行,读取所有服务记录
cur.execute( cur.execute(
""" """
SELECT DISTINCT assistant_id, member_id, service_time SELECT DISTINCT site_assistant_id, tenant_member_id, create_time
FROM fdw_etl.v_dwd_assistant_service_log FROM app.v_dwd_assistant_service_log
ORDER BY service_time ASC ORDER BY create_time ASC
""" """
) )
service_records = cur.fetchall() service_records = cur.fetchall()
conn.commit()
# ── 4-7. 逐条服务记录匹配并处理 ── # ── 4-7. 逐条服务记录匹配并处理 ──
for assistant_id, member_id, service_time in service_records: for assistant_id, member_id, service_time in service_records:

View File

@@ -1,3 +1,8 @@
# AI_CHANGELOG
# - 2026-03-20 | Prompt: H2 FDW→直连ETL统一改造 | _process_assistant() 中 3 处 fdw_etl.v_dws_member_*
# 改为直连 ETL 库查询 app.v_dws_member_*。使用 fdw_queries._fdw_context()。
# 这是风险最高的改造点WBI/NCI 全表扫描无 WHERERLS 是唯一门店过滤手段。
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
任务生成器Task Generator 任务生成器Task Generator
@@ -185,19 +190,18 @@ def _process_assistant(
) -> None: ) -> None:
"""处理单个助教下所有客户-助教对的任务生成。""" """处理单个助教下所有客户-助教对的任务生成。"""
# 通过 FDW 读取该助教关联的客户指数数据 # CHANGE 2026-03-20 | H2 FDW→直连ETL | fdw_etl.v_dws_member_* → app.v_dws_member_*
# 需要 SET LOCAL app.current_site_id 以启用 RLS # intent: 修复 RLS 门店隔离失效postgres_fdw 不传递 GUC 参数)
with conn.cursor() as cur: # assumptions: _fdw_context 内部管理 ETL 连接conn 仅用于后续业务库写入
cur.execute("BEGIN") # 边界条件: WBI/NCI 全表扫描(无 WHERERLS 隔离是唯一的门店过滤手段
cur.execute( from app.services.fdw_queries import _fdw_context
"SET LOCAL app.current_site_id = %s", (str(site_id),)
)
with _fdw_context(conn, site_id) as cur:
# 读取 WBI流失回赢指数 # 读取 WBI流失回赢指数
cur.execute( cur.execute(
""" """
SELECT member_id, COALESCE(display_score, 0) SELECT member_id, COALESCE(display_score, 0)
FROM fdw_etl.v_dws_member_winback_index FROM app.v_dws_member_winback_index
""" """
) )
wbi_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()} wbi_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
@@ -206,7 +210,7 @@ def _process_assistant(
cur.execute( cur.execute(
""" """
SELECT member_id, COALESCE(display_score, 0) SELECT member_id, COALESCE(display_score, 0)
FROM fdw_etl.v_dws_member_newconv_index FROM app.v_dws_member_newconv_index
""" """
) )
nci_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()} nci_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
@@ -215,15 +219,13 @@ def _process_assistant(
cur.execute( cur.execute(
""" """
SELECT member_id, COALESCE(rs_display, 0) SELECT member_id, COALESCE(rs_display, 0)
FROM fdw_etl.v_dws_member_assistant_relation_index FROM app.v_dws_member_assistant_relation_index
WHERE assistant_id = %s WHERE assistant_id = %s
""", """,
(assistant_id,), (assistant_id,),
) )
rs_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()} rs_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
conn.commit()
# 合并所有涉及的 member_id # 合并所有涉及的 member_id
all_member_ids = set(wbi_map.keys()) | set(nci_map.keys()) | set(rs_map.keys()) all_member_ids = set(wbi_map.keys()) | set(nci_map.keys()) | set(rs_map.keys())

View File

@@ -1,8 +1,13 @@
# AI_CHANGELOG
# - 2026-03-20 | Prompt: H2 FDW→直连ETL统一改造 | get_task_list() 中 2 处、get_task_list_v2() 中 1 处、
# get_task_detail() 中 1 处 fdw_etl.v_dim_member / v_dws_member_assistant_relation_index
# 改为直连 ETL 库查询 app.v_* RLS 视图。使用 fdw_queries._fdw_context()。
""" """
任务管理服务 任务管理服务
负责任务 CRUD、置顶、放弃、取消放弃等操作。 负责任务 CRUD、置顶、放弃、取消放弃等操作。
通过 FDW 读取客户信息和 RS 指数,计算爱心 icon 档位。 直连 ETL 库查询 app.v_* RLS 视图获取客户信息和 RS 指数,计算爱心 icon 档位。
RNS1.1 扩展get_task_list_v2TASK-1、get_task_detailTASK-2 RNS1.1 扩展get_task_list_v2TASK-1、get_task_detailTASK-2
""" """
@@ -169,17 +174,18 @@ async def get_task_list(user_id: int, site_id: int) -> list[dict]:
member_info_map: dict[int, dict] = {} member_info_map: dict[int, dict] = {}
rs_map: dict[int, Decimal] = {} rs_map: dict[int, Decimal] = {}
with conn.cursor() as cur: # CHANGE 2026-03-20 | H2 FDW→直连ETL | fdw_etl.v_dim_member + v_dws_member_assistant_relation_index
cur.execute("BEGIN") # → 直连 ETL 库查 app.v_* RLS 视图
cur.execute( # intent: 修复 RLS 门店隔离失效postgres_fdw 不传递 GUC 参数)
"SET LOCAL app.current_site_id = %s", (str(site_id),) from app.services.fdw_queries import _fdw_context
)
with _fdw_context(conn, site_id) as cur:
# 读取客户基本信息 # 读取客户基本信息
# 列名映射: FDW 外部表 member_name/member_phone → RLS 视图 nickname/mobile
cur.execute( cur.execute(
""" """
SELECT member_id, member_name, member_phone SELECT member_id, nickname, mobile
FROM fdw_etl.v_dim_member FROM app.v_dim_member
WHERE member_id = ANY(%s) WHERE member_id = ANY(%s)
""", """,
(member_ids,), (member_ids,),
@@ -194,7 +200,7 @@ async def get_task_list(user_id: int, site_id: int) -> list[dict]:
cur.execute( cur.execute(
""" """
SELECT member_id, COALESCE(rs_display, 0) SELECT member_id, COALESCE(rs_display, 0)
FROM fdw_etl.v_dws_member_assistant_relation_index FROM app.v_dws_member_assistant_relation_index
WHERE assistant_id = %s WHERE assistant_id = %s
AND member_id = ANY(%s) AND member_id = ANY(%s)
""", """,
@@ -203,8 +209,6 @@ async def get_task_list(user_id: int, site_id: int) -> list[dict]:
for row in cur.fetchall(): for row in cur.fetchall():
rs_map[row[0]] = Decimal(str(row[1])) rs_map[row[0]] = Decimal(str(row[1]))
conn.commit()
# 组装结果 # 组装结果
result = [] result = []
for task_row in tasks: for task_row in tasks:
@@ -598,26 +602,24 @@ async def get_task_list_v2(
logger.warning("FDW 查询 lastVisitDays 失败", exc_info=True) logger.warning("FDW 查询 lastVisitDays 失败", exc_info=True)
# ── 5. RS 指数(用于 heart_score ── # ── 5. RS 指数(用于 heart_score ──
# CHANGE 2026-03-20 | H2 FDW→直连ETL | fdw_etl → app直连 ETL 库)
rs_map: dict[int, Decimal] = {} rs_map: dict[int, Decimal] = {}
try: try:
with conn.cursor() as cur: from app.services.fdw_queries import _fdw_context
cur.execute("BEGIN")
cur.execute( with _fdw_context(conn, site_id) as cur:
"SET LOCAL app.current_site_id = %s", (str(site_id),)
)
cur.execute( cur.execute(
""" """
SELECT member_id, COALESCE(rs_display, 0) SELECT member_id, COALESCE(rs_display, 0)
FROM fdw_etl.v_dws_member_assistant_relation_index FROM app.v_dws_member_assistant_relation_index
WHERE assistant_id = %s AND member_id = ANY(%s) WHERE assistant_id = %s AND member_id = ANY(%s)
""", """,
(assistant_id, member_ids), (assistant_id, member_ids),
) )
for row in cur.fetchall(): for row in cur.fetchall():
rs_map[row[0]] = Decimal(str(row[1])) rs_map[row[0]] = Decimal(str(row[1]))
conn.commit()
except Exception: except Exception:
logger.warning("FDW 查询 RS 指数失败", exc_info=True) logger.warning("ETL 查询 RS 指数失败", exc_info=True)
try: try:
conn.rollback() conn.rollback()
except Exception: except Exception:
@@ -831,17 +833,16 @@ async def get_task_detail(
customer_name = info.get("nickname") or "未知客户" customer_name = info.get("nickname") or "未知客户"
# RS 指数 # RS 指数
# CHANGE 2026-03-20 | H2 FDW→直连ETL | fdw_etl → app直连 ETL 库)
rs_score = Decimal("0") rs_score = Decimal("0")
try: try:
with conn.cursor() as cur: from app.services.fdw_queries import _fdw_context
cur.execute("BEGIN")
cur.execute( with _fdw_context(conn, site_id) as cur:
"SET LOCAL app.current_site_id = %s", (str(site_id),)
)
cur.execute( cur.execute(
""" """
SELECT COALESCE(rs_display, 0) SELECT COALESCE(rs_display, 0)
FROM fdw_etl.v_dws_member_assistant_relation_index FROM app.v_dws_member_assistant_relation_index
WHERE assistant_id = %s AND member_id = %s WHERE assistant_id = %s AND member_id = %s
""", """,
(assistant_id, member_id), (assistant_id, member_id),
@@ -849,13 +850,8 @@ async def get_task_detail(
rs_row = cur.fetchone() rs_row = cur.fetchone()
if rs_row: if rs_row:
rs_score = Decimal(str(rs_row[0])) rs_score = Decimal(str(rs_row[0]))
conn.commit()
except Exception: except Exception:
logger.warning("FDW 查询 RS 指数失败", exc_info=True) logger.warning("ETL 查询 RS 指数失败", exc_info=True)
try:
conn.rollback()
except Exception:
pass
# ── 3. 查询维客线索 ── # ── 3. 查询维客线索 ──
retention_clues = [] retention_clues = []

View File

@@ -465,6 +465,150 @@ MVP 全链路验证端点,从 `test."xcx-test"` 表读取数据。
--- ---
## 16. 小程序配置 `/api/xcx/config`
所有端点需 JWTapproved 状态)。
### GET `/api/xcx/config/skill-types`
项目类型筛选器配置CONFIG-1。返回前端筛选器选项列表。
数据源:`app.v_cfg_area_category`(基于 `dws.cfg_area_category` 去重到 category 级别,排除 SPECIAL/OTHER按 sort_order 排序)。
响应头部自动插入"不限"选项key=ALL不存储在数据库中。
响应:`SkillTypeItem[]`
```json
[
{ "key": "ALL", "label": "不限", "emoji": "🔍", "cls": "" },
{ "key": "BILLIARD", "label": "🎱 中式/追分", "emoji": "🎱", "cls": "" },
{ "key": "SNOOKER", "label": "斯诺克", "emoji": "斯", "cls": "" },
{ "key": "MAHJONG", "label": "🀄 麻将/棋牌", "emoji": "🀄", "cls": "" },
{ "key": "KTV", "label": "🎤 团建/K歌", "emoji": "🎤", "cls": "" }
]
```
降级行为:查询失败时返回空数组 `[]`
---
## 17. 小程序三看板 `/api/xcx/board`
所有端点需 JWT + 对应权限。
### GET `/api/xcx/board/coaches`
助教看板BOARD-1。返回助教列表支持排序×技能×时间三重筛选。
查询参数:
- `sort`:排序维度(`perf_desc`/`perf_asc`/`salary_desc`/`salary_asc`/`sv_desc`/`task_desc`,默认 `perf_desc`
- `skill`:技能筛选(`ALL`/`BILLIARD`/`SNOOKER`/`MAHJONG`/`KTV`,默认 `ALL`
- `time`:时间范围(`month`/`quarter`/`last_month`/`last_3m`/`last_quarter`/`last_6m`,默认 `month`
约束:`time=last_6m` + `sort=sv_desc` 互斥,返回 400。
权限:`view_board_coach`
响应:`CoachBoardResponse`(含 `items` + `dimType`
### GET `/api/xcx/board/customers`
客户看板BOARD-2。返回客户列表支持维度×项目筛选 + 分页。
查询参数:
- `dimension`:客户维度(`recall`/`potential`/`balance`/`recharge`/`recent`/`spend60`/`freq60`/`loyal`,默认 `recall`
- `project`:项目筛选(`ALL`/`BILLIARD`/`SNOOKER`/`MAHJONG`/`KTV`,默认 `ALL`
- `page`:页码(默认 1
- `page_size`:每页条数(默认 20最大 100
权限:`view_board_customer`
响应:`CustomerBoardResponse`(含 `items`/`total`/`page`/`pageSize`
### GET `/api/xcx/board/finance`
财务看板BOARD-3。返回 6 大板块(经营一览/预收资产/营收结构/现金流/支出/助教分析)。
查询参数:
- `time`:时间范围(`month`/`lastMonth`/`week`/`lastWeek`/`quarter3`/`quarter`/`lastQuarter`/`half6`,默认 `month`
- `area`:区域筛选(`all`/`hall`/`hallA`/`hallB`/`hallC`/`mahjong`/`teamBuilding`,默认 `all`
- `compare`环比开关0=关闭/1=开启,默认 0
约束:`area≠all``recharge` 板块为 null`compare=0` 时响应不含环比字段。
权限:`view_board_finance`
响应:`FinanceBoardResponse`(含 `overview`/`recharge`/`revenue`/`cashflow`/`expense`/`coachAnalysis`
---
## 18. 小程序 CHAT `/api/xcx/chat`
所有端点需 JWTapproved 状态)。替代原 `xcx_ai_chat``/api/ai/*`),统一迁移到 `/api/xcx/chat/*` 路径。
### GET `/api/xcx/chat/history`
对话历史列表CHAT-1。返回当前用户的对话列表按最后消息时间倒序。
查询参数:
- `page`:页码(默认 1
- `page_size`:每页条数(默认 20最大 100
响应:`ChatHistoryResponse`(含 `items`/`total`/`page`/`pageSize`
每条对话包含:`id`(对话 ID`title`(对话标题)、`customerName`(关联客户姓名,可选)、`lastMessage`(最后消息摘要)、`timestamp`(最后消息时间)、`unreadCount`(未读数)。
### GET `/api/xcx/chat/messages`
通过上下文查询消息CHAT-2b。根据 `contextType` + `contextId` 自动查找或创建对话。
查询参数:
- `contextType`:上下文类型(`task`/`customer`/`coach`/`general`,必填)
- `contextId`:上下文 ID必填
- `page`:页码(默认 1
- `page_size`:每页条数(默认 50最大 100
对话复用规则:`task` 始终复用(无时限);`customer`/`coach` ≤ 3 天复用、> 3 天新建;`general` 始终新建。
响应:`ChatMessagesResponse`(含 `chatId`/`items`/`total`/`page`/`pageSize`
### GET `/api/xcx/chat/{chat_id}/messages`
通过 chatId 查询消息CHAT-2a。消息按 `created_at` 正序。
路径参数:
- `chat_id`:对话 ID
查询参数:
- `page`:页码(默认 1
- `page_size`:每页条数(默认 50最大 100
响应:`ChatMessagesResponse`
每条消息包含:`id``role``user`/`assistant`)、`content``createdAt``referenceCard`(可选,含 `type`/`title`/`summary`/`data` 键值对)。
### POST `/api/xcx/chat/{chat_id}/messages`
发送消息并获取同步 AI 回复CHAT-3。chatId 归属验证:不属于当前用户返回 403。
请求体:
```json
{ "content": "消息内容" }
```
响应:`SendMessageResponse`(含 `userMessage``aiReply`,各含 `id`/`content`/`createdAt`
AI 失败降级:用户消息仍保存,`aiReply.content` 返回错误提示HTTP 200。
### POST `/api/xcx/chat/stream`
SSE 流式对话端点CHAT-4。chatId 归属验证在流开始前完成。
请求体:
```json
{ "chatId": 1, "content": "消息内容" }
```
响应:`text/event-stream`(不经过 ResponseWrapper 包装)
SSE 事件类型:
- `event: message``data: {"token": "文本片段"}`
- `event: done``data: {"messageId": 123, "createdAt": "ISO8601"}`
- `event: error``data: {"message": "错误描述"}`
---
## 15. 维客线索 `/api/member-retention-clue` ## 15. 维客线索 `/api/member-retention-clue`
替代原 `member-birthday` 端点,提供维客线索管理能力。 替代原 `member-birthday` 端点,提供维客线索管理能力。

View File

@@ -64,6 +64,10 @@ apps/miniprogram/
| `pages/reviewing/reviewing` | 审核中等待页 | | `pages/reviewing/reviewing` | 审核中等待页 |
| `pages/no-permission/no-permission` | 无权限提示页 | | `pages/no-permission/no-permission` | 无权限提示页 |
| `pages/task-list/task-list` | 任务列表页H5 原型 1:1 重写,四种任务类型分组) | | `pages/task-list/task-list` | 任务列表页H5 原型 1:1 重写,四种任务类型分组) |
| `pages/notes/notes` | 备注管理页(备注 CRUD + 关联任务上下文) |
| `pages/chat/chat` | AI 对话页CHAT-2b/3/4按上下文进入对话 |
| `pages/chat-history/chat-history` | 对话历史列表页CHAT-1 |
| `pages/board-coach/board-coach` | 助教看板页BOARD-1排序×技能×时间筛选 |
| `pages/dev-tools/dev-tools` | 开发调试面板(仅 develop 环境,通过 dev-fab 浮动按钮进入) | | `pages/dev-tools/dev-tools` | 开发调试面板(仅 develop 环境,通过 dev-fab 浮动按钮进入) |
| `pages/logs/logs` | 日志页(框架默认) | | `pages/logs/logs` | 日志页(框架默认) |
@@ -137,6 +141,15 @@ POST /api/xcx-auth/login → 重新登录获取完整令牌(含 site_id + ro
| `/api/xcx/tasks/{task_id}/pin` | POST | 置顶/取消置顶任务 | | `/api/xcx/tasks/{task_id}/pin` | POST | 置顶/取消置顶任务 |
| `/api/xcx/tasks/{task_id}/abandon` | POST | 放弃/取消放弃任务 | | `/api/xcx/tasks/{task_id}/abandon` | POST | 放弃/取消放弃任务 |
| `/api/xcx/notes` | GET/POST/DELETE | 备注 CRUD | | `/api/xcx/notes` | GET/POST/DELETE | 备注 CRUD |
| `/api/xcx/config/skill-types` | GET | 项目类型筛选器配置CONFIG-1 |
| `/api/xcx/board/coaches` | GET | 助教看板BOARD-1排序×技能×时间筛选 |
| `/api/xcx/board/customers` | GET | 客户看板BOARD-2维度×项目筛选 + 分页) |
| `/api/xcx/board/finance` | GET | 财务看板BOARD-36 大板块 + 环比开关) |
| `/api/xcx/chat/history` | GET | CHAT-1 对话历史列表 |
| `/api/xcx/chat/{chat_id}/messages` | GET | CHAT-2a 通过 chatId 查消息 |
| `/api/xcx/chat/messages` | GET | CHAT-2b 通过上下文查消息contextType + contextId |
| `/api/xcx/chat/{chat_id}/messages` | POST | CHAT-3 发送消息(同步回复) |
| `/api/xcx/chat/stream` | POST | CHAT-4 SSE 流式对话 |
| `/api/xcx-test` | GET | MVP 全链路验证 | | `/api/xcx-test` | GET | MVP 全链路验证 |
> 完整 API 文档见 [`apps/backend/docs/API-REFERENCE.md`](../backend/docs/API-REFERENCE.md) > 完整 API 文档见 [`apps/backend/docs/API-REFERENCE.md`](../backend/docs/API-REFERENCE.md)

View File

@@ -1,3 +1,8 @@
// AI_CHANGELOG
// - 2026-03-20 | Prompt: R3 项目类型筛选接口重建 | SKILL_OPTIONS value 从
// all/chinese/snooker/mahjong/karaoke 改为 ALL/BILLIARD/SNOOKER/MAHJONG/KTV
// 与后端枚举和数据库 category_code 一致。
// 助教看板页 — 排序×技能×时间三重筛选4 种维度卡片 // 助教看板页 — 排序×技能×时间三重筛选4 种维度卡片
// TODO: 联调时替换 mock 数据为真实 API 调用 // TODO: 联调时替换 mock 数据为真实 API 调用
import { formatMoney, formatCount } from '../../utils/money' import { formatMoney, formatCount } from '../../utils/money'
@@ -26,12 +31,14 @@ const SORT_OPTIONS = [
{ value: 'task_desc', text: '任务完成最多' }, { value: 'task_desc', text: '任务完成最多' },
] ]
// CHANGE 2026-03-20 | R3 修复value 改为数据库 category_code与后端枚举一致。
// 后续应改为调用 fetchSkillTypes() API 动态获取,此处作为 fallback。
const SKILL_OPTIONS = [ const SKILL_OPTIONS = [
{ value: 'all', text: '不限' }, { value: 'ALL', text: '不限' },
{ value: 'chinese', text: '🎱 中式/追分' }, { value: 'BILLIARD', text: '🎱 中式/追分' },
{ value: 'snooker', text: '斯诺克' }, { value: 'SNOOKER', text: '斯诺克' },
{ value: 'mahjong', text: '🀄 麻将/棋牌' }, { value: 'MAHJONG', text: '🀄 麻将/棋牌' },
{ value: 'karaoke', text: '🎤 团建/K歌' }, { value: 'KTV', text: '🎤 团建/K歌' },
] ]
const TIME_OPTIONS = [ const TIME_OPTIONS = [
@@ -230,6 +237,7 @@ Page({
loadData() { loadData() {
this.setData({ pageState: 'loading' }) this.setData({ pageState: 'loading' })
setTimeout(() => { setTimeout(() => {
try {
const data = MOCK_COACHES const data = MOCK_COACHES
if (!data || data.length === 0) { if (!data || data.length === 0) {
this.setData({ pageState: 'empty' }) this.setData({ pageState: 'empty' })
@@ -250,6 +258,9 @@ Page({
taskCallbackLabel: formatCount(c.taskCallback, '次'), taskCallbackLabel: formatCount(c.taskCallback, '次'),
})) }))
this.setData({ allCoaches: enriched, coaches: enriched, pageState: 'normal' }) this.setData({ allCoaches: enriched, coaches: enriched, pageState: 'normal' })
} catch {
this.setData({ pageState: 'error' })
}
}, 400) }, 400)
}, },

View File

@@ -260,6 +260,7 @@ Page({
loadData() { loadData() {
this.setData({ pageState: 'loading' }) this.setData({ pageState: 'loading' })
setTimeout(() => { setTimeout(() => {
try {
const data = MOCK_CUSTOMERS const data = MOCK_CUSTOMERS
if (!data || data.length === 0) { if (!data || data.length === 0) {
this.setData({ pageState: 'empty' }) this.setData({ pageState: 'empty' })
@@ -271,6 +272,9 @@ Page({
totalCount: data.length, totalCount: data.length,
pageState: 'normal', pageState: 'normal',
}) })
} catch {
this.setData({ pageState: 'error' })
}
}, 400) }, 400)
}, },

View File

@@ -1,5 +1,4 @@
import { mockChatHistory } from '../../utils/mock-data' import { fetchChatHistory } from '../../services/api'
import { sortByTimestamp } from '../../utils/sort'
import { formatRelativeTime } from '../../utils/time' import { formatRelativeTime } from '../../utils/time'
/** VI 规范 §6.2AI 图标配色系统6种 */ /** VI 规范 §6.2AI 图标配色系统6种 */
@@ -42,15 +41,17 @@ Page({
}, },
/** 加载数据 */ /** 加载数据 */
loadData() { async loadData() {
this.setData({ pageState: 'loading' }) this.setData({ pageState: 'loading' })
try { try {
setTimeout(() => { const res = await fetchChatHistory()
// TODO: 替换为真实 API 调用 const list: ChatHistoryDisplay[] = (res.items || []).map((item: any) => ({
const sorted = sortByTimestamp(mockChatHistory) id: String(item.id),
const list: ChatHistoryDisplay[] = sorted.map((item) => ({ title: item.title || '',
...item, lastMessage: item.lastMessage || '',
timestamp: item.timestamp || '',
customerName: item.customerName,
timeLabel: formatRelativeTime(item.timestamp), timeLabel: formatRelativeTime(item.timestamp),
iconGradient: ICON_GRADIENTS[Math.floor(Math.random() * ICON_GRADIENTS.length)], iconGradient: ICON_GRADIENTS[Math.floor(Math.random() * ICON_GRADIENTS.length)],
})) }))
@@ -59,7 +60,6 @@ Page({
list, list,
pageState: list.length === 0 ? 'empty' : 'normal', pageState: list.length === 0 ? 'empty' : 'normal',
}) })
}, 400)
} catch { } catch {
this.setData({ pageState: 'error' }) this.setData({ pageState: 'error' })
} }
@@ -82,8 +82,8 @@ Page({
}, },
/** 下拉刷新 */ /** 下拉刷新 */
onPullDownRefresh() { async onPullDownRefresh() {
this.loadData() await this.loadData()
setTimeout(() => wx.stopPullDownRefresh(), 600) wx.stopPullDownRefresh()
}, },
}) })

View File

@@ -1,8 +1,23 @@
// pages/chat/chat.ts — AI 对话页 // pages/chat/chat.ts — AI 对话页
import { mockChatMessages } from '../../utils/mock-data' // CHANGE 2026-03-20 | RNS1.4 T8.1: 多入口参数路由,替换 mock 为真实 API
import type { ChatMessage } from '../../utils/mock-data' // CHANGE 2026-03-20 | RNS1.4 T8.2: 替换 simulateStreamOutput 为真实 SSE 连接
import { simulateStreamOutput } from '../../utils/chat' import { fetchChatMessages, fetchChatMessagesByContext } from '../../services/api'
import { formatRelativeTime, formatIMTime, shouldShowTimeDivider } from '../../utils/time' import { formatRelativeTime, formatIMTime, shouldShowTimeDivider } from '../../utils/time'
import { API_BASE } from '../../utils/config'
/** API 返回的消息项类型 */
interface ApiMessageItem {
id: number
role: 'user' | 'assistant'
content: string
createdAt: string
referenceCard?: {
type: 'customer' | 'record'
title: string
summary: string
data: Record<string, string>
}
}
/** 将 referenceCard.data (Record<string,string>) 转为数组供 WXML wx:for 渲染 */ /** 将 referenceCard.data (Record<string,string>) 转为数组供 WXML wx:for 渲染 */
function toDataList(data?: Record<string, string>): Array<{ key: string; value: string }> { function toDataList(data?: Record<string, string>): Array<{ key: string; value: string }> {
@@ -11,14 +26,17 @@ function toDataList(data?: Record<string, string>): Array<{ key: string; value:
} }
/** 为消息列表补充展示字段时间分割线、IM时间、引用卡片*/ /** 为消息列表补充展示字段时间分割线、IM时间、引用卡片*/
function enrichMessages(msgs: ChatMessage[]) { function enrichMessages(msgs: ApiMessageItem[]) {
return msgs.map((m, i) => ({ return msgs.map((m, i) => ({
...m, id: String(m.id),
timeLabel: formatRelativeTime(m.timestamp), role: m.role,
imTimeLabel: formatIMTime(m.timestamp), content: m.content,
timestamp: m.createdAt,
timeLabel: formatRelativeTime(m.createdAt),
imTimeLabel: formatIMTime(m.createdAt),
showTimeDivider: shouldShowTimeDivider( showTimeDivider: shouldShowTimeDivider(
i === 0 ? null : msgs[i - 1].timestamp, i === 0 ? null : msgs[i - 1].createdAt,
m.timestamp, m.createdAt,
), ),
referenceCard: m.referenceCard referenceCard: m.referenceCard
? { ...m.referenceCard, dataList: toDataList(m.referenceCard.data) } ? { ...m.referenceCard, dataList: toDataList(m.referenceCard.data) }
@@ -26,12 +44,114 @@ function enrichMessages(msgs: ChatMessage[]) {
})) }))
} }
/** Mock AI 回复模板 */ /** 从 Storage 获取 access_token与 request.ts 保持一致) */
const mockAIReplies = [ function getAccessToken(): string | undefined {
'根据数据分析,这位客户近期消费频次有所下降,建议安排一次主动回访,了解具体原因。', const app = getApp<IAppOption>()
'好的,我已经记录了你的需求。下次回访时我会提醒你。', return app.globalData.token || wx.getStorageSync('token') || undefined
'这位客户偏好中式台球,最近对斯诺克也产生了兴趣,可以推荐相关课程。', }
]
/**
* SSE 行缓冲解析器
* 微信 onChunkReceived 返回的 ArrayBuffer 可能跨事件边界切割,
* 需要缓冲不完整的行,只处理完整的 SSE 事件。
*/
class SSEParser {
private buffer = ''
private currentEvent = ''
private currentData = ''
constructor(
private onMessage: (token: string) => void,
private onDone: (messageId: number, createdAt: string) => void,
private onError: (message: string) => void,
) {}
/** 将 ArrayBuffer 解码为 UTF-8 字符串(兼容微信环境) */
private decodeChunk(chunk: ArrayBuffer): string {
// 微信基础库支持 TextDecoder2.19.2+
if (typeof TextDecoder !== 'undefined') {
return new TextDecoder('utf-8').decode(chunk)
}
// 降级:手动解码 UTF-8 字节
const bytes = new Uint8Array(chunk)
let str = ''
for (let i = 0; i < bytes.length; i++) {
str += String.fromCharCode(bytes[i])
}
try {
return decodeURIComponent(escape(str))
} catch {
return str
}
}
/** 接收一个 chunk 并解析完整的 SSE 事件 */
feed(chunk: ArrayBuffer): void {
this.buffer += this.decodeChunk(chunk)
const lines = this.buffer.split('\n')
// 最后一个元素可能是不完整的行,保留在 buffer 中
this.buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.replace(/\r$/, '')
if (trimmed === '') {
// 空行 = 事件分隔符,派发当前事件
this.dispatch()
} else if (trimmed.startsWith('event:')) {
this.currentEvent = trimmed.slice(6).trim()
} else if (trimmed.startsWith('data:')) {
this.currentData = trimmed.slice(5).trim()
}
// 忽略其他行(如 id:、retry:、注释等)
}
}
private dispatch(): void {
if (!this.currentEvent || !this.currentData) {
this.currentEvent = ''
this.currentData = ''
return
}
try {
const data = JSON.parse(this.currentData)
switch (this.currentEvent) {
case 'message':
if (data.token != null) this.onMessage(data.token)
break
case 'done':
this.onDone(data.messageId, data.createdAt)
break
case 'error':
this.onError(data.message || 'AI 服务暂时不可用')
break
}
} catch {
// JSON 解析失败,忽略此事件
}
this.currentEvent = ''
this.currentData = ''
}
}
/** 展示用消息类型 */
type DisplayMessage = {
id: string
role: 'user' | 'assistant'
content: string
timestamp: string
timeLabel?: string
imTimeLabel?: string
showTimeDivider?: boolean
referenceCard?: {
type: string
title: string
summary: string
data: Record<string, string>
dataList?: Array<{ key: string; value: string }>
}
}
Page({ Page({
data: { data: {
@@ -40,12 +160,7 @@ Page({
/** 状态栏高度 */ /** 状态栏高度 */
statusBarHeight: 0, statusBarHeight: 0,
/** 消息列表 */ /** 消息列表 */
messages: [] as Array<ChatMessage & { messages: [] as DisplayMessage[],
timeLabel?: string
imTimeLabel?: string
showTimeDivider?: boolean
referenceCard?: ChatMessage['referenceCard'] & { dataList?: Array<{ key: string; value: string }> }
}>,
/** 输入框内容 */ /** 输入框内容 */
inputText: '', inputText: '',
/** AI 正在流式回复 */ /** AI 正在流式回复 */
@@ -56,7 +171,9 @@ Page({
scrollToId: '', scrollToId: '',
/** 页面顶部引用卡片(从其他页面跳转时) */ /** 页面顶部引用卡片(从其他页面跳转时) */
referenceCard: null as { title: string; summary: string } | null, referenceCard: null as { title: string; summary: string } | null,
/** 客户 ID */ /** 当前对话 ID后端返回用于发送消息 */
chatId: '',
/** 客户 ID兼容旧逻辑 */
customerId: '', customerId: '',
/** 输入栏距底部距离(软键盘弹出时动态调整)*/ /** 输入栏距底部距离(软键盘弹出时动态调整)*/
inputBarBottom: 0, inputBarBottom: 0,
@@ -65,41 +182,80 @@ Page({
/** 消息计数器,用于生成唯一 ID */ /** 消息计数器,用于生成唯一 ID */
_msgCounter: 0, _msgCounter: 0,
/** 当前 SSE 请求任务(用于中断/重试) */
_sseTask: null as WechatMiniprogram.RequestTask | null,
/** 最后一次发送的用户消息内容(用于重试) */
_lastUserContent: '',
// CHANGE 2026-03-20 | RNS1.4 T8.1: 多入口参数路由
// 优先级historyId → taskId → customerId → coachId → general
onLoad(options) { onLoad(options) {
const sysInfo = wx.getWindowInfo() const sysInfo = wx.getWindowInfo()
const customerId = options?.customerId || ''
this.setData({ this.setData({
customerId,
statusBarHeight: sysInfo.statusBarHeight || 44, statusBarHeight: sysInfo.statusBarHeight || 44,
}) })
this.loadMessages(customerId)
if (options?.historyId) {
// 从 chat-history 跳转:直接用 historyId 作为 chatId 加载历史消息
this.setData({ chatId: options.historyId })
this.loadMessages(options.historyId)
} else if (options?.taskId) {
// 从 task-detail 跳转:同一 taskId 始终复用同一对话(无时限)
this.loadMessagesByContext('task', options.taskId)
} else if (options?.customerId) {
// 从 customer-detail 跳转:≤ 3 天复用、> 3 天新建
this.setData({ customerId: options.customerId })
this.loadMessagesByContext('customer', options.customerId)
} else if (options?.coachId) {
// 从 coach-detail 跳转:≤ 3 天复用、> 3 天新建
this.loadMessagesByContext('coach', options.coachId)
} else {
// 无参数入口:始终新建通用对话
this.loadMessagesByContext('general', '')
}
}, },
/** 加载消息Mock */ /** 通过 chatId 加载历史消息historyId 入口 */
loadMessages(customerId: string) { async loadMessages(chatId: string) {
this.setData({ pageState: 'loading' }) this.setData({ pageState: 'loading' })
try { try {
setTimeout(() => { const res = await fetchChatMessages(chatId)
// TODO: 替换为真实 API 调用 this.setData({ chatId: String(res.chatId) })
const messages = enrichMessages(mockChatMessages) const messages = enrichMessages(res.items as ApiMessageItem[])
this._msgCounter = messages.length
this.setData({
pageState: messages.length === 0 ? 'empty' : 'normal',
messages,
})
this.scrollToBottom()
} catch {
this.setData({ pageState: 'error' })
}
},
/** 通过上下文类型加载消息task/customer/coach/general 入口) */
async loadMessagesByContext(contextType: string, contextId: string) {
this.setData({ pageState: 'loading' })
try {
const res = await fetchChatMessagesByContext(contextType, contextId)
// 缓存后端返回的 chatId后续发送消息使用
this.setData({ chatId: String(res.chatId) })
const messages = enrichMessages(res.items as ApiMessageItem[])
this._msgCounter = messages.length this._msgCounter = messages.length
// 如果携带 customerId显示引用卡片 // customerId 入口时显示引用卡片
const referenceCard = customerId const referenceCard = contextType === 'customer' && contextId
? { title: '客户详情', summary: `正在查看客户 ${customerId} 的相关信息` } ? { title: '客户详情', summary: `正在查看客户 ${contextId} 的相关信息` }
: null : null
const isEmpty = messages.length === 0 && !referenceCard const isEmpty = messages.length === 0 && !referenceCard
this.setData({ this.setData({
pageState: isEmpty ? 'empty' : 'normal', pageState: isEmpty ? 'empty' : 'normal',
messages, messages,
referenceCard, referenceCard,
}) })
this.scrollToBottom() this.scrollToBottom()
}, 500)
} catch { } catch {
this.setData({ pageState: 'error' }) this.setData({ pageState: 'error' })
} }
@@ -107,12 +263,29 @@ Page({
/** 返回上一页 */ /** 返回上一页 */
onBack() { onBack() {
// 中断进行中的 SSE 连接
if (this._sseTask) {
this._sseTask.abort()
this._sseTask = null
}
wx.navigateBack() wx.navigateBack()
}, },
/** 页面卸载时清理 SSE 连接 */
onUnload() {
if (this._sseTask) {
this._sseTask.abort()
this._sseTask = null
}
},
/** 重试加载 */ /** 重试加载 */
onRetry() { onRetry() {
this.loadMessages(this.data.customerId) if (this.data.chatId) {
this.loadMessages(this.data.chatId)
} else {
this.loadMessagesByContext('general', '')
}
}, },
/** 软键盘弹出:输入栏上移至键盘顶部 */ /** 软键盘弹出:输入栏上移至键盘顶部 */
@@ -141,9 +314,9 @@ Page({
const now = new Date().toISOString() const now = new Date().toISOString()
const prevMsgs = this.data.messages const prevMsgs = this.data.messages
const prevTs = prevMsgs.length > 0 ? prevMsgs[prevMsgs.length - 1].timestamp : null const prevTs = prevMsgs.length > 0 ? prevMsgs[prevMsgs.length - 1].timestamp : null
const userMsg = { const userMsg: DisplayMessage = {
id: `msg-user-${this._msgCounter}`, id: `msg-user-${this._msgCounter}`,
role: 'user' as const, role: 'user',
content: text, content: text,
timestamp: now, timestamp: now,
timeLabel: '刚刚', timeLabel: '刚刚',
@@ -160,23 +333,30 @@ Page({
this.scrollToBottom() this.scrollToBottom()
// 保存用户消息内容,用于 SSE 发送和重试
this._lastUserContent = text
// CHANGE 2026-03-20 | T8.2: 使用真实 SSE 替代 mock
setTimeout(() => { setTimeout(() => {
this.triggerAIReply() this.triggerAIReply()
}, 300) }, 300)
}, },
/** 触发 AI 流式回复 */ // CHANGE 2026-03-20 | T8.2: 真实 SSE 流式连接替代 mock
/** 触发 AI 流式回复(通过 SSE 连接后端 /api/xcx/chat/stream */
triggerAIReply() { triggerAIReply() {
const chatId = this.data.chatId
const content = this._lastUserContent
if (!chatId || !content) return
this._msgCounter++ this._msgCounter++
const aiMsgId = `msg-ai-${this._msgCounter}` const aiMsgId = `msg-ai-${this._msgCounter}`
const replyText = mockAIReplies[this._msgCounter % mockAIReplies.length]
const aiNow = new Date().toISOString() const aiNow = new Date().toISOString()
const prevMsgs = this.data.messages const prevMsgs = this.data.messages
const prevTs = prevMsgs.length > 0 ? prevMsgs[prevMsgs.length - 1].timestamp : null const prevTs = prevMsgs.length > 0 ? prevMsgs[prevMsgs.length - 1].timestamp : null
const aiMsg = { const aiMsg: DisplayMessage = {
id: aiMsgId, id: aiMsgId,
role: 'assistant' as const, role: 'assistant',
content: '', content: '',
timestamp: aiNow, timestamp: aiNow,
timeLabel: '刚刚', timeLabel: '刚刚',
@@ -190,23 +370,97 @@ Page({
isStreaming: true, isStreaming: true,
streamingContent: '', streamingContent: '',
}) })
this.scrollToBottom() this.scrollToBottom()
const aiIndex = messages.length - 1 const aiIndex = messages.length - 1
simulateStreamOutput(replyText, (partial: string) => { let fullContent = ''
const parser = new SSEParser(
// onMessage: 逐 token 追加
(token: string) => {
fullContent += token
const key = `messages[${aiIndex}].content` const key = `messages[${aiIndex}].content`
this.setData({ this.setData({
[key]: partial, [key]: fullContent,
streamingContent: partial, streamingContent: fullContent,
}) })
this.scrollToBottom() this.scrollToBottom()
}).then(() => { },
// onDone: 流结束,更新消息 ID 和时间
(messageId: number, createdAt: string) => {
this.setData({ this.setData({
[`messages[${aiIndex}].id`]: String(messageId),
[`messages[${aiIndex}].timestamp`]: createdAt,
[`messages[${aiIndex}].timeLabel`]: formatRelativeTime(createdAt),
[`messages[${aiIndex}].imTimeLabel`]: formatIMTime(createdAt),
isStreaming: false, isStreaming: false,
streamingContent: '', streamingContent: '',
}) })
this._sseTask = null
},
// onError: 显示错误
(message: string) => {
// 将错误信息作为 AI 回复内容展示
const errorContent = fullContent || message
this.setData({
[`messages[${aiIndex}].content`]: errorContent,
isStreaming: false,
streamingContent: '',
}) })
this._sseTask = null
wx.showToast({ title: message, icon: 'none', duration: 3000 })
},
)
// 构建认证 header
const token = getAccessToken()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
// 使用 wx.request + enableChunked 接收 SSE
// enableChunked 和 onChunkReceived 在基础库 2.20.2+ 可用,
// 当前 typings 未包含,使用 as any 绕过类型检查
const requestTask = wx.request({
url: `${API_BASE}/api/xcx/chat/stream`,
method: 'POST',
data: { chatId: Number(chatId), content },
header: headers,
enableChunked: true,
responseType: 'arraybuffer',
success: () => {
// enableChunked 模式下 success 在连接关闭时触发
// 如果此时仍在 streaming 状态,说明连接正常关闭但未收到 done 事件
if (this.data.isStreaming) {
this.setData({ isStreaming: false, streamingContent: '' })
this._sseTask = null
}
},
fail: () => {
// 网络错误或连接中断
if (this.data.isStreaming) {
const errorContent = fullContent || '连接中断,请重试'
this.setData({
[`messages[${aiIndex}].content`]: errorContent,
isStreaming: false,
streamingContent: '',
})
wx.showToast({ title: '连接中断', icon: 'none', duration: 3000 })
}
this._sseTask = null
},
} as WechatMiniprogram.RequestOption)
// 监听 chunked 数据SSE 事件流)
// onChunkReceived 在 typings 中未定义,使用 as any
;(requestTask as any).onChunkReceived((res: { data: ArrayBuffer }) => {
parser.feed(res.data)
})
this._sseTask = requestTask
}, },
/** 滚动到底部 */ /** 滚动到底部 */

View File

@@ -98,6 +98,20 @@
<view class="bubble bubble-assistant"> <view class="bubble bubble-assistant">
<text class="bubble-text">{{item.content}}</text> <text class="bubble-text">{{item.content}}</text>
</view> </view>
<!-- AI 侧引用卡片(后端 referenceCard 附加在 assistant 回复中)-->
<view class="inline-ref-card inline-ref-card--assistant" wx:if="{{item.referenceCard}}">
<view class="inline-ref-header">
<text class="inline-ref-type">{{item.referenceCard.type === 'customer' ? '👤 客户' : '📋 记录'}}</text>
<text class="inline-ref-title">{{item.referenceCard.title}}</text>
</view>
<text class="inline-ref-summary">{{item.referenceCard.summary}}</text>
<view class="inline-ref-data">
<view class="ref-data-item" wx:for="{{item.referenceCard.dataList}}" wx:for-item="entry" wx:key="key">
<text class="ref-data-key">{{entry.key}}</text>
<text class="ref-data-value">{{entry.value}}</text>
</view>
</view>
</view>
</view> </view>
</view> </view>

View File

@@ -245,6 +245,12 @@ page {
background-color: #ecf2fe; background-color: #ecf2fe;
max-width: 100%; max-width: 100%;
} }
/* AI 侧引用卡片:左对齐,挂在 AI 气泡下方 */
.inline-ref-card--assistant {
max-width: 100%;
margin-top: 12rpx;
}
.inline-ref-header { .inline-ref-header {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -1,4 +1,4 @@
import { mockCoaches } from '../../utils/mock-data' import { fetchCoachDetail } from '../../services/api'
import { sortByTimestamp } from '../../utils/sort' import { sortByTimestamp } from '../../utils/sort'
/* ── 进度条动画参数(与 task-list 共享相同逻辑) ── /* ── 进度条动画参数(与 task-list 共享相同逻辑) ──
@@ -328,13 +328,12 @@ Page({
}, sparkTriggerDelay) }, sparkTriggerDelay)
}, },
loadData(id: string) { async loadData(id: string) {
this.setData({ pageState: 'loading' }) this.setData({ pageState: 'loading' })
setTimeout(() => {
try { try {
// TODO: 替换为真实 API 调用 GET /api/coaches/:id // 从真实 API 获取助教基础信息
const basicCoach = mockCoaches.find((c) => c.id === id) const basicCoach = await fetchCoachDetail(id)
const detail: CoachDetail = basicCoach const detail: CoachDetail = basicCoach
? { ...mockCoachDetail, id: basicCoach.id, name: basicCoach.name } ? { ...mockCoachDetail, id: basicCoach.id, name: basicCoach.name }
: mockCoachDetail : mockCoachDetail
@@ -398,7 +397,6 @@ Page({
} catch (_e) { } catch (_e) {
this.setData({ pageState: 'error' }) this.setData({ pageState: 'error' })
} }
}, 500)
}, },
/** 切换收入明细 Tab */ /** 切换收入明细 Tab */

View File

@@ -1,4 +1,4 @@
import { mockCustomerDetail } from "../../utils/mock-data" import { fetchCustomerDetail } from '../../services/api'
interface ConsumptionRecord { interface ConsumptionRecord {
id: string id: string
@@ -248,15 +248,35 @@ Page({
const aiColors = ['red', 'orange', 'yellow', 'blue', 'indigo', 'purple'] as const const aiColors = ['red', 'orange', 'yellow', 'blue', 'indigo', 'purple'] as const
const aiColor = aiColors[Math.floor(Math.random() * aiColors.length)] const aiColor = aiColors[Math.floor(Math.random() * aiColors.length)]
this.setData({ aiColor }) this.setData({ aiColor })
this.loadDetail() const id = options?.id || options?.customerId || ''
this.loadDetail(id)
}, },
loadDetail() { async loadDetail(id?: string) {
this.setData({ pageState: "normal" }) this.setData({ pageState: 'loading' })
try {
if (id) {
const detail = await fetchCustomerDetail(id)
if (detail) {
this.setData({
detail: {
...this.data.detail,
id: detail.id ?? id,
name: detail.name || this.data.detail.name,
phone: detail.phone || this.data.detail.phone,
},
})
}
}
this.setData({ pageState: 'normal' })
} catch {
this.setData({ pageState: 'error' })
}
}, },
onRetry() { onRetry() {
this.loadDetail() const id = this.data.detail?.id || ''
this.loadDetail(id)
}, },
/** 查看/隐藏手机号 */ /** 查看/隐藏手机号 */
@@ -276,11 +296,19 @@ Page({
}, },
onViewServiceRecords() { onViewServiceRecords() {
wx.navigateTo({ url: "/pages/customer-service-records/customer-service-records" }) const customerId = this.data.detail?.id || ''
wx.navigateTo({
url: `/pages/customer-service-records/customer-service-records?customerId=${customerId}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
}, },
onStartChat() { onStartChat() {
wx.navigateTo({ url: "/pages/chat/chat" }) const customerId = this.data.detail?.id || ''
wx.navigateTo({
url: `/pages/chat/chat?customerId=${customerId}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
}, },
onAddNote() { onAddNote() {

View File

@@ -1,9 +1,14 @@
import { mockCustomerDetail, mockCustomers } from '../../utils/mock-data' // CHANGE 2026-03-20 | RNS1.4 T10.2: 月份切换改为按月请求 API移除 mock 全量加载+本地过滤
import type { ConsumptionRecord } from '../../utils/mock-data' import { fetchCustomerRecords, fetchCustomerDetail } from '../../services/api'
import { sortByTimestamp } from '../../utils/sort'
/** 服务记录(对齐 task-detail ServiceRecord 结构,复用 service-record-card 组件) */ /** 服务记录(对齐 task-detail ServiceRecord 结构,复用 service-record-card 组件) */
interface ServiceRecord extends ConsumptionRecord { interface ServiceRecord {
id: string
date: string
project: string
duration: number
amount: number
coachName: string
/** 台桌号,如 "A12号台" */ /** 台桌号,如 "A12号台" */
table: string table: string
/** 课程类型标签,如 "基础课" */ /** 课程类型标签,如 "基础课" */
@@ -13,7 +18,7 @@ interface ServiceRecord extends ConsumptionRecord {
/** 卡片类型course=普通课recharge=充值提成 */ /** 卡片类型course=普通课recharge=充值提成 */
recordType: 'course' | 'recharge' recordType: 'course' | 'recharge'
/** 折算后小时数(原始数字,组件负责加 h 后缀) */ /** 折算后小时数(原始数字,组件负责加 h 后缀) */
duration: number durationHours: number
/** 折算前小时数(原始数字,组件负责加 h 后缀) */ /** 折算前小时数(原始数字,组件负责加 h 后缀) */
durationRaw: number durationRaw: number
/** 到手金额(原始数字,组件负责加 ¥ 前缀) */ /** 到手金额(原始数字,组件负责加 ¥ 前缀) */
@@ -23,7 +28,7 @@ interface ServiceRecord extends ConsumptionRecord {
/** 商品/饮品描述 */ /** 商品/饮品描述 */
drinks: string drinks: string
/** 显示用日期,如 "2月5日 15:00 - 17:00" */ /** 显示用日期,如 "2月5日 15:00 - 17:00" */
date: string displayDate: string
} }
Page({ Page({
@@ -37,9 +42,9 @@ Page({
/** 客户名首字 */ /** 客户名首字 */
customerInitial: '', customerInitial: '',
/** 客户电话(脱敏) */ /** 客户电话(脱敏) */
customerPhone: '139****5678', customerPhone: '',
/** 客户电话(完整,查看后显示) */ /** 客户电话(完整,查看后显示) */
customerPhoneFull: '13900005678', customerPhoneFull: '',
/** 手机号是否已展开 */ /** 手机号是否已展开 */
phoneVisible: false, phoneVisible: false,
/** 累计服务次数 */ /** 累计服务次数 */
@@ -49,119 +54,142 @@ Page({
/** 当前月份标签 */ /** 当前月份标签 */
monthLabel: '', monthLabel: '',
/** 当前年 */ /** 当前年 */
currentYear: 2026, currentYear: new Date().getFullYear(),
/** 当前月 */ /** 当前月 */
currentMonth: 2, currentMonth: new Date().getMonth() + 1,
/** 最小年月(数据起始) */ /** 最小年月(数据起始) */
minYearMonth: 202601, minYearMonth: 202501,
/** 最大年月(当前月) */ /** 最大年月(当前月) */
maxYearMonth: 202602, maxYearMonth: new Date().getFullYear() * 100 + (new Date().getMonth() + 1),
/** 是否可切换上月 */ /** 是否可切换上月 */
canPrev: true, canPrev: true,
/** 是否可切换下月 */ /** 是否可切换下月 */
canNext: false, canNext: false,
/** 月度统计 */ /** 月度统计 */
monthCount: '6次', monthCount: '0次',
monthHours: '11.5h', monthHours: '0h',
monthRelation: '0.85', monthRelation: '0.85',
/** 当前月的服务记录 */ /** 当前月的服务记录 */
records: [] as ServiceRecord[], records: [] as ServiceRecord[],
/** 所有记录(原始) */
allRecords: [] as ConsumptionRecord[],
/** 是否还有更多 */ /** 是否还有更多 */
hasMore: false, hasMore: false,
/** 加载更多中 */ /** 加载更多中 */
loadingMore: false, loadingMore: false,
/** 月份数据加载中(用于月份切换时的 loading 状态) */
monthLoading: false,
}, },
onLoad(options) { onLoad(options) {
const id = options?.customerId || options?.id || '' const id = options?.customerId || options?.id || ''
this.setData({ customerId: id }) // 默认当前年月
this.loadData(id) const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth() + 1
this.setData({
customerId: id,
currentYear,
currentMonth,
maxYearMonth: currentYear * 100 + currentMonth,
})
this.loadCustomerInfo(id)
this.loadMonthRecords(id, currentYear, currentMonth)
}, },
/** 加载数据 */ /** 加载客户基本信息(头部展示) */
loadData(id: string) { async loadCustomerInfo(id: string) {
this.setData({ pageState: 'loading' }) try {
const detail = await fetchCustomerDetail(id)
setTimeout(() => { if (detail) {
// TODO: 替换为真实 API 调用
const customer = mockCustomers.find((c) => c.id === id)
const detail = customer
? { ...mockCustomerDetail, id: customer.id, name: customer.name }
: mockCustomerDetail
const allRecords = sortByTimestamp(detail.consumptionRecords || [], 'date') as ConsumptionRecord[]
const name = detail.name || '客户' const name = detail.name || '客户'
this.setData({ this.setData({
customerName: name, customerName: name,
customerInitial: name[0] || '?', customerInitial: name[0] || '?',
allRecords, customerPhone: detail.phone ? detail.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '',
totalServiceCount: allRecords.length, customerPhoneFull: detail.phone || '',
// totalServiceCount 由真实 API 返回mock 类型无此字段,安全取值
totalServiceCount: (detail as any).totalServiceCount ?? 0,
}) })
}
this.updateMonthView() } catch (err) {
}, 400) console.error('[customer-service-records] loadCustomerInfo failed:', err)
// 客户信息加载失败不阻塞记录展示
}
}, },
/** 根据当前月份筛选并更新视图 */ /** 按月加载服务记录(核心方法) */
updateMonthView() { async loadMonthRecords(customerId: string, year: number, month: number) {
const { currentYear, currentMonth, allRecords } = this.data const monthLabel = `${year}${month}`
const monthLabel = `${currentYear}${currentMonth}`
// 筛选当月记录 // 清空列表 → 显示 loading
const monthPrefix = `${currentYear}-${String(currentMonth).padStart(2, '0')}` this.setData({
const monthRecords = allRecords.filter((r) => r.date.startsWith(monthPrefix)) monthLabel,
records: [],
monthLoading: true,
pageState: 'loading',
})
// 转换为展示格式(对齐 task-detail ServiceRecord复用 service-record-card 组件) try {
// income / duration 均传原始数字,由组件统一加 ¥ 和 h const res = await fetchCustomerRecords({ customerId, year, month })
const records: ServiceRecord[] = monthRecords.map((r) => { const rawRecords: any[] = res.records || []
// 转换为展示格式(对齐 service-record-card 组件)
const records: ServiceRecord[] = rawRecords.map((r: any) => {
const d = new Date(r.date) const d = new Date(r.date)
const month = d.getMonth() + 1 const m = d.getMonth() + 1
const day = d.getDate() const day = d.getDate()
const dateLabel = `${month}${day}` const dateLabel = `${m}${day}`
const timeRange = this.generateTimeRange(r.duration) const timeRange = this.generateTimeRange(r.duration || 0)
const isRecharge = r.project.includes('充值') const isRecharge = (r.project || '').includes('充值')
return { return {
...r, id: r.id || '',
table: this.getTableNo(r.id), date: r.date || '',
type: this.getTypeLabel(r.project), project: r.project || '',
typeClass: this.getTypeClass(r.project) as 'basic' | 'vip' | 'tip' | 'recharge', duration: r.duration || 0,
amount: r.amount || 0,
coachName: r.coachName || '',
table: r.table || this.getTableNo(r.id || ''),
type: this.getTypeLabel(r.project || ''),
typeClass: this.getTypeClass(r.project || '') as 'basic' | 'vip' | 'tip' | 'recharge',
recordType: (isRecharge ? 'recharge' : 'course') as 'course' | 'recharge', recordType: (isRecharge ? 'recharge' : 'course') as 'course' | 'recharge',
duration: isRecharge ? 0 : parseFloat((r.duration / 60).toFixed(1)), durationHours: isRecharge ? 0 : parseFloat(((r.duration || 0) / 60).toFixed(1)),
durationRaw: 0, durationRaw: 0,
income: r.amount, income: r.amount || 0,
isEstimate: false, isEstimate: r.isEstimate || false,
drinks: '', drinks: r.drinks || '',
date: isRecharge ? dateLabel : `${dateLabel} ${timeRange}`, displayDate: isRecharge ? dateLabel : `${dateLabel} ${timeRange}`,
} }
}) })
// 月度统计 // 月度统计
const totalMinutes = monthRecords.reduce((sum, r) => sum + r.duration, 0) const totalMinutes = rawRecords.reduce((sum: number, r: any) => sum + (r.duration || 0), 0)
const monthCount = monthRecords.length + '次' const monthCount = records.length + '次'
const monthHours = (totalMinutes / 60).toFixed(1) + 'h' const monthHours = (totalMinutes / 60).toFixed(1) + 'h'
// 边界判断 // 边界判断
const yearMonth = currentYear * 100 + currentMonth const yearMonth = year * 100 + month
const canPrev = yearMonth > this.data.minYearMonth const canPrev = yearMonth > this.data.minYearMonth
const canNext = yearMonth < this.data.maxYearMonth const canNext = yearMonth < this.data.maxYearMonth
const isEmpty = records.length === 0 && allRecords.length === 0
this.setData({ this.setData({
monthLabel,
records, records,
monthCount, monthCount,
monthHours, monthHours,
canPrev, canPrev,
canNext, canNext,
pageState: isEmpty ? 'empty' : 'normal', hasMore: res.hasMore || false,
monthLoading: false,
pageState: 'normal',
}) })
} catch (err) {
console.error('[customer-service-records] loadMonthRecords failed:', err)
this.setData({
monthLoading: false,
pageState: 'error',
})
}
}, },
/** 生成模拟时间段 */ /** 生成模拟时间段(后端未返回具体时段时的 fallback */
generateTimeRange(durationMin: number): string { generateTimeRange(durationMin: number): string {
const startHour = 14 + Math.floor(Math.random() * 6) const startHour = 14 + Math.floor(Math.random() * 6)
const endMin = startHour * 60 + durationMin const endMin = startHour * 60 + durationMin
@@ -187,7 +215,7 @@ Page({
return 'basic' return 'basic'
}, },
/** 模拟台号 */ /** 模拟台号(后端未返回台号时的 fallback */
getTableNo(id: string): string { getTableNo(id: string): string {
const tables = ['A12号台', '3号台', 'VIP1号房', '5号台', 'VIP2号房', '8号台'] const tables = ['A12号台', '3号台', 'VIP1号房', '5号台', 'VIP2号房', '8号台']
const idx = parseInt(id.replace(/\D/g, '') || '0', 10) % tables.length const idx = parseInt(id.replace(/\D/g, '') || '0', 10) % tables.length
@@ -196,44 +224,47 @@ Page({
/** 切换到上一月 */ /** 切换到上一月 */
onPrevMonth() { onPrevMonth() {
if (!this.data.canPrev) return if (!this.data.canPrev || this.data.monthLoading) return
let { currentYear, currentMonth } = this.data let { currentYear, currentMonth, customerId } = this.data
currentMonth-- currentMonth--
if (currentMonth < 1) { if (currentMonth < 1) {
currentMonth = 12 currentMonth = 12
currentYear-- currentYear--
} }
this.setData({ currentYear, currentMonth }) this.setData({ currentYear, currentMonth })
this.updateMonthView() this.loadMonthRecords(customerId, currentYear, currentMonth)
}, },
/** 切换到下一月 */ /** 切换到下一月 */
onNextMonth() { onNextMonth() {
if (!this.data.canNext) return if (!this.data.canNext || this.data.monthLoading) return
let { currentYear, currentMonth } = this.data let { currentYear, currentMonth, customerId } = this.data
currentMonth++ currentMonth++
if (currentMonth > 12) { if (currentMonth > 12) {
currentMonth = 1 currentMonth = 1
currentYear++ currentYear++
} }
this.setData({ currentYear, currentMonth }) this.setData({ currentYear, currentMonth })
this.updateMonthView() this.loadMonthRecords(customerId, currentYear, currentMonth)
}, },
/** 下拉刷新 */ /** 下拉刷新 */
onPullDownRefresh() { onPullDownRefresh() {
this.loadData(this.data.customerId) const { customerId, currentYear, currentMonth } = this.data
this.loadCustomerInfo(customerId)
this.loadMonthRecords(customerId, currentYear, currentMonth)
setTimeout(() => wx.stopPullDownRefresh(), 600) setTimeout(() => wx.stopPullDownRefresh(), 600)
}, },
/** 重试 */ /** 重试 */
onRetry() { onRetry() {
this.loadData(this.data.customerId) const { customerId, currentYear, currentMonth } = this.data
this.loadMonthRecords(customerId, currentYear, currentMonth)
}, },
/** 触底加载 */ /** 触底加载 */
onReachBottom() { onReachBottom() {
// Mock 阶段数据有限,不做分页 // 按月请求模式下暂不分页,后续可扩展
if (this.data.loadingMore || !this.data.hasMore) return if (this.data.loadingMore || !this.data.hasMore) return
this.setData({ loadingMore: true }) this.setData({ loadingMore: true })
setTimeout(() => { setTimeout(() => {

View File

@@ -82,8 +82,14 @@
<!-- 记录列表service-record-card 组件)--> <!-- 记录列表service-record-card 组件)-->
<view class="records-container"> <view class="records-container">
<!-- 月份数据加载中 -->
<view class="month-loading" wx:if="{{monthLoading}}">
<t-loading theme="circular" size="40rpx" />
<text class="month-loading-text">加载中...</text>
</view>
<!-- 无当月记录 --> <!-- 无当月记录 -->
<view class="no-month-data" wx:if="{{records.length === 0}}"> <view class="no-month-data" wx:elif="{{records.length === 0}}">
<t-icon name="chart-bar" size="100rpx" color="#dcdcdc" /> <t-icon name="chart-bar" size="100rpx" color="#dcdcdc" />
<text class="no-month-text">本月暂无服务记录</text> <text class="no-month-text">本月暂无服务记录</text>
</view> </view>
@@ -91,12 +97,12 @@
<service-record-card <service-record-card
wx:for="{{records}}" wx:for="{{records}}"
wx:key="id" wx:key="id"
time="{{item.date}}" time="{{item.displayDate}}"
course-label="{{item.type}}" course-label="{{item.type}}"
type-class="{{item.typeClass}}" type-class="{{item.typeClass}}"
type="{{item.recordType}}" type="{{item.recordType}}"
table-no="{{item.table}}" table-no="{{item.table}}"
hours="{{item.duration}}" hours="{{item.durationHours}}"
hours-raw="{{item.durationRaw}}" hours-raw="{{item.durationRaw}}"
drinks="{{item.drinks}}" drinks="{{item.drinks}}"
income="{{item.income}}" income="{{item.income}}"

View File

@@ -253,6 +253,20 @@ page {
gap: 20rpx; gap: 20rpx;
} }
/* 月份数据加载中 */
.month-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 0;
gap: 16rpx;
}
.month-loading-text {
font-size: 26rpx;
color: #a6a6a6;
}
/* 无当月数据 */ /* 无当月数据 */
.no-month-data { .no-month-data {
display: flex; display: flex;

View File

@@ -1,17 +1,25 @@
import { mockUserProfile } from '../../utils/mock-data' import { fetchMe } from '../../services/api'
import { getMenuRoute, navigateTo } from '../../utils/router' import { getMenuRoute, navigateTo } from '../../utils/router'
// TODO: 联调时替换为真实 API 获取用户信息
Page({ Page({
data: { data: {
userInfo: mockUserProfile, userInfo: null as any,
}, },
onShow() { onShow() {
// 同步 custom-tab-bar 选中态 // 同步 custom-tab-bar 选中态
const tabBar = this.getTabBar?.() const tabBar = this.getTabBar?.()
if (tabBar) tabBar.setData({ active: 'my' }) if (tabBar) tabBar.setData({ active: 'my' })
// TODO: 联调时在此刷新用户信息 this.loadUserInfo()
},
async loadUserInfo() {
try {
const info = await fetchMe()
this.setData({ userInfo: info })
} catch {
wx.showToast({ title: '加载用户信息失败', icon: 'none' })
}
}, },
onMenuTap(e: WechatMiniprogram.TouchEvent) { onMenuTap(e: WechatMiniprogram.TouchEvent) {

View File

@@ -1,4 +1,4 @@
import { mockNotes } from '../../utils/mock-data' import { fetchNotes, deleteNote } from '../../services/api'
import type { Note } from '../../utils/mock-data' import type { Note } from '../../utils/mock-data'
import { formatRelativeTime } from '../../utils/time' import { formatRelativeTime } from '../../utils/time'
@@ -7,12 +7,21 @@ interface NoteDisplay extends Note {
timeLabel: string timeLabel: string
} }
/** 每页条数 */
const PAGE_SIZE = 20
Page({ Page({
data: { data: {
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal', pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
notes: [] as NoteDisplay[], notes: [] as NoteDisplay[],
/** 系统状态栏高度px用于自定义导航栏顶部偏移 */ /** 系统状态栏高度px用于自定义导航栏顶部偏移 */
statusBarHeight: 20, statusBarHeight: 20,
/** 当前页码 */
page: 1,
/** 是否正在加载更多 */
isLoadingMore: false,
/** 是否还有更多数据 */
hasMore: true,
}, },
onLoad() { onLoad() {
@@ -21,24 +30,52 @@ Page({
this.loadData() this.loadData()
}, },
loadData() { /** 首次加载 / 刷新:重置分页,从第 1 页开始 */
this.setData({ pageState: 'loading' }) async loadData() {
this.setData({ pageState: 'loading', page: 1, hasMore: true })
setTimeout(() => {
// TODO: 替换为真实 API 调用 GET /api/xcx/notes
try { try {
const notes: NoteDisplay[] = mockNotes.map((n) => ({ const res = await fetchNotes({ page: 1, pageSize: PAGE_SIZE })
const notes: NoteDisplay[] = res.notes.map((n) => ({
...n, ...n,
timeLabel: formatRelativeTime(n.createdAt), timeLabel: formatRelativeTime(n.createdAt),
})) }))
const hasMore = res.hasMore !== false && notes.length >= PAGE_SIZE
this.setData({ this.setData({
pageState: notes.length > 0 ? 'normal' : 'empty', pageState: notes.length > 0 ? 'normal' : 'empty',
notes, notes,
hasMore,
}) })
} catch { } catch {
this.setData({ pageState: 'error' }) this.setData({ pageState: 'error' })
} }
}, 400) },
/** 触底加载更多 */
async onReachBottom() {
if (this.data.isLoadingMore || !this.data.hasMore) return
const nextPage = this.data.page + 1
this.setData({ isLoadingMore: true })
try {
const res = await fetchNotes({ page: nextPage, pageSize: PAGE_SIZE })
const newNotes: NoteDisplay[] = res.notes.map((n) => ({
...n,
timeLabel: formatRelativeTime(n.createdAt),
}))
const hasMore = res.hasMore !== false && newNotes.length >= PAGE_SIZE
this.setData({
notes: [...this.data.notes, ...newNotes],
page: nextPage,
hasMore,
isLoadingMore: false,
})
} catch {
// 加载更多失败时不改变页面状态,仅停止 loading
this.setData({ isLoadingMore: false })
wx.showToast({ title: '加载失败', icon: 'none' })
}
}, },
/** 返回上一页 */ /** 返回上一页 */
@@ -58,19 +95,27 @@ Page({
title: '删除备注', title: '删除备注',
content: '确定要删除这条备注吗?删除后无法恢复。', content: '确定要删除这条备注吗?删除后无法恢复。',
confirmColor: '#e34d59', confirmColor: '#e34d59',
success: (res) => { success: async (res) => {
if (res.confirm) { if (res.confirm) {
try {
await deleteNote(noteId)
} catch {
// 静默mock 阶段可能失败,不影响 UI
}
const notes = this.data.notes.filter((n) => n.id !== noteId) const notes = this.data.notes.filter((n) => n.id !== noteId)
this.setData({ notes }) this.setData({
notes,
pageState: notes.length > 0 ? 'normal' : 'empty',
})
wx.showToast({ title: '已删除', icon: 'success' }) wx.showToast({ title: '已删除', icon: 'success' })
} }
}, },
}) })
}, },
/** 下拉刷新 */ /** 下拉刷新:重置分页重新加载 */
onPullDownRefresh() { async onPullDownRefresh() {
this.loadData() await this.loadData()
wx.stopPullDownRefresh() wx.stopPullDownRefresh()
}, },
}) })

View File

@@ -49,8 +49,12 @@
</view> </view>
<!-- 底部提示 --> <!-- 底部提示 -->
<view class="list-footer"> <view class="list-footer" wx:if="{{isLoadingMore}}">
<text class="footer-text">— 已加载全部记录 —</text> <t-loading theme="circular" size="32rpx" />
<text class="footer-text">加载中...</text>
</view>
<view class="list-footer" wx:elif="{{!hasMore}}">
<text class="footer-text">— 没有更多了 —</text>
</view> </view>
<!-- AI 悬浮按钮 --> <!-- AI 悬浮按钮 -->

View File

@@ -1,5 +1,5 @@
import { mockPerformanceRecords } from '../../utils/mock-data'
import type { PerformanceRecord } from '../../utils/mock-data' import type { PerformanceRecord } from '../../utils/mock-data'
import { fetchPerformanceRecords } from '../../services/api'
import { nameToAvatarColor } from '../../utils/avatar-color' import { nameToAvatarColor } from '../../utils/avatar-color'
import { formatMoney, formatCount } from '../../utils/money' import { formatMoney, formatCount } from '../../utils/money'
import { formatHours } from '../../utils/time' import { formatHours } from '../../utils/time'
@@ -57,7 +57,7 @@ Page({
dateGroups: [] as DateGroup[], dateGroups: [] as DateGroup[],
/** 所有记录(用于筛选) */ /** 所有记录(用于筛选) */
allRecords: [] as PerformanceRecord[], allRecords: [] as any[],
/** 分页 */ /** 分页 */
page: 1, page: 1,
@@ -79,149 +79,82 @@ Page({
this.loadData() this.loadData()
}, },
loadData(cb?: () => void) { async loadData(cb?: () => void) {
this.setData({ pageState: 'loading' }) this.setData({ pageState: 'loading' })
setTimeout(() => {
try { try {
// TODO: 替换为真实 API按月份请求 const { currentYear, currentMonth, page, pageSize } = this.data
const allRecords = mockPerformanceRecords const res = await fetchPerformanceRecords({
year: currentYear,
month: currentMonth,
page,
pageSize,
})
const dateGroups: DateGroup[] = [ const records = res.records || []
{ // 按日期分组(后端返回的记录已按日期排序)
date: '2月7日', const groupMap = new Map<string, DateGroup>()
totalHours: 6.0, totalHoursLabel: formatHours(6.0), totalIncomeLabel: formatMoney(510), for (const r of records) {
totalIncome: 510, const dateKey = r.date || '未知日期'
records: [ if (!groupMap.has(dateKey)) {
{ id: 'r1', customerName: '王先生', avatarChar: '王', avatarColor: nameToAvatarColor('王'), timeRange: '20:00-22:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '3号台', income: 160 }, groupMap.set(dateKey, {
{ id: 'r2', customerName: '李女士', avatarChar: '李', avatarColor: nameToAvatarColor('李'), timeRange: '16:00-18:00', hours: 2.0, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP1号房', income: 190 }, date: dateKey,
{ id: 'r3', customerName: '陈女士', avatarChar: '陈', avatarColor: nameToAvatarColor('陈'), timeRange: '10:00-12:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: 160 }, totalHours: 0,
], totalIncome: 0,
}, totalHoursLabel: '',
{ totalIncomeLabel: '',
date: '2月6日', records: [],
totalHours: 3.5, totalHoursLabel: formatHours(3.5), totalIncomeLabel: formatMoney(280), })
totalIncome: 280, }
records: [ const group = groupMap.get(dateKey)!
{ id: 'r4', customerName: '张先生', avatarChar: '张', avatarColor: nameToAvatarColor('张'), timeRange: '19:00-21:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '5号台', income: 160 }, const hours = (r as any).hours ?? 0
{ id: 'r5', customerName: '刘先生', avatarChar: '刘', avatarColor: nameToAvatarColor('刘'), timeRange: '15:30-17:00', hours: 1.5, courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: 120 }, const income = r.amount ?? 0
], group.totalHours += hours
}, group.totalIncome += income
{ group.records.push({
date: '2月5日', id: r.id,
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320), customerName: r.customerName,
totalIncome: 320, avatarChar: r.customerName?.charAt(0) || '?',
records: [ avatarColor: nameToAvatarColor(r.customerName || ''),
{ id: 'r6', customerName: '陈女士', avatarChar: '陈', avatarColor: nameToAvatarColor('陈'), timeRange: '20:00-22:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: 160 }, timeRange: (r as any).timeRange || '',
{ id: 'r7', customerName: '赵先生', avatarChar: '赵', avatarColor: nameToAvatarColor('赵'), timeRange: '14:00-16:00', hours: 2.0, hoursRaw: 2.5, courseType: '基础课', courseTypeClass: 'tag-basic', location: '7号台', income: 160 }, hours,
], hoursRaw: (r as any).hoursRaw,
}, courseType: r.type || '',
{ courseTypeClass: `tag-${(r.category || 'basic').toLowerCase()}`,
date: '2月4日', location: (r as any).location || '',
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320), income,
totalIncome: 350, })
records: [ }
{ id: 'r8', customerName: '孙先生', avatarChar: '孙', avatarColor: nameToAvatarColor('孙'), timeRange: '19:00-21:00', hours: 2.0, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP2号房', income: 190 },
{ id: 'r9', customerName: '吴女士', avatarChar: '吴', avatarColor: nameToAvatarColor('吴'), timeRange: '15:00-17:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '1号台', income: 160 }, const dateGroups = Array.from(groupMap.values()).map(g => ({
], ...g,
}, totalHoursLabel: formatHours(g.totalHours),
{ totalIncomeLabel: formatMoney(g.totalIncome),
date: '2月3日', }))
totalHours: 3.5, totalHoursLabel: formatHours(3.5), totalIncomeLabel: formatMoney(280),
totalIncome: 280, // 汇总统计
records: [ const totalCount = records.length
{ id: 'r10', customerName: '郑先生', avatarChar: '郑', avatarColor: nameToAvatarColor('郑'), timeRange: '20:00-22:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '4号台', income: 160 }, const totalHours = dateGroups.reduce((s, g) => s + g.totalHours, 0)
{ id: 'r11', customerName: '黄女士', avatarChar: '黄', avatarColor: nameToAvatarColor('黄'), timeRange: '14:30-16:00', hours: 1.5, courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: 120 }, const totalIncome = dateGroups.reduce((s, g) => s + g.totalIncome, 0)
],
},
{
date: '2月2日',
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
totalIncome: 350,
records: [
{ id: 'r12', customerName: '林先生', avatarChar: '林', avatarColor: nameToAvatarColor('林'), timeRange: '19:00-21:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '6号台', income: 160 },
{ id: 'r13', customerName: '何女士', avatarChar: '何', avatarColor: nameToAvatarColor('何'), timeRange: '13:00-15:00', hours: 2.0, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP3号房', income: 190 },
],
},
{
date: '2月1日',
totalHours: 6.0, totalHoursLabel: formatHours(6.0), totalIncomeLabel: formatMoney(510),
totalIncome: 510,
records: [
{ id: 'r14', customerName: '王先生', avatarChar: '王', avatarColor: nameToAvatarColor('王'), timeRange: '20:30-22:30', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '3号台', income: 160 },
{ id: 'r15', customerName: '马先生', avatarChar: '马', avatarColor: nameToAvatarColor('马'), timeRange: '16:00-18:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '8号台', income: 160 },
{ id: 'r16', customerName: '罗女士', avatarChar: '罗', avatarColor: nameToAvatarColor('罗'), timeRange: '12:30-14:30', hours: 2.0, hoursRaw: 2.5, courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: 160 },
{ id: 'r17', customerName: '梁先生', avatarChar: '梁', avatarColor: nameToAvatarColor('梁'), timeRange: '10:00-12:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '5号台', income: 160 },
{ id: 'r18', customerName: '宋女士', avatarChar: '宋', avatarColor: nameToAvatarColor('宋'), timeRange: '8:30-10:00', hours: 1.5, courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: 120 },
{ id: 'r19', customerName: '谢先生', avatarChar: '谢', avatarColor: nameToAvatarColor('谢'), timeRange: '7:00-8:00', hours: 1.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '1号台', income: 80 },
],
},
{
date: '1月31日',
totalHours: 5.5, totalHoursLabel: formatHours(5.5), totalIncome: 470, totalIncomeLabel: formatMoney(470),
records: [
{ id: 'r20', customerName: '韩女士', avatarChar: '韩', avatarColor: nameToAvatarColor('韩'), timeRange: '21:00-23:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '4号台', income: 160 },
{ id: 'r21', customerName: '唐先生', avatarChar: '唐', avatarColor: nameToAvatarColor('唐'), timeRange: '18:30-20:30', hours: 2.0, hoursRaw: 2.5, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP2号房', income: 190 },
{ id: 'r22', customerName: '冯女士', avatarChar: '冯', avatarColor: nameToAvatarColor('冯'), timeRange: '14:00-16:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '6号台', income: 160 },
],
},
{
date: '1月30日',
totalHours: 3.5, totalHoursLabel: formatHours(3.5), totalIncomeLabel: formatMoney(280),
totalIncome: 280,
records: [
{ id: 'r23', customerName: '张先生', avatarChar: '张', avatarColor: nameToAvatarColor('张'), timeRange: '19:30-21:30', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '5号台', income: 160 },
{ id: 'r24', customerName: '刘先生', avatarChar: '刘', avatarColor: nameToAvatarColor('刘'), timeRange: '14:30-16:00', hours: 1.5, courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: 120 },
],
},
{
date: '1月29日',
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
totalIncome: 320,
records: [
{ id: 'r25', customerName: '陈女士', avatarChar: '陈', avatarColor: nameToAvatarColor('陈'), timeRange: '20:00-22:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: 160 },
{ id: 'r26', customerName: '李女士', avatarChar: '李', avatarColor: nameToAvatarColor('李'), timeRange: '13:00-15:00', hours: 2.0, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP1号房', income: 190 },
],
},
{
date: '1月28日',
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
totalIncome: 350,
records: [
{ id: 'r27', customerName: '赵先生', avatarChar: '赵', avatarColor: nameToAvatarColor('赵'), timeRange: '19:00-21:00', hours: 2.0, hoursRaw: 2.5, courseType: '基础课', courseTypeClass: 'tag-basic', location: '7号台', income: 160 },
{ id: 'r28', customerName: '董先生', avatarChar: '董', avatarColor: nameToAvatarColor('董'), timeRange: '15:00-17:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '3号台', income: 160 },
],
},
{
date: '1月27日',
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
totalIncome: 350,
records: [
{ id: 'r29', customerName: '孙先生', avatarChar: '孙', avatarColor: nameToAvatarColor('孙'), timeRange: '20:00-22:00', hours: 2.0, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP2号房', income: 190 },
{ id: 'r30', customerName: '黄女士', avatarChar: '黄', avatarColor: nameToAvatarColor('黄'), timeRange: '14:30-16:30', hours: 1.5, courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: 120 },
],
},
]
this.setData({ this.setData({
pageState: dateGroups.length > 0 ? 'normal' : 'empty', pageState: dateGroups.length > 0 ? 'normal' : 'empty',
allRecords, allRecords: records,
dateGroups, dateGroups,
totalCount: 32, totalCount,
totalHours: 59.0, totalHours,
totalIncome: 4720, totalIncome,
totalCountLabel: formatCount(32, '笔'), totalCountLabel: formatCount(totalCount, '笔'),
totalHoursLabel: formatHours(59.0), totalHoursLabel: formatHours(totalHours),
totalHoursRawLabel: formatHours(63.5), totalHoursRawLabel: '',
totalIncomeLabel: formatMoney(4720), totalIncomeLabel: formatMoney(totalIncome),
hasMore: false, hasMore: res.hasMore ?? false,
}) })
} catch (_err) { } catch (_err) {
this.setData({ pageState: 'error' }) this.setData({ pageState: 'error' })
} }
cb?.() cb?.()
}, 500)
}, },
/** 重试加载 */ /** 重试加载 */

View File

@@ -1,5 +1,5 @@
import { mockTaskDetails } from '../../utils/mock-data'
import type { TaskDetail, Note } from '../../utils/mock-data' import type { TaskDetail, Note } from '../../utils/mock-data'
import { fetchTaskDetail } from '../../services/api'
import { sortByTimestamp } from '../../utils/sort' import { sortByTimestamp } from '../../utils/sort'
import { formatRelativeTime } from '../../utils/time' import { formatRelativeTime } from '../../utils/time'
import { formatMoney } from '../../utils/money' import { formatMoney } from '../../utils/money'
@@ -130,11 +130,10 @@ Page({
this.loadData(id) this.loadData(id)
}, },
loadData(id: string) { async loadData(id: string) {
this.setData({ pageState: 'loading' }) this.setData({ pageState: 'loading' })
setTimeout(() => {
try { try {
const detail = mockTaskDetails.find((t) => t.id === id) || mockTaskDetails[0] const detail = await fetchTaskDetail(id)
if (!detail) { if (!detail) {
this.setData({ pageState: 'empty' }) this.setData({ pageState: 'empty' })
return return
@@ -152,17 +151,9 @@ Page({
bannerBgSvg = '/assets/images/banner-bg-teal-aurora.svg' bannerBgSvg = '/assets/images/banner-bg-teal-aurora.svg'
} }
// 添加更多 mock 备注
const mockNotes: Note[] = [
{ id: 'note-1', content: '已通过微信联系王先生,表示对新到的斯诺克球桌感兴趣,周末可能来体验。', tagType: 'customer', tagLabel: '客户:王先生', createdAt: '2026-03-10T16:30', score: 10 },
{ id: 'note-2', content: '王先生最近出差较多,到店频率降低。建议等他回来后再约。', tagType: 'customer', tagLabel: '客户:王先生', createdAt: '2026-03-05T14:20', score: 7.5 },
{ id: 'note-3', content: '上次到店时推荐了会员续费活动,客户说考虑一下。', tagType: 'customer', tagLabel: '客户:王先生', createdAt: '2026-02-28T18:45', score: 6 },
{ id: 'note-4', content: '客户对今天的服务非常满意,特别提到小燕的教学很专业。', tagType: 'customer', tagLabel: '客户:王先生', createdAt: '2026-02-20T21:15', score: 9.5 },
{ id: 'note-5', content: '完成高优先召回任务。客户反馈最近工作太忙,这周末会来店里。', tagType: 'customer', tagLabel: '客户:王先生', createdAt: '2026-02-15T10:30', score: 8 },
]
// 附加 timeLabel 字段 // 附加 timeLabel 字段
const notesWithLabel = mockNotes.map((n) => ({ const notes = detail.notes || []
const notesWithLabel = notes.map((n: Note) => ({
...n, ...n,
timeLabel: formatRelativeTime(n.createdAt), timeLabel: formatRelativeTime(n.createdAt),
})) }))
@@ -178,7 +169,6 @@ Page({
} catch (_e) { } catch (_e) {
this.setData({ pageState: 'error' }) this.setData({ pageState: 'error' })
} }
}, 500)
}, },
/** 更新关系等级显示 */ /** 更新关系等级显示 */
@@ -340,16 +330,19 @@ Page({
/** 问问助手 */ /** 问问助手 */
onAskAssistant() { onAskAssistant() {
const customerId = this.data.detail?.id || '' // CHANGE 2026-03-20 | T12.2: 从 task-detail 进入 chat 应传 taskId非 customerId
// 使 chat 页面使用 contextType=task 入口,同一 taskId 始终复用同一对话
const taskId = this.data.detail?.id || ''
wx.navigateTo({ wx.navigateTo({
url: `/pages/chat/chat?customerId=${customerId}`, url: `/pages/chat/chat?taskId=${taskId}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }), fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
}) })
}, },
/** 查看全部服务记录 */ /** 查看全部服务记录 */
onViewAllRecords() { onViewAllRecords() {
const customerId = this.data.detail?.id || '' // CHANGE 2026-03-20 | T12.2: 使用后端返回的 customerId 而非 task id
const customerId = (this.data.detail as any)?.customerId || this.data.detail?.id || ''
wx.navigateTo({ wx.navigateTo({
url: `/pages/customer-service-records/customer-service-records?customerId=${customerId}`, url: `/pages/customer-service-records/customer-service-records?customerId=${customerId}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }), fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),

View File

@@ -7,8 +7,8 @@
| 2026-03-13 | 任务类型标签做成 SVG | 添加 tagSvgMap 映射 taskType→SVG 路径,供 WXML image 组件使用 | | 2026-03-13 | 任务类型标签做成 SVG | 添加 tagSvgMap 映射 taskType→SVG 路径,供 WXML image 组件使用 |
| 2026-03-13 | 5项修复+精确还原 | 移除 tagSvgMap标签恢复 CSS 渐变实现) | | 2026-03-13 | 5项修复+精确还原 | 移除 tagSvgMap标签恢复 CSS 渐变实现) |
*/ */
import { mockTasks, mockPerformance } from '../../utils/mock-data'
import type { Task } from '../../utils/mock-data' import type { Task } from '../../utils/mock-data'
import { fetchTasks } from '../../services/api'
import { formatMoney } from '../../utils/money' import { formatMoney } from '../../utils/money'
import { formatDeadline } from '../../utils/time' import { formatDeadline } from '../../utils/time'
@@ -356,27 +356,12 @@ Page({
wx.showToast({ title: '没有更多了', icon: 'none' }) wx.showToast({ title: '没有更多了', icon: 'none' })
}, },
loadData(cb?: () => void) { async loadData(cb?: () => void) {
this.setData({ pageState: 'loading', stampAnimated: false }) this.setData({ pageState: 'loading', stampAnimated: false })
setTimeout(() => { try {
/* CHANGE 2026-03-13 | mock 数据贴近 H5 原型7 条任务2 置顶 + 3 一般 + 2 已放弃) */ const res = await fetchTasks()
const allTasks: Task[] = [ const allTasks: Task[] = res.tasks || []
...mockTasks,
{
id: 'task-007',
customerName: '孙丽',
customerAvatar: '/assets/images/avatar-default.png',
taskType: 'callback',
taskTypeLabel: '客户回访',
deadline: '2026-03-06',
heartScore: 3.5,
hobbies: [],
isPinned: false,
hasNote: false,
status: 'abandoned',
},
]
const enriched = allTasks.map(enrichTask) const enriched = allTasks.map(enrichTask)
@@ -386,8 +371,8 @@ Page({
const totalCount = pinnedTasks.length + normalTasks.length + abandonedTasks.length const totalCount = pinnedTasks.length + normalTasks.length + abandonedTasks.length
const perfData = buildPerfData() const perfData = buildPerfData()
const perf = mockPerformance const perf = res.performance
const bannerTitle = `${perf.currentTier}` const bannerTitle = perf ? `${perf.currentTier}` : ''
const bannerMetrics: Array<{ label: string; value: string }> = [] const bannerMetrics: Array<{ label: string; value: string }> = []
this.setData({ this.setData({
@@ -399,7 +384,7 @@ Page({
bannerTitle, bannerTitle,
bannerMetrics, bannerMetrics,
perfData, perfData,
hasMore: true, hasMore: res.hasMore ?? false,
}) })
if (perfData.tierCompleted) { if (perfData.tierCompleted) {
@@ -407,9 +392,11 @@ Page({
this.setData({ stampAnimated: true }) this.setData({ stampAnimated: true })
}, 300) }, 300)
} }
} catch {
this.setData({ pageState: 'error' })
}
cb?.() cb?.()
}, 600)
}, },
onRetry() { onRetry() {
@@ -496,8 +483,9 @@ Page({
onCtxAI() { onCtxAI() {
const target = this.data.contextMenuTarget const target = this.data.contextMenuTarget
this.setData({ contextMenuVisible: false }) this.setData({ contextMenuVisible: false })
// CHANGE 2026-03-20 | T12.2: 修正路由从 ai-chat 到 chat传 taskId 使用 task 上下文入口
wx.navigateTo({ wx.navigateTo({
url: `/pages/ai-chat/ai-chat?taskId=${target.id}&customerName=${target.customerName}`, url: `/pages/chat/chat?taskId=${target.id}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }), fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
}) })
}, },

View File

@@ -1,31 +1,18 @@
// AI_CHANGELOG
// - 2026-03-20 | Prompt: R3 项目类型筛选接口重建 | fetchSkillTypes() fallback 数据
// value 从 all/chinese/snooker 改为 ALL/BILLIARD/SNOOKERAPI 响应映射从
// data.skills 改为直接 map data 数组(后端返回 [{key,label,emoji,cls}])。
// - 2026-03-20 | Prompt: RNS1.4 T12.1 移除 mock 数据残留 | 移除所有 mock 导入、
// USE_REAL_API 开关、delay() 辅助函数和 mock fallback 分支,全部直连真实 API。
/** /**
* Service 层 — 统一数据请求入口 * Service 层 — 统一数据请求入口
* *
* 页面只调用 service 函数,不直接引用 mock 数据或 wx.request。 * 页面只调用 service 函数,不直接引用 mock 数据或 wx.request。
* 联调时只需修改此文件内部实现mock → request页面代码不动 * 所有函数均直连真实后端 API
*
* 当前阶段:全部走 mock
* 联调阶段:逐个替换为 request() 调用
*/ */
import { request } from '../utils/request' import { request } from '../utils/request'
// ============================================
// Mock 数据导入(联调时逐步删除)
// ============================================
import {
mockTasks,
mockTaskDetails,
mockNotes,
mockPerformance,
mockPerformanceRecords,
mockBoardFinance,
mockCustomers,
mockCoaches,
mockCustomerDetail,
mockChatMessages,
mockChatHistory,
} from '../utils/mock-data'
import type { import type {
Task, Task,
TaskDetail, TaskDetail,
@@ -35,28 +22,11 @@ import type {
BoardFinanceData, BoardFinanceData,
CustomerCard, CustomerCard,
CoachCard, CoachCard,
CustomerDetail as MockCustomerDetail, CustomerDetail,
ChatMessage,
ChatHistoryItem,
} from '../utils/mock-data' } from '../utils/mock-data'
// ============================================ // ============================================
// 内部工具 // 认证模块
// ============================================
/** 模拟网络延迟(联调时删除) */
function delay(ms = 400): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* 是否使用真实 API联调开关
* 联调时改为 true或按模块逐个开启
*/
const USE_REAL_API = false
// ============================================
// 认证模块(已对接真实 API
// ============================================ // ============================================
/** 查询当前用户信息 */ /** 查询当前用户信息 */
@@ -81,32 +51,16 @@ export async function fetchTasks(params: FetchTasksParams = {}): Promise<{
total: number total: number
hasMore: boolean hasMore: boolean
}> { }> {
if (USE_REAL_API) { return request({
const data = await request({
url: '/api/xcx/tasks', url: '/api/xcx/tasks',
method: 'GET', method: 'GET',
data: params, data: params,
needAuth: true, needAuth: true,
}) })
// TODO: 联调时映射 snake_case → camelCase
return data
}
await delay()
const filtered = params.status
? mockTasks.filter(t => t.status === params.status)
: mockTasks
return {
tasks: filtered,
performance: mockPerformance,
total: filtered.length,
hasMore: false,
}
} }
/** 获取任务详情 */ /** 获取任务详情 */
export async function fetchTaskDetail(taskId: string): Promise<TaskDetail | null> { export async function fetchTaskDetail(taskId: string): Promise<TaskDetail | null> {
if (USE_REAL_API) {
return request({ return request({
url: `/api/xcx/tasks/${taskId}`, url: `/api/xcx/tasks/${taskId}`,
method: 'GET', method: 'GET',
@@ -114,62 +68,42 @@ export async function fetchTaskDetail(taskId: string): Promise<TaskDetail | null
}) })
} }
await delay()
return mockTaskDetails.find(t => t.id === taskId) || mockTaskDetails[0] || null
}
/** 放弃任务 */ /** 放弃任务 */
export async function abandonTask(taskId: string, reason: string): Promise<void> { export async function abandonTask(taskId: string, reason: string): Promise<void> {
if (USE_REAL_API) {
await request({ await request({
url: `/api/xcx/tasks/${taskId}/abandon`, url: `/api/xcx/tasks/${taskId}/abandon`,
method: 'POST', method: 'POST',
data: { reason }, data: { reason },
needAuth: true, needAuth: true,
}) })
return
}
await delay(300)
} }
/** 取消放弃(恢复任务) */ /** 取消放弃(恢复任务) */
export async function restoreTask(taskId: string): Promise<void> { export async function restoreTask(taskId: string): Promise<void> {
if (USE_REAL_API) {
await request({ await request({
url: `/api/xcx/tasks/${taskId}/restore`, url: `/api/xcx/tasks/${taskId}/restore`,
method: 'POST', method: 'POST',
needAuth: true, needAuth: true,
}) })
return
}
await delay(300)
} }
/** 置顶任务 */ /** 置顶任务 */
export async function pinTask(taskId: string): Promise<{ isPinned: boolean }> { export async function pinTask(taskId: string): Promise<{ isPinned: boolean }> {
if (USE_REAL_API) {
return request({ return request({
url: `/api/xcx/tasks/${taskId}/pin`, url: `/api/xcx/tasks/${taskId}/pin`,
method: 'POST', method: 'POST',
needAuth: true, needAuth: true,
}) })
} }
await delay(300)
return { isPinned: true }
}
/** 取消置顶任务 */ /** 取消置顶任务 */
export async function unpinTask(taskId: string): Promise<{ isPinned: boolean }> { export async function unpinTask(taskId: string): Promise<{ isPinned: boolean }> {
if (USE_REAL_API) {
return request({ return request({
url: `/api/xcx/tasks/${taskId}/unpin`, url: `/api/xcx/tasks/${taskId}/unpin`,
method: 'POST', method: 'POST',
needAuth: true, needAuth: true,
}) })
} }
await delay(300)
return { isPinned: false }
}
// ============================================ // ============================================
// 备注模块 // 备注模块
@@ -181,12 +115,8 @@ export async function fetchNotes(params: { page?: number; pageSize?: number } =
total: number total: number
hasMore: boolean hasMore: boolean
}> { }> {
if (USE_REAL_API) {
return request({ url: '/api/xcx/notes', method: 'GET', data: params, needAuth: true }) return request({ url: '/api/xcx/notes', method: 'GET', data: params, needAuth: true })
} }
await delay()
return { notes: mockNotes, total: mockNotes.length, hasMore: false }
}
/** 新增备注 */ /** 新增备注 */
export async function createNote(data: { export async function createNote(data: {
@@ -196,20 +126,12 @@ export async function createNote(data: {
ratingServiceWillingness?: number ratingServiceWillingness?: number
ratingRevisitLikelihood?: number ratingRevisitLikelihood?: number
}): Promise<{ id: string; createdAt: string }> { }): Promise<{ id: string; createdAt: string }> {
if (USE_REAL_API) {
return request({ url: '/api/xcx/notes', method: 'POST', data, needAuth: true }) return request({ url: '/api/xcx/notes', method: 'POST', data, needAuth: true })
} }
await delay(300)
return { id: `note-${Date.now()}`, createdAt: new Date().toISOString() }
}
/** 删除备注 */ /** 删除备注 */
export async function deleteNote(noteId: string): Promise<void> { export async function deleteNote(noteId: string): Promise<void> {
if (USE_REAL_API) {
await request({ url: `/api/xcx/notes/${noteId}`, method: 'DELETE', needAuth: true }) await request({ url: `/api/xcx/notes/${noteId}`, method: 'DELETE', needAuth: true })
return
}
await delay(200)
} }
// ============================================ // ============================================
@@ -221,12 +143,8 @@ export async function fetchPerformanceOverview(params: {
year: number year: number
month: number month: number
}): Promise<PerformanceData> { }): Promise<PerformanceData> {
if (USE_REAL_API) {
return request({ url: '/api/xcx/performance', method: 'GET', data: params, needAuth: true }) return request({ url: '/api/xcx/performance', method: 'GET', data: params, needAuth: true })
} }
await delay()
return mockPerformance
}
/** 绩效明细(按月) */ /** 绩效明细(按月) */
export async function fetchPerformanceRecords(params: { export async function fetchPerformanceRecords(params: {
@@ -235,7 +153,6 @@ export async function fetchPerformanceRecords(params: {
page?: number page?: number
pageSize?: number pageSize?: number
}): Promise<{ records: PerformanceRecord[]; hasMore: boolean }> { }): Promise<{ records: PerformanceRecord[]; hasMore: boolean }> {
if (USE_REAL_API) {
return request({ return request({
url: '/api/xcx/performance/records', url: '/api/xcx/performance/records',
method: 'GET', method: 'GET',
@@ -243,26 +160,19 @@ export async function fetchPerformanceRecords(params: {
needAuth: true, needAuth: true,
}) })
} }
await delay()
return { records: mockPerformanceRecords, hasMore: false }
}
// ============================================ // ============================================
// 客户模块 // 客户模块
// ============================================ // ============================================
/** 客户详情 */ /** 客户详情 */
export async function fetchCustomerDetail(customerId: string): Promise<MockCustomerDetail | null> { export async function fetchCustomerDetail(customerId: string): Promise<CustomerDetail | null> {
if (USE_REAL_API) {
return request({ return request({
url: `/api/xcx/customers/${customerId}`, url: `/api/xcx/customers/${customerId}`,
method: 'GET', method: 'GET',
needAuth: true, needAuth: true,
}) })
} }
await delay()
return mockCustomerDetail || null
}
/** 客户服务记录 */ /** 客户服务记录 */
export async function fetchCustomerRecords(params: { export async function fetchCustomerRecords(params: {
@@ -271,7 +181,6 @@ export async function fetchCustomerRecords(params: {
month?: number month?: number
table?: string table?: string
}): Promise<{ records: any[]; hasMore: boolean }> { }): Promise<{ records: any[]; hasMore: boolean }> {
if (USE_REAL_API) {
const { customerId, ...rest } = params const { customerId, ...rest } = params
return request({ return request({
url: `/api/xcx/customers/${customerId}/records`, url: `/api/xcx/customers/${customerId}/records`,
@@ -280,10 +189,6 @@ export async function fetchCustomerRecords(params: {
needAuth: true, needAuth: true,
}) })
} }
await delay()
// 联调前返回空,页面内联 mock 数据仍生效
return { records: [], hasMore: false }
}
// ============================================ // ============================================
// 看板模块 // 看板模块
@@ -295,7 +200,6 @@ export async function fetchBoardCoaches(params: {
sort?: string sort?: string
time?: string time?: string
} = {}): Promise<CoachCard[]> { } = {}): Promise<CoachCard[]> {
if (USE_REAL_API) {
const data = await request({ const data = await request({
url: '/api/xcx/board/coaches', url: '/api/xcx/board/coaches',
method: 'GET', method: 'GET',
@@ -304,9 +208,6 @@ export async function fetchBoardCoaches(params: {
}) })
return data.items return data.items
} }
await delay()
return mockCoaches
}
// CHANGE 2026-03-19 | RNS1.3 T13: 增加 page/pageSize 参数支持分页 // CHANGE 2026-03-19 | RNS1.3 T13: 增加 page/pageSize 参数支持分页
/** 客户看板 */ /** 客户看板 */
@@ -316,7 +217,6 @@ export async function fetchBoardCustomers(params: {
page?: number page?: number
pageSize?: number pageSize?: number
} = {}): Promise<CustomerCard[]> { } = {}): Promise<CustomerCard[]> {
if (USE_REAL_API) {
const data = await request({ const data = await request({
url: '/api/xcx/board/customers', url: '/api/xcx/board/customers',
method: 'GET', method: 'GET',
@@ -325,9 +225,6 @@ export async function fetchBoardCustomers(params: {
}) })
return data.items return data.items
} }
await delay()
return mockCustomers
}
// CHANGE 2026-03-19 | RNS1.3 T14: 扩展参数为 time/area/compare // CHANGE 2026-03-19 | RNS1.3 T14: 扩展参数为 time/area/compare
/** 财务看板 */ /** 财务看板 */
@@ -336,7 +233,6 @@ export async function fetchBoardFinance(params: {
area?: string area?: string
compare?: number compare?: number
} = {}): Promise<BoardFinanceData> { } = {}): Promise<BoardFinanceData> {
if (USE_REAL_API) {
return request({ return request({
url: '/api/xcx/board/finance', url: '/api/xcx/board/finance',
method: 'GET', method: 'GET',
@@ -344,9 +240,6 @@ export async function fetchBoardFinance(params: {
needAuth: true, needAuth: true,
}) })
} }
await delay()
return mockBoardFinance
}
// ============================================ // ============================================
// 助教模块 // 助教模块
@@ -354,16 +247,12 @@ export async function fetchBoardFinance(params: {
/** 助教详情 */ /** 助教详情 */
export async function fetchCoachDetail(coachId: string): Promise<CoachCard | null> { export async function fetchCoachDetail(coachId: string): Promise<CoachCard | null> {
if (USE_REAL_API) {
return request({ return request({
url: `/api/xcx/coaches/${coachId}`, url: `/api/xcx/coaches/${coachId}`,
method: 'GET', method: 'GET',
needAuth: true, needAuth: true,
}) })
} }
await delay()
return mockCoaches.find(c => c.id === coachId) || mockCoaches[0] || null
}
// ============================================ // ============================================
// 对话模块 // 对话模块
@@ -373,8 +262,7 @@ export async function fetchCoachDetail(coachId: string): Promise<CoachCard | nul
export async function fetchChatHistory(params: { export async function fetchChatHistory(params: {
page?: number page?: number
pageSize?: number pageSize?: number
} = {}): Promise<{ items: ChatHistoryItem[]; total: number; hasMore: boolean }> { } = {}): Promise<{ items: any[]; total: number; page: number; pageSize: number }> {
if (USE_REAL_API) {
return request({ return request({
url: '/api/xcx/chat/history', url: '/api/xcx/chat/history',
method: 'GET', method: 'GET',
@@ -382,16 +270,12 @@ export async function fetchChatHistory(params: {
needAuth: true, needAuth: true,
}) })
} }
await delay()
return { items: mockChatHistory, total: mockChatHistory.length, hasMore: false }
}
/** 对话消息列表 */ /** 对话消息列表(通过 chatId */
export async function fetchChatMessages(chatId: string, params: { export async function fetchChatMessages(chatId: string, params: {
page?: number page?: number
pageSize?: number pageSize?: number
} = {}): Promise<{ messages: ChatMessage[]; total: number; hasMore: boolean }> { } = {}): Promise<{ chatId: number; items: any[]; total: number; page: number; pageSize: number }> {
if (USE_REAL_API) {
return request({ return request({
url: `/api/xcx/chat/${chatId}/messages`, url: `/api/xcx/chat/${chatId}/messages`,
method: 'GET', method: 'GET',
@@ -399,16 +283,26 @@ export async function fetchChatMessages(chatId: string, params: {
needAuth: true, needAuth: true,
}) })
} }
await delay()
return { messages: mockChatMessages, total: mockChatMessages.length, hasMore: false } /** 对话消息列表(通过上下文类型和 ID自动查找/创建对话) */
export async function fetchChatMessagesByContext(
contextType: string,
contextId: string,
params: { page?: number; pageSize?: number } = {},
): Promise<{ chatId: number; items: any[]; total: number; page: number; pageSize: number }> {
return request({
url: '/api/xcx/chat/messages',
method: 'GET',
data: { contextType, contextId, ...params },
needAuth: true,
})
} }
/** 发送消息 */ /** 发送消息 */
export async function sendChatMessage(chatId: string, content: string): Promise<{ export async function sendChatMessage(chatId: string, content: string): Promise<{
userMessage: { id: string; content: string; createdAt: string } userMessage: { id: number; content: string; createdAt: string }
aiReply: { id: string; content: string; createdAt: string } aiReply: { id: number; content: string; createdAt: string }
}> { }> {
if (USE_REAL_API) {
return request({ return request({
url: `/api/xcx/chat/${chatId}/messages`, url: `/api/xcx/chat/${chatId}/messages`,
method: 'POST', method: 'POST',
@@ -416,37 +310,25 @@ export async function sendChatMessage(chatId: string, content: string): Promise<
needAuth: true, needAuth: true,
}) })
} }
await delay(800)
return {
userMessage: { id: `msg-${Date.now()}`, content, createdAt: new Date().toISOString() },
aiReply: {
id: `msg-${Date.now() + 1}`,
content: '这是 AI 助手的模拟回复,联调后将替换为真实响应。',
createdAt: new Date().toISOString(),
},
}
}
// ============================================ // ============================================
// 配置模块 // 配置模块
// ============================================ // ============================================
/** 技能类型列表REQ-1 */ /** 项目类型筛选器列表CONFIG-1 */
// CHANGE 2026-03-20 | R3 修复value 改为数据库 category_codefallback 与后端一致
export async function fetchSkillTypes(): Promise<Array<{ value: string; text: string; icon?: string }>> { export async function fetchSkillTypes(): Promise<Array<{ value: string; text: string; icon?: string }>> {
if (USE_REAL_API) {
const data = await request({ const data = await request({
url: '/api/xcx/config/skill-types', url: '/api/xcx/config/skill-types',
method: 'GET', method: 'GET',
needAuth: true, needAuth: true,
}) })
return data.skills // 后端返回 [{key, label, emoji, cls}, ...],映射为前端 {value, text, icon}
} return (data as Array<{key: string; label: string; emoji: string; cls: string}>).map(
// 联调前返回硬编码值,与 board-coach 页面 SKILL_OPTIONS 一致 (item: {key: string; label: string; emoji: string}) => ({
return [ value: item.key,
{ value: '', text: '全部' }, text: item.label,
{ value: 'chinese', text: '中🎱' }, icon: item.emoji || undefined,
{ value: 'snooker', text: '🎯斯诺克' }, })
{ value: 'group', text: '小组课' }, )
{ value: 'tip', text: '打赏课' },
]
} }

View File

@@ -0,0 +1,48 @@
-- 迁移:给 cfg_area_category 加 sort_order 字段 + 创建 app.v_cfg_area_category 视图
-- 关联R3 修复(项目类型筛选接口重建)
-- 日期2026-03-20
-- 回滚:见文件末尾
-- ============================================================
-- 1. 给 cfg_area_category 加 sort_order 字段
-- ============================================================
ALTER TABLE dws.cfg_area_category
ADD COLUMN IF NOT EXISTS sort_order INTEGER DEFAULT 100 NOT NULL;
COMMENT ON COLUMN dws.cfg_area_category.sort_order
IS '前端筛选器显示排序(值越小越靠前)';
-- 按业务优先级设置排序值
UPDATE dws.cfg_area_category SET sort_order = 10 WHERE category_code = 'BILLIARD';
UPDATE dws.cfg_area_category SET sort_order = 20 WHERE category_code = 'SNOOKER';
UPDATE dws.cfg_area_category SET sort_order = 30 WHERE category_code = 'MAHJONG';
UPDATE dws.cfg_area_category SET sort_order = 40 WHERE category_code = 'KTV';
UPDATE dws.cfg_area_category SET sort_order = 900 WHERE category_code = 'SPECIAL';
UPDATE dws.cfg_area_category SET sort_order = 999 WHERE category_code = 'OTHER';
-- ============================================================
-- 2. 创建 app.v_cfg_area_category 视图
-- 去重到 category 级别,排除 SPECIAL/OTHER不在前端筛选器显示
-- ============================================================
CREATE OR REPLACE VIEW app.v_cfg_area_category AS
SELECT DISTINCT
category_code,
category_name,
display_name,
short_name,
sort_order
FROM dws.cfg_area_category
WHERE is_active = TRUE
AND category_code NOT IN ('SPECIAL', 'OTHER')
ORDER BY sort_order;
GRANT SELECT ON app.v_cfg_area_category TO app_reader;
COMMENT ON VIEW app.v_cfg_area_category
IS '项目类型筛选器配置视图(去重到 category 级别,排除 SPECIAL/OTHER';
-- ============================================================
-- 回滚
-- ============================================================
-- DROP VIEW IF EXISTS app.v_cfg_area_category;
-- ALTER TABLE dws.cfg_area_category DROP COLUMN IF EXISTS sort_order;

View File

@@ -0,0 +1,58 @@
-- =============================================================================
-- 迁移:移除废弃的 dws_member_recall_index 表及 RLS 视图
-- 日期: 2026-03-20
-- 原因: RecallIndexTask 已于 2026-02-13 被 WBI+NCI 替代,但表和视图的 DDL
-- 在 2026-02-19 schema 重命名billiards_dws → dws时残留至今。
-- 表中无有效数据ETL 任务已删除),属于孤儿对象。
-- 影响: 无下游消费者依赖此表数据(后端 recall 维度已改用 v_dws_member_winback_index
-- 回滚: 从 git 历史恢复 DDL 定义并重建表和视图
-- =============================================================================
BEGIN;
-- 1. 删除 RLS 视图
DROP VIEW IF EXISTS app.v_dws_member_recall_index;
-- 2. 删除索引
DROP INDEX IF EXISTS dws.idx_dws_recall_display;
-- 3. 删除表CASCADE 会自动清理约束)
DROP TABLE IF EXISTS dws.dws_member_recall_index CASCADE;
-- 4. 删除序列
DROP SEQUENCE IF EXISTS dws.dws_member_recall_index_recall_id_seq;
COMMIT;
-- =============================================================================
-- 验证
-- =============================================================================
DO $$
DECLARE
tbl_exists BOOLEAN;
view_exists BOOLEAN;
seq_exists BOOLEAN;
BEGIN
SELECT EXISTS(
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'dws' AND table_name = 'dws_member_recall_index'
) INTO tbl_exists;
SELECT EXISTS(
SELECT 1 FROM information_schema.views
WHERE table_schema = 'app' AND table_name = 'v_dws_member_recall_index'
) INTO view_exists;
SELECT EXISTS(
SELECT 1 FROM information_schema.sequences
WHERE sequence_schema = 'dws' AND sequence_name = 'dws_member_recall_index_recall_id_seq'
) INTO seq_exists;
RAISE NOTICE '表存在: % (应为 false)', tbl_exists;
RAISE NOTICE '视图存在: % (应为 false)', view_exists;
RAISE NOTICE '序列存在: % (应为 false)', seq_exists;
IF tbl_exists OR view_exists OR seq_exists THEN
RAISE EXCEPTION '清理不完整,仍有残留对象';
END IF;
END $$;

View File

@@ -0,0 +1,89 @@
-- =============================================================================
-- 迁移:扩展 biz.ai_conversations 和 biz.ai_messages 支持 CHAT 模块
-- 日期2026-03-20
-- 关联 SPECrns1-chat-integrationRNS1.4 CHAT 对齐与联调收尾)
-- 需求R2.3, R3.8, R3.10, R4.3, R7.5
-- 说明:
-- 1. ai_conversations 新增 context_type/context_id多入口对话复用
-- title/last_message/last_message_at历史列表展示与排序
-- 2. ai_messages 新增 reference_card引用卡片 JSON
-- 3. 两个索引:上下文对话查找 + 历史列表排序
-- =============================================================================
-- ---------------------------------------------------------------------------
-- 1. 扩展 ai_conversations新增 5 个字段
-- ---------------------------------------------------------------------------
ALTER TABLE biz.ai_conversations
ADD COLUMN IF NOT EXISTS context_type varchar(20),
ADD COLUMN IF NOT EXISTS context_id varchar(50),
ADD COLUMN IF NOT EXISTS title varchar(200),
ADD COLUMN IF NOT EXISTS last_message text,
ADD COLUMN IF NOT EXISTS last_message_at timestamptz;
COMMENT ON COLUMN biz.ai_conversations.context_type
IS '对话关联上下文类型task任务/ customer客户/ coach助教/ general通用';
COMMENT ON COLUMN biz.ai_conversations.context_id
IS '关联上下文 IDtask 入口为 taskIdcustomer 入口为 customerIdcoach 入口为 coachIdgeneral 为 NULL';
COMMENT ON COLUMN biz.ai_conversations.title
IS '对话标题:自定义 > 上下文名称 > 首条消息前20字';
COMMENT ON COLUMN biz.ai_conversations.last_message
IS '最后一条消息内容摘要截断至100字';
COMMENT ON COLUMN biz.ai_conversations.last_message_at
IS '最后消息时间,用于历史列表排序和对话复用时限判断';
-- ---------------------------------------------------------------------------
-- 2. 扩展 ai_messages新增 reference_card 字段
-- ---------------------------------------------------------------------------
ALTER TABLE biz.ai_messages
ADD COLUMN IF NOT EXISTS reference_card jsonb;
COMMENT ON COLUMN biz.ai_messages.reference_card
IS 'referenceCard JSON{type, title, summary, data}';
-- ---------------------------------------------------------------------------
-- 3. 索引:上下文对话查找(按 context_type + context_id 查找可复用对话)
-- ---------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS idx_ai_conv_context
ON biz.ai_conversations (user_id, site_id, context_type, context_id, last_message_at DESC NULLS LAST)
WHERE context_type IS NOT NULL;
-- ---------------------------------------------------------------------------
-- 4. 索引历史列表排序优化CHAT-1: 按 last_message_at 倒序)
-- ---------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS idx_ai_conv_last_msg
ON biz.ai_conversations (user_id, site_id, last_message_at DESC NULLS LAST);
-- =============================================================================
-- 回滚
-- =============================================================================
-- DROP INDEX IF EXISTS biz.idx_ai_conv_context;
-- DROP INDEX IF EXISTS biz.idx_ai_conv_last_msg;
-- ALTER TABLE biz.ai_messages DROP COLUMN IF EXISTS reference_card;
-- ALTER TABLE biz.ai_conversations DROP COLUMN IF EXISTS last_message_at;
-- ALTER TABLE biz.ai_conversations DROP COLUMN IF EXISTS last_message;
-- ALTER TABLE biz.ai_conversations DROP COLUMN IF EXISTS title;
-- ALTER TABLE biz.ai_conversations DROP COLUMN IF EXISTS context_id;
-- ALTER TABLE biz.ai_conversations DROP COLUMN IF EXISTS context_type;
-- =============================================================================
-- 验证
-- =============================================================================
-- 1) ai_conversations 新字段存在性
-- SELECT column_name, data_type, character_maximum_length
-- FROM information_schema.columns
-- WHERE table_schema = 'biz' AND table_name = 'ai_conversations'
-- AND column_name IN ('context_type','context_id','title','last_message','last_message_at');
-- 预期5 行
-- 2) ai_messages 新字段存在性
-- SELECT column_name, data_type
-- FROM information_schema.columns
-- WHERE table_schema = 'biz' AND table_name = 'ai_messages'
-- AND column_name = 'reference_card';
-- 预期1 行jsonb
-- 3) 索引存在性
-- SELECT indexname FROM pg_indexes
-- WHERE schemaname = 'biz' AND tablename = 'ai_conversations'
-- AND indexname IN ('idx_ai_conv_context','idx_ai_conv_last_msg');
-- 预期2 行

View File

@@ -2,7 +2,7 @@
> 本文档记录项目中所有文档资产的位置、类型和内容概要,方便快速定位。 > 本文档记录项目中所有文档资产的位置、类型和内容概要,方便快速定位。
> 归档规则见末尾「文档归档规则」章节;程序输出路径规范见 `docs/deployment/EXPORT-PATHS.md`。 > 归档规则见末尾「文档归档规则」章节;程序输出路径规范见 `docs/deployment/EXPORT-PATHS.md`。
> 最后更新2026-03-19RNS1.3 三看板接口文档补充 > 最后更新2026-03-20RNS1.4 CHAT 模块重建 + FDW→直连统一 + R3 筛选修复 审计收口
--- ---
@@ -55,7 +55,7 @@
| `README.md` | 审计目录说明 | | `README.md` | 审计目录说明 |
| `SESSION-LOG-GUIDE.md` | Session 日志使用指南:索引字段说明、查询方法、典型场景、与其他审计产物的关系 | | `SESSION-LOG-GUIDE.md` | Session 日志使用指南:索引字段说明、查询方法、典型场景、与其他审计产物的关系 |
| `AUDIT-HOOKS-GUIDE.md` | 审计 Hooks 使用指南Hook 触发机制、配置方式、与审计流程的集成 | | `AUDIT-HOOKS-GUIDE.md` | 审计 Hooks 使用指南Hook 触发机制、配置方式、与审计流程的集成 |
| `changes/` | 56 份变更审计文档(`YYYY-MM-DD__<slug>.md` 格式),每份包含:变更原因、影响范围、回滚策略、验证 SQL | | `changes/` | 67 份变更审计文档(`YYYY-MM-DD__<slug>.md` 格式),每份包含:变更原因、影响范围、回滚策略、验证 SQL |
| `prompt_logs/` | Prompt 日志(`prompt_log_YYYYMMDD_HHMMSS.md`),记录每次 AI 交互的输入输出 | | `prompt_logs/` | Prompt 日志(`prompt_log_YYYYMMDD_HHMMSS.md`),记录每次 AI 交互的输入输出 |
| `session_logs/` | 全量会话记录(按 `YYYY-MM/DD/` 分层),含双索引(`_session_index.json` / `_session_index_full.json`)、每轮 execution 的完整 Markdown 记录、LLM 生成的操作摘要 | | `session_logs/` | 全量会话记录(按 `YYYY-MM/DD/` 分层),含双索引(`_session_index.json` / `_session_index_full.json`)、每轮 execution 的完整 Markdown 记录、LLM 生成的操作摘要 |
@@ -209,6 +209,14 @@ RNS1.3 新增模块(三看板接口):
| `app/schemas/xcx_board.py` | 看板相关 Pydantic Schema7 枚举 + ~40 响应 Schema | | `app/schemas/xcx_board.py` | 看板相关 Pydantic Schema7 枚举 + ~40 响应 Schema |
| `app/schemas/xcx_config.py` | 配置相关 Pydantic SchemaSkillTypeItem | | `app/schemas/xcx_config.py` | 配置相关 Pydantic SchemaSkillTypeItem |
RNS1.4 新增模块CHAT 对齐与联调收尾):
| 路径 | 内容 |
|------|------|
| `app/routers/xcx_chat.py` | CHAT 端点CHAT-1 对话历史、CHAT-2a/2b 消息查询、CHAT-3 发送消息、CHAT-4 SSE 流式(替代原 `xcx_ai_chat.py`,路径从 `/api/ai/*` 迁移到 `/api/xcx/chat/*` |
| `app/services/chat_service.py` | CHAT 业务逻辑对话管理、消息持久化、对话复用规则、referenceCard 组装、标题生成 |
| `app/schemas/xcx_chat.py` | CHAT 相关 Pydantic SchemaChatHistoryResponse、ChatMessagesResponse、ReferenceCard、SendMessageResponse、ChatStreamRequest 等) |
Monorepo 级属性测试(`tests/` Monorepo 级属性测试(`tests/`
| 路径 | 内容 | | 路径 | 内容 |
@@ -216,6 +224,12 @@ Monorepo 级属性测试(`tests/`
| `tests/test_rns12_properties.py` | RNS1.2 属性测试14 个 PropertyHypothesis 框架) | | `tests/test_rns12_properties.py` | RNS1.2 属性测试14 个 PropertyHypothesis 框架) |
| `tests/test_board_properties.py` | RNS1.3 属性测试18 个测试函数12 个 PropertyHypothesis 框架) | | `tests/test_board_properties.py` | RNS1.3 属性测试18 个测试函数12 个 PropertyHypothesis 框架) |
| `tests/test_board_service_unit.py` | RNS1.3 看板工具函数单元测试 | | `tests/test_board_service_unit.py` | RNS1.3 看板工具函数单元测试 |
| `tests/test_rns1_chat_title_properties.py` | RNS1.4 属性测试标题生成优先级Property 4Hypothesis 框架) |
| `tests/test_rns1_chat_reuse_properties.py` | RNS1.4 属性测试对话复用规则Property 6 |
| `tests/test_rns1_chat_reference_card_properties.py` | RNS1.4 属性测试referenceCard round tripProperty 7 |
| `tests/test_rns1_chat_persistence_properties.py` | RNS1.4 属性测试消息持久化与会话元数据更新Property 8 |
| `tests/test_rns1_chat_sse_properties.py` | RNS1.4 属性测试SSE 事件类型有效性Property 9 |
| `tests/test_rns1_chat_ordering_properties.py` | RNS1.4 属性测试列表排序不变量Property 3 |
### 3.2 ETL Connector `apps/etl/connectors/feiqiu/` ### 3.2 ETL Connector `apps/etl/connectors/feiqiu/`
@@ -382,6 +396,7 @@ Monorepo 级属性测试(`tests/`
| `rns1-task-performance-api` | RNS1.1 任务与绩效接口TASK-1 扩展、TASK-2、PERF-1、PERF-2、前端适配 | | `rns1-task-performance-api` | RNS1.1 任务与绩效接口TASK-1 扩展、TASK-2、PERF-1、PERF-2、前端适配 |
| `rns1-customer-coach-api` | RNS1.2 客户与助教接口CUST-1 客户详情、CUST-2 客户服务记录、COACH-1 助教详情) | | `rns1-customer-coach-api` | RNS1.2 客户与助教接口CUST-1 客户详情、CUST-2 客户服务记录、COACH-1 助教详情) |
| `rns1-board-apis` | RNS1.3 三看板接口BOARD-1 助教看板、BOARD-2 客户看板、BOARD-3 财务看板、CONFIG-1 技能类型) | | `rns1-board-apis` | RNS1.3 三看板接口BOARD-1 助教看板、BOARD-2 客户看板、BOARD-3 财务看板、CONFIG-1 技能类型) |
| `rns1-chat-integration` | RNS1.4 CHAT 对齐与联调收尾CHAT-1/2/3/4 路径迁移、对话复用、referenceCard、SSE 流式、FDW 验证、13 页面联调) |
| `spi-spending-power-index` | SPI 消费力指数 | | `spi-spending-power-index` | SPI 消费力指数 |
--- ---

View File

@@ -111,20 +111,20 @@ graph TB
| 服务文件 | 职责 | 数据源 | | 服务文件 | 职责 | 数据源 |
|----------|------|--------| |----------|------|--------|
| `task_manager.py` | 任务 CRUD、`get_task_list_v2()``get_task_detail()` | biz.coach_tasks + FDW | | `task_manager.py` | 任务 CRUD、`get_task_list_v2()``get_task_detail()` | biz.coach_tasks + ETL 直连 |
| `performance_service.py` | `get_overview()``get_records()`,绩效汇总与明细 | FDW | | `performance_service.py` | `get_overview()``get_records()`,绩效汇总与明细 | ETL 直连 |
| `note_service.py` | 备注创建(含 score、AI 占位、回访触发 | biz.notes | | `note_service.py` | 备注创建(含 score、AI 占位、回访触发 | biz.notes |
| `customer_service.py` | 客户详情(CUST-1)、客户服务记录(CUST-2)RNS1.2 新增) | biz.coach_tasks + biz.ai_cache + biz.notes + public.member_retention_clue + FDW | | `customer_service.py` | 客户详情(CUST-1)、客户服务记录(CUST-2)RNS1.2 新增) | biz.coach_tasks + biz.ai_cache + biz.notes + public.member_retention_clue + ETL 直连 |
| `coach_service.py` | 助教详情(COACH-1):绩效/收入/任务分组/TOP客户/历史月份RNS1.2 新增) | biz.coach_tasks + biz.notes + FDW | | `coach_service.py` | 助教详情(COACH-1):绩效/收入/任务分组/TOP客户/历史月份RNS1.2 新增) | biz.coach_tasks + biz.notes + ETL 直连 |
| `fdw_queries.py` | ETL RLS 视图查询集中封装,直连 ETL 库 + `SET LOCAL app.current_site_id` 门店隔离 | app.v_* (ETL 直连) | | `fdw_queries.py` | ETL RLS 视图查询集中封装,直连 ETL 库 + `SET LOCAL app.current_site_id` 门店隔离 | app.v_* (ETL 直连) |
| `task_generator.py` | 定时任务:基于 WBI/NCI/RS 指数自动生成助教任务 | biz + FDW | | `task_generator.py` | 定时任务:基于 WBI/NCI/RS 指数自动生成助教任务 | biz + ETL 直连 |
| `task_expiry.py` | 定时任务:检测过期任务并标记 inactive | biz.coach_tasks | | `task_expiry.py` | 定时任务:检测过期任务并标记 inactive | biz.coach_tasks |
| `recall_detector.py` | 事件驱动ETL 数据更新后检测召回完成 | biz + FDW | | `recall_detector.py` | 事件驱动ETL 数据更新后检测召回完成 | biz + ETL 直连 |
| `note_reclassifier.py` | 事件驱动:召回完成后回溯重分类备注 | biz.notes | | `note_reclassifier.py` | 事件驱动:召回完成后回溯重分类备注 | biz.notes |
| `wechat.py` | 微信 code2session、Token 管理 | 外部 API | | `wechat.py` | 微信 code2session、Token 管理 | 外部 API |
| `role.py` | 角色权限查询 | auth.* | | `role.py` | 角色权限查询 | auth.* |
| `scheduler.py` | 触发器调度引擎 | biz.trigger_jobs | | `scheduler.py` | 触发器调度引擎 | biz.trigger_jobs |
| `board_service.py` | 三看板编排:`get_coach_board()``get_customer_board()``get_finance_board()`,含日期范围/环比/排序/分页/降级RNS1.3 新增) | FDW + biz.coach_tasks | | `board_service.py` | 三看板编排:`get_coach_board()``get_customer_board()``get_finance_board()`,含日期范围/环比/排序/分页/降级RNS1.3 新增) | ETL 直连 + biz.coach_tasks |
| `application.py` | 入驻申请处理 | auth.applications | | `application.py` | 入驻申请处理 | auth.applications |
## 4. FDW 查询封装fdw_queries.py ## 4. FDW 查询封装fdw_queries.py
@@ -156,7 +156,7 @@ graph TB
| `get_all_assistants()` | BOARD-1按技能筛选助教列表RNS1.3 新增) | v_dim_assistant | | `get_all_assistants()` | BOARD-1按技能筛选助教列表RNS1.3 新增) | v_dim_assistant |
| `get_salary_calc_batch()` | BOARD-1批量查询当期/上期绩效RNS1.3 新增) | items_sum + assistant_pd/cx_money | | `get_salary_calc_batch()` | BOARD-1批量查询当期/上期绩效RNS1.3 新增) | items_sum + assistant_pd/cx_money |
| `get_top_customers_for_coaches()` | BOARD-1按亲密度 Top3 客户 + 四级 emojiRNS1.3 新增) | DQ-6 + v_dws_member_assistant_relation_index | | `get_top_customers_for_coaches()` | BOARD-1按亲密度 Top3 客户 + 四级 emojiRNS1.3 新增) | DQ-6 + v_dws_member_assistant_relation_index |
| `get_coach_sv_data()` | BOARD-1助教客源储值数据RNS1.3 新增) | v_dws_assistant_monthly_summary | | `get_coach_sv_data()` | BOARD-1助教客源储值数据RNS1.3 新增) | v_dws_assistant_recharge_commission |
| `get_customer_board_recall()` | BOARD-2召回维度WBI 降序RNS1.3 新增) | v_dws_member_winback_index + DQ-6 | | `get_customer_board_recall()` | BOARD-2召回维度WBI 降序RNS1.3 新增) | v_dws_member_winback_index + DQ-6 |
| `get_customer_board_potential()` | BOARD-2潜力维度SPI 降序RNS1.3 新增) | v_dws_member_spending_power_index | | `get_customer_board_potential()` | BOARD-2潜力维度SPI 降序RNS1.3 新增) | v_dws_member_spending_power_index |
| `get_customer_board_balance()` | BOARD-2余额维度RNS1.3 新增) | v_dim_member_card_account + DQ-7 | | `get_customer_board_balance()` | BOARD-2余额维度RNS1.3 新增) | v_dim_member_card_account + DQ-7 |

View File

@@ -1,12 +1,17 @@
# 审计一览表 # 审计一览表
> 自动生成于 2026-03-19 18:47:42,请勿手动编辑。 > 自动生成于 2026-03-20 07:28:24,请勿手动编辑。
## 时间线视图 ## 时间线视图
| 日期 | 项目 | 需求摘要 | 变更类型 | 影响模块 | 风险 | 详情 | | 日期 | 项目 | 需求摘要 | 变更类型 | 影响模块 | 风险 | 详情 |
|------|------|----------|----------|----------|------|------| |------|------|----------|----------|----------|------|------|
| 2026-03-20 | 项目级 | H2 修复FDW → 直连 ETL 架构统一 | bugfix | 其他 | 未知 | [链接](changes/2026-03-20__h2-fdw-to-direct-etl-unification.md) |
| 2026-03-20 | ETL-feiqiu, 后端 | 变更审计记录R3 项目类型筛选接口重建fetchSkillTypes / cfg_area_category | bugfix | 其他, 文档 | 高 | [链接](changes/2026-03-20__r3-skill-type-filter-rebuild.md) |
| 2026-03-20 | 项目级 | RNS1 系列 AI 自主决策风险审计报告(完整版) | bugfix | 其他 | 高 | [链接](changes/2026-03-20__rns1-ai-autonomous-decision-risk-audit.md) |
| 2026-03-20 | ETL-feiqiu, 后端, 项目级 | 变更审计记录RNS1.3 三看板 FDW 查询层数据口径修复 | bugfix | 其他, 文档 | 未知 | [链接](changes/2026-03-20__rns13-board-apis-e2e-fix.md) | | 2026-03-20 | ETL-feiqiu, 后端, 项目级 | 变更审计记录RNS1.3 三看板 FDW 查询层数据口径修复 | bugfix | 其他, 文档 | 未知 | [链接](changes/2026-03-20__rns13-board-apis-e2e-fix.md) |
| 2026-03-20 | 项目级 | 变更审计记录RNS1.4 CHAT 模块重建 + FDW→直连统一 + R3 筛选修复 | bugfix | 其他 | 未知 | [链接](changes/2026-03-20__rns14-chat-fdw-filter-audit.md) |
| 2026-03-20 | 项目级 | 变更审计记录RNS1.4 CHAT 模块迁移 + R3 项目类型筛选重建 | bugfix | 其他 | 高 | [链接](changes/2026-03-20__rns14-chat-module-r3-filter-rebuild.md) |
| 2026-03-19 | ETL-feiqiu, 后端 | 变更审计记录card_type_id 年卡/月卡映射文档同步 | bugfix | 其他, 文档 | 未知 | [链接](changes/2026-03-19__card-type-id-doc-sync.md) | | 2026-03-19 | ETL-feiqiu, 后端 | 变更审计记录card_type_id 年卡/月卡映射文档同步 | bugfix | 其他, 文档 | 未知 | [链接](changes/2026-03-19__card-type-id-doc-sync.md) |
| 2026-03-19 | 项目级 | 变更审计记录coach_service 绩效档位硬编码修复 | bugfix | 其他 | 低 | [链接](changes/2026-03-19__coach-tier-hardcode-fix.md) | | 2026-03-19 | 项目级 | 变更审计记录coach_service 绩效档位硬编码修复 | bugfix | 其他 | 低 | [链接](changes/2026-03-19__coach-tier-hardcode-fix.md) |
| 2026-03-19 | ETL-feiqiu, 后端 | 变更审计记录助教等级映射硬编码修复P2-9 | bugfix | 其他, 文档 | 低 | [链接](changes/2026-03-19__level-map-hardcode-fix.md) | | 2026-03-19 | ETL-feiqiu, 后端 | 变更审计记录助教等级映射硬编码修复P2-9 | bugfix | 其他, 文档 | 低 | [链接](changes/2026-03-19__level-map-hardcode-fix.md) |
@@ -75,6 +80,7 @@
| 日期 | 需求摘要 | 变更类型 | 影响模块 | 风险 | 详情 | | 日期 | 需求摘要 | 变更类型 | 影响模块 | 风险 | 详情 |
|------|----------|----------|----------|------|------| |------|----------|----------|----------|------|------|
| 2026-03-20 | 变更审计记录R3 项目类型筛选接口重建fetchSkillTypes / cfg_area_category | bugfix | 其他, 文档 | 高 | [链接](changes/2026-03-20__r3-skill-type-filter-rebuild.md) |
| 2026-03-20 | 变更审计记录RNS1.3 三看板 FDW 查询层数据口径修复 | bugfix | 其他, 文档 | 未知 | [链接](changes/2026-03-20__rns13-board-apis-e2e-fix.md) | | 2026-03-20 | 变更审计记录RNS1.3 三看板 FDW 查询层数据口径修复 | bugfix | 其他, 文档 | 未知 | [链接](changes/2026-03-20__rns13-board-apis-e2e-fix.md) |
| 2026-03-19 | 变更审计记录card_type_id 年卡/月卡映射文档同步 | bugfix | 其他, 文档 | 未知 | [链接](changes/2026-03-19__card-type-id-doc-sync.md) | | 2026-03-19 | 变更审计记录card_type_id 年卡/月卡映射文档同步 | bugfix | 其他, 文档 | 未知 | [链接](changes/2026-03-19__card-type-id-doc-sync.md) |
| 2026-03-19 | 变更审计记录助教等级映射硬编码修复P2-9 | bugfix | 其他, 文档 | 低 | [链接](changes/2026-03-19__level-map-hardcode-fix.md) | | 2026-03-19 | 变更审计记录助教等级映射硬编码修复P2-9 | bugfix | 其他, 文档 | 低 | [链接](changes/2026-03-19__level-map-hardcode-fix.md) |
@@ -113,6 +119,7 @@
| 日期 | 需求摘要 | 变更类型 | 影响模块 | 风险 | 详情 | | 日期 | 需求摘要 | 变更类型 | 影响模块 | 风险 | 详情 |
|------|----------|----------|----------|------|------| |------|----------|----------|----------|------|------|
| 2026-03-20 | 变更审计记录R3 项目类型筛选接口重建fetchSkillTypes / cfg_area_category | bugfix | 其他, 文档 | 高 | [链接](changes/2026-03-20__r3-skill-type-filter-rebuild.md) |
| 2026-03-20 | 变更审计记录RNS1.3 三看板 FDW 查询层数据口径修复 | bugfix | 其他, 文档 | 未知 | [链接](changes/2026-03-20__rns13-board-apis-e2e-fix.md) | | 2026-03-20 | 变更审计记录RNS1.3 三看板 FDW 查询层数据口径修复 | bugfix | 其他, 文档 | 未知 | [链接](changes/2026-03-20__rns13-board-apis-e2e-fix.md) |
| 2026-03-19 | 变更审计记录card_type_id 年卡/月卡映射文档同步 | bugfix | 其他, 文档 | 未知 | [链接](changes/2026-03-19__card-type-id-doc-sync.md) | | 2026-03-19 | 变更审计记录card_type_id 年卡/月卡映射文档同步 | bugfix | 其他, 文档 | 未知 | [链接](changes/2026-03-19__card-type-id-doc-sync.md) |
| 2026-03-19 | 变更审计记录助教等级映射硬编码修复P2-9 | bugfix | 其他, 文档 | 低 | [链接](changes/2026-03-19__level-map-hardcode-fix.md) | | 2026-03-19 | 变更审计记录助教等级映射硬编码修复P2-9 | bugfix | 其他, 文档 | 低 | [链接](changes/2026-03-19__level-map-hardcode-fix.md) |
@@ -154,7 +161,11 @@
| 日期 | 需求摘要 | 变更类型 | 影响模块 | 风险 | 详情 | | 日期 | 需求摘要 | 变更类型 | 影响模块 | 风险 | 详情 |
|------|----------|----------|----------|------|------| |------|----------|----------|----------|------|------|
| 2026-03-20 | H2 修复FDW → 直连 ETL 架构统一 | bugfix | 其他 | 未知 | [链接](changes/2026-03-20__h2-fdw-to-direct-etl-unification.md) |
| 2026-03-20 | RNS1 系列 AI 自主决策风险审计报告(完整版) | bugfix | 其他 | 高 | [链接](changes/2026-03-20__rns1-ai-autonomous-decision-risk-audit.md) |
| 2026-03-20 | 变更审计记录RNS1.3 三看板 FDW 查询层数据口径修复 | bugfix | 其他, 文档 | 未知 | [链接](changes/2026-03-20__rns13-board-apis-e2e-fix.md) | | 2026-03-20 | 变更审计记录RNS1.3 三看板 FDW 查询层数据口径修复 | bugfix | 其他, 文档 | 未知 | [链接](changes/2026-03-20__rns13-board-apis-e2e-fix.md) |
| 2026-03-20 | 变更审计记录RNS1.4 CHAT 模块重建 + FDW→直连统一 + R3 筛选修复 | bugfix | 其他 | 未知 | [链接](changes/2026-03-20__rns14-chat-fdw-filter-audit.md) |
| 2026-03-20 | 变更审计记录RNS1.4 CHAT 模块迁移 + R3 项目类型筛选重建 | bugfix | 其他 | 高 | [链接](changes/2026-03-20__rns14-chat-module-r3-filter-rebuild.md) |
| 2026-03-19 | 变更审计记录coach_service 绩效档位硬编码修复 | bugfix | 其他 | 低 | [链接](changes/2026-03-19__coach-tier-hardcode-fix.md) | | 2026-03-19 | 变更审计记录coach_service 绩效档位硬编码修复 | bugfix | 其他 | 低 | [链接](changes/2026-03-19__coach-tier-hardcode-fix.md) |
| 2026-03-19 | 数据库变更审计RNS1.2 客户与助教接口 | 文档 | 其他 | 低 | [链接](changes/2026-03-19__rns12-db-audit.md) | | 2026-03-19 | 数据库变更审计RNS1.2 客户与助教接口 | 文档 | 其他 | 低 | [链接](changes/2026-03-19__rns12-db-audit.md) |
| 2026-03-18 | RNS1.1 E2E 测试 — FDW 直连改造 + performance_service bug 修复 | bugfix | 其他 | 未知 | [链接](changes/2026-03-18__rns1-e2e-fdw-direct-connect-bugfix.md) | | 2026-03-18 | RNS1.1 E2E 测试 — FDW 直连改造 + performance_service bug 修复 | bugfix | 其他 | 未知 | [链接](changes/2026-03-18__rns1-e2e-fdw-direct-connect-bugfix.md) |
@@ -231,7 +242,12 @@
| 日期 | 需求摘要 | 变更类型 | 风险 | 详情 | | 日期 | 需求摘要 | 变更类型 | 风险 | 详情 |
|------|----------|----------|------|------| |------|----------|----------|------|------|
| 2026-03-20 | H2 修复FDW → 直连 ETL 架构统一 | bugfix | 未知 | [链接](changes/2026-03-20__h2-fdw-to-direct-etl-unification.md) |
| 2026-03-20 | 变更审计记录R3 项目类型筛选接口重建fetchSkillTypes / cfg_area_category | bugfix | 高 | [链接](changes/2026-03-20__r3-skill-type-filter-rebuild.md) |
| 2026-03-20 | RNS1 系列 AI 自主决策风险审计报告(完整版) | bugfix | 高 | [链接](changes/2026-03-20__rns1-ai-autonomous-decision-risk-audit.md) |
| 2026-03-20 | 变更审计记录RNS1.3 三看板 FDW 查询层数据口径修复 | bugfix | 未知 | [链接](changes/2026-03-20__rns13-board-apis-e2e-fix.md) | | 2026-03-20 | 变更审计记录RNS1.3 三看板 FDW 查询层数据口径修复 | bugfix | 未知 | [链接](changes/2026-03-20__rns13-board-apis-e2e-fix.md) |
| 2026-03-20 | 变更审计记录RNS1.4 CHAT 模块重建 + FDW→直连统一 + R3 筛选修复 | bugfix | 未知 | [链接](changes/2026-03-20__rns14-chat-fdw-filter-audit.md) |
| 2026-03-20 | 变更审计记录RNS1.4 CHAT 模块迁移 + R3 项目类型筛选重建 | bugfix | 高 | [链接](changes/2026-03-20__rns14-chat-module-r3-filter-rebuild.md) |
| 2026-03-19 | 变更审计记录card_type_id 年卡/月卡映射文档同步 | bugfix | 未知 | [链接](changes/2026-03-19__card-type-id-doc-sync.md) | | 2026-03-19 | 变更审计记录card_type_id 年卡/月卡映射文档同步 | bugfix | 未知 | [链接](changes/2026-03-19__card-type-id-doc-sync.md) |
| 2026-03-19 | 变更审计记录coach_service 绩效档位硬编码修复 | bugfix | 低 | [链接](changes/2026-03-19__coach-tier-hardcode-fix.md) | | 2026-03-19 | 变更审计记录coach_service 绩效档位硬编码修复 | bugfix | 低 | [链接](changes/2026-03-19__coach-tier-hardcode-fix.md) |
| 2026-03-19 | 变更审计记录助教等级映射硬编码修复P2-9 | bugfix | 低 | [链接](changes/2026-03-19__level-map-hardcode-fix.md) | | 2026-03-19 | 变更审计记录助教等级映射硬编码修复P2-9 | bugfix | 低 | [链接](changes/2026-03-19__level-map-hardcode-fix.md) |
@@ -294,6 +310,7 @@
| 日期 | 需求摘要 | 变更类型 | 风险 | 详情 | | 日期 | 需求摘要 | 变更类型 | 风险 | 详情 |
|------|----------|----------|------|------| |------|----------|----------|------|------|
| 2026-03-20 | 变更审计记录R3 项目类型筛选接口重建fetchSkillTypes / cfg_area_category | bugfix | 高 | [链接](changes/2026-03-20__r3-skill-type-filter-rebuild.md) |
| 2026-03-20 | 变更审计记录RNS1.3 三看板 FDW 查询层数据口径修复 | bugfix | 未知 | [链接](changes/2026-03-20__rns13-board-apis-e2e-fix.md) | | 2026-03-20 | 变更审计记录RNS1.3 三看板 FDW 查询层数据口径修复 | bugfix | 未知 | [链接](changes/2026-03-20__rns13-board-apis-e2e-fix.md) |
| 2026-03-19 | 变更审计记录card_type_id 年卡/月卡映射文档同步 | bugfix | 未知 | [链接](changes/2026-03-19__card-type-id-doc-sync.md) | | 2026-03-19 | 变更审计记录card_type_id 年卡/月卡映射文档同步 | bugfix | 未知 | [链接](changes/2026-03-19__card-type-id-doc-sync.md) |
| 2026-03-19 | 变更审计记录助教等级映射硬编码修复P2-9 | bugfix | 低 | [链接](changes/2026-03-19__level-map-hardcode-fix.md) | | 2026-03-19 | 变更审计记录助教等级映射硬编码修复P2-9 | bugfix | 低 | [链接](changes/2026-03-19__level-map-hardcode-fix.md) |

View File

@@ -1,24 +1,33 @@
# BD_Manualbiz Schema AI 表(对话记录 + 消息 + 缓存) # BD_Manualbiz Schema AI 表(对话记录 + 消息 + 缓存)
> 目标库:`test_zqyy_app`(通过 `APP_DB_DSN` 连接) > 目标库:`test_zqyy_app`(通过 `APP_DB_DSN` 连接)
> 迁移脚本:`db/zqyy_app/migrations/2026-03-08__create_ai_tables.sql` > 迁移脚本:
> 关联 SPEC`05-miniapp-ai-integration`P5 AI 集成层 > - `db/zqyy_app/migrations/2026-03-08__create_ai_tables.sql`(初始建表
> - `db/zqyy_app/migrations/2026-03-20__rns14_chat_module_extend.sql`RNS1.4 CHAT 扩展)
> 关联 SPEC`05-miniapp-ai-integration`P5 AI 集成层)、`rns1-chat-integration`RNS1.4 CHAT 对齐与联调收尾)
--- ---
## 1. 变更说明 ## 1. 变更说明
### 新增表3 张) ### 新增表3 张P5 初始建表
| # | 表名 | 用途 | 字段数 | | # | 表名 | 用途 | 字段数(初始→当前) |
|---|------|------|--------| |---|------|------|---------------------|
| 1 | `biz.ai_conversations` | AI 对话记录:每次 AI 调用(用户主动或系统自动)创建一条 | 8 | | 1 | `biz.ai_conversations` | AI 对话记录:每次 AI 调用(用户主动或系统自动)创建一条 | 8 → 13 |
| 2 | `biz.ai_messages` | AI 消息记录:对话中的每条消息(输入/输出/系统) | 6 | | 2 | `biz.ai_messages` | AI 消息记录:对话中的每条消息(输入/输出/系统) | 6 → 7 |
| 3 | `biz.ai_cache` | AI 应用缓存:各应用的结构化输出结果 | 9 | | 3 | `biz.ai_cache` | AI 应用缓存:各应用的结构化输出结果 | 9 |
### RNS1.4 CHAT 模块扩展字段2026-03-20
| # | 表名 | 新增字段 | 用途 |
|---|------|---------|------|
| 1 | `biz.ai_conversations` | `context_type`, `context_id`, `title`, `last_message`, `last_message_at` | 多入口对话复用 + 历史列表展示与排序 |
| 2 | `biz.ai_messages` | `reference_card` | 引用卡片 JSON客户概览等结构化上下文数据 |
### 表字段明细 ### 表字段明细
#### biz.ai_conversations8 字段) #### biz.ai_conversations13 字段)
| 字段 | 类型 | 约束 | 说明 | | 字段 | 类型 | 约束 | 说明 |
|------|------|------|------| |------|------|------|------|
@@ -30,8 +39,13 @@
| `source_page` | VARCHAR(100) | 可空 | 来源页面标识 | | `source_page` | VARCHAR(100) | 可空 | 来源页面标识 |
| `source_context` | JSONB | 可空 | 页面上下文 JSON | | `source_context` | JSONB | 可空 | 页面上下文 JSON |
| `created_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 创建时间 | | `created_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 创建时间 |
| `context_type` | VARCHAR(20) | 可空 | **RNS1.4 新增** — 对话关联上下文类型task任务/ customer客户/ coach助教/ general通用 |
| `context_id` | VARCHAR(50) | 可空 | **RNS1.4 新增** — 关联上下文 IDtask 入口为 taskIdcustomer 入口为 customerIdcoach 入口为 coachIdgeneral 为 NULL |
| `title` | VARCHAR(200) | 可空 | **RNS1.4 新增** — 对话标题:自定义 > 上下文名称 > 首条消息前20字 |
| `last_message` | TEXT | 可空 | **RNS1.4 新增** — 最后一条消息内容摘要截断至100字 |
| `last_message_at` | TIMESTAMPTZ | 可空 | **RNS1.4 新增** — 最后消息时间,用于历史列表排序和对话复用时限判断 |
#### biz.ai_messages6 字段) #### biz.ai_messages7 字段)
| 字段 | 类型 | 约束 | 说明 | | 字段 | 类型 | 约束 | 说明 |
|------|------|------|------| |------|------|------|------|
@@ -41,6 +55,7 @@
| `content` | TEXT | NOT NULL | 消息内容 | | `content` | TEXT | NOT NULL | 消息内容 |
| `tokens_used` | INTEGER | 可空 | 本条消息消耗的 token 数 | | `tokens_used` | INTEGER | 可空 | 本条消息消耗的 token 数 |
| `created_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 创建时间 | | `created_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 创建时间 |
| `reference_card` | JSONB | 可空 | **RNS1.4 新增** — 引用卡片 JSON`{type, title, summary, data}`,用于展示客户概览等结构化上下文数据 |
#### biz.ai_cache9 字段) #### biz.ai_cache9 字段)
@@ -80,6 +95,8 @@
| `ai_cache` | `chk_ai_cache_type` | CHECK | 7 个枚举值 | | `ai_cache` | `chk_ai_cache_type` | CHECK | 7 个枚举值 |
| `ai_cache` | `idx_ai_cache_lookup` | INDEX | `(cache_type, site_id, target_id, created_at DESC)` — 查询最新缓存 | | `ai_cache` | `idx_ai_cache_lookup` | INDEX | `(cache_type, site_id, target_id, created_at DESC)` — 查询最新缓存 |
| `ai_cache` | `idx_ai_cache_cleanup` | INDEX | `(cache_type, site_id, target_id, created_at)` — 清理超限记录ASC 排序便于删除最旧) | | `ai_cache` | `idx_ai_cache_cleanup` | INDEX | `(cache_type, site_id, target_id, created_at)` — 清理超限记录ASC 排序便于删除最旧) |
| `ai_conversations` | `idx_ai_conv_context` | INDEX条件 | **RNS1.4 新增**`(user_id, site_id, context_type, context_id, last_message_at DESC NULLS LAST) WHERE context_type IS NOT NULL` — 上下文对话查找(多入口复用) |
| `ai_conversations` | `idx_ai_conv_last_msg` | INDEX | **RNS1.4 新增**`(user_id, site_id, last_message_at DESC NULLS LAST)` — 历史列表排序优化CHAT-1 倒序) |
--- ---
@@ -89,15 +106,44 @@
|------|------| |------|------|
| ETL 任务 | 无影响。AI 表属于 `biz` Schema不参与 ETL 流程 | | ETL 任务 | 无影响。AI 表属于 `biz` Schema不参与 ETL 流程 |
| 后端 API | 直接依赖。P5 AI 模块(`apps/backend/app/ai/`将基于这三张表实现对话持久化、缓存读写、SSE 流式对话等功能 | | 后端 API | 直接依赖。P5 AI 模块(`apps/backend/app/ai/`将基于这三张表实现对话持久化、缓存读写、SSE 流式对话等功能 |
| 后端 APIRNS1.4 | **直接依赖**。CHAT 模块(`apps/backend/app/routers/xcx_chat.py``apps/backend/app/services/chat_service.py`)依赖 `ai_conversations` 的 5 个新字段(`context_type`/`context_id`/`title`/`last_message`/`last_message_at`)实现多入口对话复用、历史列表展示与排序;依赖 `ai_messages.reference_card` 存储引用卡片 JSON |
| 小程序 | 间接依赖。小程序通过后端 AI API 间接使用对话和缓存数据 | | 小程序 | 间接依赖。小程序通过后端 AI API 间接使用对话和缓存数据 |
| 小程序RNS1.4 | **间接依赖**`pages/chat/chat.ts``pages/chat-history/chat-history.ts` 通过 CHAT-1/2/3/4 端点间接依赖新字段(`title`→对话标题、`lastMessage`→摘要、`timestamp`→排序、`referenceCard`→引用卡片渲染) |
| 管理后台 | 暂无影响。后续可能增加 AI 调用统计和缓存管理界面 | | 管理后台 | 暂无影响。后续可能增加 AI 调用统计和缓存管理界面 |
| `member_retention_clue` | 间接关联。App8维客线索整理的结果同时写入 `ai_cache``member_retention_clue` 表 | | `member_retention_clue` | 间接关联。App8维客线索整理的结果同时写入 `ai_cache``member_retention_clue` 表 |
| 现有 `biz` Schema | 兼容。新增 3 张表不修改已有对象coach_tasks、notes、trigger_jobs 等) | | 现有 `biz` Schema | 兼容。P5 新增 3 张表RNS1.4 仅在已有表上 ADD COLUMN / CREATE INDEX不修改已有字段或约束 |
--- ---
## 3. 回滚策略 ## 3. 回滚策略
### 3a. 回滚 RNS1.4 CHAT 扩展2026-03-20 迁移)
按逆序 DROP 新增索引和字段:
```sql
-- 删除 RNS1.4 新增索引
DROP INDEX IF EXISTS biz.idx_ai_conv_context;
DROP INDEX IF EXISTS biz.idx_ai_conv_last_msg;
-- 回退 ai_messages 新增字段
ALTER TABLE biz.ai_messages DROP COLUMN IF EXISTS reference_card;
-- 回退 ai_conversations 新增字段(逆序)
ALTER TABLE biz.ai_conversations DROP COLUMN IF EXISTS last_message_at;
ALTER TABLE biz.ai_conversations DROP COLUMN IF EXISTS last_message;
ALTER TABLE biz.ai_conversations DROP COLUMN IF EXISTS title;
ALTER TABLE biz.ai_conversations DROP COLUMN IF EXISTS context_id;
ALTER TABLE biz.ai_conversations DROP COLUMN IF EXISTS context_type;
```
注意:
- 回滚后 CHAT 模块xcx_chat 路由、chat_service将无法正常工作
- 如果新字段中已有数据,需先备份再执行回滚
- 回滚不影响 P5 初始建表的 8 个原始字段
### 3b. 回滚 P5 初始建表2026-03-08 迁移)
按逆序 DROPCASCADE 处理外键依赖): 按逆序 DROPCASCADE 处理外键依赖):
```sql ```sql
@@ -123,6 +169,8 @@ DROP TABLE IF EXISTS biz.ai_conversations CASCADE;
## 4. 验证 SQL ## 4. 验证 SQL
### 4a. P5 初始建表验证
```sql ```sql
-- 1. 验证 3 张 AI 表全部存在 -- 1. 验证 3 张 AI 表全部存在
SELECT table_name SELECT table_name
@@ -137,7 +185,7 @@ SELECT column_name, data_type, is_nullable
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = 'biz' AND table_name = 'ai_conversations' WHERE table_schema = 'biz' AND table_name = 'ai_conversations'
ORDER BY ordinal_position; ORDER BY ordinal_position;
-- 预期:返回 8 行,包含 id/user_id/nickname/app_id/site_id/source_page/source_context/created_at -- 预期:返回 13 行P5 原始 8 字段 + RNS1.4 新增 5 字段)
-- 3. 验证 ai_messages 的外键和 CHECK 约束 -- 3. 验证 ai_messages 的外键和 CHECK 约束
SELECT conname, contype, pg_get_constraintdef(oid) AS constraint_def SELECT conname, contype, pg_get_constraintdef(oid) AS constraint_def
@@ -152,7 +200,7 @@ FROM pg_constraint
WHERE conrelid = 'biz.ai_cache'::regclass AND contype = 'c'; WHERE conrelid = 'biz.ai_cache'::regclass AND contype = 'c';
-- 预期:返回 1 行 chk_ai_cache_type包含 7 个枚举值 -- 预期:返回 1 行 chk_ai_cache_type包含 7 个枚举值
-- 5. 验证索引全部存在5 个) -- 5. 验证 P5 初始索引全部存在5 个)
SELECT indexname SELECT indexname
FROM pg_indexes FROM pg_indexes
WHERE schemaname = 'biz' WHERE schemaname = 'biz'
@@ -164,3 +212,35 @@ WHERE schemaname = 'biz'
ORDER BY indexname; ORDER BY indexname;
-- 预期:返回 5 行 -- 预期:返回 5 行
``` ```
### 4b. RNS1.4 CHAT 扩展验证
```sql
-- 6. 验证 ai_conversations 新增 5 个字段存在
SELECT column_name, data_type, character_maximum_length
FROM information_schema.columns
WHERE table_schema = 'biz' AND table_name = 'ai_conversations'
AND column_name IN ('context_type', 'context_id', 'title', 'last_message', 'last_message_at');
-- 预期:返回 5 行
-- context_type | character varying | 20
-- context_id | character varying | 50
-- title | character varying | 200
-- last_message | text | NULL
-- last_message_at | timestamp with time zone | NULL
-- 7. 验证 ai_messages 新增 reference_card 字段存在
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'biz' AND table_name = 'ai_messages'
AND column_name = 'reference_card';
-- 预期:返回 1 行data_type = 'jsonb'
-- 8. 验证 RNS1.4 新增 2 个索引存在
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'biz' AND tablename = 'ai_conversations'
AND indexname IN ('idx_ai_conv_context', 'idx_ai_conv_last_msg');
-- 预期:返回 2 行
-- idx_ai_conv_context — 含 WHERE context_type IS NOT NULL 条件
-- idx_ai_conv_last_msg — (user_id, site_id, last_message_at DESC NULLS LAST)
```

View File

@@ -1,9 +1,9 @@
# BD_Manualapp Schema 与 RLS 视图层 # BD_Manualapp Schema 与 RLS 视图层
> 目标库:`test_etl_feiqiu`(通过 `PG_DSN` 连接) > 目标库:`test_etl_feiqiu`(通过 `PG_DSN` 连接)
> 迁移脚本:`db/etl_feiqiu/migrations/2026-02-24__p1_create_app_schema_rls_views.sql`、`db/etl_feiqiu/migrations/2026-03-19_add_board_rls_views.sql` > 迁移脚本:`db/etl_feiqiu/migrations/2026-02-24__p1_create_app_schema_rls_views.sql`、`db/etl_feiqiu/migrations/2026-03-19_add_board_rls_views.sql`、`db/zqyy_app/migrations/2026-03-20_rebuild_rls_view_gift_breakdown.sql`
> DDL 位置:`docs/database/ddl/etl_feiqiu__app.sql`(执行后需重新生成) > DDL 位置:`docs/database/ddl/etl_feiqiu__app.sql`(执行后需重新生成)
> 关联 SPEC`miniapp-db-foundation`P1 基础设施层)、`rns1-board-apis`BOARD 看板) > 关联 SPEC`miniapp-db-foundation`P1 基础设施层)、`rns1-board-apis`BOARD 看板)、`gift-card-breakdown`(赠送卡矩阵细分)
--- ---
@@ -15,7 +15,7 @@
### 新增角色 ### 新增角色
- `app_reader`:只读角色(`LOGIN`),拥有 `app` Schema 的 `USAGE` + `SELECT` 权限 - `app_reader`:只读角色(`LOGIN`),拥有 `app` Schema 的 `USAGE` + `SELECT` 权限
### 新增视图38 张) ### 新增视图39 张)
**DWD 层11 张,全部含 `site_id` 过滤):** **DWD 层11 张,全部含 `site_id` 过滤):**
@@ -41,7 +41,6 @@
| `app.v_dws_member_visit_detail` | `dws.dws_member_visit_detail` | | `app.v_dws_member_visit_detail` | `dws.dws_member_visit_detail` |
| `app.v_dws_member_winback_index` | `dws.dws_member_winback_index` | | `app.v_dws_member_winback_index` | `dws.dws_member_winback_index` |
| `app.v_dws_member_newconv_index` | `dws.dws_member_newconv_index` | | `app.v_dws_member_newconv_index` | `dws.dws_member_newconv_index` |
| `app.v_dws_member_recall_index` | `dws.dws_member_recall_index` |
| `app.v_dws_member_assistant_relation_index` | `dws.dws_member_assistant_relation_index` | | `app.v_dws_member_assistant_relation_index` | `dws.dws_member_assistant_relation_index` |
| `app.v_dws_member_assistant_intimacy` | `dws.dws_member_assistant_intimacy` | | `app.v_dws_member_assistant_intimacy` | `dws.dws_member_assistant_intimacy` |
| `app.v_dws_assistant_daily_detail` | `dws.dws_assistant_daily_detail` | | `app.v_dws_assistant_daily_detail` | `dws.dws_assistant_daily_detail` |
@@ -61,7 +60,7 @@
| `app.v_dws_member_project_tag` | `dws.dws_member_project_tag` | | `app.v_dws_member_project_tag` | `dws.dws_member_project_tag` |
| `app.v_dws_member_spending_power_index` | `dws.dws_member_spending_power_index` | | `app.v_dws_member_spending_power_index` | `dws.dws_member_spending_power_index` |
**DWS 层 — cfg_* 配置表(4 张,无 `site_id`,直接 `SELECT *`** **DWS 层 — cfg_* 配置表(5 张,无 `site_id`**
| 视图 | 源表 | 说明 | | 视图 | 源表 | 说明 |
|------|------|------| |------|------|------|
@@ -69,6 +68,7 @@
| `app.v_cfg_assistant_level_price` | `dws.cfg_assistant_level_price` | 同上 | | `app.v_cfg_assistant_level_price` | `dws.cfg_assistant_level_price` | 同上 |
| `app.v_cfg_bonus_rules` | `dws.cfg_bonus_rules` | 同上 | | `app.v_cfg_bonus_rules` | `dws.cfg_bonus_rules` | 同上 |
| `app.v_cfg_index_parameters` | `dws.cfg_index_parameters` | 同上 | | `app.v_cfg_index_parameters` | `dws.cfg_index_parameters` | 同上 |
| `app.v_cfg_area_category` | `dws.cfg_area_category` | DISTINCT 去重到 category 级别,排除 SPECIAL/OTHER按 sort_order 排序。用于项目类型筛选器CONFIG-1。2026-03-20 新增。 |
### 权限配置 ### 权限配置
@@ -81,6 +81,8 @@
> `v_dws_member_spending_power_index`、`v_dws_assistant_project_tag`、`v_dws_member_project_tag` 已于 2026-03-19 正式创建(迁移脚本 `2026-03-19_add_board_rls_views.sql`)。 > `v_dws_member_spending_power_index`、`v_dws_assistant_project_tag`、`v_dws_member_project_tag` 已于 2026-03-19 正式创建(迁移脚本 `2026-03-19_add_board_rls_views.sql`)。
> `v_dws_finance_recharge_summary` 已于 2026-03-20 重建,新增 6 个赠送卡细分字段(`gift_liquor_balance`、`gift_table_fee_balance`、`gift_voucher_balance`、`gift_liquor_recharge`、`gift_table_fee_recharge`、`gift_voucher_recharge`)。迁移脚本:`db/zqyy_app/migrations/2026-03-20_rebuild_rls_view_gift_breakdown.sql`。关联 SPEC`gift-card-breakdown`。
--- ---
## 2. 兼容性影响 ## 2. 兼容性影响
@@ -117,7 +119,7 @@ DROP ROLE IF EXISTS app_reader;
-- 1. 验证 app Schema 存在 -- 1. 验证 app Schema 存在
SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'app'; SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'app';
-- 2. 验证视图数量(应为 38 张:原 35 + 2026-03-19 新增 3 -- 2. 验证视图数量(应为 39 张:原 35 + 2026-03-19 新增 3 + 2026-03-20 新增 1
SELECT count(*) FROM information_schema.views WHERE table_schema = 'app'; SELECT count(*) FROM information_schema.views WHERE table_schema = 'app';
-- 3. 验证 app_reader 角色存在且有 app Schema 权限 -- 3. 验证 app_reader 角色存在且有 app Schema 权限

View File

@@ -68,7 +68,7 @@ RNS1.2(客户与助教接口)通过 `fdw_queries.py` **直连 ETL 库**查
| 组件 | 影响 | | 组件 | 影响 |
|------|------| |------|------|
| ETL 任务 | 无影响。本脚本仅在业务库创建外部表映射,不修改 ETL 库 | | ETL 任务 | 无影响。本脚本仅在业务库创建外部表映射,不修改 ETL 库 |
| 后端 API | 前置依赖。后端可通过 `fdw_etl.v_dim_member` 等外部表读取 ETL 数据,无需直连 ETL 库 | | 后端 API | ⚠️ 已不再使用 FDW 外部表。后端自 2026-03-18 起通过 `get_etl_readonly_connection(site_id)` 直连 ETL 库查询 `app.v_*` RLS 视图(原因:`postgres_fdw` 不传递自定义 GUC 参数RLS 门店隔离失效。FDW 外部表仅作为备用保留,不再被后端代码引用 |
| 小程序 | 无直接影响。小程序通过后端 API 间接访问 | | 小程序 | 无直接影响。小程序通过后端 API 间接访问 |
| 管理后台 | 无直接影响 | | 管理后台 | 无直接影响 |
| `auth`/`biz` Schema | 无影响。FDW 配置独立于业务 Schema | | `auth`/`biz` Schema | 无影响。FDW 配置独立于业务 Schema |

View File

@@ -104,6 +104,19 @@ SELECT tier_id,
FROM dws.cfg_performance_tier; FROM dws.cfg_performance_tier;
; ;
CREATE OR REPLACE VIEW app.v_cfg_area_category AS
SELECT DISTINCT
category_code,
category_name,
display_name,
short_name,
sort_order
FROM dws.cfg_area_category
WHERE is_active = TRUE
AND category_code NOT IN ('SPECIAL', 'OTHER')
ORDER BY sort_order;
;
CREATE OR REPLACE VIEW app.v_dim_assistant AS CREATE OR REPLACE VIEW app.v_dim_assistant AS
SELECT assistant_id, SELECT assistant_id,
user_id, user_id,
@@ -950,31 +963,7 @@ SELECT newconv_id,
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint); WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
; ;
CREATE OR REPLACE VIEW app.v_dws_member_recall_index AS
SELECT recall_id,
site_id,
tenant_id,
member_id,
days_since_last_visit,
visit_interval_median,
visit_interval_mad,
days_since_first_visit,
days_since_last_recharge,
visits_last_14_days,
visits_last_60_days,
score_overdue,
score_new_bonus,
score_recharge_bonus,
score_hot_drop,
raw_score,
display_score,
calc_time,
calc_version,
created_at,
updated_at
FROM dws.dws_member_recall_index
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
;
CREATE OR REPLACE VIEW app.v_dws_member_visit_detail AS CREATE OR REPLACE VIEW app.v_dws_member_visit_detail AS
SELECT id, SELECT id,

View File

@@ -32,7 +32,6 @@ CREATE SEQUENCE IF NOT EXISTS dws.dws_member_assistant_relation_index_relation_i
CREATE SEQUENCE IF NOT EXISTS dws.dws_member_consumption_summary_id_seq AS bigint; CREATE SEQUENCE IF NOT EXISTS dws.dws_member_consumption_summary_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS dws.dws_member_newconv_index_newconv_id_seq AS bigint; CREATE SEQUENCE IF NOT EXISTS dws.dws_member_newconv_index_newconv_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS dws.dws_member_project_tag_id_seq AS bigint; CREATE SEQUENCE IF NOT EXISTS dws.dws_member_project_tag_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS dws.dws_member_recall_index_recall_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS dws.dws_member_spending_power_index_spi_id_seq AS bigint; CREATE SEQUENCE IF NOT EXISTS dws.dws_member_spending_power_index_spi_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS dws.dws_member_visit_detail_id_seq AS bigint; CREATE SEQUENCE IF NOT EXISTS dws.dws_member_visit_detail_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS dws.dws_member_winback_index_winback_id_seq AS bigint; CREATE SEQUENCE IF NOT EXISTS dws.dws_member_winback_index_winback_id_seq AS bigint;
@@ -54,7 +53,8 @@ CREATE TABLE dws.cfg_area_category (
updated_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL,
source_table_name character varying(100) DEFAULT NULL::character varying, source_table_name character varying(100) DEFAULT NULL::character varying,
display_name character varying(50) DEFAULT NULL::character varying, display_name character varying(50) DEFAULT NULL::character varying,
short_name character varying(20) DEFAULT NULL::character varying short_name character varying(20) DEFAULT NULL::character varying,
sort_order integer DEFAULT 100 NOT NULL
); );
CREATE TABLE dws.cfg_assistant_level_price ( CREATE TABLE dws.cfg_assistant_level_price (
@@ -741,30 +741,6 @@ CREATE TABLE dws.dws_member_project_tag (
updated_at timestamp with time zone DEFAULT now() NOT NULL updated_at timestamp with time zone DEFAULT now() NOT NULL
); );
CREATE TABLE dws.dws_member_recall_index (
recall_id bigint DEFAULT nextval('dws.dws_member_recall_index_recall_id_seq'::regclass) NOT NULL,
site_id bigint NOT NULL,
tenant_id bigint NOT NULL,
member_id bigint NOT NULL,
days_since_last_visit integer,
visit_interval_median numeric(10,2),
visit_interval_mad numeric(10,2),
days_since_first_visit integer,
days_since_last_recharge integer,
visits_last_14_days integer DEFAULT 0 NOT NULL,
visits_last_60_days integer DEFAULT 0 NOT NULL,
score_overdue numeric(10,4),
score_new_bonus numeric(10,4),
score_recharge_bonus numeric(10,4),
score_hot_drop numeric(10,4),
raw_score numeric(14,6),
display_score numeric(4,2),
calc_time timestamp with time zone DEFAULT now() NOT NULL,
calc_version integer DEFAULT 1 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE dws.dws_member_spending_power_index ( CREATE TABLE dws.dws_member_spending_power_index (
spi_id bigint DEFAULT nextval('dws.dws_member_spending_power_index_spi_id_seq'::regclass) NOT NULL, spi_id bigint DEFAULT nextval('dws.dws_member_spending_power_index_spi_id_seq'::regclass) NOT NULL,
site_id bigint NOT NULL, site_id bigint NOT NULL,
@@ -1025,8 +1001,6 @@ ALTER TABLE dws.dws_member_newconv_index ADD CONSTRAINT dws_member_newconv_index
ALTER TABLE dws.dws_member_newconv_index ADD CONSTRAINT uk_dws_member_newconv UNIQUE (site_id, member_id); ALTER TABLE dws.dws_member_newconv_index ADD CONSTRAINT uk_dws_member_newconv UNIQUE (site_id, member_id);
ALTER TABLE dws.dws_member_project_tag ADD CONSTRAINT pk_dws_member_project_tag PRIMARY KEY (id); ALTER TABLE dws.dws_member_project_tag ADD CONSTRAINT pk_dws_member_project_tag PRIMARY KEY (id);
ALTER TABLE dws.dws_member_project_tag ADD CONSTRAINT uk_dws_member_project_tag UNIQUE (site_id, member_id, time_window, category_code); ALTER TABLE dws.dws_member_project_tag ADD CONSTRAINT uk_dws_member_project_tag UNIQUE (site_id, member_id, time_window, category_code);
ALTER TABLE dws.dws_member_recall_index ADD CONSTRAINT dws_member_recall_index_pkey PRIMARY KEY (recall_id);
ALTER TABLE dws.dws_member_recall_index ADD CONSTRAINT uk_dws_member_recall UNIQUE (site_id, member_id);
ALTER TABLE dws.dws_member_spending_power_index ADD CONSTRAINT dws_member_spending_power_index_pkey PRIMARY KEY (spi_id); ALTER TABLE dws.dws_member_spending_power_index ADD CONSTRAINT dws_member_spending_power_index_pkey PRIMARY KEY (spi_id);
ALTER TABLE dws.dws_member_visit_detail ADD CONSTRAINT dws_member_visit_detail_pkey PRIMARY KEY (id); ALTER TABLE dws.dws_member_visit_detail ADD CONSTRAINT dws_member_visit_detail_pkey PRIMARY KEY (id);
ALTER TABLE dws.dws_member_visit_detail ADD CONSTRAINT uk_dws_member_visit UNIQUE (site_id, member_id, order_settle_id); ALTER TABLE dws.dws_member_visit_detail ADD CONSTRAINT uk_dws_member_visit UNIQUE (site_id, member_id, order_settle_id);
@@ -1098,7 +1072,6 @@ CREATE INDEX idx_dws_member_consumption_member ON dws.dws_member_consumption_sum
CREATE INDEX idx_dws_member_consumption_tier ON dws.dws_member_consumption_summary USING btree (customer_tier); CREATE INDEX idx_dws_member_consumption_tier ON dws.dws_member_consumption_summary USING btree (customer_tier);
CREATE INDEX idx_dws_newconv_display ON dws.dws_member_newconv_index USING btree (site_id, display_score DESC); CREATE INDEX idx_dws_newconv_display ON dws.dws_member_newconv_index USING btree (site_id, display_score DESC);
CREATE INDEX idx_mpt_site_window_tagged ON dws.dws_member_project_tag USING btree (site_id, time_window) WHERE (is_tagged = true); CREATE INDEX idx_mpt_site_window_tagged ON dws.dws_member_project_tag USING btree (site_id, time_window) WHERE (is_tagged = true);
CREATE INDEX idx_dws_recall_display ON dws.dws_member_recall_index USING btree (site_id, display_score DESC);
CREATE INDEX idx_spi_display_score ON dws.dws_member_spending_power_index USING btree (site_id, display_score DESC); CREATE INDEX idx_spi_display_score ON dws.dws_member_spending_power_index USING btree (site_id, display_score DESC);
CREATE UNIQUE INDEX idx_spi_site_member ON dws.dws_member_spending_power_index USING btree (site_id, member_id); CREATE UNIQUE INDEX idx_spi_site_member ON dws.dws_member_spending_power_index USING btree (site_id, member_id);
CREATE INDEX idx_dws_member_visit_date ON dws.dws_member_visit_detail USING btree (visit_date); CREATE INDEX idx_dws_member_visit_date ON dws.dws_member_visit_detail USING btree (visit_date);
@@ -1760,94 +1733,94 @@ TRUNCATE TABLE dws.cfg_area_category RESTART IDENTITY CASCADE;
INSERT INTO dws.cfg_area_category ( INSERT INTO dws.cfg_area_category (
source_area_name, source_table_name, category_code, category_name, source_area_name, source_table_name, category_code, category_name,
display_name, short_name, display_name, short_name,
match_type, match_priority, is_active, description match_type, match_priority, is_active, description, sort_order
) VALUES ) VALUES
-- ============ BILLIARD 🎱 中式/追分 ============ -- ============ BILLIARD 🎱 中式/追分 (sort_order=10) ============
-- A区18台 -- A区18台
('A区', 'A1', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A1'), ('A区', 'A1', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A1', 10),
('A区', 'A2', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A2'), ('A区', 'A2', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A2', 10),
('A区', 'A3', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A3'), ('A区', 'A3', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A3', 10),
('A区', 'A4', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A4'), ('A区', 'A4', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A4', 10),
('A区', 'A5', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A5'), ('A区', 'A5', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A5', 10),
('A区', 'A6', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A6'), ('A区', 'A6', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A6', 10),
('A区', 'A7', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A7'), ('A区', 'A7', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A7', 10),
('A区', 'A8', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A8'), ('A区', 'A8', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A8', 10),
('A区', 'A9', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A9'), ('A区', 'A9', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A9', 10),
('A区', 'A10', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A10'), ('A区', 'A10', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A10', 10),
('A区', 'A11', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A11'), ('A区', 'A11', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A11', 10),
('A区', 'A12', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A12'), ('A区', 'A12', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A12', 10),
('A区', 'A13', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A13'), ('A区', 'A13', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A13', 10),
('A区', 'A14', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A14'), ('A区', 'A14', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A14', 10),
('A区', 'A15', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A15'), ('A区', 'A15', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A15', 10),
('A区', 'A16', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A16'), ('A区', 'A16', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A16', 10),
('A区', 'A17', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A17'), ('A区', 'A17', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A17', 10),
('A区', 'A18', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A18'), ('A区', 'A18', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A18', 10),
-- B区15台 -- B区15台
('B区', 'B1', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B1'), ('B区', 'B1', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B1', 10),
('B区', 'B2', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B2'), ('B区', 'B2', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B2', 10),
('B区', 'B3', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B3'), ('B区', 'B3', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B3', 10),
('B区', 'B4', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B4'), ('B区', 'B4', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B4', 10),
('B区', 'B5', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B5'), ('B区', 'B5', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B5', 10),
('B区', 'B6', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B6'), ('B区', 'B6', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B6', 10),
('B区', 'B7', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B7'), ('B区', 'B7', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B7', 10),
('B区', 'B8', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B8'), ('B区', 'B8', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B8', 10),
('B区', 'B9', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B9'), ('B区', 'B9', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B9', 10),
('B区', 'B10', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B10'), ('B区', 'B10', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B10', 10),
('B区', 'B11', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B11'), ('B区', 'B11', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B11', 10),
('B区', 'B12', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B12'), ('B区', 'B12', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B12', 10),
('B区', 'B13', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B13'), ('B区', 'B13', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B13', 10),
('B区', 'B14', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B14'), ('B区', 'B14', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B14', 10),
('B区', 'B15', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B15'), ('B区', 'B15', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B15', 10),
-- C区6台 -- C区6台
('C区', 'C1', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'C区-C1'), ('C区', 'C1', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'C区-C1', 10),
('C区', 'C2', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'C区-C2'), ('C区', 'C2', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'C区-C2', 10),
('C区', 'C3', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'C区-C3'), ('C区', 'C3', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'C区-C3', 10),
('C区', 'C4', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'C区-C4'), ('C区', 'C4', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'C区-C4', 10),
('C区', 'C5', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'C区-C5'), ('C区', 'C5', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'C区-C5', 10),
('C区', 'C6', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'C区-C6'), ('C区', 'C6', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'C区-C6', 10),
-- VIP包厢 BILLIARD3台 -- VIP包厢 BILLIARD3台
('VIP包厢', 'VIP1', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'VIP包厢-VIP1'), ('VIP包厢', 'VIP1', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'VIP包厢-VIP1', 10),
('VIP包厢', 'VIP2', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'VIP包厢-VIP2'), ('VIP包厢', 'VIP2', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'VIP包厢-VIP2', 10),
('VIP包厢', 'VIP3', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'VIP包厢-VIP3'), ('VIP包厢', 'VIP3', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'VIP包厢-VIP3', 10),
-- TV台1台 -- TV台1台
('TV台', 'TV', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'TV台-TV'), ('TV台', 'TV', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'TV台-TV', 10),
-- ============ SNOOKER 斯诺克 ============ -- ============ SNOOKER 斯诺克 ============
('VIP包厢', 'VIP5', 'SNOOKER', '斯诺克', '斯诺克', '', 'EXACT', 10, TRUE, 'VIP包厢-VIP5→斯诺克'), ('VIP包厢', 'VIP5', 'SNOOKER', '斯诺克', '斯诺克', '', 'EXACT', 10, TRUE, 'VIP包厢-VIP5→斯诺克', 20),
('斯诺克区', 'S1', 'SNOOKER', '斯诺克', '斯诺克', '', 'EXACT', 10, TRUE, '斯诺克区-S1'), ('斯诺克区', 'S1', 'SNOOKER', '斯诺克', '斯诺克', '', 'EXACT', 10, TRUE, '斯诺克区-S1', 20),
('斯诺克区', 'S2', 'SNOOKER', '斯诺克', '斯诺克', '', 'EXACT', 10, TRUE, '斯诺克区-S2'), ('斯诺克区', 'S2', 'SNOOKER', '斯诺克', '斯诺克', '', 'EXACT', 10, TRUE, '斯诺克区-S2', 20),
('斯诺克区', 'S3', 'SNOOKER', '斯诺克', '斯诺克', '', 'EXACT', 10, TRUE, '斯诺克区-S3'), ('斯诺克区', 'S3', 'SNOOKER', '斯诺克', '斯诺克', '', 'EXACT', 10, TRUE, '斯诺克区-S3', 20),
('斯诺克区', 'S4', 'SNOOKER', '斯诺克', '斯诺克', '', 'EXACT', 10, TRUE, '斯诺克区-S4'), ('斯诺克区', 'S4', 'SNOOKER', '斯诺克', '斯诺克', '', 'EXACT', 10, TRUE, '斯诺克区-S4', 20),
-- ============ MAHJONG 🀄 麻将/棋牌 ============ -- ============ MAHJONG 🀄 麻将/棋牌 ============
('666', '董事办', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '666-董事办'), ('666', '董事办', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '666-董事办', 30),
('666', '666', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '666-666'), ('666', '666', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '666-666', 30),
('麻将房', '1', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '麻将房-1'), ('麻将房', '1', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '麻将房-1', 30),
('麻将房', 'M2', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '麻将房-M2'), ('麻将房', 'M2', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '麻将房-M2', 30),
('麻将房', 'M3', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '麻将房-M3'), ('麻将房', 'M3', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '麻将房-M3', 30),
('麻将房', 'M4', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '麻将房-M4'), ('麻将房', 'M4', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '麻将房-M4', 30),
('麻将房', 'M5', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '麻将房-M5'), ('麻将房', 'M5', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '麻将房-M5', 30),
('M7', 'M7', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, 'M7-M7'), ('M7', 'M7', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, 'M7-M7', 30),
('M7', '大包麻将房', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, 'M7-大包麻将房'), ('M7', '大包麻将房', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, 'M7-大包麻将房', 30),
('M8', 'M8', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, 'M8-M8'), ('M8', 'M8', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, 'M8-M8', 30),
('发财', '发财', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '发财-发财'), ('发财', '发财', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '发财-发财', 30),
-- ============ KTV 🎤 团建/K歌 ============ -- ============ KTV 🎤 团建/K歌 ============
('K包', '常乐', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, 'K包-常乐'), ('K包', '常乐', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, 'K包-常乐', 40),
('K包', '幸会(纯k)', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, 'K包-幸会(纯k)'), ('K包', '幸会(纯k)', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, 'K包-幸会(纯k)', 40),
('K包', '虚拟188', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, 'K包-虚拟188'), ('K包', '虚拟188', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, 'K包-虚拟188', 40),
('K包', '888', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, 'K包-888'), ('K包', '888', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, 'K包-888', 40),
('k包活动区', '大包', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, 'k包活动区-大包'), ('k包活动区', '大包', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, 'k包活动区-大包', 40),
('k包活动区', '小包', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, 'k包活动区-小包'), ('k包活动区', '小包', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, 'k包活动区-小包', 40),
('幸会158', '纯k', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, '幸会158-纯k'), ('幸会158', '纯k', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, '幸会158-纯k', 40),
-- ============ SPECIAL 补时长/虚拟台 ============ -- ============ SPECIAL 补时长/虚拟台 ============
('补时长', '补时长', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长'), ('补时长', '补时长', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长', 900),
('补时长', '补时长2', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长2'), ('补时长', '补时长2', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长2', 900),
('补时长', '补时长3', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长3'), ('补时长', '补时长3', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长3', 900),
('补时长', '补时长4', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长4'), ('补时长', '补时长4', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长4', 900),
('补时长', '补时长5', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长5'), ('补时长', '补时长5', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长5', 900),
('补时长', '补时长6', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长6'), ('补时长', '补时长6', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长6', 900),
('补时长', '补时长7', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长7'), ('补时长', '补时长7', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长7', 900),
('虚拟台', '虚拟台1号', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '虚拟台-虚拟台1号'), ('虚拟台', '虚拟台1号', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '虚拟台-虚拟台1号', 900),
-- ============ OTHER 兜底 ============ -- ============ OTHER 兜底 ============
('DEFAULT', NULL, 'OTHER', '其他', '其他', '', 'DEFAULT', 999, TRUE, '兜底规则:无法匹配的归入其他'); ('DEFAULT', NULL, 'OTHER', '其他', '其他', '', 'DEFAULT', 999, TRUE, '兜底规则:无法匹配的归入其他', 999);
-- ============================================================================= -- =============================================================================

View File

@@ -1,6 +1,6 @@
-- ============================================================================= -- =============================================================================
-- zqyy_app / biz核心业务表任务/备注/触发器)) -- zqyy_app / biz核心业务表任务/备注/触发器))
-- 生成日期2026-03-15 -- 生成日期2026-03-20
-- 来源:测试库(通过脚本自动导出) -- 来源:测试库(通过脚本自动导出)
-- ============================================================================= -- =============================================================================
@@ -36,6 +36,11 @@ CREATE TABLE biz.ai_conversations (
site_id bigint NOT NULL, site_id bigint NOT NULL,
source_page character varying(100), source_page character varying(100),
source_context jsonb, source_context jsonb,
context_type character varying(20),
context_id character varying(50),
title character varying(200),
last_message text,
last_message_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL created_at timestamp with time zone DEFAULT now() NOT NULL
); );
@@ -45,6 +50,7 @@ CREATE TABLE biz.ai_messages (
role character varying(10) NOT NULL, role character varying(10) NOT NULL,
content text NOT NULL, content text NOT NULL,
tokens_used integer, tokens_used integer,
reference_card jsonb,
created_at timestamp with time zone DEFAULT now() NOT NULL created_at timestamp with time zone DEFAULT now() NOT NULL
); );
@@ -127,6 +133,8 @@ CREATE INDEX idx_ai_cache_cleanup ON biz.ai_cache USING btree (cache_type, site_
CREATE INDEX idx_ai_cache_lookup ON biz.ai_cache USING btree (cache_type, site_id, target_id, created_at DESC); CREATE INDEX idx_ai_cache_lookup ON biz.ai_cache USING btree (cache_type, site_id, target_id, created_at DESC);
CREATE INDEX idx_ai_conv_app_site ON biz.ai_conversations USING btree (app_id, site_id, created_at DESC); CREATE INDEX idx_ai_conv_app_site ON biz.ai_conversations USING btree (app_id, site_id, created_at DESC);
CREATE INDEX idx_ai_conv_user_site ON biz.ai_conversations USING btree (user_id, site_id, created_at DESC); CREATE INDEX idx_ai_conv_user_site ON biz.ai_conversations USING btree (user_id, site_id, created_at DESC);
CREATE INDEX idx_ai_conv_context ON biz.ai_conversations USING btree (user_id, site_id, context_type, context_id, last_message_at DESC NULLS LAST) WHERE (context_type IS NOT NULL);
CREATE INDEX idx_ai_conv_last_msg ON biz.ai_conversations USING btree (user_id, site_id, last_message_at DESC NULLS LAST);
CREATE INDEX idx_ai_msg_conv ON biz.ai_messages USING btree (conversation_id, created_at); CREATE INDEX idx_ai_msg_conv ON biz.ai_messages USING btree (conversation_id, created_at);
CREATE INDEX idx_coach_tasks_assistant_status ON biz.coach_tasks USING btree (site_id, assistant_id, status); CREATE INDEX idx_coach_tasks_assistant_status ON biz.coach_tasks USING btree (site_id, assistant_id, status);
CREATE UNIQUE INDEX idx_coach_tasks_site_assistant_member_type ON biz.coach_tasks USING btree (site_id, assistant_id, member_id, task_type) WHERE ((status)::text = 'active'::text); CREATE UNIQUE INDEX idx_coach_tasks_site_assistant_member_type ON biz.coach_tasks USING btree (site_id, assistant_id, member_id, task_type) WHERE ((status)::text = 'active'::text);

View File

@@ -528,7 +528,7 @@ interface CoachTask {
// ─── 最亲密助教 ─── // ─── 最亲密助教 ───
interface FavoriteCoach { interface FavoriteCoach {
/** 亲密度 emoji如 "💖"、"💛" */ /** 亲密度 emoji如 "💖"、"🧡"、"💛"、"💙" */
emoji: string emoji: string
/** 助教姓名 */ /** 助教姓名 */
name: string name: string
@@ -699,7 +699,7 @@ interface CustomerNote {
| 字段 | 类型 | 说明 | 数据源 | | 字段 | 类型 | 说明 | 数据源 |
|------|------|------|--------| |------|------|------|--------|
| `emoji` | string | 亲密度 emoji💖 = 高亲密度 ≥ 0.7,💛 = 中亲密度 < 0.7 | 后端根据 `relationIndex` 映射 | | `emoji` | string | 亲密度 emoji💖(>8.5) / 🧡(>7) / 💛(>5) / 💙(≤5),后端 compute_heart_icon() 根据 rs_display 映射 | 后端根据 `relationIndex` 映射 |
| `name` | string | 助教姓名 | `biz.users.nickname` | | `name` | string | 助教姓名 | `biz.users.nickname` |
| `relationIndex` | string | 关系指数,如 `"0.92"` | 后端根据服务频率/消费金额综合计算 | | `relationIndex` | string | 关系指数,如 `"0.92"` | 后端根据服务频率/消费金额综合计算 |
| `indexColor` | string | 关系指数对应颜色 | 后端根据 `relationIndex` 阈值映射 | | `indexColor` | string | 关系指数对应颜色 | 后端根据 `relationIndex` 阈值映射 |
@@ -759,7 +759,7 @@ interface CustomerNote {
| `settle_type` 过滤 | 消费记录取正向交易 `settle_type IN (1, 3)`,本表无 `is_delete` 字段 | | `settle_type` 过滤 | 消费记录取正向交易 `settle_type IN (1, 3)`,本表无 `is_delete` 字段 |
| 废单排除 | 使用 `dwd_assistant_service_log_ex.is_trash` 排除废单,`dwd_assistant_trash_event` 已废弃 | | 废单排除 | 使用 `dwd_assistant_service_log_ex.is_trash` 排除废单,`dwd_assistant_trash_event` 已废弃 |
| `type` 枚举映射 | `table`(台桌消费)/ `shop`(酒水/商品消费)/ `recharge`(充值),后端根据 `settle_type` 和业务逻辑映射 | | `type` 枚举映射 | `table`(台桌消费)/ `shop`(酒水/商品消费)/ `recharge`(充值),后端根据 `settle_type` 和业务逻辑映射 |
| 亲密度 emoji | 💖 = 高亲密度(关系指数 ≥ 0.7),💛 = 中亲密度(关系指数 < 0.7 | | 亲密度 emoji | 💖(>8.5) / 🧡(>7) / 💛(>5) / 💙(≤5) |
#### 数据源映射表 #### 数据源映射表
@@ -867,8 +867,8 @@ interface CoachBoardItem {
skills: Array<{ text: string; cls: string }> skills: Array<{ text: string; cls: string }>
/** /**
* Top 客户列表(含亲密度 emoji 前缀) * Top 客户列表(含亲密度 emoji 前缀)
* 示例:['💖 王先生', '💖 李女士', '💛 赵总'] * 示例:['💖 王先生', '🧡 李女士', '💛 赵总', '💙 新客户']
* 💖 = 高亲密度,💛 = 中亲密度 * 💖(>8.5) / 🧡(>7) / 💛(>5) / 💙(≤5)
*/ */
topCustomers: string[] topCustomers: string[]
@@ -922,7 +922,7 @@ interface CoachBoardItem {
| `avatarGradient` | string | 头像渐变色标识(`blue`/`green`/`pink`/`amber`/`violet`/`cyan` | 后端根据 ID 哈希分配 | | `avatarGradient` | string | 头像渐变色标识(`blue`/`green`/`pink`/`amber`/`violet`/`cyan` | 后端根据 ID 哈希分配 |
| `level` | string | 等级英文 key`star`/`senior`/`middle`/`junior` | `fdw_etl.v_dws_assistant_salary_calc.coach_level` | | `level` | string | 等级英文 key`star`/`senior`/`middle`/`junior` | `fdw_etl.v_dws_assistant_salary_calc.coach_level` |
| `skills` | `Array<{ text: string, cls: string }>` | 技能标签,`text` 为 emoji 或文字,`cls` 为前端样式类 | `biz.coach_skills` 或配置表 | | `skills` | `Array<{ text: string, cls: string }>` | 技能标签,`text` 为 emoji 或文字,`cls` 为前端样式类 | `biz.coach_skills` 或配置表 |
| `topCustomers` | `string[]` | Top 客户列表,含亲密度 emoji 前缀(💖/💛 | 后端按亲密度排序取 Top 3拼接 emoji + 姓名 | | `topCustomers` | `string[]` | Top 客户列表,含亲密度 emoji 前缀(💖/🧡/💛/💙 | 后端按亲密度排序取 Top 3拼接 emoji + 姓名 |
##### perf 维度专属字段4 个) ##### perf 维度专属字段4 个)
@@ -978,7 +978,7 @@ interface CoachBoardItem {
| 助教费用拆分DWD-DOC 规则 2 | 工资计算中基础课 = `assistant_pd_money`,激励课 = `assistant_cx_money` | | 助教费用拆分DWD-DOC 规则 2 | 工资计算中基础课 = `assistant_pd_money`,激励课 = `assistant_cx_money` |
| `levelClass` 前端计算 | 后端仅返回 `level`(英文 key前端通过 `LEVEL_CLASS` 映射生成样式类 | | `levelClass` 前端计算 | 后端仅返回 `level`(英文 key前端通过 `LEVEL_CLASS` 映射生成样式类 |
| `perfGap` 后端计算 | 后端根据档位阈值计算差距描述字符串,已达标时不返回该字段 | | `perfGap` 后端计算 | 后端根据档位阈值计算差距描述字符串,已达标时不返回该字段 |
| `topCustomers` emoji 规则 | 💖 = 高亲密度(关系指数 ≥ 0.7),💛 = 中亲密度(关系指数 < 0.7 | | `topCustomers` emoji 规则 | 💖(>8.5) / 🧡(>7) / 💛(>5) / 💙(≤5),基于 rs_display 值域 0-10 |
### BOARD-2: 客户看板 ### BOARD-2: 客户看板
@@ -1856,10 +1856,12 @@ interface NoteItem {
--- ---
## 八、对话模块 ## 八、对话模块(已实现 ✅ — RNS1.4
> 数据源:`zqyy_app.chat_sessions`(对话会话)、`zqyy_app.chat_messages`(消息 > 路由前缀:`/api/xcx/chat`RNS1.4 从 `/api/ai/*` 迁移,旧路径已移除
> 数据源:`zqyy_app.biz.ai_conversations`(对话会话)、`zqyy_app.biz.ai_messages`(消息)
> 时间字段统一使用 `createdAt`camelCase替代前端 `timestamp` 和旧契约 `created_at` > 时间字段统一使用 `createdAt`camelCase替代前端 `timestamp` 和旧契约 `created_at`
> 所有端点需 JWTapproved 状态),使用 `require_approved()` 权限检查
### CHAT-1: 对话历史列表 ### CHAT-1: 对话历史列表
@@ -1867,15 +1869,22 @@ interface NoteItem {
GET /api/xcx/chat/history?page=1&pageSize=20 GET /api/xcx/chat/history?page=1&pageSize=20
``` ```
#### 请求参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|:----:|------|
| `page` | number (query) | 否 | 页码,默认 1 |
| `pageSize` | number (query) | 否 | 每页条数,默认 20最大 100 |
#### 响应结构 #### 响应结构
```typescript ```typescript
interface ChatHistoryResponse { interface ChatHistoryResponse {
items: Array<{ items: Array<{
id: string // 对话 ID id: number // 对话 ID(即 chatId
title: string // 对话标题(新增,需求 5.8.2 title: string // 对话标题(自定义 > 客户姓名 > 首条消息前20字
customerName?: string // 关联客户姓名(可选 customerName?: string // 关联客户姓名(仅 contextType=customer 时有值
lastMessage: string // 最后一条消息摘要 lastMessage?: string // 最后一条消息摘要
timestamp: string // 最后消息时间ISO 8601 timestamp: string // 最后消息时间ISO 8601
unreadCount: number // 未读消息数 unreadCount: number // 未读消息数
}> }>
@@ -1885,33 +1894,55 @@ interface ChatHistoryResponse {
} }
``` ```
### CHAT-2: 对话消息 #### 排序规则
> 支持 `customerId` 查询参数(需求 5.8.5):后端根据 `customerId` 自动查找或创建对话,返回对应的 `chatId` 和消息列表 `last_message_at` 倒序(最新对话在前)。
### CHAT-2a: 通过 chatId 查询消息
``` ```
GET /api/xcx/chat/{chatId}/messages?page=1&pageSize=50 GET /api/xcx/chat/{chatId}/messages?page=1&pageSize=50
GET /api/xcx/chat/messages?customerId={customerId}&page=1&pageSize=50
``` ```
#### 响应结构 #### 请求参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|:----:|------|
| `chatId` | number (path) | 是 | 对话 ID |
| `page` | number (query) | 否 | 页码,默认 1 |
| `pageSize` | number (query) | 否 | 每页条数,默认 50最大 100 |
### CHAT-2b: 通过上下文查询消息
> 后端根据 `contextType` + `contextId` 自动查找或创建对话,返回对应的 `chatId` 和消息列表。
> 对话复用规则:`task` 入口同一 taskId 始终复用(无时限);`customer`/`coach` 入口 ≤ 3 天复用、> 3 天新建;`general` 入口始终新建。
```
GET /api/xcx/chat/messages?contextType={type}&contextId={id}&page=1&pageSize=50
```
#### 请求参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|:----:|------|
| `contextType` | string (query) | 是 | 上下文类型:`task` / `customer` / `coach` / `general` |
| `contextId` | string (query) | 是 | 上下文 IDtaskId / customerId / coachIdgeneral 时为空) |
| `page` | number (query) | 否 | 页码,默认 1 |
| `pageSize` | number (query) | 否 | 每页条数,默认 50最大 100 |
#### 响应结构CHAT-2a / CHAT-2b 共用)
```typescript ```typescript
interface ChatMessagesResponse { interface ChatMessagesResponse {
chatId: string // 对话 IDcustomerId 入口时由后端返回 chatId: number // 对话 ID上下文入口时由后端返回,供后续发送消息使用
items: Array<{ items: Array<{
id: string id: number
role: 'user' | 'assistant' role: 'user' | 'assistant'
content: string content: string
createdAt: string // 统一时间字段名(需求 5.8.1ISO 8601 createdAt: string // 统一时间字段名ISO 8601
// 引用卡片(可选,需求 5.8.3 // 引用卡片(可选,AI 回复涉及特定客户时附带
referenceCard?: { referenceCard?: ReferenceCard
type: 'customer' | 'record' // 引用类型
title: string // 卡片标题,如 '张伟 — 消费概览'
summary: string // 摘要文字
data: Record<string, string> // 键值对详情,如 { '近30天消费': '¥2,380', '到店次数': '8次' }
}
}> }>
total: number total: number
page: number page: number
@@ -1919,42 +1950,89 @@ interface ChatMessagesResponse {
} }
``` ```
### CHAT-3: 发送消息 #### 排序规则
消息按 `created_at` 正序(最早的消息在前)。
#### referenceCard 结构定义
```typescript
interface ReferenceCard {
type: 'customer' | 'record' // 引用类型
title: string // 卡片标题,如 '张伟 — 消费概览'
summary: string // 摘要文字,如 '余额 ¥5,200近30天消费 ¥2,380'
data: Record<string, string> // 键值对详情
}
```
示例:
```json
{
"type": "customer",
"title": "张伟 — 消费概览",
"summary": "余额 ¥5,200近30天消费 ¥2,380",
"data": {
"余额": "¥5,200",
"近30天消费": "¥2,380",
"到店次数": "8次",
"最近到店": "3天前"
}
}
```
> referenceCard 中金额使用 `items_sum` 口径DWD-DOC 强制规则 1会员信息通过 `member_id` JOIN `dim_member` 获取DWD-DOC 规则 DQ-6
### CHAT-3: 发送消息(同步回复)
``` ```
POST /api/xcx/chat/{chatId}/messages POST /api/xcx/chat/{chatId}/messages
Content-Type: application/json
Body: { content: string } Body: { content: string }
``` ```
#### 请求参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|:----:|------|
| `chatId` | number (path) | 是 | 对话 ID归属验证不属于当前用户返回 403 |
| `content` | string (body) | 是 | 消息内容(不能为空) |
#### 响应结构 #### 响应结构
```typescript ```typescript
interface SendMessageResponse { interface SendMessageResponse {
userMessage: { userMessage: {
id: string id: number
content: string content: string
createdAt: string createdAt: string // ISO 8601
} }
aiReply: { aiReply: {
id: string id: number
content: string content: string
createdAt: string createdAt: string // ISO 8601
} }
} }
``` ```
### CHAT-4: SSE 流式端点(需求 5.8.4 #### AI 失败降级
> 用于 AI 流式回复,前端通过 SSE 接收逐 token 输出 AI 服务调用失败时,用户消息仍保存,`aiReply.content` 返回错误提示消息(如 `"抱歉AI 助手暂时无法回复,请稍后重试"`HTTP 状态码保持 200。
### CHAT-4: SSE 流式端点
> 用于 AI 流式回复,前端通过 SSE 接收逐 token 输出。
> chatId 归属验证在 SSE 流开始前完成,失败时返回普通 HTTP 403非 SSE
``` ```
POST /api/xcx/chat/stream POST /api/xcx/chat/stream
Content-Type: application/json Content-Type: application/json
Body: { chatId: string, content: string } Body: { chatId: number, content: string }
Response: text/event-stream Response: text/event-stream
``` ```
#### SSE 事件格式 #### SSE 事件类型定义
三种事件类型:`message`(逐 token`done`(完成)、`error`(错误)。
``` ```
event: message event: message
@@ -1964,24 +2042,40 @@ event: message
data: {"token": "数据分析"} data: {"token": "数据分析"}
event: done event: done
data: {"messageId": "msg-xxx", "createdAt": "2026-03-05T14:30:00+08:00"} data: {"messageId": 123, "createdAt": "2026-03-20T14:30:00+08:00"}
event: error
data: {"message": "AI 服务暂时不可用"}
``` ```
- `event: message` — 逐 token 输出,`data.token` 为文本片段 | 事件类型 | data 结构 | 说明 |
- `event: done` — 流结束,`data.messageId` 为完整消息 ID |----------|----------|------|
- `event: error` — 错误,`data.message` 为错误描述 | `message` | `{ "token": string }` | 逐 token 输出,`token` 为文本片段 |
| `done` | `{ "messageId": number, "createdAt": string }` | 流结束,返回完整消息 ID 和创建时间 |
| `error` | `{ "message": string }` | 错误,返回错误描述 |
> 注意SSE 端点的响应不经过 ResponseWrapperMiddleware 包装(`text/event-stream` 自动跳过 > 注意SSE 端点的响应不经过 ResponseWrapperMiddleware 包装(`text/event-stream` 自动跳过RNS1.0 已实现)。
#### 对话模块错误码
| HTTP 状态码 | 场景 | 响应示例 |
|:-----------:|------|---------|
| 403 | 用户未通过审核 | `{ "code": 403, "message": "用户未通过审核,无法访问此资源" }` |
| 403 | chatId 不属于当前用户 | `{ "code": 403, "message": "无权访问此对话" }` |
| 404 | 对话不存在 | `{ "code": 404, "message": "对话不存在" }` |
| 422 | 消息内容为空 | `{ "code": 422, "message": "消息内容不能为空" }` |
#### 字段说明 #### 字段说明
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |------|------|------|
| `CHAT-1.items[].title` | string | 对话标题,新增字段 | | `CHAT-1.items[].title` | string | 对话标题(自定义 > 客户姓名 > 首条消息前20字 |
| `CHAT-1.items[].timestamp` | string | 最后消息时间ISO 8601 |
| `CHAT-2.items[].createdAt` | string | 统一时间字段名,替代 `created_at``timestamp` | | `CHAT-2.items[].createdAt` | string | 统一时间字段名,替代 `created_at``timestamp` |
| `CHAT-2.items[].referenceCard` | object? | 引用卡片,从其他页面跳转时附带的上下文信息 | | `CHAT-2.items[].referenceCard` | ReferenceCard? | 引用卡片AI 回复涉及客户时附带结构化上下文数据 |
| `CHAT-2` `customerId` 参数 | query | 支持通过客户 ID 查找/创建对话 | | `CHAT-2b` `contextType` 参数 | query | 上下文类型:`task` / `customer` / `coach` / `general` |
| `CHAT-4` | SSE | 流式端点,`text/event-stream` 格式,不经过响应包装 | | `CHAT-2b` `contextId` 参数 | query | 上下文 IDtaskId / customerId / coachId |
| `CHAT-4` SSE | text/event-stream | 三种事件:`message`token/ `done`(完成)/ `error`(错误) |
--- ---
@@ -2017,6 +2111,6 @@ Response: {
| board-coach | BOARD-1, CONFIG-1 | P2 | | board-coach | BOARD-1, CONFIG-1 | P2 |
| board-customer | BOARD-2 | P2 | | board-customer | BOARD-2 | P2 |
| board-finance | BOARD-3 | P2 | | board-finance | BOARD-3 | P2 |
| chat-history | CHAT-1 | P2 | | chat-history | CHAT-1 | ✅ 后端已实现 |
| chat | CHAT-2, CHAT-3 | P2 | | chat | CHAT-2a, CHAT-2b, CHAT-3, CHAT-4 | ✅ 后端已实现 |
| my-profile | AUTH-3 | ✅ 已实现(读 globalData | | my-profile | AUTH-3 | ✅ 已实现(读 globalData |

Some files were not shown because too many files have changed in this diff Show More