feat: chat integration, tenant admin spec, backend chat service, miniprogram updates, DEMO moved to tmp, XCX-TEST removed, migrations & docs
@@ -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` |
|
||||||
|
|||||||
@@ -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 个新字段
|
||||||
|
|||||||
@@ -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 模式:
|
||||||
|
|||||||
1
.kiro/specs/rns1-chat-integration/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "9eccc890-b6c3-41a3-8ba1-bb2f0e09f653", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
704
.kiro/specs/rns1-chat-integration/design.md
Normal file
@@ -0,0 +1,704 @@
|
|||||||
|
# 技术设计文档 — RNS1.4:CHAT 对齐与联调收尾
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
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-5):13 个页面移除 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 事件
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 组件与接口
|
||||||
|
|
||||||
|
### 组件 1:xcx_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: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 组件 2:chat_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}次",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 组件 3:Pydantic Schema(CamelModel)
|
||||||
|
|
||||||
|
**位置**:`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`
|
||||||
|
|
||||||
|
### 组件 7:FDW 验证脚本
|
||||||
|
|
||||||
|
**位置**:`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), -- 关联上下文 ID(taskId / 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 '关联上下文 ID:task 入口为 taskId,customer 入口为 customerId,coach 入口为 coachId,general 为 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 应用 ID(CHAT 模块固定为 `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) | **新增** — 关联上下文 ID(taskId/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` | 未审核用户 403;chatId 不属于当前用户 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
|
||||||
|
```
|
||||||
|
|
||||||
201
.kiro/specs/rns1-chat-integration/requirements.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# 需求文档 — RNS1.4:CHAT 对齐与联调收尾
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
|
||||||
|
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 强制使用的消费金额口径
|
||||||
|
- **联调**:前后端联合调试,验证所有页面使用真实后端数据正常运行
|
||||||
|
|
||||||
|
## 需求
|
||||||
|
|
||||||
|
### 需求 1:CHAT 路径迁移(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)` 组合,不同用户的对话互不影响
|
||||||
|
|
||||||
|
|
||||||
|
### 需求 4:CHAT 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 创建一个通用对话
|
||||||
|
|
||||||
|
### 需求 5:CHAT-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
|
||||||
|
|
||||||
|
### 需求 6:CHAT-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` 响应
|
||||||
|
|
||||||
|
|
||||||
|
### 需求 7:FDW 端到端验证(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` 获取
|
||||||
311
.kiro/specs/rns1-chat-integration/tasks.md
Normal 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 始终返回同一 chatId;customer/coach 入口 ≤ 3 天复用、> 3 天新建;general 入口每次返回不同 chatId
|
||||||
|
- **验证: 需求 R3.8, R3.9, R3.10**
|
||||||
|
|
||||||
|
- [x] 3.4 编写属性测试:referenceCard Round Trip
|
||||||
|
- **Property 7: referenceCard 持久化 Round Trip**
|
||||||
|
- 使用 Hypothesis 生成随机 referenceCard JSON(type/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
|
||||||
|
- 验证未审核用户收到 403;chatId 不属于当前用户收到 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-4(SSE 流式)
|
||||||
|
- 启动后端服务连接 `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),单元测试验证具体边界条件
|
||||||
|
- 检查点任务确保增量验证,避免问题累积
|
||||||
|
- 后端使用 Python(FastAPI + 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 |
|
||||||
1
.kiro/specs/tenant-admin-web/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "9eccc890-b6c3-41a3-8ba1-bb2f0e09f653", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
623
.kiro/specs/tenant-admin-web/design.md
Normal 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,提取管理员信息。
|
||||||
|
拒绝小程序 JWT(aud 不匹配)。
|
||||||
|
"""
|
||||||
|
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_id(4.5)→ 403
|
||||||
|
- 无效 Excel 文件格式(5.5)→ 400
|
||||||
|
- 写入数据库失败回滚(8.3)→ log status=failed
|
||||||
|
- 线索 ID 不存在/越权(11.3, 12.3, 13.4)→ 404
|
||||||
241
.kiro/specs/tenant-admin-web/requirements.md
Normal 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 状态码
|
||||||
|
|
||||||
|
### 需求 5:Excel 上传 — 文件解析与格式校验
|
||||||
|
|
||||||
|
**用户故事:** 作为 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 状态码并提示文件格式错误
|
||||||
|
|
||||||
|
### 需求 6:Excel 上传 — 人员匹配校验
|
||||||
|
|
||||||
|
**用户故事:** 作为 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 在校验结果页面汇总展示:通过行数、警告行数、错误行数
|
||||||
|
|
||||||
|
### 需求 7:Excel 上传 — 冲突检测与解决
|
||||||
|
|
||||||
|
**用户故事:** 作为 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 确认后写入
|
||||||
|
|
||||||
|
### 需求 8:Excel 上传 — 数据写入与记录
|
||||||
|
|
||||||
|
**用户故事:** 作为 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)、来源(source:manual / 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` 表,包含字段:id(BIGSERIAL PK)、username(VARCHAR(50) UNIQUE NOT NULL)、password_hash(VARCHAR(255) NOT NULL)、display_name(VARCHAR(100))、tenant_id(BIGINT NOT NULL)、managed_site_ids(BIGINT[] NOT NULL)、is_active(BOOLEAN DEFAULT true)、created_by(BIGINT)、created_at(TIMESTAMPTZ DEFAULT NOW())、last_login_at(TIMESTAMPTZ),并创建 tenant_id 索引
|
||||||
|
2. THE 迁移脚本 SHALL 在 `biz` Schema 中创建 `salary_adjustments` 表,包含字段:id(BIGSERIAL PK)、site_id(BIGINT NOT NULL)、assistant_id(BIGINT 可空)、assistant_name(VARCHAR(100) NOT NULL)、assistant_number(VARCHAR(50) NOT NULL)、salary_month(VARCHAR(7) NOT NULL)、adjustment_type(CHECK IN deduction/bonus)、amount(NUMERIC(12,2) > 0)、reason(VARCHAR(200) NOT NULL)、upload_batch_id(FK → excel_upload_log)、created_at、created_by,并创建 (site_id, salary_month) 和 (assistant_id, salary_month) 索引
|
||||||
|
3. THE 迁移脚本 SHALL 在 `biz` Schema 中创建 `excel_upload_log` 表,包含字段:id(BIGSERIAL PK)、site_id(BIGINT NOT NULL)、upload_type(CHECK IN expense/platform_income/salary_adj/recharge_commission)、file_name(VARCHAR(255) NOT NULL)、uploaded_by(BIGINT NOT NULL)、row_count(INTEGER DEFAULT 0)、conflict_count(INTEGER DEFAULT 0)、resolved_count(INTEGER DEFAULT 0)、status(CHECK IN pending/confirmed/failed)、error_detail(JSONB)、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_id(FK)、synced_at(TIMESTAMPTZ 可空,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 }`
|
||||||
409
.kiro/specs/tenant-admin-web/tasks.md
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
# 实施计划:租户管理后台(tenant-admin-web)
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
按照设计文档,将实施拆分为:DDL 迁移 → 后端认证模块 → 后端路由模块 → 前端项目骨架 → 前端页面实现 → 联调收尾。每个任务增量构建,确保无孤立代码。属性测试(Hypothesis / fast-check)和单元测试作为可选子任务紧跟实现步骤。
|
||||||
|
|
||||||
|
后端使用 Python(FastAPI + Pydantic),前端使用 TypeScript(React + 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` dataclass(admin_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 验证,签发 JWT(aud=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_assistant(phone, scd2_is_current=1)和 v_dim_staff + v_dim_staff_ex(phone)
|
||||||
|
- `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.py(Excel 上传/校验/冲突/写入)
|
||||||
|
- [ ] 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_member(nickname 模糊匹配 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.py,ask 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 Select(6 值枚举:客户基础/消费习惯/玩法偏好/促销偏好/社交关系/重要反馈)
|
||||||
|
- 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),单元测试覆盖具体边界条件
|
||||||
|
- 检查点任务确保增量验证,避免问题累积
|
||||||
|
- 后端使用 Python(FastAPI + Pydantic + Hypothesis),前端使用 TypeScript(React + Vite + Ant Design + fast-check)
|
||||||
|
- 会员信息一律通过 `member_id` JOIN `v_dim_member`(`scd2_is_current=1`),不使用结算单冗余字段(DQ-6 规则)
|
||||||
|
- 多店铺 FDW 查询采用逐 site_id 查询后合并结果的策略
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ DWS(Data 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 @@ DWS(Data 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`
|
||||||
|
|||||||
@@ -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/` 目录存放已废弃内容,除非用户明确要求,禁止读取或参考。
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"ios": {
|
|
||||||
"name": "桌球运营助手"
|
|
||||||
},
|
|
||||||
"android": {
|
|
||||||
"name": "桌球运营助手"
|
|
||||||
},
|
|
||||||
"common": {
|
|
||||||
"name": "桌球运营助手"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"adapteByMiniprogram": {
|
|
||||||
"userName": "gh_521029c3a9c7"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 928 B |
|
Before Width: | Height: | Size: 498 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 806 B |
|
Before Width: | Height: | Size: 494 B |
|
Before Width: | Height: | Size: 778 B |
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"usingComponents": {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -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>
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
8
apps/XCX-TEST/typings/index.d.ts
vendored
@@ -1,8 +0,0 @@
|
|||||||
/// <reference path="./types/index.d.ts" />
|
|
||||||
|
|
||||||
interface IAppOption {
|
|
||||||
globalData: {
|
|
||||||
userInfo?: WechatMiniprogram.UserInfo,
|
|
||||||
}
|
|
||||||
userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback,
|
|
||||||
}
|
|
||||||
1
apps/XCX-TEST/typings/types/index.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
/// <reference path="./wx/index.d.ts" />
|
|
||||||
74
apps/XCX-TEST/typings/types/wx/index.d.ts
vendored
@@ -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
|
|
||||||
19671
apps/XCX-TEST/typings/types/wx/lib.wx.api.d.ts
vendored
270
apps/XCX-TEST/typings/types/wx/lib.wx.app.d.ts
vendored
@@ -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
|
|
||||||
* - 1036(App 分享消息卡片):来源应用 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:公众号自定义菜单
|
|
||||||
* - 1036:App 分享消息卡片
|
|
||||||
* - 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
|
|
||||||
@@ -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
|
|
||||||
924
apps/XCX-TEST/typings/types/wx/lib.wx.cloud.d.ts
vendored
@@ -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'>>
|
|
||||||
636
apps/XCX-TEST/typings/types/wx/lib.wx.component.d.ts
vendored
@@ -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
|
|
||||||
1435
apps/XCX-TEST/typings/types/wx/lib.wx.event.d.ts
vendored
259
apps/XCX-TEST/typings/types/wx/lib.wx.page.d.ts
vendored
@@ -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
|
|
||||||
/** 自定义图片,显示图片长宽比为 1:1,默认值:页面截图 */
|
|
||||||
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
|
|
||||||
@@ -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) |
|
||||||
|
|
||||||
## 依赖
|
## 依赖
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
]
|
|
||||||
@@ -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")),
|
||||||
|
|||||||
329
apps/backend/app/routers/xcx_chat.py
Normal 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
|
||||||
@@ -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 Schema(BOARD-1/2/3 请求参数枚举 + 响应模型)。"""
|
"""三看板接口 Pydantic Schema(BOARD-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_code(BILLIARD/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_code(BILLIARD/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):
|
||||||
|
|||||||
106
apps/backend/app/schemas/xcx_chat.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
"""CONFIG-1 技能类型响应 Schema。"""
|
# AI_CHANGELOG
|
||||||
|
# - 2026-03-20 | Prompt: R3 项目类型筛选接口重建 | SkillTypeItem.key 注释从
|
||||||
|
# chinese/snooker 改为 BILLIARD/SNOOKER,label 说明改为从 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 读取(含 emoji),cls 保留但后端不再填充。
|
||||||
|
key: str # BILLIARD/SNOOKER/MAHJONG/KTV/ALL
|
||||||
|
label: str # display_name(含 emoji,如 "🎱 中式/追分")
|
||||||
|
emoji: str # short_name(单 emoji,如 "🎱")
|
||||||
|
cls: str # 前端样式类(预留,当前为空字符串)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
685
apps/backend/app/services/chat_service.py
Normal 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_messages(role=user)
|
||||||
|
- 流式完成后完整 assistant 回复写入 ai_messages(role=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 可能是 dict(psycopg2 自动解析 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_member(scd2_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_amount(items_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)
|
||||||
@@ -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_head,WHERE 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_head(1: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
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 全表扫描无 WHERE,RLS 是唯一门店过滤手段。
|
||||||
|
|
||||||
# -*- 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 全表扫描(无 WHERE),RLS 隔离是唯一的门店过滤手段
|
||||||
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())
|
||||||
|
|
||||||
|
|||||||
@@ -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_v2(TASK-1)、get_task_detail(TASK-2)。
|
RNS1.1 扩展:get_task_list_v2(TASK-1)、get_task_detail(TASK-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 = []
|
||||||
|
|||||||
@@ -465,6 +465,150 @@ MVP 全链路验证端点,从 `test."xcx-test"` 表读取数据。
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 16. 小程序配置 `/api/xcx/config`
|
||||||
|
|
||||||
|
所有端点需 JWT(approved 状态)。
|
||||||
|
|
||||||
|
### 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`
|
||||||
|
|
||||||
|
所有端点需 JWT(approved 状态)。替代原 `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` 端点,提供维客线索管理能力。
|
||||||
|
|||||||
@@ -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-3,6 大板块 + 环比开关) |
|
||||||
|
| `/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)
|
||||||
|
|||||||
@@ -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)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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.2:AI 图标配色系统(6种) */
|
/** VI 规范 §6.2:AI 图标配色系统(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()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
// 微信基础库支持 TextDecoder(2.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
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 滚动到底部 */
|
/** 滚动到底部 */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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}}"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 悬浮按钮 -->
|
||||||
|
|||||||
@@ -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)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 重试加载 */
|
/** 重试加载 */
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
|||||||
@@ -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' }),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,31 +1,18 @@
|
|||||||
|
// AI_CHANGELOG
|
||||||
|
// - 2026-03-20 | Prompt: R3 项目类型筛选接口重建 | fetchSkillTypes() fallback 数据
|
||||||
|
// value 从 all/chinese/snooker 改为 ALL/BILLIARD/SNOOKER;API 响应映射从
|
||||||
|
// 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,94 +51,58 @@ 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',
|
||||||
needAuth: true,
|
needAuth: true,
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
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,11 +115,7 @@ 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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新增备注 */
|
/** 新增备注 */
|
||||||
@@ -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,11 +143,7 @@ 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 绩效明细(按月) */
|
/** 绩效明细(按月) */
|
||||||
@@ -235,16 +153,12 @@ 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',
|
||||||
data: params,
|
data: params,
|
||||||
needAuth: true,
|
needAuth: true,
|
||||||
})
|
})
|
||||||
}
|
|
||||||
await delay()
|
|
||||||
return { records: mockPerformanceRecords, hasMore: false }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -252,16 +166,12 @@ export async function fetchPerformanceRecords(params: {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
/** 客户详情 */
|
/** 客户详情 */
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 客户服务记录 */
|
/** 客户服务记录 */
|
||||||
@@ -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`,
|
||||||
@@ -279,10 +188,6 @@ export async function fetchCustomerRecords(params: {
|
|||||||
data: rest,
|
data: rest,
|
||||||
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',
|
||||||
@@ -303,9 +207,6 @@ export async function fetchBoardCoaches(params: {
|
|||||||
needAuth: true,
|
needAuth: true,
|
||||||
})
|
})
|
||||||
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',
|
||||||
@@ -324,9 +224,6 @@ export async function fetchBoardCustomers(params: {
|
|||||||
needAuth: true,
|
needAuth: true,
|
||||||
})
|
})
|
||||||
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,16 +233,12 @@ 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',
|
||||||
data: params,
|
data: params,
|
||||||
needAuth: true,
|
needAuth: true,
|
||||||
})
|
})
|
||||||
}
|
|
||||||
await delay()
|
|
||||||
return mockBoardFinance
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -354,15 +247,11 @@ 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,80 +262,73 @@ 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',
|
||||||
data: params,
|
data: 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',
|
||||||
data: params,
|
data: 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',
|
||||||
data: { content },
|
data: { content },
|
||||||
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_code,fallback 与后端一致
|
||||||
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: '打赏课' },
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
58
db/etl_feiqiu/migrations/2026-03-20_drop_recall_index.sql
Normal 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 $$;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 迁移:扩展 biz.ai_conversations 和 biz.ai_messages 支持 CHAT 模块
|
||||||
|
-- 日期:2026-03-20
|
||||||
|
-- 关联 SPEC:rns1-chat-integration(RNS1.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 '关联上下文 ID:task 入口为 taskId,customer 入口为 customerId,coach 入口为 coachId,general 为 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 行
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 本文档记录项目中所有文档资产的位置、类型和内容概要,方便快速定位。
|
> 本文档记录项目中所有文档资产的位置、类型和内容概要,方便快速定位。
|
||||||
> 归档规则见末尾「文档归档规则」章节;程序输出路径规范见 `docs/deployment/EXPORT-PATHS.md`。
|
> 归档规则见末尾「文档归档规则」章节;程序输出路径规范见 `docs/deployment/EXPORT-PATHS.md`。
|
||||||
> 最后更新:2026-03-19(RNS1.3 三看板接口文档补充)
|
> 最后更新:2026-03-20(RNS1.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 Schema(7 枚举 + ~40 响应 Schema) |
|
| `app/schemas/xcx_board.py` | 看板相关 Pydantic Schema(7 枚举 + ~40 响应 Schema) |
|
||||||
| `app/schemas/xcx_config.py` | 配置相关 Pydantic Schema(SkillTypeItem) |
|
| `app/schemas/xcx_config.py` | 配置相关 Pydantic Schema(SkillTypeItem) |
|
||||||
|
|
||||||
|
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 Schema(ChatHistoryResponse、ChatMessagesResponse、ReferenceCard、SendMessageResponse、ChatStreamRequest 等) |
|
||||||
|
|
||||||
Monorepo 级属性测试(`tests/`):
|
Monorepo 级属性测试(`tests/`):
|
||||||
|
|
||||||
| 路径 | 内容 |
|
| 路径 | 内容 |
|
||||||
@@ -216,6 +224,12 @@ Monorepo 级属性测试(`tests/`):
|
|||||||
| `tests/test_rns12_properties.py` | RNS1.2 属性测试(14 个 Property,Hypothesis 框架) |
|
| `tests/test_rns12_properties.py` | RNS1.2 属性测试(14 个 Property,Hypothesis 框架) |
|
||||||
| `tests/test_board_properties.py` | RNS1.3 属性测试(18 个测试函数,12 个 Property,Hypothesis 框架) |
|
| `tests/test_board_properties.py` | RNS1.3 属性测试(18 个测试函数,12 个 Property,Hypothesis 框架) |
|
||||||
| `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 4,Hypothesis 框架) |
|
||||||
|
| `tests/test_rns1_chat_reuse_properties.py` | RNS1.4 属性测试:对话复用规则(Property 6) |
|
||||||
|
| `tests/test_rns1_chat_reference_card_properties.py` | RNS1.4 属性测试:referenceCard round trip(Property 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 消费力指数 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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 客户 + 四级 emoji(RNS1.3 新增) | DQ-6 + v_dws_member_assistant_relation_index |
|
| `get_top_customers_for_coaches()` | BOARD-1:按亲密度 Top3 客户 + 四级 emoji(RNS1.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 |
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
# BD_Manual:biz Schema AI 表(对话记录 + 消息 + 缓存)
|
# BD_Manual:biz 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_conversations(8 字段)
|
#### biz.ai_conversations(13 字段)
|
||||||
|
|
||||||
| 字段 | 类型 | 约束 | 说明 |
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
@@ -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 新增** — 关联上下文 ID:task 入口为 taskId,customer 入口为 customerId,coach 入口为 coachId,general 为 NULL |
|
||||||
|
| `title` | VARCHAR(200) | 可空 | **RNS1.4 新增** — 对话标题:自定义 > 上下文名称 > 首条消息前20字 |
|
||||||
|
| `last_message` | TEXT | 可空 | **RNS1.4 新增** — 最后一条消息内容摘要(截断至100字) |
|
||||||
|
| `last_message_at` | TIMESTAMPTZ | 可空 | **RNS1.4 新增** — 最后消息时间,用于历史列表排序和对话复用时限判断 |
|
||||||
|
|
||||||
#### biz.ai_messages(6 字段)
|
#### biz.ai_messages(7 字段)
|
||||||
|
|
||||||
| 字段 | 类型 | 约束 | 说明 |
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
@@ -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_cache(9 字段)
|
#### biz.ai_cache(9 字段)
|
||||||
|
|
||||||
@@ -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 流式对话等功能 |
|
||||||
|
| 后端 API(RNS1.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 迁移)
|
||||||
|
|
||||||
按逆序 DROP(CASCADE 处理外键依赖):
|
按逆序 DROP(CASCADE 处理外键依赖):
|
||||||
|
|
||||||
```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)
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# BD_Manual:app Schema 与 RLS 视图层
|
# BD_Manual:app 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 权限
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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包厢 BILLIARD(3台)
|
-- VIP包厢 BILLIARD(3台)
|
||||||
('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),
|
||||||
('麻将房', 'M1', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '麻将房-M1'),
|
('麻将房', 'M1', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '麻将房-M1', 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);
|
||||||
|
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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`
|
||||||
|
> 所有端点需 JWT(approved 状态),使用 `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) | 是 | 上下文 ID(taskId / customerId / coachId,general 时为空) |
|
||||||
|
| `page` | number (query) | 否 | 页码,默认 1 |
|
||||||
|
| `pageSize` | number (query) | 否 | 每页条数,默认 50,最大 100 |
|
||||||
|
|
||||||
|
#### 响应结构(CHAT-2a / CHAT-2b 共用)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface ChatMessagesResponse {
|
interface ChatMessagesResponse {
|
||||||
chatId: string // 对话 ID(customerId 入口时由后端返回)
|
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.1),ISO 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 | 上下文 ID(taskId / 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) |
|
||||||
|
|||||||