1
222
apps/backend/app/ai/apps/app1_chat.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""应用 1:通用对话(SSE 流式)。
|
||||
|
||||
每次进入 chat 页面新建 ai_conversations 记录(不复用),
|
||||
首条消息注入页面上下文,流式返回 AI 回复。
|
||||
|
||||
app_id = "app1_chat"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.schemas import SSEEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app1_chat"
|
||||
|
||||
|
||||
async def chat_stream(
|
||||
*,
|
||||
message: str,
|
||||
user_id: int | str,
|
||||
nickname: str,
|
||||
role: str,
|
||||
site_id: int,
|
||||
source_page: str | None = None,
|
||||
page_context: dict | None = None,
|
||||
screen_content: str | None = None,
|
||||
bailian: BailianClient,
|
||||
conv_svc: ConversationService,
|
||||
) -> AsyncGenerator[SSEEvent, None]:
|
||||
"""流式对话入口,返回 SSEEvent 异步生成器。
|
||||
|
||||
流程:
|
||||
1. 创建 conversation 记录
|
||||
2. 写入 user message
|
||||
3. 构建 system prompt(注入页面上下文)
|
||||
4. 调用 bailian.chat_stream 流式获取回复
|
||||
5. 逐 chunk yield SSEEvent(type="chunk")
|
||||
6. 完成后写入 assistant message,yield SSEEvent(type="done")
|
||||
7. 异常时 yield SSEEvent(type="error")
|
||||
"""
|
||||
conversation_id: int | None = None
|
||||
|
||||
try:
|
||||
# 1. 每次新建 conversation(不复用)
|
||||
source_ctx = _build_source_context(
|
||||
source_page=source_page,
|
||||
page_context=page_context,
|
||||
screen_content=screen_content,
|
||||
)
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_page=source_page,
|
||||
source_context=source_ctx,
|
||||
)
|
||||
logger.info(
|
||||
"App1 新建对话: conversation_id=%s user_id=%s site_id=%s",
|
||||
conversation_id, user_id, site_id,
|
||||
)
|
||||
|
||||
# 2. 立即写入 user message
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=message,
|
||||
)
|
||||
|
||||
# 3. 构建消息列表(system prompt + user message)
|
||||
messages = _build_messages(
|
||||
message=message,
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
role=role,
|
||||
source_page=source_page,
|
||||
page_context=page_context,
|
||||
screen_content=screen_content,
|
||||
)
|
||||
|
||||
# 4-5. 流式调用百炼,逐 chunk yield
|
||||
full_reply_parts: list[str] = []
|
||||
async for chunk in bailian.chat_stream(messages):
|
||||
full_reply_parts.append(chunk)
|
||||
yield SSEEvent(type="chunk", content=chunk)
|
||||
|
||||
# 6. 流式完成,拼接完整回复并写入 assistant message
|
||||
full_reply = "".join(full_reply_parts)
|
||||
# 百炼流式模式不返回 tokens_used,按字符数估算(粗略)
|
||||
estimated_tokens = len(full_reply)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=full_reply,
|
||||
tokens_used=estimated_tokens,
|
||||
)
|
||||
|
||||
yield SSEEvent(
|
||||
type="done",
|
||||
conversation_id=conversation_id,
|
||||
tokens_used=estimated_tokens,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"App1 对话异常: conversation_id=%s error=%s",
|
||||
conversation_id, e,
|
||||
exc_info=True,
|
||||
)
|
||||
yield SSEEvent(type="error", message=str(e))
|
||||
|
||||
|
||||
def _build_messages(
|
||||
*,
|
||||
message: str,
|
||||
user_id: int | str,
|
||||
nickname: str,
|
||||
role: str,
|
||||
source_page: str | None,
|
||||
page_context: dict | None,
|
||||
screen_content: str | None,
|
||||
) -> list[dict]:
|
||||
"""构建发送给百炼的消息列表。
|
||||
|
||||
首条 system 消息注入页面上下文和用户信息。
|
||||
"""
|
||||
system_content = _build_system_prompt(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
role=role,
|
||||
source_page=source_page,
|
||||
page_context=page_context,
|
||||
screen_content=screen_content,
|
||||
)
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "user", "content": message},
|
||||
]
|
||||
|
||||
|
||||
def _build_system_prompt(
|
||||
*,
|
||||
user_id: int | str,
|
||||
nickname: str,
|
||||
role: str,
|
||||
source_page: str | None,
|
||||
page_context: dict | None,
|
||||
screen_content: str | None,
|
||||
) -> dict:
|
||||
"""构建 system prompt JSON。
|
||||
|
||||
通过 biz_params.user_prompt_params 传入用户信息,
|
||||
注入页面上下文供 AI 理解当前场景。
|
||||
"""
|
||||
prompt: dict = {
|
||||
"task": "你是台球门店的 AI 助手,根据用户的问题和当前页面上下文提供帮助。",
|
||||
"biz_params": {
|
||||
"user_prompt_params": {
|
||||
"User_ID": str(user_id),
|
||||
"Role": role,
|
||||
"Nickname": nickname,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# 注入页面上下文(首条消息)
|
||||
page_ctx = _build_page_context(
|
||||
source_page=source_page,
|
||||
page_context=page_context,
|
||||
screen_content=screen_content,
|
||||
)
|
||||
if page_ctx:
|
||||
prompt["page_context"] = page_ctx
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
def _build_page_context(
|
||||
*,
|
||||
source_page: str | None,
|
||||
page_context: dict | None,
|
||||
screen_content: str | None,
|
||||
) -> dict:
|
||||
"""构建页面上下文信息。
|
||||
|
||||
P5-A 阶段:直接透传前端传入的上下文字段。
|
||||
P5-B 阶段:各页面逐步实现文本化工具,丰富 screen_content。
|
||||
"""
|
||||
# TODO: P5-B 各页面文本化工具细化
|
||||
ctx: dict = {}
|
||||
if source_page:
|
||||
ctx["source_page"] = source_page
|
||||
if page_context:
|
||||
ctx["page_context"] = page_context
|
||||
if screen_content:
|
||||
ctx["screen_content"] = screen_content
|
||||
return ctx
|
||||
|
||||
|
||||
def _build_source_context(
|
||||
*,
|
||||
source_page: str | None,
|
||||
page_context: dict | None,
|
||||
screen_content: str | None,
|
||||
) -> dict | None:
|
||||
"""构建存入 ai_conversations.source_context 的 JSON。"""
|
||||
ctx: dict = {}
|
||||
if source_page:
|
||||
ctx["source_page"] = source_page
|
||||
if page_context:
|
||||
ctx["page_context"] = page_context
|
||||
if screen_content:
|
||||
ctx["screen_content"] = screen_content
|
||||
return ctx if ctx else None
|
||||
210
apps/backend/app/ai/apps/app2_finance.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""应用 2:财务洞察。
|
||||
|
||||
8 个时间维度独立调用,每次调用结果写入 ai_cache,
|
||||
同时创建 ai_conversations + ai_messages 记录。
|
||||
|
||||
营业日分界点:每日 08:00(BUSINESS_DAY_START_HOUR 环境变量,默认 8)。
|
||||
|
||||
app_id = "app2_finance"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.prompts.app2_finance_prompt import build_prompt
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app2_finance"
|
||||
|
||||
# 8 个时间维度编码
|
||||
TIME_DIMENSIONS = (
|
||||
"this_month",
|
||||
"last_month",
|
||||
"this_week",
|
||||
"last_week",
|
||||
"last_3_months",
|
||||
"this_quarter",
|
||||
"last_quarter",
|
||||
"last_6_months",
|
||||
)
|
||||
|
||||
|
||||
def get_business_date() -> date:
|
||||
"""根据营业日分界点计算当前营业日。
|
||||
|
||||
分界点前(如 07:59)视为前一天营业日,
|
||||
分界点及之后(如 08:00)视为当天营业日。
|
||||
"""
|
||||
hour = int(os.environ.get("BUSINESS_DAY_START_HOUR", "8"))
|
||||
now = datetime.now()
|
||||
if now.hour < hour:
|
||||
return (now - timedelta(days=1)).date()
|
||||
return now.date()
|
||||
|
||||
|
||||
def compute_time_range(dimension: str, business_date: date) -> tuple[date, date]:
|
||||
"""计算时间维度对应的日期范围 [start, end](闭区间)。
|
||||
|
||||
Args:
|
||||
dimension: 时间维度编码
|
||||
business_date: 当前营业日
|
||||
|
||||
Returns:
|
||||
(start_date, end_date) 元组
|
||||
"""
|
||||
y, m, d = business_date.year, business_date.month, business_date.day
|
||||
|
||||
if dimension == "this_month":
|
||||
start = date(y, m, 1)
|
||||
return start, business_date
|
||||
|
||||
if dimension == "last_month":
|
||||
prev = _month_offset(y, m, -1)
|
||||
start = date(prev[0], prev[1], 1)
|
||||
end = date(y, m, 1) - timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
if dimension == "this_week":
|
||||
# 周一起算
|
||||
weekday = business_date.weekday() # 0=周一
|
||||
start = business_date - timedelta(days=weekday)
|
||||
return start, business_date
|
||||
|
||||
if dimension == "last_week":
|
||||
weekday = business_date.weekday()
|
||||
this_monday = business_date - timedelta(days=weekday)
|
||||
last_monday = this_monday - timedelta(days=7)
|
||||
last_sunday = this_monday - timedelta(days=1)
|
||||
return last_monday, last_sunday
|
||||
|
||||
if dimension == "last_3_months":
|
||||
# 当前月 - 3 ~ 当前月 - 1
|
||||
end_ym = _month_offset(y, m, -1)
|
||||
start_ym = _month_offset(y, m, -3)
|
||||
start = date(start_ym[0], start_ym[1], 1)
|
||||
# end = 上月最后一天
|
||||
end = date(y, m, 1) - timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
if dimension == "this_quarter":
|
||||
q_start_month = ((m - 1) // 3) * 3 + 1
|
||||
start = date(y, q_start_month, 1)
|
||||
return start, business_date
|
||||
|
||||
if dimension == "last_quarter":
|
||||
q_start_month = ((m - 1) // 3) * 3 + 1
|
||||
# 上季度结束 = 本季度第一天 - 1
|
||||
this_q_start = date(y, q_start_month, 1)
|
||||
end = this_q_start - timedelta(days=1)
|
||||
# 上季度开始
|
||||
ly, lm = end.year, end.month
|
||||
lq_start_month = ((lm - 1) // 3) * 3 + 1
|
||||
start = date(ly, lq_start_month, 1)
|
||||
return start, end
|
||||
|
||||
if dimension == "last_6_months":
|
||||
# 当前月 - 6 ~ 当前月 - 1
|
||||
end_ym = _month_offset(y, m, -1)
|
||||
start_ym = _month_offset(y, m, -6)
|
||||
start = date(start_ym[0], start_ym[1], 1)
|
||||
end = date(y, m, 1) - timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
raise ValueError(f"未知时间维度: {dimension}")
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App2 财务洞察调用。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, time_dimension, user_id(默认'system'), nickname(默认'')
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(insights 数组)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
time_dimension = context["time_dimension"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 构建 Prompt
|
||||
prompt_context = {
|
||||
"site_id": site_id,
|
||||
"time_dimension": time_dimension,
|
||||
"current_data": context.get("current_data", {}),
|
||||
"previous_data": context.get("previous_data", {}),
|
||||
}
|
||||
messages = build_prompt(prompt_context)
|
||||
|
||||
# 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"time_dimension": time_dimension},
|
||||
)
|
||||
|
||||
# 写入 system prompt 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
# 写入 user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 写入缓存
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP2_FINANCE.value,
|
||||
site_id=site_id,
|
||||
target_id=time_dimension,
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App2 财务洞察完成: site_id=%s dimension=%s conversation_id=%s tokens=%d",
|
||||
site_id, time_dimension, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _month_offset(year: int, month: int, offset: int) -> tuple[int, int]:
|
||||
"""计算月份偏移,返回 (year, month)。"""
|
||||
# 转为 0-based 计算
|
||||
total = (year * 12 + (month - 1)) + offset
|
||||
return total // 12, total % 12 + 1
|
||||
213
apps/backend/app/ai/apps/app3_clue.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""应用 3:客户数据维客线索分析(骨架)。
|
||||
|
||||
客户新增消费时自动触发,通过 AI 分析客户数据提取维客线索。
|
||||
线索 category 限定 3 个枚举值:客户基础、消费习惯、玩法偏好。
|
||||
线索提供者统一标记为"系统"。
|
||||
|
||||
使用 items_sum 口径(= table_charge_money + goods_money
|
||||
+ assistant_pd_money + assistant_cx_money + electricity_money),
|
||||
禁止使用 consume_money。
|
||||
|
||||
app_id = "app3_clue"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app3_clue"
|
||||
|
||||
|
||||
def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
P5-A 阶段:返回占位 Prompt,标注待细化字段。
|
||||
P5-B 阶段(P9-T1):补充 consumption_records 等完整数据。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, member_id, nickname 等
|
||||
cache_svc: 缓存服务,用于获取 reference 历史数据
|
||||
|
||||
Returns:
|
||||
消息列表 [{"role": "system", "content": ...}, {"role": "user", ...}]
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
|
||||
# 构建 reference:App6 线索 + 最近 2 套 App8 历史(附 generated_at)
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
|
||||
system_content = {
|
||||
"task": "分析客户消费数据,提取维客线索。",
|
||||
"app_id": APP_ID,
|
||||
"rules": {
|
||||
"category_enum": ["客户基础", "消费习惯", "玩法偏好"],
|
||||
"providers": "系统",
|
||||
"amount_caliber": "items_sum = table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money",
|
||||
"禁止使用": "consume_money",
|
||||
},
|
||||
"output_format": {
|
||||
"clues": [
|
||||
{
|
||||
"category": "枚举值(客户基础/消费习惯/玩法偏好)",
|
||||
"summary": "一句话摘要",
|
||||
"detail": "详细说明",
|
||||
"emoji": "表情符号",
|
||||
}
|
||||
]
|
||||
},
|
||||
# TODO: P9-T1 细化 - consumption_records 等客户消费数据
|
||||
"data": {
|
||||
"consumption_records": "待 P9-T1 补充",
|
||||
"member_info": "待 P9-T1 补充",
|
||||
},
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
user_content = (
|
||||
f"请分析会员 {member_id} 的消费数据,提取维客线索。"
|
||||
"每条线索包含 category、summary、detail、emoji 四个字段。"
|
||||
"category 必须是:客户基础、消费习惯、玩法偏好 之一。"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
|
||||
def _build_reference(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
cache_svc: AICacheService | None,
|
||||
) -> dict:
|
||||
"""构建 Prompt reference 字段。
|
||||
|
||||
包含:
|
||||
- App6 备注分析线索(最新一条,如有)
|
||||
- 最近 2 套 App8 维客线索整理历史(附 generated_at)
|
||||
|
||||
缓存不存在时返回空对象 {}。
|
||||
"""
|
||||
if cache_svc is None:
|
||||
return {}
|
||||
|
||||
reference: dict = {}
|
||||
target_id = str(member_id)
|
||||
|
||||
# App6 备注分析线索
|
||||
app6_latest = cache_svc.get_latest(
|
||||
CacheTypeEnum.APP6_NOTE_ANALYSIS.value, site_id, target_id,
|
||||
)
|
||||
if app6_latest:
|
||||
reference["app6_note_clues"] = {
|
||||
"result_json": app6_latest.get("result_json"),
|
||||
"generated_at": app6_latest.get("created_at"),
|
||||
}
|
||||
|
||||
# 最近 2 套 App8 历史
|
||||
app8_history = cache_svc.get_history(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id, limit=2,
|
||||
)
|
||||
if app8_history:
|
||||
reference["app8_history"] = [
|
||||
{
|
||||
"result_json": h.get("result_json"),
|
||||
"generated_at": h.get("created_at"),
|
||||
}
|
||||
for h in app8_history
|
||||
]
|
||||
|
||||
return reference
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App3 客户数据维客线索分析。
|
||||
|
||||
流程:
|
||||
1. build_prompt 构建 Prompt
|
||||
2. bailian.chat_json 调用百炼
|
||||
3. 写入 conversation + messages
|
||||
4. 写入 ai_cache
|
||||
5. 返回结果
|
||||
|
||||
Args:
|
||||
context: site_id, member_id, user_id(默认'system'), nickname(默认'')
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(clues 数组)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = build_prompt(context, cache_svc)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"member_id": member_id},
|
||||
)
|
||||
|
||||
# 写入 system + user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 3. 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 4. 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 5. 写入缓存
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP3_CLUE.value,
|
||||
site_id=site_id,
|
||||
target_id=str(member_id),
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App3 线索分析完成: site_id=%s member_id=%s conversation_id=%s tokens=%d",
|
||||
site_id, member_id, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
200
apps/backend/app/ai/apps/app4_analysis.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""应用 4:关系分析/任务建议(骨架)。
|
||||
|
||||
助教参与新结算或被分配召回任务时自动触发,
|
||||
生成关系分析和任务建议。
|
||||
|
||||
Prompt reference 包含 App8 最新 + 最近 2 套历史(附 generated_at)。
|
||||
缓存不存在时 reference 传空对象,标注"暂无历史线索"。
|
||||
|
||||
app_id = "app4_analysis"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app4_analysis"
|
||||
|
||||
|
||||
def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
P5-A 阶段:返回占位 Prompt,标注待细化字段。
|
||||
P5-B 阶段(P6-T4):补充 service_history、assistant_info 等完整数据。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, assistant_id, member_id
|
||||
cache_svc: 缓存服务,用于获取 reference 历史数据
|
||||
|
||||
Returns:
|
||||
消息列表
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
assistant_id = context["assistant_id"]
|
||||
member_id = context["member_id"]
|
||||
|
||||
# 构建 reference:App8 最新 + 最近 2 套历史
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
|
||||
system_content = {
|
||||
"task": "分析助教与客户的关系,生成任务建议。",
|
||||
"app_id": APP_ID,
|
||||
"output_format": {
|
||||
"task_description": "任务描述文本",
|
||||
"action_suggestions": ["建议1", "建议2"],
|
||||
"one_line_summary": "一句话总结",
|
||||
},
|
||||
# TODO: P6-T4 细化 - service_history、assistant_info
|
||||
"data": {
|
||||
"service_history": "待 P6-T4 补充",
|
||||
"assistant_info": "待 P6-T4 补充",
|
||||
},
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
# 缓存不存在时在 user prompt 中标注
|
||||
no_history_hint = ""
|
||||
if not reference:
|
||||
no_history_hint = "(暂无历史线索,请基于现有信息分析)"
|
||||
|
||||
user_content = (
|
||||
f"请分析助教 {assistant_id} 与会员 {member_id} 的关系,"
|
||||
f"生成任务建议。{no_history_hint}"
|
||||
"返回 task_description、action_suggestions、one_line_summary 三个字段。"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
|
||||
def _build_reference(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
cache_svc: AICacheService | None,
|
||||
) -> dict:
|
||||
"""构建 Prompt reference 字段。
|
||||
|
||||
包含:
|
||||
- App8 最新维客线索(如有)
|
||||
- 最近 2 套 App8 历史(附 generated_at)
|
||||
|
||||
缓存不存在时返回空对象 {}。
|
||||
"""
|
||||
if cache_svc is None:
|
||||
return {}
|
||||
|
||||
reference: dict = {}
|
||||
target_id = str(member_id)
|
||||
|
||||
# App8 最新
|
||||
app8_latest = cache_svc.get_latest(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id,
|
||||
)
|
||||
if app8_latest:
|
||||
reference["app8_latest"] = {
|
||||
"result_json": app8_latest.get("result_json"),
|
||||
"generated_at": app8_latest.get("created_at"),
|
||||
}
|
||||
|
||||
# 最近 2 套 App8 历史
|
||||
app8_history = cache_svc.get_history(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id, limit=2,
|
||||
)
|
||||
if app8_history:
|
||||
reference["app8_history"] = [
|
||||
{
|
||||
"result_json": h.get("result_json"),
|
||||
"generated_at": h.get("created_at"),
|
||||
}
|
||||
for h in app8_history
|
||||
]
|
||||
|
||||
return reference
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App4 关系分析。
|
||||
|
||||
Args:
|
||||
context: site_id, assistant_id, member_id
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(task_description, action_suggestions, one_line_summary)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
assistant_id = context["assistant_id"]
|
||||
member_id = context["member_id"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = build_prompt(context, cache_svc)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"assistant_id": assistant_id, "member_id": member_id},
|
||||
)
|
||||
|
||||
# 写入 system + user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 3. 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 4. 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 5. 写入缓存(target_id = {assistant_id}_{member_id})
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP4_ANALYSIS.value,
|
||||
site_id=site_id,
|
||||
target_id=f"{assistant_id}_{member_id}",
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App4 关系分析完成: site_id=%s assistant=%s member=%s conversation_id=%s tokens=%d",
|
||||
site_id, assistant_id, member_id, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
182
apps/backend/app/ai/apps/app5_tactics.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""应用 5:话术参考(骨架)。
|
||||
|
||||
App4 完成后自动联动触发,接收 App4 完整返回结果
|
||||
作为 Prompt 中的 task_suggestion 字段。
|
||||
|
||||
Prompt reference 包含最近 2 套 App8 历史(附 generated_at)。
|
||||
|
||||
app_id = "app5_tactics"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app5_tactics"
|
||||
|
||||
|
||||
def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
P5-A 阶段:返回占位 Prompt,标注待细化字段。
|
||||
P5-B 阶段(P6-T4):补充 service_history、assistant_info(随 App4 同步)。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, assistant_id, member_id, app4_result(dict)
|
||||
cache_svc: 缓存服务,用于获取 reference 历史数据
|
||||
|
||||
Returns:
|
||||
消息列表
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
assistant_id = context["assistant_id"]
|
||||
member_id = context["member_id"]
|
||||
app4_result = context.get("app4_result", {})
|
||||
|
||||
# 构建 reference:最近 2 套 App8 历史
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
|
||||
system_content = {
|
||||
"task": "基于关系分析和任务建议,生成沟通话术参考。",
|
||||
"app_id": APP_ID,
|
||||
"task_suggestion": app4_result,
|
||||
"output_format": {
|
||||
"tactics": [
|
||||
{"scenario": "场景描述", "script": "话术内容"}
|
||||
]
|
||||
},
|
||||
# TODO: P6-T4 细化 - service_history、assistant_info(随 App4 同步)
|
||||
"data": {
|
||||
"service_history": "待 P6-T4 补充",
|
||||
"assistant_info": "待 P6-T4 补充",
|
||||
},
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
user_content = (
|
||||
f"请为助教 {assistant_id} 生成与会员 {member_id} 沟通的话术参考。"
|
||||
"返回 tactics 数组,每条包含 scenario 和 script 字段。"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
|
||||
def _build_reference(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
cache_svc: AICacheService | None,
|
||||
) -> dict:
|
||||
"""构建 Prompt reference 字段。
|
||||
|
||||
包含最近 2 套 App8 历史(附 generated_at)。
|
||||
缓存不存在时返回空对象 {}。
|
||||
"""
|
||||
if cache_svc is None:
|
||||
return {}
|
||||
|
||||
reference: dict = {}
|
||||
target_id = str(member_id)
|
||||
|
||||
# 最近 2 套 App8 历史
|
||||
app8_history = cache_svc.get_history(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id, limit=2,
|
||||
)
|
||||
if app8_history:
|
||||
reference["app8_history"] = [
|
||||
{
|
||||
"result_json": h.get("result_json"),
|
||||
"generated_at": h.get("created_at"),
|
||||
}
|
||||
for h in app8_history
|
||||
]
|
||||
|
||||
return reference
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App5 话术参考。
|
||||
|
||||
Args:
|
||||
context: site_id, assistant_id, member_id, app4_result(dict)
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(tactics 数组)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
assistant_id = context["assistant_id"]
|
||||
member_id = context["member_id"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = build_prompt(context, cache_svc)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"assistant_id": assistant_id, "member_id": member_id},
|
||||
)
|
||||
|
||||
# 写入 system + user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 3. 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 4. 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 5. 写入缓存(target_id = {assistant_id}_{member_id})
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP5_TACTICS.value,
|
||||
site_id=site_id,
|
||||
target_id=f"{assistant_id}_{member_id}",
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App5 话术参考完成: site_id=%s assistant=%s member=%s conversation_id=%s tokens=%d",
|
||||
site_id, assistant_id, member_id, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
217
apps/backend/app/ai/apps/app6_note.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""应用 6:备注分析(骨架)。
|
||||
|
||||
助教提交备注后自动触发,通过 AI 分析备注内容,
|
||||
提取维客线索并评分。
|
||||
|
||||
返回 score(1-10)+ clues 数组。
|
||||
评分规则:6 分为标准分,重复/低价值/时效性低酌情扣分,高价值信息酌情加分。
|
||||
线索 category 限定 6 个枚举值。
|
||||
线索提供者标记为当前备注提供人(context.noted_by_name)。
|
||||
|
||||
app_id = "app6_note"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app6_note"
|
||||
|
||||
|
||||
def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
P5-A 阶段:返回占位 Prompt,标注待细化字段。
|
||||
P5-B 阶段(P9-T1):补充 consumption_data 等完整数据。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, member_id, note_content, noted_by_name
|
||||
cache_svc: 缓存服务,用于获取 reference 历史数据
|
||||
|
||||
Returns:
|
||||
消息列表
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
note_content = context.get("note_content", "")
|
||||
noted_by_name = context.get("noted_by_name", "")
|
||||
|
||||
# 构建 reference:App3 线索 + 最近 2 套 App8 历史
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
|
||||
system_content = {
|
||||
"task": "分析备注内容,提取维客线索并评分。",
|
||||
"app_id": APP_ID,
|
||||
"rules": {
|
||||
"category_enum": [
|
||||
"客户基础", "消费习惯", "玩法偏好",
|
||||
"促销偏好", "社交关系", "重要反馈",
|
||||
],
|
||||
"providers": noted_by_name,
|
||||
"scoring": "6 分为标准分,重复/低价值/时效性低酌情扣分,高价值信息酌情加分",
|
||||
"score_range": "1-10",
|
||||
},
|
||||
"output_format": {
|
||||
"score": "1-10 整数",
|
||||
"clues": [
|
||||
{
|
||||
"category": "枚举值(6 选 1)",
|
||||
"summary": "一句话摘要",
|
||||
"detail": "详细说明",
|
||||
"emoji": "表情符号",
|
||||
}
|
||||
],
|
||||
},
|
||||
"note_content": note_content,
|
||||
"noted_by_name": noted_by_name,
|
||||
# TODO: P9-T1 细化 - consumption_data 等客户消费数据
|
||||
"data": {
|
||||
"consumption_data": "待 P9-T1 补充",
|
||||
},
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
user_content = (
|
||||
f"请分析以下备注内容,提取维客线索并评分。\n"
|
||||
f"备注提供人:{noted_by_name}\n"
|
||||
f"备注内容:{note_content}\n"
|
||||
"返回 score(1-10 整数)和 clues 数组。"
|
||||
"category 必须是:客户基础、消费习惯、玩法偏好、促销偏好、社交关系、重要反馈 之一。"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
|
||||
def _build_reference(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
cache_svc: AICacheService | None,
|
||||
) -> dict:
|
||||
"""构建 Prompt reference 字段。
|
||||
|
||||
包含:
|
||||
- App3 客户数据线索(最新一条,如有)
|
||||
- 最近 2 套 App8 维客线索整理历史(附 generated_at)
|
||||
|
||||
缓存不存在时返回空对象 {}。
|
||||
"""
|
||||
if cache_svc is None:
|
||||
return {}
|
||||
|
||||
reference: dict = {}
|
||||
target_id = str(member_id)
|
||||
|
||||
# App3 客户数据线索
|
||||
app3_latest = cache_svc.get_latest(
|
||||
CacheTypeEnum.APP3_CLUE.value, site_id, target_id,
|
||||
)
|
||||
if app3_latest:
|
||||
reference["app3_clues"] = {
|
||||
"result_json": app3_latest.get("result_json"),
|
||||
"generated_at": app3_latest.get("created_at"),
|
||||
}
|
||||
|
||||
# 最近 2 套 App8 历史
|
||||
app8_history = cache_svc.get_history(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id, limit=2,
|
||||
)
|
||||
if app8_history:
|
||||
reference["app8_history"] = [
|
||||
{
|
||||
"result_json": h.get("result_json"),
|
||||
"generated_at": h.get("created_at"),
|
||||
}
|
||||
for h in app8_history
|
||||
]
|
||||
|
||||
return reference
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App6 备注分析。
|
||||
|
||||
Args:
|
||||
context: site_id, member_id, note_content, noted_by_name
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(score + clues 数组)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = build_prompt(context, cache_svc)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"member_id": member_id},
|
||||
)
|
||||
|
||||
# 写入 system + user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 3. 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 4. 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 5. 写入缓存(score 存入 ai_cache.score)
|
||||
score = result.get("score")
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP6_NOTE_ANALYSIS.value,
|
||||
site_id=site_id,
|
||||
target_id=str(member_id),
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
score=score,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App6 备注分析完成: site_id=%s member_id=%s score=%s conversation_id=%s tokens=%d",
|
||||
site_id, member_id, score, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
200
apps/backend/app/ai/apps/app7_customer.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""应用 7:客户分析(骨架)。
|
||||
|
||||
消费事件链中 App8 完成后串行触发,生成客户全量分析与运营建议。
|
||||
|
||||
使用 items_sum 口径(= table_charge_money + goods_money
|
||||
+ assistant_pd_money + assistant_cx_money + electricity_money),
|
||||
禁止使用 consume_money。
|
||||
|
||||
对主观信息(来自备注)标注【来源:XXX,请甄别信息真实性】。
|
||||
|
||||
app_id = "app7_customer"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app7_customer"
|
||||
|
||||
|
||||
def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
P5-A 阶段:返回占位 Prompt,标注待细化字段。
|
||||
P5-B 阶段(P9-T1):补充 objective_data 等完整数据。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, member_id
|
||||
cache_svc: 缓存服务,用于获取 reference 历史数据
|
||||
|
||||
Returns:
|
||||
消息列表
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
|
||||
# 构建 reference:最新 + 最近 2 套 App8 历史
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
|
||||
system_content = {
|
||||
"task": "综合分析客户数据,生成运营策略建议。",
|
||||
"app_id": APP_ID,
|
||||
"rules": {
|
||||
"amount_caliber": "items_sum = table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money",
|
||||
"禁止使用": "consume_money",
|
||||
"subjective_info_label": "对主观信息(来自备注)标注【来源:XXX,请甄别信息真实性】",
|
||||
},
|
||||
"output_format": {
|
||||
"strategies": [
|
||||
{"title": "策略标题", "content": "策略内容"}
|
||||
],
|
||||
"summary": "一句话总结",
|
||||
},
|
||||
# TODO: P9-T1 细化 - objective_data 等客户消费数据
|
||||
"data": {
|
||||
"objective_data": "待 P9-T1 补充",
|
||||
},
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
user_content = (
|
||||
f"请综合分析会员 {member_id} 的客户数据,生成运营策略建议。"
|
||||
"返回 strategies 数组(每条含 title 和 content)和 summary 字段。"
|
||||
"对来自备注的主观信息,请标注【来源:XXX,请甄别信息真实性】。"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
|
||||
def _build_reference(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
cache_svc: AICacheService | None,
|
||||
) -> dict:
|
||||
"""构建 Prompt reference 字段。
|
||||
|
||||
包含:
|
||||
- App8 最新维客线索(如有)
|
||||
- 最近 2 套 App8 历史(附 generated_at)
|
||||
|
||||
缓存不存在时返回空对象 {}。
|
||||
"""
|
||||
if cache_svc is None:
|
||||
return {}
|
||||
|
||||
reference: dict = {}
|
||||
target_id = str(member_id)
|
||||
|
||||
# App8 最新
|
||||
app8_latest = cache_svc.get_latest(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id,
|
||||
)
|
||||
if app8_latest:
|
||||
reference["app8_latest"] = {
|
||||
"result_json": app8_latest.get("result_json"),
|
||||
"generated_at": app8_latest.get("created_at"),
|
||||
}
|
||||
|
||||
# 最近 2 套 App8 历史
|
||||
app8_history = cache_svc.get_history(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id, limit=2,
|
||||
)
|
||||
if app8_history:
|
||||
reference["app8_history"] = [
|
||||
{
|
||||
"result_json": h.get("result_json"),
|
||||
"generated_at": h.get("created_at"),
|
||||
}
|
||||
for h in app8_history
|
||||
]
|
||||
|
||||
return reference
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App7 客户分析。
|
||||
|
||||
Args:
|
||||
context: site_id, member_id
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(strategies 数组 + summary)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = build_prompt(context, cache_svc)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"member_id": member_id},
|
||||
)
|
||||
|
||||
# 写入 system + user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 3. 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 4. 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 5. 写入缓存
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP7_CUSTOMER_ANALYSIS.value,
|
||||
site_id=site_id,
|
||||
target_id=str(member_id),
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App7 客户分析完成: site_id=%s member_id=%s conversation_id=%s tokens=%d",
|
||||
site_id, member_id, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
211
apps/backend/app/ai/apps/app8_consolidation.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""应用 8:维客线索整理。
|
||||
|
||||
接收 App3(消费分析)和 App6(备注分析)的线索,
|
||||
通过百炼 AI 整合去重,然后全量替换写入 member_retention_clue 表。
|
||||
|
||||
app_id = "app8_consolidation"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.prompts.app8_consolidation_prompt import build_prompt
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
from app.database import get_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app8_consolidation"
|
||||
|
||||
|
||||
class ClueWriter:
|
||||
"""维客线索全量替换写入器。
|
||||
|
||||
DELETE source IN ('ai_consumption', 'ai_note') → INSERT 新线索(事务)。
|
||||
人工线索(source='manual')不受影响。
|
||||
"""
|
||||
|
||||
def replace_ai_clues(
|
||||
self,
|
||||
member_id: int,
|
||||
site_id: int,
|
||||
clues: list[dict],
|
||||
) -> int:
|
||||
"""全量替换该客户的 AI 来源线索,返回写入数量。
|
||||
|
||||
在单个事务中执行 DELETE + INSERT,失败时回滚保留原有线索。
|
||||
|
||||
字段映射:
|
||||
- category → category
|
||||
- emoji + " " + summary → summary(如 "📅 偏好周末下午时段消费")
|
||||
- detail → detail
|
||||
- providers → recorded_by_name
|
||||
- source: 根据 providers 判断(见 _determine_source)
|
||||
- recorded_by_assistant_id: NULL(系统触发)
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 1. 删除该客户所有 AI 来源线索
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM member_retention_clue
|
||||
WHERE member_id = %s AND site_id = %s
|
||||
AND source IN ('ai_consumption', 'ai_note')
|
||||
""",
|
||||
(member_id, site_id),
|
||||
)
|
||||
|
||||
# 2. 插入新线索
|
||||
for clue in clues:
|
||||
emoji = clue.get("emoji", "")
|
||||
raw_summary = clue.get("summary", "")
|
||||
summary = f"{emoji} {raw_summary}" if emoji else raw_summary
|
||||
source = _determine_source(clue.get("providers", ""))
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO member_retention_clue
|
||||
(member_id, site_id, category, summary, detail,
|
||||
source, recorded_by_name, recorded_by_assistant_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, NULL)
|
||||
""",
|
||||
(
|
||||
member_id,
|
||||
site_id,
|
||||
clue.get("category", ""),
|
||||
summary,
|
||||
clue.get("detail", ""),
|
||||
source,
|
||||
clue.get("providers", ""),
|
||||
),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return len(clues)
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _determine_source(providers: str) -> str:
|
||||
"""根据 providers 判断 source 值。
|
||||
|
||||
- 纯 App3(providers 仅含"系统")→ ai_consumption
|
||||
- 纯 App6(providers 不含"系统")→ ai_note
|
||||
- 混合来源 → ai_consumption
|
||||
"""
|
||||
if not providers:
|
||||
return "ai_consumption"
|
||||
provider_list = [p.strip() for p in providers.split(",")]
|
||||
has_system = "系统" in provider_list
|
||||
has_human = any(p != "系统" for p in provider_list if p)
|
||||
if has_system and not has_human:
|
||||
# 纯 App3(系统自动分析)
|
||||
return "ai_consumption"
|
||||
elif has_human and not has_system:
|
||||
# 纯 App6(人工备注分析)
|
||||
return "ai_note"
|
||||
else:
|
||||
# 混合来源
|
||||
return "ai_consumption"
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App8 维客线索整理。
|
||||
|
||||
流程:
|
||||
1. build_prompt 构建 Prompt
|
||||
2. bailian.chat_json 调用百炼
|
||||
3. 写入 conversation + messages
|
||||
4. 写入 ai_cache
|
||||
5. ClueWriter 全量替换 member_retention_clue
|
||||
6. 返回结果
|
||||
|
||||
Args:
|
||||
context: site_id, member_id, app3_clues, app6_clues,
|
||||
app3_generated_at, app6_generated_at
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(clues 数组)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = build_prompt(context)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"member_id": member_id},
|
||||
)
|
||||
|
||||
# 写入 system + user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 3. 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 4. 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 5. 写入缓存
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value,
|
||||
site_id=site_id,
|
||||
target_id=str(member_id),
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
)
|
||||
|
||||
# 6. 全量替换 member_retention_clue
|
||||
clues = result.get("clues", [])
|
||||
if clues:
|
||||
writer = ClueWriter()
|
||||
written = writer.replace_ai_clues(member_id, site_id, clues)
|
||||
logger.info(
|
||||
"App8 线索写入完成: site_id=%s member_id=%s written=%d",
|
||||
site_id, member_id, written,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App8 线索整理完成: site_id=%s member_id=%s conversation_id=%s tokens=%d",
|
||||
site_id, member_id, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
338
apps/backend/app/ai/dispatcher.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""AI 事件调度与调用链编排器。
|
||||
|
||||
根据业务事件(消费、备注、任务分配)编排 AI 应用调用链,
|
||||
确保执行顺序和数据依赖正确。
|
||||
|
||||
调用链:
|
||||
- 消费事件(无助教):App3 → App8 → App7
|
||||
- 消费事件(有助教):App3 → App8 → App7 + App4 → App5
|
||||
- 备注事件:App6 → App8
|
||||
- 任务分配事件:App4 → App5(读已有 App8 缓存)
|
||||
|
||||
容错策略:
|
||||
- 某步失败记录错误日志,后续应用使用已有缓存继续
|
||||
- 失败应用写入失败 conversation 记录
|
||||
- 整条链后台异步执行,不阻塞业务请求
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Callable, Coroutine
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AIDispatcher:
|
||||
"""AI 应用调用链编排器。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bailian: BailianClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> None:
|
||||
self.bailian = bailian
|
||||
self.cache_svc = cache_svc
|
||||
self.conv_svc = conv_svc
|
||||
|
||||
async def handle_consumption_event(
|
||||
self,
|
||||
member_id: int,
|
||||
site_id: int,
|
||||
settle_id: int,
|
||||
assistant_id: int | None = None,
|
||||
) -> None:
|
||||
"""消费事件链:App3 → App8 → App7(+ App4 → App5 如有助教)。"""
|
||||
from app.ai.apps.app3_clue import run as app3_run
|
||||
from app.ai.apps.app4_analysis import run as app4_run
|
||||
from app.ai.apps.app5_tactics import run as app5_run
|
||||
from app.ai.apps.app7_customer import run as app7_run
|
||||
from app.ai.apps.app8_consolidation import run as app8_run
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"member_id": member_id,
|
||||
"site_id": site_id,
|
||||
"settle_id": settle_id,
|
||||
"user_id": "system",
|
||||
"nickname": "",
|
||||
}
|
||||
|
||||
# 步骤 1:App3 线索分析
|
||||
app3_result = await self._run_step("app3_clue", app3_run, context)
|
||||
|
||||
# 步骤 2:App8 线索整理(需要 App3 的 clues)
|
||||
app8_context = {**context}
|
||||
# 从 App3 结果提取 clues;同时从缓存获取 App6 已有线索
|
||||
if app3_result:
|
||||
app8_context["app3_clues"] = app3_result.get("clues", [])
|
||||
app8_context["app3_generated_at"] = None # 刚生成,无需时间戳
|
||||
else:
|
||||
app8_context["app3_clues"] = []
|
||||
app8_context["app3_generated_at"] = None
|
||||
|
||||
# 从缓存获取 App6 已有线索
|
||||
app6_cache = self.cache_svc.get_latest(
|
||||
CacheTypeEnum.APP6_NOTE_ANALYSIS.value, site_id, str(member_id),
|
||||
)
|
||||
if app6_cache:
|
||||
app6_result_json = app6_cache.get("result_json", {})
|
||||
if isinstance(app6_result_json, str):
|
||||
try:
|
||||
app6_result_json = json.loads(app6_result_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
app6_result_json = {}
|
||||
app8_context["app6_clues"] = app6_result_json.get("clues", [])
|
||||
app8_context["app6_generated_at"] = app6_cache.get("created_at")
|
||||
else:
|
||||
app8_context["app6_clues"] = []
|
||||
app8_context["app6_generated_at"] = None
|
||||
|
||||
await self._run_step("app8_consolidation", app8_run, app8_context)
|
||||
|
||||
# 步骤 3:App7 客户分析
|
||||
await self._run_step("app7_customer", app7_run, context)
|
||||
|
||||
# 步骤 4(可选):如有助教,App4 → App5
|
||||
if assistant_id is not None:
|
||||
app4_context = {**context, "assistant_id": assistant_id}
|
||||
app4_result = await self._run_step("app4_analysis", app4_run, app4_context)
|
||||
|
||||
app5_context = {
|
||||
**context,
|
||||
"assistant_id": assistant_id,
|
||||
"app4_result": app4_result or {},
|
||||
}
|
||||
await self._run_step("app5_tactics", app5_run, app5_context)
|
||||
|
||||
async def handle_note_event(
|
||||
self,
|
||||
member_id: int,
|
||||
site_id: int,
|
||||
note_id: int,
|
||||
note_content: str,
|
||||
noted_by_name: str,
|
||||
) -> None:
|
||||
"""备注事件链:App6 → App8。"""
|
||||
from app.ai.apps.app6_note import run as app6_run
|
||||
from app.ai.apps.app8_consolidation import run as app8_run
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"member_id": member_id,
|
||||
"site_id": site_id,
|
||||
"note_id": note_id,
|
||||
"note_content": note_content,
|
||||
"noted_by_name": noted_by_name,
|
||||
"user_id": "system",
|
||||
"nickname": "",
|
||||
}
|
||||
|
||||
# 步骤 1:App6 备注分析
|
||||
app6_result = await self._run_step("app6_note", app6_run, context)
|
||||
|
||||
# 步骤 2:App8 线索整理(需要 App6 的 clues)
|
||||
app8_context: dict[str, Any] = {
|
||||
"member_id": member_id,
|
||||
"site_id": site_id,
|
||||
"user_id": "system",
|
||||
"nickname": "",
|
||||
}
|
||||
|
||||
if app6_result:
|
||||
app8_context["app6_clues"] = app6_result.get("clues", [])
|
||||
app8_context["app6_generated_at"] = None
|
||||
else:
|
||||
app8_context["app6_clues"] = []
|
||||
app8_context["app6_generated_at"] = None
|
||||
|
||||
# 从缓存获取 App3 已有线索
|
||||
app3_cache = self.cache_svc.get_latest(
|
||||
CacheTypeEnum.APP3_CLUE.value, site_id, str(member_id),
|
||||
)
|
||||
if app3_cache:
|
||||
app3_result_json = app3_cache.get("result_json", {})
|
||||
if isinstance(app3_result_json, str):
|
||||
try:
|
||||
app3_result_json = json.loads(app3_result_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
app3_result_json = {}
|
||||
app8_context["app3_clues"] = app3_result_json.get("clues", [])
|
||||
app8_context["app3_generated_at"] = app3_cache.get("created_at")
|
||||
else:
|
||||
app8_context["app3_clues"] = []
|
||||
app8_context["app3_generated_at"] = None
|
||||
|
||||
await self._run_step("app8_consolidation", app8_run, app8_context)
|
||||
|
||||
async def handle_task_assign_event(
|
||||
self,
|
||||
assistant_id: int,
|
||||
member_id: int,
|
||||
site_id: int,
|
||||
task_type: str,
|
||||
) -> None:
|
||||
"""任务分配事件链:App4 → App5(读已有 App8 缓存)。"""
|
||||
from app.ai.apps.app4_analysis import run as app4_run
|
||||
from app.ai.apps.app5_tactics import run as app5_run
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"assistant_id": assistant_id,
|
||||
"member_id": member_id,
|
||||
"site_id": site_id,
|
||||
"task_type": task_type,
|
||||
"user_id": "system",
|
||||
"nickname": "",
|
||||
}
|
||||
|
||||
# 步骤 1:App4 关系分析
|
||||
app4_result = await self._run_step("app4_analysis", app4_run, context)
|
||||
|
||||
# 步骤 2:App5 话术参考
|
||||
app5_context = {
|
||||
**context,
|
||||
"app4_result": app4_result or {},
|
||||
}
|
||||
await self._run_step("app5_tactics", app5_run, app5_context)
|
||||
|
||||
async def _run_chain(
|
||||
self,
|
||||
chain: list[tuple[str, Callable[..., Coroutine], dict]],
|
||||
) -> None:
|
||||
"""串行执行调用链,某步失败记录日志后继续。
|
||||
|
||||
Args:
|
||||
chain: [(app_name, run_func, context), ...] 的列表
|
||||
"""
|
||||
for app_name, run_func, ctx in chain:
|
||||
await self._run_step(app_name, run_func, ctx)
|
||||
|
||||
async def _run_step(
|
||||
self,
|
||||
app_name: str,
|
||||
run_func: Callable[..., Coroutine],
|
||||
context: dict,
|
||||
) -> dict | None:
|
||||
"""执行单个应用步骤,失败时记录日志并写入失败 conversation。
|
||||
|
||||
Returns:
|
||||
应用返回结果,失败时返回 None
|
||||
"""
|
||||
try:
|
||||
result = await run_func(
|
||||
context,
|
||||
self.bailian,
|
||||
self.cache_svc,
|
||||
self.conv_svc,
|
||||
)
|
||||
logger.info("调用链步骤成功: %s", app_name)
|
||||
return result
|
||||
except Exception:
|
||||
logger.exception("调用链步骤失败: %s", app_name)
|
||||
# 写入失败 conversation 记录
|
||||
try:
|
||||
site_id = context.get("site_id", 0)
|
||||
conv_id = self.conv_svc.create_conversation(
|
||||
user_id="system",
|
||||
nickname="",
|
||||
app_id=app_name,
|
||||
site_id=site_id,
|
||||
source_context={"error": True, "chain_step": app_name},
|
||||
)
|
||||
self.conv_svc.add_message(
|
||||
conversation_id=conv_id,
|
||||
role="system",
|
||||
content=f"调用链步骤 {app_name} 执行失败",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("写入失败 conversation 记录也失败: %s", app_name)
|
||||
return None
|
||||
|
||||
def _create_ai_event_handlers(dispatcher: AIDispatcher) -> dict[str, Callable]:
|
||||
"""创建 AI 事件处理器,用于注册到 trigger_scheduler。
|
||||
|
||||
每个处理器从 payload 提取参数,通过 asyncio.create_task 后台执行,
|
||||
不阻塞同步的 fire_event 调用。
|
||||
|
||||
Returns:
|
||||
{event_job_type: handler_func} 映射
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
def _get_or_create_loop() -> asyncio.AbstractEventLoop:
|
||||
"""获取当前事件循环,兼容同步调用场景。"""
|
||||
try:
|
||||
return asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.new_event_loop()
|
||||
|
||||
def handle_consumption_settled(payload: dict | None = None, **_kw: Any) -> None:
|
||||
"""消费结算事件处理器(同步入口,内部异步执行)。"""
|
||||
if not payload:
|
||||
logger.warning("consumption_settled 事件缺少 payload")
|
||||
return
|
||||
loop = _get_or_create_loop()
|
||||
loop.create_task(
|
||||
dispatcher.handle_consumption_event(
|
||||
member_id=payload["member_id"],
|
||||
site_id=payload["site_id"],
|
||||
settle_id=payload["settle_id"],
|
||||
assistant_id=payload.get("assistant_id"),
|
||||
)
|
||||
)
|
||||
|
||||
def handle_note_created(payload: dict | None = None, **_kw: Any) -> None:
|
||||
"""备注创建事件处理器。"""
|
||||
if not payload:
|
||||
logger.warning("note_created 事件缺少 payload")
|
||||
return
|
||||
loop = _get_or_create_loop()
|
||||
loop.create_task(
|
||||
dispatcher.handle_note_event(
|
||||
member_id=payload["member_id"],
|
||||
site_id=payload["site_id"],
|
||||
note_id=payload["note_id"],
|
||||
note_content=payload.get("note_content", ""),
|
||||
noted_by_name=payload.get("noted_by_name", ""),
|
||||
)
|
||||
)
|
||||
|
||||
def handle_task_assigned(payload: dict | None = None, **_kw: Any) -> None:
|
||||
"""任务分配事件处理器。"""
|
||||
if not payload:
|
||||
logger.warning("task_assigned 事件缺少 payload")
|
||||
return
|
||||
loop = _get_or_create_loop()
|
||||
loop.create_task(
|
||||
dispatcher.handle_task_assign_event(
|
||||
assistant_id=payload["assistant_id"],
|
||||
member_id=payload["member_id"],
|
||||
site_id=payload["site_id"],
|
||||
task_type=payload.get("task_type", ""),
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"ai_consumption_settled": handle_consumption_settled,
|
||||
"ai_note_created": handle_note_created,
|
||||
"ai_task_assigned": handle_task_assigned,
|
||||
}
|
||||
|
||||
|
||||
def register_ai_handlers(dispatcher: AIDispatcher) -> None:
|
||||
"""将 AI 事件处理器注册到 trigger_scheduler。
|
||||
|
||||
在 FastAPI lifespan 中调用,将三个 AI 事件处理器
|
||||
注册为 trigger_scheduler 的 job handler。
|
||||
"""
|
||||
from app.services.trigger_scheduler import register_job
|
||||
|
||||
handlers = _create_ai_event_handlers(dispatcher)
|
||||
for job_type, handler in handlers.items():
|
||||
register_job(job_type, handler)
|
||||
logger.info("已注册 AI 事件处理器: %s", job_type)
|
||||
145
apps/backend/app/ai/prompts/app2_finance_prompt.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""应用 2 财务洞察 Prompt 模板。
|
||||
|
||||
构建包含当期和上期收入结构的完整 Prompt,供百炼 API 生成财务洞察。
|
||||
|
||||
收入字段映射(严格遵守 items_sum 口径):
|
||||
- table_fee = table_charge_money(台费)
|
||||
- assistant_pd = assistant_pd_money(陪打费)
|
||||
- assistant_cx = assistant_cx_money(超休费)
|
||||
- goods = goods_money(商品收入)
|
||||
- recharge = 充值 pay_amount settle_type=5(充值收入)
|
||||
|
||||
禁止使用 consume_money,统一使用:
|
||||
items_sum = table_charge_money + goods_money + assistant_pd_money
|
||||
+ assistant_cx_money + electricity_money
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def build_prompt(context: dict) -> list[dict]:
|
||||
"""构建 App2 财务洞察 Prompt 消息列表。
|
||||
|
||||
Args:
|
||||
context: 包含以下字段:
|
||||
- site_id: int,门店 ID
|
||||
- time_dimension: str,时间维度编码
|
||||
- current_data: dict,当期数据
|
||||
- previous_data: dict,上期数据
|
||||
|
||||
Returns:
|
||||
messages 列表(system + user),供 BailianClient.chat_json 调用
|
||||
"""
|
||||
site_id = context.get("site_id", 0)
|
||||
time_dimension = context.get("time_dimension", "")
|
||||
current_data = context.get("current_data", {})
|
||||
previous_data = context.get("previous_data", {})
|
||||
|
||||
system_content = _build_system_content(
|
||||
site_id=site_id,
|
||||
time_dimension=time_dimension,
|
||||
current_data=current_data,
|
||||
previous_data=previous_data,
|
||||
)
|
||||
|
||||
user_content = (
|
||||
f"请根据以上数据,为门店 {site_id} 生成 {_dimension_label(time_dimension)} 的财务洞察分析。"
|
||||
"以 JSON 格式返回,包含 insights 数组,每项含 seq(序号)、title(标题)、body(正文)。"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
|
||||
def _build_system_content(
|
||||
*,
|
||||
site_id: int,
|
||||
time_dimension: str,
|
||||
current_data: dict,
|
||||
previous_data: dict,
|
||||
) -> dict:
|
||||
"""构建 system prompt JSON 结构。"""
|
||||
return {
|
||||
"task": (
|
||||
"你是台球门店的财务分析 AI 助手。"
|
||||
"根据提供的当期和上期经营数据,生成结构化的财务洞察。"
|
||||
"分析维度包括:收入结构变化、各收入项占比、环比趋势、异常波动。"
|
||||
"输出 JSON 格式:{\"insights\": [{\"seq\": 1, \"title\": \"...\", \"body\": \"...\"}]}"
|
||||
),
|
||||
"data": {
|
||||
"site_id": site_id,
|
||||
"time_dimension": time_dimension,
|
||||
"time_dimension_label": _dimension_label(time_dimension),
|
||||
"current_period": _build_period_data(current_data),
|
||||
"previous_period": _build_period_data(previous_data),
|
||||
},
|
||||
"reference": {
|
||||
"field_mapping": {
|
||||
"items_sum": (
|
||||
"table_charge_money + goods_money + assistant_pd_money"
|
||||
" + assistant_cx_money + electricity_money"
|
||||
),
|
||||
"table_fee": "table_charge_money(台费收入)",
|
||||
"assistant_pd": "assistant_pd_money(陪打费)",
|
||||
"assistant_cx": "assistant_cx_money(超休费)",
|
||||
"goods": "goods_money(商品收入)",
|
||||
"recharge": "充值 pay_amount(settle_type=5,充值收入)",
|
||||
"electricity": "electricity_money(电费,当前未启用,全为 0)",
|
||||
},
|
||||
"rules": [
|
||||
"统一使用 items_sum 口径计算营收总额",
|
||||
"助教费用必须拆分为 assistant_pd_money(陪打)和 assistant_cx_money(超休)",
|
||||
"支付渠道恒等式:balance_amount = recharge_card_amount + gift_card_amount",
|
||||
"金额单位:元(CNY),保留两位小数",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_period_data(data: dict) -> dict:
|
||||
"""构建单期数据结构,确保字段名遵守 items_sum 口径。"""
|
||||
return {
|
||||
# 收入结构(items_sum 口径)
|
||||
"table_charge_money": data.get("table_charge_money", 0),
|
||||
"goods_money": data.get("goods_money", 0),
|
||||
"assistant_pd_money": data.get("assistant_pd_money", 0),
|
||||
"assistant_cx_money": data.get("assistant_cx_money", 0),
|
||||
"electricity_money": data.get("electricity_money", 0),
|
||||
# 充值收入
|
||||
"recharge_income": data.get("recharge_income", 0),
|
||||
# 储值资产
|
||||
"balance_pay": data.get("balance_pay", 0),
|
||||
"recharge_card_pay": data.get("recharge_card_pay", 0),
|
||||
"gift_card_pay": data.get("gift_card_pay", 0),
|
||||
# 费用汇总
|
||||
"discount_amount": data.get("discount_amount", 0),
|
||||
"adjust_amount": data.get("adjust_amount", 0),
|
||||
# 平台结算
|
||||
"platform_settlement_amount": data.get("platform_settlement_amount", 0),
|
||||
"groupbuy_pay_amount": data.get("groupbuy_pay_amount", 0),
|
||||
# 汇总
|
||||
"order_count": data.get("order_count", 0),
|
||||
"member_count": data.get("member_count", 0),
|
||||
}
|
||||
|
||||
|
||||
# 时间维度编码 → 中文标签
|
||||
_DIMENSION_LABELS: dict[str, str] = {
|
||||
"this_month": "本月",
|
||||
"last_month": "上月",
|
||||
"this_week": "本周",
|
||||
"last_week": "上周",
|
||||
"last_3_months": "近三个月",
|
||||
"this_quarter": "本季度",
|
||||
"last_quarter": "上季度",
|
||||
"last_6_months": "近六个月",
|
||||
}
|
||||
|
||||
|
||||
def _dimension_label(dimension: str) -> str:
|
||||
"""将时间维度编码转为中文标签。"""
|
||||
return _DIMENSION_LABELS.get(dimension, dimension)
|
||||
93
apps/backend/app/ai/prompts/app8_consolidation_prompt.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""应用 8:维客线索整理 Prompt 模板。
|
||||
|
||||
接收 App3(消费分析)和 App6(备注分析)的全部线索,
|
||||
整合去重后输出统一维客线索。
|
||||
|
||||
分类标签限定 6 个枚举值(与 member_retention_clue CHECK 约束一致):
|
||||
客户基础、消费习惯、玩法偏好、促销偏好、社交关系、重要反馈。
|
||||
|
||||
合并规则:
|
||||
- 相似线索合并,providers 以逗号分隔
|
||||
- 其余线索原文返回
|
||||
- 最小改动原则
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def build_prompt(context: dict) -> list[dict]:
|
||||
"""构建 App8 维客线索整理 Prompt。
|
||||
|
||||
Args:
|
||||
context: 包含以下字段:
|
||||
- site_id: int
|
||||
- member_id: int
|
||||
- app3_clues: list[dict] — App3 产出的线索列表
|
||||
- app6_clues: list[dict] — App6 产出的线索列表
|
||||
- app3_generated_at: str | None — App3 线索生成时间
|
||||
- app6_generated_at: str | None — App6 线索生成时间
|
||||
|
||||
Returns:
|
||||
消息列表 [{"role": "system", ...}, {"role": "user", ...}]
|
||||
"""
|
||||
member_id = context["member_id"]
|
||||
app3_clues = context.get("app3_clues", [])
|
||||
app6_clues = context.get("app6_clues", [])
|
||||
app3_generated_at = context.get("app3_generated_at")
|
||||
app6_generated_at = context.get("app6_generated_at")
|
||||
|
||||
system_content = {
|
||||
"task": "整合去重来自消费分析和备注分析的维客线索,输出统一线索列表。",
|
||||
"app_id": "app8_consolidation",
|
||||
"rules": {
|
||||
"category_enum": [
|
||||
"客户基础", "消费习惯", "玩法偏好",
|
||||
"促销偏好", "社交关系", "重要反馈",
|
||||
],
|
||||
"merge_strategy": (
|
||||
"相似线索合并为一条,providers 以逗号分隔(如 '系统,张三');"
|
||||
"不相似的线索原文保留,不做修改。最小改动原则。"
|
||||
),
|
||||
"output_format": {
|
||||
"clues": [
|
||||
{
|
||||
"category": "枚举值(6 选 1)",
|
||||
"summary": "一句话摘要",
|
||||
"detail": "详细说明",
|
||||
"emoji": "表情符号",
|
||||
"providers": "提供者(逗号分隔)",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
"input": {
|
||||
"app3_clues": {
|
||||
"source": "消费数据分析(App3)",
|
||||
"generated_at": app3_generated_at,
|
||||
"clues": app3_clues,
|
||||
},
|
||||
"app6_clues": {
|
||||
"source": "备注分析(App6)",
|
||||
"generated_at": app6_generated_at,
|
||||
"clues": app6_clues,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
user_content = (
|
||||
f"请整合会员 {member_id} 的维客线索。\n"
|
||||
"输入包含两个来源的线索:App3(消费数据分析)和 App6(备注分析)。\n"
|
||||
"规则:\n"
|
||||
"1. 相似线索合并为一条,providers 字段以逗号分隔多个提供者\n"
|
||||
"2. 不相似的线索原文保留\n"
|
||||
"3. category 必须是:客户基础、消费习惯、玩法偏好、促销偏好、社交关系、重要反馈 之一\n"
|
||||
"4. 每条线索包含 category、summary、detail、emoji、providers 五个字段\n"
|
||||
"5. 最小改动原则,尽量保留原始表述"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
@@ -17,7 +17,9 @@ from app import config
|
||||
# CHANGE 2026-02-26 | member_birthday 路由替换为 member_retention_clue(维客线索重构)
|
||||
# CHANGE 2026-02-26 | 新增 admin_applications 路由(管理端申请审核)
|
||||
# CHANGE 2026-02-27 | 新增 xcx_tasks / xcx_notes 路由(小程序核心业务)
|
||||
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
|
||||
# CHANGE 2026-03-09 | 新增 xcx_ai_chat 路由(AI SSE 对话 + 历史对话)
|
||||
# CHANGE 2026-03-09 | 新增 xcx_ai_cache 路由(AI 缓存查询)
|
||||
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
|
||||
from app.services.scheduler import scheduler
|
||||
from app.services.task_queue import task_queue
|
||||
from app.ws.logs import ws_router
|
||||
@@ -57,6 +59,25 @@ async def lifespan(app: FastAPI):
|
||||
register_job("recall_completion_check", recall_detector.run)
|
||||
register_job("note_reclassify_backfill", note_reclassifier.run)
|
||||
|
||||
# CHANGE 2026-03-10 | 注册 AI 事件处理器(消费/备注/任务分配 → AI 调用链)
|
||||
try:
|
||||
import os
|
||||
_api_key = os.environ.get("BAILIAN_API_KEY", "")
|
||||
_base_url = os.environ.get("BAILIAN_BASE_URL", "")
|
||||
_model = os.environ.get("BAILIAN_MODEL", "qwen-plus")
|
||||
if _api_key and _base_url:
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.dispatcher import AIDispatcher, register_ai_handlers
|
||||
|
||||
_bailian = BailianClient(api_key=_api_key, base_url=_base_url, model=_model)
|
||||
_dispatcher = AIDispatcher(_bailian, AICacheService(), ConversationService())
|
||||
register_ai_handlers(_dispatcher)
|
||||
except Exception:
|
||||
import logging as _log
|
||||
_log.getLogger(__name__).warning("AI 事件处理器注册失败,AI 功能不可用", exc_info=True)
|
||||
|
||||
yield
|
||||
# 关闭
|
||||
await scheduler.stop()
|
||||
@@ -100,6 +121,8 @@ app.include_router(admin_applications.router)
|
||||
app.include_router(business_day.router)
|
||||
app.include_router(xcx_tasks.router)
|
||||
app.include_router(xcx_notes.router)
|
||||
app.include_router(xcx_ai_chat.router)
|
||||
app.include_router(xcx_ai_cache.router)
|
||||
|
||||
|
||||
@app.get("/health", tags=["系统"])
|
||||
|
||||
59
apps/backend/app/routers/xcx_ai_cache.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
小程序 AI 缓存查询路由 —— 查询各 AI 应用的最新缓存结果。
|
||||
|
||||
端点清单:
|
||||
- GET /api/ai/cache/{cache_type}?target_id=xxx — 查询最新缓存
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/ai", tags=["小程序 AI 缓存"])
|
||||
|
||||
|
||||
@router.get("/cache/{cache_type}")
|
||||
async def get_ai_cache(
|
||||
cache_type: str,
|
||||
target_id: str = Query(..., description="目标 ID(member_id / assistant_id_member_id / 时间维度编码)"),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""查询指定类型的最新 AI 缓存结果。
|
||||
|
||||
site_id 从 JWT 提取,强制过滤,确保门店隔离。
|
||||
"""
|
||||
# 校验 cache_type 合法性
|
||||
valid_types = {e.value for e in CacheTypeEnum}
|
||||
if cache_type not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"无效的 cache_type: {cache_type},合法值: {sorted(valid_types)}",
|
||||
)
|
||||
|
||||
cache_svc = AICacheService()
|
||||
result = cache_svc.get_latest(
|
||||
cache_type=cache_type,
|
||||
site_id=user.site_id,
|
||||
target_id=target_id,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": result.get("id"),
|
||||
"cache_type": result.get("cache_type"),
|
||||
"target_id": result.get("target_id"),
|
||||
"result_json": result.get("result_json"),
|
||||
"score": result.get("score"),
|
||||
"created_at": result.get("created_at"),
|
||||
}
|
||||
223
apps/backend/app/routers/xcx_ai_chat.py
Normal file
@@ -0,0 +1,223 @@
|
||||
# -*- 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
|
||||
]
|
||||
@@ -26,6 +26,8 @@ class TaskListItem(BaseModel):
|
||||
# RS 指数 + 爱心 icon
|
||||
rs_score: float | None
|
||||
heart_icon: str # 💖 / 🧡 / 💛 / 💙
|
||||
# 放弃原因(仅 abandoned 任务有值)
|
||||
abandon_reason: str | None = None
|
||||
|
||||
|
||||
class AbandonRequest(BaseModel):
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
"""
|
||||
备注回溯重分类器(Note Reclassifier)
|
||||
|
||||
召回完成后,回溯检查是否有普通备注需重分类为回访备注。
|
||||
查找 service_time 之后的第一条 normal 备注 → 更新为 follow_up →
|
||||
触发 AI 应用 6 接口(占位)→ 根据 ai_score 生成 follow_up_visit 任务。
|
||||
召回完成后,回溯检查是否有普通备注需重分类为回访备注,并创建回访任务。
|
||||
|
||||
流程:
|
||||
1. 查找 service_time 之后的第一条 normal 备注
|
||||
2. 若找到 → 重分类为 follow_up,任务状态 = completed(回溯完成)
|
||||
3. 若未找到 → 任务状态 = active(等待备注)
|
||||
4. 冲突检查:已有 completed → 跳过;已有 active → 顶替;否则正常创建
|
||||
5. 保留 ai_analyze_note() 占位调用,返回值仅更新 ai_score 字段
|
||||
|
||||
由 trigger_jobs 中的 note_reclassify_backfill 配置驱动(event: recall_completed)。
|
||||
"""
|
||||
@@ -62,21 +67,27 @@ def ai_analyze_note(note_id: int) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def run(payload: dict | None = None) -> dict:
|
||||
def run(payload: dict | None = None, job_id: int | None = None) -> dict:
|
||||
"""
|
||||
备注回溯主流程。
|
||||
|
||||
payload 包含: {site_id, assistant_id, member_id, service_time}
|
||||
|
||||
1. 查找 biz.notes 中该 (site_id, target_type='member', target_id=member_id)
|
||||
在 service_time 之后提交的第一条 type='normal' 的备注
|
||||
2. 将该备注 type 从 'normal' 更新为 'follow_up'
|
||||
3. 触发 AI 应用 6 接口(P5 实现,本 SPEC 仅定义触发接口):
|
||||
- 调用 ai_analyze_note(note_id) → 返回 ai_score
|
||||
4. 若 ai_score >= 6:
|
||||
- 生成 follow_up_visit 任务,status='completed'(回溯完成)
|
||||
5. 若 ai_score < 6:
|
||||
- 生成 follow_up_visit 任务,status='active'(需助教重新备注)
|
||||
流程:
|
||||
1. 查找 service_time 之后的第一条 normal 备注 → note_id
|
||||
2. 若 note_id 存在:重分类为 follow_up,task_status = 'completed'(回溯完成)
|
||||
3. 若 note_id 不存在:task_status = 'active'(等待备注)
|
||||
4. 保留 ai_analyze_note() 占位调用,返回值仅更新 ai_score 字段
|
||||
5. 冲突检查(T3):
|
||||
- 已有 completed → 跳过创建
|
||||
- 已有 active → 旧任务标记 inactive + superseded 历史,创建新任务
|
||||
- 不存在(或仅 inactive/abandoned)→ 正常创建
|
||||
6. 创建 follow_up_visit 任务
|
||||
|
||||
参数:
|
||||
payload: 事件载荷(由 trigger_scheduler 传入)
|
||||
job_id: 触发器 job ID(由 trigger_scheduler 传入),用于在最终事务中
|
||||
更新 last_run_at,保证 handler 数据变更与 last_run_at 原子提交
|
||||
|
||||
返回: {"reclassified_count": int, "tasks_created": int}
|
||||
"""
|
||||
@@ -119,84 +130,166 @@ def run(payload: dict | None = None) -> dict:
|
||||
note_id = row[0]
|
||||
conn.commit()
|
||||
|
||||
if note_id is None:
|
||||
logger.info(
|
||||
"未找到符合条件的 normal 备注: site_id=%s, member_id=%s",
|
||||
site_id, member_id,
|
||||
)
|
||||
return {"reclassified_count": 0, "tasks_created": 0}
|
||||
|
||||
# ── 2. 将备注 type 从 'normal' 更新为 'follow_up' ──
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.notes
|
||||
SET type = 'follow_up', updated_at = NOW()
|
||||
WHERE id = %s AND type = 'normal'
|
||||
""",
|
||||
(note_id,),
|
||||
)
|
||||
conn.commit()
|
||||
reclassified_count = 1
|
||||
|
||||
# ── 3. 触发 AI 应用 6 接口(占位,当前返回 None) ──
|
||||
ai_score = ai_analyze_note(note_id)
|
||||
|
||||
# ── 4/5. 根据 ai_score 生成 follow_up_visit 任务 ──
|
||||
if ai_score is not None:
|
||||
if ai_score >= 6:
|
||||
# 回溯完成:生成 completed 任务
|
||||
task_status = "completed"
|
||||
else:
|
||||
# 需助教重新备注:生成 active 任务
|
||||
task_status = "active"
|
||||
|
||||
# ── 2. 根据是否找到备注确定任务状态(T4) ──
|
||||
if note_id is not None:
|
||||
# 找到备注 → 重分类为 follow_up
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_tasks
|
||||
(site_id, assistant_id, member_id, task_type,
|
||||
status, completed_at, completed_task_type)
|
||||
VALUES (
|
||||
%s, %s, %s, 'follow_up_visit',
|
||||
%s,
|
||||
CASE WHEN %s = 'completed' THEN NOW() ELSE NULL END,
|
||||
CASE WHEN %s = 'completed' THEN 'follow_up_visit' ELSE NULL END
|
||||
)
|
||||
RETURNING id
|
||||
UPDATE biz.notes
|
||||
SET type = 'follow_up', updated_at = NOW()
|
||||
WHERE id = %s AND type = 'normal'
|
||||
""",
|
||||
(
|
||||
site_id, assistant_id, member_id,
|
||||
task_status, task_status, task_status,
|
||||
),
|
||||
)
|
||||
new_task_row = cur.fetchone()
|
||||
new_task_id = new_task_row[0]
|
||||
|
||||
# 记录任务创建历史
|
||||
_insert_history(
|
||||
cur,
|
||||
new_task_id,
|
||||
action="created_by_reclassify",
|
||||
old_status=None,
|
||||
new_status=task_status,
|
||||
old_task_type=None,
|
||||
new_task_type="follow_up_visit",
|
||||
detail={
|
||||
"note_id": note_id,
|
||||
"ai_score": ai_score,
|
||||
"source": "note_reclassifier",
|
||||
},
|
||||
(note_id,),
|
||||
)
|
||||
conn.commit()
|
||||
tasks_created = 1
|
||||
reclassified_count = 1
|
||||
|
||||
# 保留 AI 占位调用,返回值仅用于更新 ai_score 字段
|
||||
ai_score = ai_analyze_note(note_id)
|
||||
if ai_score is not None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.notes
|
||||
SET ai_score = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(ai_score, note_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# 有备注 → 回溯完成
|
||||
task_status = "completed"
|
||||
else:
|
||||
# AI 未就绪,跳过任务创建
|
||||
# 未找到备注 → 等待备注
|
||||
logger.info(
|
||||
"AI 接口未就绪,跳过任务创建: note_id=%s", note_id
|
||||
"未找到符合条件的 normal 备注: site_id=%s, member_id=%s",
|
||||
site_id, member_id,
|
||||
)
|
||||
ai_score = None
|
||||
task_status = "active"
|
||||
|
||||
# ── 3. 冲突检查(T3):查询已有 follow_up_visit 任务 ──
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, status
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s AND assistant_id = %s AND member_id = %s
|
||||
AND task_type = 'follow_up_visit'
|
||||
AND status IN ('active', 'completed')
|
||||
ORDER BY CASE WHEN status = 'completed' THEN 0 ELSE 1 END
|
||||
LIMIT 1
|
||||
""",
|
||||
(site_id, assistant_id, member_id),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
conn.commit()
|
||||
|
||||
if existing:
|
||||
existing_id, existing_status = existing
|
||||
if existing_status == "completed":
|
||||
# 已完成 → 跳过创建(回访完成语义已满足)
|
||||
logger.info(
|
||||
"已存在 completed 回访任务 id=%s,跳过创建: "
|
||||
"site_id=%s, assistant_id=%s, member_id=%s",
|
||||
existing_id, site_id, assistant_id, member_id,
|
||||
)
|
||||
# 事务安全(T5):即使跳过创建,handler 仍成功,更新 last_run_at
|
||||
if job_id is not None:
|
||||
from app.services.trigger_scheduler import (
|
||||
update_job_last_run_at,
|
||||
)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
update_job_last_run_at(cur, job_id)
|
||||
conn.commit()
|
||||
return {
|
||||
"reclassified_count": reclassified_count,
|
||||
"tasks_created": 0,
|
||||
}
|
||||
elif existing_status == "active":
|
||||
# 顶替:旧任务 → inactive + superseded 历史
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'inactive', updated_at = NOW()
|
||||
WHERE id = %s AND status = 'active'
|
||||
""",
|
||||
(existing_id,),
|
||||
)
|
||||
_insert_history(
|
||||
cur,
|
||||
existing_id,
|
||||
action="superseded",
|
||||
old_status="active",
|
||||
new_status="inactive",
|
||||
detail={
|
||||
"reason": "new_reclassify_task_supersedes",
|
||||
"source": "note_reclassifier",
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(
|
||||
"顶替旧 active 回访任务 id=%s → inactive: "
|
||||
"site_id=%s, assistant_id=%s, member_id=%s",
|
||||
existing_id, site_id, assistant_id, member_id,
|
||||
)
|
||||
|
||||
# ── 4. 创建 follow_up_visit 任务 ──
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_tasks
|
||||
(site_id, assistant_id, member_id, task_type,
|
||||
status, completed_at, completed_task_type)
|
||||
VALUES (
|
||||
%s, %s, %s, 'follow_up_visit',
|
||||
%s,
|
||||
CASE WHEN %s = 'completed' THEN NOW() ELSE NULL END,
|
||||
CASE WHEN %s = 'completed' THEN 'follow_up_visit' ELSE NULL END
|
||||
)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
site_id, assistant_id, member_id,
|
||||
task_status, task_status, task_status,
|
||||
),
|
||||
)
|
||||
new_task_row = cur.fetchone()
|
||||
new_task_id = new_task_row[0]
|
||||
|
||||
# 记录任务创建历史
|
||||
_insert_history(
|
||||
cur,
|
||||
new_task_id,
|
||||
action="created_by_reclassify",
|
||||
old_status=None,
|
||||
new_status=task_status,
|
||||
old_task_type=None,
|
||||
new_task_type="follow_up_visit",
|
||||
detail={
|
||||
"note_id": note_id,
|
||||
"ai_score": ai_score,
|
||||
"source": "note_reclassifier",
|
||||
},
|
||||
)
|
||||
|
||||
# 事务安全(T5):在最终 commit 前更新 last_run_at
|
||||
if job_id is not None:
|
||||
from app.services.trigger_scheduler import update_job_last_run_at
|
||||
|
||||
update_job_last_run_at(cur, job_id)
|
||||
|
||||
conn.commit()
|
||||
tasks_created = 1
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
@@ -215,3 +308,4 @@ def run(payload: dict | None = None) -> dict:
|
||||
"reclassified_count": reclassified_count,
|
||||
"tasks_created": tasks_created,
|
||||
}
|
||||
|
||||
|
||||
@@ -81,8 +81,8 @@ async def create_note(
|
||||
- 否则 → type='normal'
|
||||
3. INSERT INTO biz.notes
|
||||
4. 若 type='follow_up':
|
||||
- 触发 AI 应用 6 分析(P5 实现)
|
||||
- 若 ai_score >= 6 且关联任务 status='active' → 标记任务 completed
|
||||
- 保留 AI 占位调用(P5 接入时调用链不变),返回值仅更新 ai_score
|
||||
- 不论 ai_score 如何,有备注即标记关联 active 回访任务 completed
|
||||
5. 返回创建的备注记录
|
||||
|
||||
注意:星星评分不参与回访完成判定,不参与 AI 分析,仅存储。
|
||||
@@ -171,8 +171,9 @@ async def create_note(
|
||||
"updated_at": row[13].isoformat() if row[13] else None,
|
||||
}
|
||||
|
||||
# 若 type='follow_up',触发 AI 分析并可能标记任务完成
|
||||
# 若 type='follow_up',触发 AI 分析并标记回访任务完成
|
||||
if note_type == "follow_up" and task_id is not None:
|
||||
# 保留 AI 占位调用(P5 接入时调用链不变)
|
||||
ai_score = ai_analyze_note(note["id"])
|
||||
|
||||
if ai_score is not None:
|
||||
@@ -187,32 +188,32 @@ async def create_note(
|
||||
)
|
||||
note["ai_score"] = ai_score
|
||||
|
||||
# 若 ai_score >= 6 且关联任务 status='active' → 标记任务 completed
|
||||
if ai_score >= 6 and task_info and task_info["status"] == "active":
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'completed',
|
||||
completed_at = NOW(),
|
||||
completed_task_type = task_type,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s AND status = 'active'
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_record_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="completed_by_note",
|
||||
old_status="active",
|
||||
new_status="completed",
|
||||
old_task_type=task_info["task_type"],
|
||||
new_task_type=task_info["task_type"],
|
||||
detail={
|
||||
"note_id": note["id"],
|
||||
"ai_score": ai_score,
|
||||
},
|
||||
)
|
||||
# 不论 ai_score 如何,有备注即标记回访任务完成(T4)
|
||||
if task_info and task_info["status"] == "active":
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'completed',
|
||||
completed_at = NOW(),
|
||||
completed_task_type = task_type,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s AND status = 'active'
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_record_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="completed_by_note",
|
||||
old_status="active",
|
||||
new_status="completed",
|
||||
old_task_type=task_info["task_type"],
|
||||
new_task_type=task_info["task_type"],
|
||||
detail={
|
||||
"note_id": note["id"],
|
||||
"ai_score": ai_score,
|
||||
},
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return note
|
||||
|
||||
@@ -52,7 +52,7 @@ def _insert_history(
|
||||
)
|
||||
|
||||
|
||||
def run(payload: dict | None = None) -> dict:
|
||||
def run(payload: dict | None = None, job_id: int | None = None) -> dict:
|
||||
"""
|
||||
召回完成检测主流程。
|
||||
|
||||
@@ -69,6 +69,11 @@ def run(payload: dict | None = None) -> dict:
|
||||
6. 记录 coach_task_history
|
||||
7. 触发 fire_event('recall_completed', {site_id, assistant_id, member_id, service_time})
|
||||
|
||||
参数:
|
||||
payload: 事件载荷(event 触发时由 trigger_scheduler 传入)
|
||||
job_id: 触发器 job ID(由 trigger_scheduler 传入),用于在最终事务中
|
||||
更新 last_run_at,保证 handler 数据变更与 last_run_at 原子提交
|
||||
|
||||
返回: {"completed_count": int}
|
||||
"""
|
||||
completed_count = 0
|
||||
@@ -111,6 +116,17 @@ def run(payload: dict | None = None) -> dict:
|
||||
)
|
||||
conn.rollback()
|
||||
|
||||
# ── 事务安全(T5):handler 成功后更新 last_run_at ──
|
||||
# job_id 由 trigger_scheduler 传入,在 handler 最终事务中更新
|
||||
# handler 异常时此处不会执行(异常向上传播),last_run_at 不变
|
||||
if job_id is not None:
|
||||
from app.services.trigger_scheduler import update_job_last_run_at
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
update_job_last_run_at(cur, job_id)
|
||||
conn.commit()
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -193,7 +209,7 @@ def _process_service_record(
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
|
||||
# 查找匹配的 active 任务
|
||||
# 查找匹配的 active 召回类任务(仅完成召回任务,回访/关系构建不在此处理)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, task_type
|
||||
@@ -202,6 +218,7 @@ def _process_service_record(
|
||||
AND assistant_id = %s
|
||||
AND member_id = %s
|
||||
AND status = 'active'
|
||||
AND task_type IN ('high_priority_recall', 'priority_recall')
|
||||
""",
|
||||
(site_id, assistant_id, member_id),
|
||||
)
|
||||
|
||||
@@ -314,22 +314,55 @@ class TaskExecutor:
|
||||
async def cancel(self, execution_id: str) -> bool:
|
||||
"""向子进程发送终止信号。
|
||||
|
||||
如果进程仍在内存中,发送 terminate 信号;
|
||||
如果进程已不在内存中(如后端重启后),但数据库中仍为 running,
|
||||
则直接将数据库状态标记为 cancelled(幽灵记录兜底)。
|
||||
|
||||
Returns:
|
||||
True 表示成功发送终止信号,False 表示进程不存在或已退出。
|
||||
True 表示成功取消,False 表示任务不存在或已完成。
|
||||
"""
|
||||
proc = self._processes.get(execution_id)
|
||||
if proc is None:
|
||||
return False
|
||||
# subprocess.Popen: poll() 返回 None 表示仍在运行
|
||||
if proc.poll() is not None:
|
||||
return False
|
||||
if proc is not None:
|
||||
# 进程仍在内存中
|
||||
if proc.poll() is not None:
|
||||
return False
|
||||
logger.info("取消 ETL 子进程 [%s], pid=%s", execution_id, proc.pid)
|
||||
try:
|
||||
proc.terminate()
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
return True
|
||||
|
||||
logger.info("取消 ETL 子进程 [%s], pid=%s", execution_id, proc.pid)
|
||||
# 进程不在内存中(后端重启等场景),尝试兜底修正数据库幽灵记录
|
||||
try:
|
||||
proc.terminate()
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
return True
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE task_execution_log
|
||||
SET status = 'cancelled',
|
||||
finished_at = NOW(),
|
||||
error_log = COALESCE(error_log, '')
|
||||
|| E'\n[cancel 兜底] 进程已不在内存中,标记为 cancelled'
|
||||
WHERE id = %s AND status = 'running'
|
||||
""",
|
||||
(execution_id,),
|
||||
)
|
||||
updated = cur.rowcount
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
if updated:
|
||||
logger.info(
|
||||
"兜底取消 execution_log [%s]:数据库状态从 running → cancelled",
|
||||
execution_id,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("兜底取消 execution_log [%s] 失败", execution_id)
|
||||
return False
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 数据库操作(同步,在线程池中执行也可,此处简单直连)
|
||||
|
||||
@@ -121,13 +121,13 @@ def _verify_task_ownership(
|
||||
|
||||
async def get_task_list(user_id: int, site_id: int) -> list[dict]:
|
||||
"""
|
||||
获取助教的活跃任务列表。
|
||||
获取助教的任务列表(含有效 + 已放弃)。
|
||||
|
||||
1. 通过 auth.user_assistant_binding 获取 assistant_id
|
||||
2. 查询 biz.coach_tasks WHERE status='active'
|
||||
2. 查询 biz.coach_tasks WHERE status IN ('active', 'abandoned')
|
||||
3. 通过 FDW 读取客户基本信息(dim_member)和 RS 指数
|
||||
4. 计算爱心 icon 档位
|
||||
5. 排序:is_pinned DESC, priority_score DESC, created_at ASC
|
||||
5. 排序:abandoned 排最后 → is_pinned DESC → priority_score DESC → created_at ASC
|
||||
|
||||
FDW 查询需要 SET LOCAL app.current_site_id。
|
||||
"""
|
||||
@@ -135,17 +135,21 @@ async def get_task_list(user_id: int, site_id: int) -> list[dict]:
|
||||
try:
|
||||
assistant_id = _get_assistant_id(conn, user_id, site_id)
|
||||
|
||||
# 查询活跃任务
|
||||
# 查询有效 + 已放弃任务(abandoned 排最后)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, task_type, status, priority_score, is_pinned,
|
||||
expires_at, created_at, member_id
|
||||
expires_at, created_at, member_id, abandon_reason
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s
|
||||
AND assistant_id = %s
|
||||
AND status = 'active'
|
||||
ORDER BY is_pinned DESC, priority_score DESC NULLS LAST, created_at ASC
|
||||
AND status IN ('active', 'abandoned')
|
||||
ORDER BY
|
||||
CASE WHEN status = 'abandoned' THEN 1 ELSE 0 END ASC,
|
||||
is_pinned DESC,
|
||||
priority_score DESC NULLS LAST,
|
||||
created_at ASC
|
||||
""",
|
||||
(site_id, assistant_id),
|
||||
)
|
||||
@@ -201,7 +205,7 @@ async def get_task_list(user_id: int, site_id: int) -> list[dict]:
|
||||
result = []
|
||||
for task_row in tasks:
|
||||
(task_id, task_type, status, priority_score,
|
||||
is_pinned, expires_at, created_at, member_id) = task_row
|
||||
is_pinned, expires_at, created_at, member_id, abandon_reason) = task_row
|
||||
|
||||
info = member_info_map.get(member_id, {})
|
||||
rs_score = rs_map.get(member_id, Decimal("0"))
|
||||
@@ -220,6 +224,7 @@ async def get_task_list(user_id: int, site_id: int) -> list[dict]:
|
||||
"member_phone": info.get("member_phone"),
|
||||
"rs_score": float(rs_score),
|
||||
"heart_icon": heart_icon,
|
||||
"abandon_reason": abandon_reason,
|
||||
})
|
||||
|
||||
return result
|
||||
@@ -372,6 +377,7 @@ async def cancel_abandon(task_id: int, user_id: int, site_id: int) -> dict:
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'active',
|
||||
is_pinned = FALSE,
|
||||
abandon_reason = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
@@ -389,7 +395,7 @@ async def cancel_abandon(task_id: int, user_id: int, site_id: int) -> dict:
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": task_id, "status": "active"}
|
||||
return {"id": task_id, "status": "active", "is_pinned": False}
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -366,6 +366,9 @@ class TaskQueue:
|
||||
|
||||
async def _process_once(self, executor: Any) -> None:
|
||||
"""单次处理:扫描所有门店的 pending 队列并执行。"""
|
||||
# CHANGE 2026-03-09 | 每次轮询先回收僵尸 running 任务
|
||||
self._recover_zombie_tasks()
|
||||
|
||||
site_ids = self._get_pending_site_ids()
|
||||
|
||||
for site_id in site_ids:
|
||||
@@ -415,6 +418,13 @@ class TaskQueue:
|
||||
except Exception:
|
||||
logger.exception("队列任务执行异常 [%s]", queue_id)
|
||||
self._mark_failed(queue_id, "执行过程中发生未捕获异常")
|
||||
finally:
|
||||
# CHANGE 2026-03-09 | 兜底:确保 task_queue 不会卡在 running
|
||||
# 背景:_update_execution_log 内部异常(如 duration_ms integer 溢出)
|
||||
# 被吞掉后,_update_queue_status_from_log 读到的 execution_log 仍是
|
||||
# running,导致 task_queue 永远卡住,后续任务全部排队。
|
||||
self._ensure_not_stuck_running(queue_id)
|
||||
|
||||
|
||||
def _get_pending_site_ids(self) -> list[int]:
|
||||
"""获取所有有 pending 任务的 site_id 列表(仅限本实例入队的)。"""
|
||||
@@ -484,6 +494,84 @@ class TaskQueue:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _ensure_not_stuck_running(self, queue_id: str) -> None:
|
||||
"""兜底检查:如果 task_queue 仍是 running,强制标记 failed。
|
||||
|
||||
CHANGE 2026-03-09 | 防止 _update_execution_log 内部异常导致
|
||||
task_queue 永远卡在 running 状态。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT status FROM task_queue WHERE id = %s",
|
||||
(queue_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row[0] == "running":
|
||||
logger.warning(
|
||||
"兜底修正:task_queue [%s] 执行完毕但仍为 running,"
|
||||
"强制标记 failed",
|
||||
queue_id,
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE task_queue
|
||||
SET status = 'failed', finished_at = NOW(),
|
||||
error_message = %s
|
||||
WHERE id = %s AND status = 'running'
|
||||
""",
|
||||
(
|
||||
"[兜底修正] 执行流程结束但状态未同步,"
|
||||
"可能因 execution_log 更新失败",
|
||||
queue_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("_ensure_not_stuck_running 异常 [%s]", queue_id)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _recover_zombie_tasks(self, max_running_minutes: int = 180) -> None:
|
||||
"""恢复僵尸 running 任务:超过阈值时间仍为 running 的任务强制标记 failed。
|
||||
|
||||
CHANGE 2026-03-09 | 在 process_loop 每次轮询时调用,作为最后防线。
|
||||
场景:后端进程崩溃/重启后,之前的 running 任务永远不会被更新。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE task_queue
|
||||
SET status = 'failed', finished_at = NOW(),
|
||||
error_message = %s
|
||||
WHERE status = 'running'
|
||||
AND (enqueued_by = %s OR enqueued_by IS NULL)
|
||||
AND started_at < NOW() - INTERVAL '%s minutes'
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
f"[僵尸回收] running 超过 {max_running_minutes} 分钟,"
|
||||
"自动标记 failed",
|
||||
_INSTANCE_ID,
|
||||
max_running_minutes,
|
||||
),
|
||||
)
|
||||
recovered = cur.fetchall()
|
||||
if recovered:
|
||||
ids = [r[0] for r in recovered]
|
||||
logger.warning(
|
||||
"僵尸回收:%d 个 running 任务超时,已标记 failed: %s",
|
||||
len(ids), ids,
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("_recover_zombie_tasks 异常")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 生命周期
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -86,6 +86,9 @@ DWS_TASKS: list[TaskDefinition] = [
|
||||
TaskDefinition("DWS_ASSISTANT_FINANCE", "助教财务汇总", "汇总助教财务数据", "助教", "DWS"),
|
||||
TaskDefinition("DWS_MEMBER_CONSUMPTION", "会员消费分析", "汇总会员消费数据", "会员", "DWS"),
|
||||
TaskDefinition("DWS_MEMBER_VISIT", "会员到店分析", "汇总会员到店频次", "会员", "DWS"),
|
||||
# CHANGE [2026-03-09] intent: 注册项目标签任务,与 ETL 侧 task_registry 同步;全量重建不依赖日期窗口
|
||||
TaskDefinition("DWS_ASSISTANT_PROJECT_TAG", "助教项目标签", "按时间窗口计算助教各项目时长占比标签", "助教", "DWS", requires_window=False),
|
||||
TaskDefinition("DWS_MEMBER_PROJECT_TAG", "客户项目标签", "按时间窗口计算客户各项目消费时长占比标签", "会员", "DWS", requires_window=False),
|
||||
TaskDefinition("DWS_FINANCE_DAILY", "财务日报", "汇总每日财务数据", "财务", "DWS"),
|
||||
TaskDefinition("DWS_FINANCE_RECHARGE", "充值汇总", "汇总充值数据", "财务", "DWS"),
|
||||
TaskDefinition("DWS_FINANCE_INCOME_STRUCTURE", "收入结构", "分析收入结构", "财务", "DWS"),
|
||||
|
||||
@@ -31,6 +31,20 @@ def register_job(job_type: str, handler: Callable) -> None:
|
||||
_JOB_REGISTRY[job_type] = handler
|
||||
|
||||
|
||||
def update_job_last_run_at(cur, job_id: int) -> None:
|
||||
"""
|
||||
在 handler 的事务内更新 last_run_at。
|
||||
|
||||
handler 在最终 commit 前调用此函数,将 last_run_at 更新纳入同一事务。
|
||||
handler 成功 → last_run_at 随事务一起 commit。
|
||||
handler 失败 → last_run_at 随事务一起 rollback。
|
||||
"""
|
||||
cur.execute(
|
||||
"UPDATE biz.trigger_jobs SET last_run_at = NOW() WHERE id = %s",
|
||||
(job_id,),
|
||||
)
|
||||
|
||||
|
||||
def fire_event(event_name: str, payload: dict[str, Any] | None = None) -> int:
|
||||
"""
|
||||
触发事件驱动型任务。
|
||||
@@ -38,6 +52,10 @@ def fire_event(event_name: str, payload: dict[str, Any] | None = None) -> int:
|
||||
查找 trigger_condition='event' 且 trigger_config.event_name 匹配的 enabled job,
|
||||
立即执行对应的 handler。
|
||||
|
||||
事务安全:将 job_id 传入 handler,由 handler 在最终 commit 前
|
||||
更新 last_run_at,保证 handler 数据变更与 last_run_at 在同一事务中。
|
||||
handler 失败时整个事务回滚,last_run_at 不更新。
|
||||
|
||||
返回: 执行的 job 数量
|
||||
"""
|
||||
conn = _get_connection()
|
||||
@@ -55,6 +73,7 @@ def fire_event(event_name: str, payload: dict[str, Any] | None = None) -> int:
|
||||
(event_name,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
conn.commit()
|
||||
|
||||
for job_id, job_type, job_name in rows:
|
||||
handler = _JOB_REGISTRY.get(job_type)
|
||||
@@ -64,18 +83,11 @@ def fire_event(event_name: str, payload: dict[str, Any] | None = None) -> int:
|
||||
)
|
||||
continue
|
||||
try:
|
||||
handler(payload=payload)
|
||||
# 将 job_id 传入 handler,handler 在最终 commit 前更新 last_run_at
|
||||
handler(payload=payload, job_id=job_id)
|
||||
executed += 1
|
||||
# 更新 last_run_at
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE biz.trigger_jobs SET last_run_at = NOW() WHERE id = %s",
|
||||
(job_id,),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("触发器 %s 执行失败", job_name)
|
||||
conn.rollback()
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -87,6 +99,11 @@ def check_scheduled_jobs() -> int:
|
||||
检查 cron/interval 类型的到期 job 并执行。
|
||||
|
||||
由 Scheduler 后台循环调用。
|
||||
|
||||
事务安全:将 conn 和 job_id 传入 handler,由 handler 在最终 commit 前
|
||||
更新 last_run_at 和 next_run_at,保证 handler 数据变更与时间戳在同一事务中。
|
||||
handler 失败时整个事务回滚。
|
||||
|
||||
返回: 执行的 job 数量
|
||||
"""
|
||||
conn = _get_connection()
|
||||
@@ -104,6 +121,7 @@ def check_scheduled_jobs() -> int:
|
||||
""",
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
conn.commit()
|
||||
|
||||
for job_id, job_type, job_name, trigger_condition, trigger_config in rows:
|
||||
handler = _JOB_REGISTRY.get(job_type)
|
||||
@@ -111,11 +129,12 @@ def check_scheduled_jobs() -> int:
|
||||
logger.warning("未注册的 job_type: %s", job_type)
|
||||
continue
|
||||
try:
|
||||
handler()
|
||||
executed += 1
|
||||
# 计算 next_run_at 并更新
|
||||
# cron/interval handler 接受 conn + job_id,在最终 commit 前更新时间戳
|
||||
handler(conn=conn, job_id=job_id)
|
||||
# 计算 next_run_at 并更新(在 handler commit 后的新事务中)
|
||||
next_run = _calculate_next_run(trigger_condition, trigger_config)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.trigger_jobs
|
||||
@@ -125,6 +144,7 @@ def check_scheduled_jobs() -> int:
|
||||
(next_run, job_id),
|
||||
)
|
||||
conn.commit()
|
||||
executed += 1
|
||||
except Exception:
|
||||
logger.exception("触发器 %s 执行失败", job_name)
|
||||
conn.rollback()
|
||||
@@ -156,6 +176,6 @@ def _calculate_next_run(
|
||||
from apps.backend.app.services.scheduler import _parse_simple_cron
|
||||
|
||||
return _parse_simple_cron(
|
||||
trigger_config.get("cron_expression", "0 4 * * *"), now
|
||||
trigger_config.get("cron_expression", "0 7 * * *"), now
|
||||
)
|
||||
return None # event 类型无 next_run_at
|
||||
|
||||
@@ -34,3 +34,4 @@ dev = [
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
@@ -132,7 +132,7 @@ class TestDequeue:
|
||||
config_dict = {"tasks": ["ODS_MEMBER"], "flow": "api_ods"}
|
||||
row = (
|
||||
task_id, 42, json.dumps(config_dict), "pending", 1,
|
||||
None, None, None, None, None,
|
||||
None, None, None, None, None, None,
|
||||
)
|
||||
cur = _mock_cursor(fetchone_val=row)
|
||||
conn = _mock_conn(cur)
|
||||
@@ -152,7 +152,7 @@ class TestDequeue:
|
||||
config_dict = {"tasks": ["ODS_MEMBER"], "flow": "api_ods"}
|
||||
row = (
|
||||
task_id, 42, json.dumps(config_dict), "pending", 1,
|
||||
None, None, None, None, None,
|
||||
None, None, None, None, None, None,
|
||||
)
|
||||
cur = _mock_cursor(fetchone_val=row)
|
||||
conn = _mock_conn(cur)
|
||||
@@ -322,14 +322,17 @@ class TestProcessLoop:
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_once_skips_when_running(self, mock_get_conn, queue):
|
||||
"""有 running 任务时不 dequeue"""
|
||||
# _get_pending_site_ids 返回 [42]
|
||||
# has_running(42) 返回 True
|
||||
# 调用顺序:_recover_zombie_tasks → _get_pending_site_ids → has_running
|
||||
call_count = 0
|
||||
|
||||
def side_effect_conn():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
# _recover_zombie_tasks(无僵尸任务)
|
||||
cur = _mock_cursor()
|
||||
return _mock_conn(cur)
|
||||
elif call_count == 2:
|
||||
# _get_pending_site_ids
|
||||
cur = _mock_cursor(fetchall_val=[(42,)])
|
||||
return _mock_conn(cur)
|
||||
@@ -372,10 +375,14 @@ class TestProcessLoop:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
# _recover_zombie_tasks(无僵尸任务)
|
||||
cur = _mock_cursor()
|
||||
return _mock_conn(cur)
|
||||
elif call_count == 2:
|
||||
# _get_pending_site_ids
|
||||
cur = _mock_cursor(fetchall_val=[(42,)])
|
||||
return _mock_conn(cur)
|
||||
elif call_count == 2:
|
||||
elif call_count == 3:
|
||||
# has_running → False
|
||||
cur = _mock_cursor(fetchone_val=(False,))
|
||||
return _mock_conn(cur)
|
||||
@@ -383,7 +390,7 @@ class TestProcessLoop:
|
||||
# dequeue → 返回任务
|
||||
row = (
|
||||
task_id, 42, config_json, "pending", 1,
|
||||
None, None, None, None, None,
|
||||
None, None, None, None, None, None,
|
||||
)
|
||||
cur = _mock_cursor(fetchone_val=row)
|
||||
return _mock_conn(cur)
|
||||
@@ -402,9 +409,21 @@ class TestProcessLoop:
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_once_no_pending(self, mock_get_conn, queue):
|
||||
"""无 pending 任务时什么都不做"""
|
||||
cur = _mock_cursor(fetchall_val=[])
|
||||
conn = _mock_conn(cur)
|
||||
mock_get_conn.return_value = conn
|
||||
call_count = 0
|
||||
|
||||
def side_effect_conn():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
# _recover_zombie_tasks(无僵尸任务)
|
||||
cur = _mock_cursor()
|
||||
return _mock_conn(cur)
|
||||
else:
|
||||
# _get_pending_site_ids → 空
|
||||
cur = _mock_cursor(fetchall_val=[])
|
||||
return _mock_conn(cur)
|
||||
|
||||
mock_get_conn.side_effect = side_effect_conn
|
||||
|
||||
mock_executor = MagicMock()
|
||||
await queue._process_once(mock_executor)
|
||||
|
||||
@@ -26,7 +26,7 @@ SCHEMA_ETL=meta
|
||||
# API 配置(上游 SaaS API)
|
||||
# ------------------------------------------------------------------------------
|
||||
API_BASE=https://pc.ficoo.vip/apiprod/admin/v1/
|
||||
API_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnQtdHlwZSI6IjQiLCJ1c2VyLXR5cGUiOiIxIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjoiMTIiLCJyb2xlLWlkIjoiMTIiLCJ0ZW5hbnQtaWQiOiIyNzkwNjgzMTYwNzA5OTU3Iiwibmlja25hbWUiOiLnp5_miLfnrqHnkIblkZjvvJrmganmgakxIiwic2l0ZS1pZCI6IjAiLCJtb2JpbGUiOiIxMzgxMDUwMjMwNCIsInNpZCI6IjI5NTA0ODk2NTgzOTU4NDUiLCJzdGFmZi1pZCI6IjMwMDk5MTg2OTE1NTkwNDUiLCJvcmctaWQiOiIwIiwicm9sZS10eXBlIjoiMyIsInJlZnJlc2hUb2tlbiI6IlI5THQvRkVjSGZubkdiOTZJZ3lmdWhjaXU5WnIwREQrZFh1amhVY1RCSDQ9IiwicmVmcmVzaEV4cGlyeVRpbWUiOiIyMDI2LzMvMTEg5LiL5Y2INjo0MjozMSIsIm5lZWRDaGVja1Rva2VuIjoiZmFsc2UiLCJleHAiOjE3NzMyMjU3NTEsImlzcyI6InRlc3QiLCJhdWQiOiJVc2VyIn0.8H5V3W0NfGJrcYo9Ex-35D-SzxhC2tRaZGrgo2reYr4
|
||||
API_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnQtdHlwZSI6IjQiLCJ1c2VyLXR5cGUiOiIxIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjoiMTIiLCJyb2xlLWlkIjoiMTIiLCJ0ZW5hbnQtaWQiOiIyNzkwNjgzMTYwNzA5OTU3Iiwibmlja25hbWUiOiLnp5_miLfnrqHnkIblkZjvvJrmganmgakxIiwic2l0ZS1pZCI6IjAiLCJtb2JpbGUiOiIxMzgxMDUwMjMwNCIsInNpZCI6IjI5NTA0ODk2NTgzOTU4NDUiLCJzdGFmZi1pZCI6IjMwMDk5MTg2OTE1NTkwNDUiLCJvcmctaWQiOiIwIiwicm9sZS10eXBlIjoiMyIsInJlZnJlc2hUb2tlbiI6IncxTkhzalRIeHU1b0Ric0hnQXp6SUgrU2Q2d2M3YndUTTU1ZTZnSXg0RTQ9IiwicmVmcmVzaEV4cGlyeVRpbWUiOiIyMDI2LzMvMTkg5LiK5Y2IMToxNzowMyIsIm5lZWRDaGVja1Rva2VuIjoiZmFsc2UiLCJleHAiOjE3NzM4NTQyMjMsImlzcyI6InRlc3QiLCJhdWQiOiJVc2VyIn0.E7oh3g_-3fyeC1-oHsJXZ-tTGqcvUCMDrd9TifJAs4U
|
||||
API_TIMEOUT=20
|
||||
API_PAGE_SIZE=200
|
||||
API_RETRY_MAX=3
|
||||
|
||||
@@ -58,10 +58,11 @@ class DatabaseConnection:
|
||||
c.execute(sql, args)
|
||||
return c.fetchall()
|
||||
|
||||
def execute(self, sql: str, args=None):
|
||||
"""Execute a SQL statement without returning rows."""
|
||||
def execute(self, sql: str, args=None) -> int:
|
||||
"""Execute a SQL statement without returning rows. Returns rowcount."""
|
||||
with self.conn.cursor() as c:
|
||||
c.execute(sql, args)
|
||||
return c.rowcount
|
||||
|
||||
def commit(self):
|
||||
"""Commit current transaction."""
|
||||
|
||||
@@ -119,9 +119,9 @@ class DatabaseOperations:
|
||||
"""执行查询并返回结果"""
|
||||
return self._connection.query(sql, args)
|
||||
|
||||
def execute(self, sql: str, args=None):
|
||||
"""执行任意 SQL"""
|
||||
self._connection.execute(sql, args)
|
||||
def execute(self, sql: str, args=None) -> int:
|
||||
"""执行任意 SQL,返回 rowcount"""
|
||||
return self._connection.execute(sql, args)
|
||||
|
||||
def cursor(self):
|
||||
"""暴露原生 cursor,供特殊操作使用"""
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# BD_Manual:dim_groupbuy_package_ex 新增团购详情字段
|
||||
|
||||
> 日期:2026-03-05
|
||||
> 涉及库:`etl_feiqiu` / `test_etl_feiqiu`
|
||||
> 迁移脚本:`db/etl_feiqiu/migrations/2026-03-05__add_detail_fields_to_dim_groupbuy_package_ex.sql`
|
||||
> 直接原因:整合团购详情接口(QueryPackageCouponInfo),需在 DWD 扩展表中存储可用台区、助教服务、关联门店等维度信息
|
||||
> Prompt 摘要:etl-coupon-detail spec — 需求 4 验收标准 1
|
||||
|
||||
---
|
||||
|
||||
## 1. 变更说明
|
||||
|
||||
### 变更内容
|
||||
|
||||
在 `dwd.dim_groupbuy_package_ex` 表新增 4 个 JSONB 列,用于存储从团购详情接口提取的维度数据。
|
||||
|
||||
| Schema | 表 | 新增列 | 类型 | 说明 |
|
||||
|--------|-----|--------|------|------|
|
||||
| dwd | dim_groupbuy_package_ex | table_area_ids | JSONB | 可用台区 ID 列表(来自详情接口 tableAreaId) |
|
||||
| dwd | dim_groupbuy_package_ex | table_area_names | JSONB | 可用台区名称列表(来自详情接口 tableAreaNameList) |
|
||||
| dwd | dim_groupbuy_package_ex | assistant_services | JSONB | 助教服务关联(来自详情接口 packageCouponAssistants) |
|
||||
| dwd | dim_groupbuy_package_ex | groupon_site_infos | JSONB | 关联门店信息(来自详情接口 grouponSiteInfos) |
|
||||
|
||||
所有列均为 NULLABLE,使用 `ADD COLUMN IF NOT EXISTS` 确保幂等性。
|
||||
|
||||
### 数据来源
|
||||
|
||||
ODS 层 `ods.group_buy_package_details` 表(由 `ODS_GROUP_PACKAGE` 任务的详情拉取子流程写入),通过 `coupon_id = groupbuy_package_id` 关联后在 DWD 加载时合并。
|
||||
|
||||
---
|
||||
|
||||
## 2. 兼容性影响
|
||||
|
||||
| 组件 | 影响 | 说明 |
|
||||
|------|------|------|
|
||||
| ETL DWD 加载 | 需配合修改 | `dwd_load_task.py` 需新增 LEFT JOIN 逻辑从 ODS 详情表读取并映射到这 4 个字段(Task 4.2/4.3) |
|
||||
| ETL SCD2 | 自动兼容 | 新增 JSONB 字段自动纳入 `_is_row_changed` 变更检测 |
|
||||
| 后端 API | 无影响 | 当前无接口直接查询 dim_groupbuy_package_ex 的详情字段 |
|
||||
| 小程序 | 无影响 | 不直接使用 DWD 层表 |
|
||||
| RLS 视图 | 无影响 | dim_groupbuy_package_ex 无 RLS 视图 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 回滚策略
|
||||
|
||||
```sql
|
||||
ALTER TABLE dwd.dim_groupbuy_package_ex
|
||||
DROP COLUMN IF EXISTS table_area_ids,
|
||||
DROP COLUMN IF EXISTS table_area_names,
|
||||
DROP COLUMN IF EXISTS assistant_services,
|
||||
DROP COLUMN IF EXISTS groupon_site_infos;
|
||||
```
|
||||
|
||||
回滚后需同步撤销 `dwd_load_task.py` 中对应的 LEFT JOIN 和字段映射逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 4. 验证 SQL
|
||||
|
||||
```sql
|
||||
-- 1. 确认 4 个新列存在且类型正确
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'dwd'
|
||||
AND table_name = 'dim_groupbuy_package_ex'
|
||||
AND column_name IN ('table_area_ids', 'table_area_names', 'assistant_services', 'groupon_site_infos')
|
||||
ORDER BY ordinal_position;
|
||||
-- 预期:4 行,data_type 均为 'jsonb',is_nullable 均为 'YES'
|
||||
|
||||
-- 2. 确认列 COMMENT 已写入
|
||||
SELECT a.attname AS column_name, d.description AS comment
|
||||
FROM pg_catalog.pg_attribute a
|
||||
JOIN pg_catalog.pg_class c ON a.attrelid = c.oid
|
||||
JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid
|
||||
LEFT JOIN pg_catalog.pg_description d ON d.objoid = c.oid AND d.objsubid = a.attnum
|
||||
WHERE n.nspname = 'dwd'
|
||||
AND c.relname = 'dim_groupbuy_package_ex'
|
||||
AND a.attname IN ('table_area_ids', 'table_area_names', 'assistant_services', 'groupon_site_infos')
|
||||
ORDER BY a.attnum;
|
||||
-- 预期:4 行,每行 comment 非 NULL
|
||||
|
||||
-- 3. 确认表主键和索引未受影响
|
||||
SELECT indexname, indexdef
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = 'dwd'
|
||||
AND tablename = 'dim_groupbuy_package_ex';
|
||||
-- 预期:原有 4 个索引不变(pkey + 3 个辅助索引)
|
||||
```
|
||||
@@ -29,10 +29,10 @@
|
||||
| 10 | settle_type | INTEGER | YES | | 结账类型。**枚举值**: 1=台桌结账, 3=商城订单, 6=退货订单, 7=退款订单 |
|
||||
| 11 | revoke_order_id | BIGINT | YES | | 撤销订单 ID(当前数据全为 0) |
|
||||
| 12 | member_id | BIGINT | YES | | 会员 ID → dim_member(0=散客,占比约 82.8%) |
|
||||
| 13 | member_name | VARCHAR(100) | YES | | 会员名称 |
|
||||
| 14 | member_phone | VARCHAR(50) | YES | | 会员电话 |
|
||||
| 15 | member_card_account_id | BIGINT | YES | | 会员卡账户 ID(当前数据全为 0) |
|
||||
| 16 | member_card_type_name | VARCHAR(100) | YES | | 卡类型名称(当前数据全为空) |
|
||||
| 13 | member_name | VARCHAR(100) | YES | | 会员名称。⚠️ **DQ-6 断档**:2025-12 起全为 NULL,需通过 `member_id` JOIN `dim_member.nickname` 获取 |
|
||||
| 14 | member_phone | VARCHAR(50) | YES | | 会员电话。⚠️ **DQ-6 断档**:2025-12 起全为 NULL,需通过 `member_id` JOIN `dim_member.mobile` 获取(`scd2_is_current=1`) |
|
||||
| 15 | member_card_account_id | BIGINT | YES | | 会员卡账户 ID。⚠️ **DQ-7 断档**:全为 0,需通过 `member_id` JOIN `dim_member_card_account.tenant_member_id` 获取(`scd2_is_current=1`) |
|
||||
| 16 | member_card_type_name | VARCHAR(100) | YES | | 卡类型名称。⚠️ **DQ-7 断档**:2025-07-21 起全为 NULL,需通过 `member_id` JOIN `dim_member_card_account` 获取 |
|
||||
| 17 | is_bind_member | BOOLEAN | YES | | 是否绑定会员。**枚举值**: False=否 |
|
||||
| 18 | member_discount_amount | NUMERIC(18,2) | YES | | 会员折扣金额 |
|
||||
| 19 | consume_money | NUMERIC(18,2) | YES | | 消费总金额(元)。⚠️ **口径不稳定**:存在三种历史口径(A/B/C),DWS 层不应直接使用,应使用 `items_sum = table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money`。详见 [consume_money 口径](../../../../docs/reports/DWD-DOC/consume/consume-money-caliber.md) |
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
# BD_Manual:dws_assistant_order_contribution(助教订单流水四项统计)
|
||||
|
||||
> DWS 表:`dws.dws_assistant_order_contribution`
|
||||
> DWD 数据源:`dwd.dwd_settlement_head`(结算主表)、`dwd.dwd_table_fee_log`(台费明细)、`dwd.dwd_assistant_service_log`(助教服务记录)
|
||||
> 任务代码:`DWS_ASSISTANT_ORDER_CONTRIBUTION`
|
||||
> 代码位置:`apps/etl/connectors/feiqiu/tasks/dws/assistant_order_contribution_task.py`
|
||||
> DDL 位置:`docs/database/ddl/etl_feiqiu__dws.sql`
|
||||
> 迁移脚本:`db/etl_feiqiu/migrations/2025-02-24__create_dws_assistant_order_contribution.sql`
|
||||
> RLS 视图:`db/etl_feiqiu/migrations/2025-02-24__create_rls_view_assistant_order_contribution.sql`
|
||||
> FDW 映射:`db/zqyy_app/migrations/2025-02-24__add_fdw_dws_extensions.sql`
|
||||
|
||||
---
|
||||
|
||||
## 1. 表结构
|
||||
|
||||
| 列名 | 类型 | 默认值 | 业务含义 | 取值范围/示例 |
|
||||
|------|------|--------|---------|-------------|
|
||||
| `contribution_id` | BIGINT (SERIAL) | nextval 序列 | 自增主键(PK) | 自增 |
|
||||
| `site_id` | BIGINT NOT NULL | — | 门店 ID | 飞球门店 ID |
|
||||
| `tenant_id` | BIGINT NOT NULL | — | 租户 ID | 飞球租户 ID |
|
||||
| `assistant_id` | BIGINT NOT NULL | — | 助教 ID | 飞球助教 ID |
|
||||
| `assistant_nickname` | VARCHAR(100) | NULL | 助教昵称 | 中文昵称 |
|
||||
| `stat_date` | DATE NOT NULL | — | 统计日期 | `2025-01-15` |
|
||||
| `order_gross_revenue` | NUMERIC(14,2) | 0 | 订单总流水 = 台费 + 酒水食品 + 所有助教服务费 | `0.00` ~ 金额值 |
|
||||
| `order_net_revenue` | NUMERIC(14,2) | 0 | 订单净流水 = 订单总流水 - 所有助教服务分成 | `0.00` ~ 金额值 |
|
||||
| `time_weighted_revenue` | NUMERIC(14,2) | 0 | 时效贡献流水 = 台费按时长分摊 + 个人服务费 + 酒水食品按时长比例 | `0.00` ~ 金额值 |
|
||||
| `time_weighted_net_revenue` | NUMERIC(14,2) | 0 | 时效净贡献 = 时效贡献流水 - 个人服务分成 | `0.00` ~ 金额值 |
|
||||
| `order_count` | INTEGER | 0 | 当日参与订单数 | `0` ~ 正整数 |
|
||||
| `total_service_seconds` | INTEGER | 0 | 当日总服务时长(秒) | `0` ~ 正整数 |
|
||||
| `created_at` | TIMESTAMPTZ | NOW() | 记录创建时间 | ISO 时间戳 |
|
||||
| `updated_at` | TIMESTAMPTZ | NOW() | 记录最后更新时间 | ISO 时间戳 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 主键与索引
|
||||
|
||||
| 名称 | 类型 | 列 | 说明 |
|
||||
|------|------|----|------|
|
||||
| `dws_assistant_order_contribution_pkey` | PRIMARY KEY | `contribution_id` | 物理主键(自增序列) |
|
||||
| `idx_aoc_site_assistant_date` | UNIQUE INDEX | `(site_id, assistant_id, stat_date)` | 业务主键:每个门店每个助教每天唯一一条记录 |
|
||||
| `idx_aoc_stat_date` | INDEX | `(site_id, stat_date)` | 按门店+日期查询,支持日度报表 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据写入策略
|
||||
|
||||
- **delete-before-insert**:每次执行按 `site_id` + 日期窗口全量刷新
|
||||
1. `DELETE FROM dws.dws_assistant_order_contribution WHERE site_id = %s AND stat_date BETWEEN %s AND %s`
|
||||
2. 批量 `INSERT` 新计算结果
|
||||
- 继承 `BaseDwsTask` 默认 load 实现,幂等可重跑
|
||||
|
||||
---
|
||||
|
||||
## 4. 算法概要
|
||||
|
||||
### 4.1 数据来源
|
||||
|
||||
| 来源表 | 筛选条件 | 提取内容 |
|
||||
|--------|---------|---------|
|
||||
| `dwd.dwd_settlement_head` | 日期窗口内,`settle_type IN (1, 3)` | 结算单信息、酒水食品金额 |
|
||||
| `dwd.dwd_table_fee_log` | 关联结算单 | 台桌使用时长、台费金额、区域 |
|
||||
| `dwd.dwd_assistant_service_log` | 关联结算单 | 助教服务时长、服务流水、分成、课程类型 |
|
||||
|
||||
### 4.2 四项统计公式
|
||||
|
||||
**订单总流水(order_gross_revenue)**
|
||||
```
|
||||
order_gross_revenue = total_table_fee + total_goods_amount + SUM(所有助教 ledger_amount)
|
||||
```
|
||||
每个参与助教获得相同值。
|
||||
|
||||
**订单净流水(order_net_revenue)**
|
||||
```
|
||||
order_net_revenue = order_gross_revenue - SUM(所有助教 commission)
|
||||
```
|
||||
每个参与助教获得相同值。
|
||||
|
||||
**时效贡献流水(time_weighted_revenue)**
|
||||
```
|
||||
对于台桌 t:
|
||||
billable_seconds = MAX(SUM(助教服务时长), 台桌使用时长)
|
||||
台费分摊_a = table_fee_t × (service_seconds_a / billable_seconds)
|
||||
|
||||
酒水食品分摊_a = total_goods_amount × (助教 a 总服务时长 / 所有助教总服务时长)
|
||||
|
||||
time_weighted_revenue_a = SUM(各台桌台费分摊_a) + ledger_amount_a + 酒水食品分摊_a
|
||||
```
|
||||
|
||||
**时效净贡献(time_weighted_net_revenue)**
|
||||
```
|
||||
time_weighted_net_revenue_a = time_weighted_revenue_a - commission_a
|
||||
```
|
||||
|
||||
### 4.3 超休/打赏课特殊处理
|
||||
|
||||
当 `course_type = BONUS` 时,四项统计均等于个人服务流水和分成,不参与订单级分摊。
|
||||
|
||||
---
|
||||
|
||||
## 5. 前置依赖
|
||||
|
||||
- 任务依赖:`DWD_LOAD_FROM_ODS`(需先完成 DWD 层数据加载)
|
||||
- 数据源表:`dwd.dwd_settlement_head`、`dwd.dwd_table_fee_log`、`dwd.dwd_assistant_service_log` 必须已有数据
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证 SQL
|
||||
|
||||
### 6.1 检查表是否存在且有数据
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
COUNT(*) AS total_rows,
|
||||
COUNT(DISTINCT site_id) AS site_count,
|
||||
COUNT(DISTINCT assistant_id) AS assistant_count,
|
||||
MIN(stat_date) AS earliest_date,
|
||||
MAX(stat_date) AS latest_date
|
||||
FROM dws.dws_assistant_order_contribution;
|
||||
```
|
||||
|
||||
### 6.2 检查业务主键唯一性(不应有重复)
|
||||
|
||||
```sql
|
||||
SELECT site_id, assistant_id, stat_date, COUNT(*) AS cnt
|
||||
FROM dws.dws_assistant_order_contribution
|
||||
GROUP BY site_id, assistant_id, stat_date
|
||||
HAVING COUNT(*) > 1;
|
||||
-- 预期:无结果返回
|
||||
```
|
||||
|
||||
### 6.3 检查四项统计数值合理性(非负)
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE order_gross_revenue < 0) AS neg_gross,
|
||||
COUNT(*) FILTER (WHERE order_net_revenue < 0) AS neg_net,
|
||||
COUNT(*) FILTER (WHERE time_weighted_revenue < 0) AS neg_twr,
|
||||
COUNT(*) FILTER (WHERE time_weighted_net_revenue < 0) AS neg_twnr
|
||||
FROM dws.dws_assistant_order_contribution;
|
||||
-- 预期:所有列均为 0
|
||||
```
|
||||
|
||||
### 6.4 按门店查看统计概况
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
site_id,
|
||||
COUNT(*) AS record_count,
|
||||
SUM(order_count) AS total_orders,
|
||||
ROUND(AVG(order_gross_revenue), 2) AS avg_gross,
|
||||
ROUND(AVG(order_net_revenue), 2) AS avg_net,
|
||||
ROUND(AVG(time_weighted_revenue), 2) AS avg_twr,
|
||||
ROUND(AVG(time_weighted_net_revenue), 2) AS avg_twnr
|
||||
FROM dws.dws_assistant_order_contribution
|
||||
GROUP BY site_id
|
||||
ORDER BY site_id;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. RLS 视图与 FDW 映射
|
||||
|
||||
### 7.1 RLS 视图(ETL 库 app schema)
|
||||
|
||||
```sql
|
||||
-- 视图名:app.v_dws_assistant_order_contribution
|
||||
CREATE OR REPLACE VIEW app.v_dws_assistant_order_contribution AS
|
||||
SELECT * FROM dws.dws_assistant_order_contribution
|
||||
WHERE site_id = current_setting('app.current_site_id')::bigint;
|
||||
```
|
||||
|
||||
### 7.2 FDW 外部表(业务库 fdw_etl schema)
|
||||
|
||||
```sql
|
||||
-- 外部表名:fdw_etl.v_dws_assistant_order_contribution
|
||||
-- 通过 app schema RLS 视图访问,非直接访问 dws schema
|
||||
CREATE FOREIGN TABLE fdw_etl.v_dws_assistant_order_contribution (...)
|
||||
SERVER etl_server
|
||||
OPTIONS (schema_name 'app', table_name 'v_dws_assistant_order_contribution');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 兼容性说明
|
||||
|
||||
| 影响范围 | 说明 |
|
||||
|---------|------|
|
||||
| ETL 任务 | 新增任务 `DWS_ASSISTANT_ORDER_CONTRIBUTION`,依赖 `DWD_LOAD_FROM_ODS`。不影响现有 DWS 任务 |
|
||||
| 后端 API | 当前无 API 直接读取此表。后续小程序助教看板需新增接口 |
|
||||
| 管理后台 | 当前无前端页面展示。后续可在助教详情页新增流水统计展示 |
|
||||
| 小程序 | 小程序助教端将通过后端 API 读取此表数据展示四项统计 |
|
||||
| 其他 DWS 表 | 独立于现有 `dws_assistant_daily_detail`,不修改任何已有表或任务逻辑 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 回滚策略
|
||||
|
||||
### 9.1 删除数据(保留表结构)
|
||||
|
||||
```sql
|
||||
DELETE FROM dws.dws_assistant_order_contribution;
|
||||
```
|
||||
|
||||
### 9.2 完整回滚(删除表 + 视图 + FDW)
|
||||
|
||||
```sql
|
||||
-- 1. 删除 FDW 外部表(业务库)
|
||||
DROP FOREIGN TABLE IF EXISTS fdw_etl.v_dws_assistant_order_contribution;
|
||||
|
||||
-- 2. 删除 RLS 视图(ETL 库)
|
||||
DROP VIEW IF EXISTS app.v_dws_assistant_order_contribution;
|
||||
|
||||
-- 3. 删除表和索引(ETL 库)
|
||||
DROP INDEX IF EXISTS dws.idx_aoc_stat_date;
|
||||
DROP INDEX IF EXISTS dws.idx_aoc_site_assistant_date;
|
||||
DROP TABLE IF EXISTS dws.dws_assistant_order_contribution;
|
||||
```
|
||||
|
||||
### 9.3 回滚任务注册
|
||||
|
||||
从 `orchestration/task_registry.py` 中移除 `DWS_ASSISTANT_ORDER_CONTRIBUTION` 注册行,并从 `tasks/dws/__init__.py` 中移除 `AssistantOrderContributionTask` 导出。
|
||||
|
||||
---
|
||||
|
||||
## 10. 代码引用
|
||||
|
||||
- 任务类:`tasks/dws/assistant_order_contribution_task.py` → `AssistantOrderContributionTask`
|
||||
- 数据结构:`TableUsage`、`AssistantService`、`OrderData`(同文件)
|
||||
- 继承:`BaseDwsTask`
|
||||
- 任务注册:`orchestration/task_registry.py` → `DWS_ASSISTANT_ORDER_CONTRIBUTION`
|
||||
- 属性测试:`tests/test_dws_contribution_properties.py`
|
||||
- 迁移脚本:`db/etl_feiqiu/migrations/2025-02-24__create_dws_assistant_order_contribution.sql`
|
||||
- RLS 视图:`db/etl_feiqiu/migrations/2025-02-24__create_rls_view_assistant_order_contribution.sql`
|
||||
- FDW 映射:`db/zqyy_app/migrations/2025-02-24__add_fdw_dws_extensions.sql`
|
||||
- 验证脚本:`apps/etl/connectors/feiqiu/scripts/verify_dws_extensions.py`
|
||||
|
||||
---
|
||||
|
||||
## 11. 关联扩展字段说明
|
||||
|
||||
本次 Spec(02-etl-dws-miniapp-extensions)同时扩展了两张已有表的字段,简要说明如下:
|
||||
|
||||
### 11.1 dws_member_consumption_summary 新增字段
|
||||
|
||||
| 列名 | 类型 | 默认值 | 业务含义 |
|
||||
|------|------|--------|---------|
|
||||
| `recharge_count_30d` | INTEGER | 0 | 近 30 天充值次数 |
|
||||
| `recharge_count_60d` | INTEGER | 0 | 近 60 天充值次数 |
|
||||
| `recharge_count_90d` | INTEGER | 0 | 近 90 天充值次数 |
|
||||
| `recharge_amount_30d` | NUMERIC(14,2) | 0 | 近 30 天充值金额 |
|
||||
| `recharge_amount_60d` | NUMERIC(14,2) | 0 | 近 60 天充值金额 |
|
||||
| `recharge_amount_90d` | NUMERIC(14,2) | 0 | 近 90 天充值金额 |
|
||||
| `avg_ticket_amount` | NUMERIC(14,2) | 0 | 次均消费 = total_consume_amount / MAX(total_visit_count, 1) |
|
||||
|
||||
迁移脚本:`db/etl_feiqiu/migrations/2025-02-24__alter_member_consumption_add_recharge_fields.sql`
|
||||
|
||||
### 11.2 dws_assistant_daily_detail 新增字段
|
||||
|
||||
| 列名 | 类型 | 默认值 | 业务含义 |
|
||||
|------|------|--------|---------|
|
||||
| `penalty_minutes` | NUMERIC(10,2) | 0 | 定档折算惩罚分钟数,无惩罚时为 0 |
|
||||
| `penalty_reason` | TEXT | NULL | 惩罚原因描述,无惩罚时为 NULL |
|
||||
| `is_exempt` | BOOLEAN | FALSE | 是否豁免惩罚 |
|
||||
| `per_hour_contribution` | NUMERIC(14,2) | NULL | 单人每小时贡献流水 = 台费每小时单价 / 助教人数 |
|
||||
|
||||
迁移脚本:`db/etl_feiqiu/migrations/2025-02-24__alter_assistant_daily_add_penalty_fields.sql`
|
||||
@@ -0,0 +1,80 @@
|
||||
# BD_Manual:DWS 库存汇总(日/周/月)
|
||||
|
||||
> DWS 表:`dws.dws_goods_stock_daily_summary`、`dws.dws_goods_stock_weekly_summary`、`dws.dws_goods_stock_monthly_summary`
|
||||
> DWD 数据源:`dwd.dwd_goods_stock_summary`
|
||||
> 任务代码:`DWS_GOODS_STOCK_DAILY`、`DWS_GOODS_STOCK_WEEKLY`、`DWS_GOODS_STOCK_MONTHLY`
|
||||
> 代码位置:`apps/etl/connectors/feiqiu/tasks/dws/goods_stock_daily_task.py`、`goods_stock_weekly_task.py`、`goods_stock_monthly_task.py`
|
||||
> DDL 位置:`docs/database/ddl/etl_feiqiu__dws.sql`
|
||||
> 迁移脚本:`db/_archived/ddl_baseline_2026-02-22/db/etl_feiqiu/migrations/2026-02-20__create_dws_goods_stock_summary.sql`(已归档)
|
||||
|
||||
---
|
||||
|
||||
## 1. 表结构(三张表结构相同)
|
||||
|
||||
| DWS 列名 | 类型 | 业务含义 | 取值范围/示例 |
|
||||
|----------|------|---------|-------------|
|
||||
| `site_id` | BIGINT | 门店 ID(PK 之一) | 飞球门店 ID |
|
||||
| `tenant_id` | BIGINT | 租户 ID | 飞球租户 ID |
|
||||
| `stat_date` | DATE | 统计日期(PK 之一)。日度=当天日期,周度=ISO 周一日期,月度=月首日期 | 如 `2026-01-15`、`2026-01-13`(周一)、`2026-01-01`(月首) |
|
||||
| `site_goods_id` | BIGINT | 门店商品 ID(PK 之一),关联 `dim_store_goods.site_goods_id` | 飞球商品 ID |
|
||||
| `goods_name` | TEXT | 商品名称 | 如 `百威啤酒` |
|
||||
| `goods_unit` | TEXT | 计量单位 | 如 `瓶`、`包` |
|
||||
| `goods_category_id` | BIGINT | 一级商品分类 ID | 飞球分类 ID |
|
||||
| `goods_category_second_id` | BIGINT | 二级商品分类 ID | 飞球分类 ID |
|
||||
| `category_name` | TEXT | 一级分类名称 | 如 `酒水` |
|
||||
| `range_start_stock` | NUMERIC | 期初库存(统计周期起点的库存量) | 数值 |
|
||||
| `range_end_stock` | NUMERIC | 期末库存(统计周期终点的库存量) | 数值 |
|
||||
| `range_in` | NUMERIC | 入库数量(统计周期内的采购/调拨入库总量) | 数值 |
|
||||
| `range_out` | NUMERIC | 出库数量(统计周期内的调拨出库/报损总量) | 数值 |
|
||||
| `range_sale` | NUMERIC | 销售数量(统计周期内的销售出库总量) | 数值 |
|
||||
| `range_sale_money` | NUMERIC(12,2) | 销售金额(元),统计周期内的销售总金额 | 金额值 |
|
||||
| `range_inventory` | NUMERIC | 盘点调整量(统计周期内的盘盈/盘亏净量) | 正/负数值 |
|
||||
| `current_stock` | NUMERIC | 当前库存(统计周期末的实时库存量) | 数值 |
|
||||
| `stat_period` | TEXT | 汇总粒度标识 | `'daily'` / `'weekly'` / `'monthly'` |
|
||||
| `created_at` | TIMESTAMPTZ | 记录创建时间 | 自动填充 `now()` |
|
||||
| `updated_at` | TIMESTAMPTZ | 记录最后更新时间 | 自动填充 `now()` |
|
||||
|
||||
---
|
||||
|
||||
## 2. 主键
|
||||
|
||||
`(site_id, stat_date, site_goods_id)` — 按门店、日期、商品维度唯一。
|
||||
|
||||
---
|
||||
|
||||
## 3. 粒度说明
|
||||
|
||||
| 表名 | 粒度 | stat_date 规则 | stat_period |
|
||||
|------|------|---------------|-------------|
|
||||
| `dws_goods_stock_daily_summary` | 日 | 当天日期 | `'daily'` |
|
||||
| `dws_goods_stock_weekly_summary` | 周 | ISO 周一日期 | `'weekly'` |
|
||||
| `dws_goods_stock_monthly_summary` | 月 | 月首日期(如 `2026-01-01`) | `'monthly'` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 聚合逻辑
|
||||
|
||||
- extract:从 `dwd.dwd_goods_stock_summary` 按时间范围查询
|
||||
- transform:按粒度对 `fetched_at` 进行分组聚合
|
||||
- `range_start_stock`:取周期内最早记录的 `range_start_stock`
|
||||
- `range_end_stock`:取周期内最晚记录的 `range_end_stock`
|
||||
- `range_in` / `range_out` / `range_sale` / `range_inventory`:SUM 汇总
|
||||
- `range_sale_money`:SUM 汇总
|
||||
- `current_stock`:取周期内最晚记录的 `current_stock`
|
||||
- `goods_name` / `goods_unit` / `category_name`:取最晚记录的值
|
||||
- load:upsert 写入目标表,主键冲突时更新
|
||||
|
||||
---
|
||||
|
||||
## 5. 前置依赖
|
||||
|
||||
- `dwd.dwd_goods_stock_summary` 表必须已创建并有数据
|
||||
- ODS 任务配置 `requires_window=True` 必须已生效并重新采集
|
||||
|
||||
---
|
||||
|
||||
## 6. 代码引用
|
||||
|
||||
- 任务代码:`tasks/dws/goods_stock_daily_task.py`、`goods_stock_weekly_task.py`、`goods_stock_monthly_task.py`
|
||||
- 继承:`BaseDwsTask`(`tasks/dws/base_dws_task.py`)
|
||||
- 任务注册:`DWS_GOODS_STOCK_DAILY`、`DWS_GOODS_STOCK_WEEKLY`、`DWS_GOODS_STOCK_MONTHLY`
|
||||
@@ -0,0 +1,251 @@
|
||||
# BD_Manual:dws_member_spending_power_index(SPI 消费力指数)
|
||||
|
||||
> DWS 表:`dws.dws_member_spending_power_index`
|
||||
> DWD 数据源:`dwd.dwd_settlement_head`(消费订单)、`dwd.dwd_recharge_order`(充值订单)
|
||||
> 配置表:`dws.cfg_index_parameters`(`index_type='SPI'`)
|
||||
> 任务代码:`DWS_SPENDING_POWER_INDEX`
|
||||
> 代码位置:`apps/etl/connectors/feiqiu/tasks/dws/index/spending_power_index_task.py`
|
||||
> DDL 位置:`docs/database/ddl/etl_feiqiu__dws.sql`
|
||||
> 迁移脚本:`db/etl_feiqiu/migrations/2026-02-23_create_dws_member_spending_power_index.sql`
|
||||
> 种子数据:`db/etl_feiqiu/seeds/seed_index_parameters.sql`(`index_type='SPI'` 部分)
|
||||
|
||||
---
|
||||
|
||||
## 1. 表结构
|
||||
|
||||
| 列名 | 类型 | 默认值 | 业务含义 | 取值范围/示例 |
|
||||
|------|------|--------|---------|-------------|
|
||||
| `spi_id` | BIGINT (SERIAL) | nextval 序列 | 自增主键(PK) | 自增 |
|
||||
| `site_id` | INTEGER NOT NULL | — | 门店 ID | 飞球门店 ID |
|
||||
| `member_id` | BIGINT NOT NULL | — | 会员 ID | 飞球会员 ID |
|
||||
| `spend_30` | NUMERIC(14,2) | 0 | 近 30 天消费总额(元) | `0.00` ~ 金额值 |
|
||||
| `spend_90` | NUMERIC(14,2) | 0 | 近 90 天消费总额(元) | `0.00` ~ 金额值 |
|
||||
| `recharge_90` | NUMERIC(14,2) | 0 | 近 90 天充值总额(元) | `0.00` ~ 金额值 |
|
||||
| `orders_30` | INTEGER | 0 | 近 30 天消费笔数 | `0` ~ 正整数 |
|
||||
| `orders_90` | INTEGER | 0 | 近 90 天消费笔数 | `0` ~ 正整数 |
|
||||
| `visit_days_30` | INTEGER | 0 | 近 30 天消费日数(按天去重) | `0` ~ `30` |
|
||||
| `visit_days_90` | INTEGER | 0 | 近 90 天消费日数(按天去重) | `0` ~ `90` |
|
||||
| `avg_ticket_90` | NUMERIC(14,2) | 0 | 90 天客单价(= spend_90 / max(orders_90, 1)) | `0.00` ~ 金额值 |
|
||||
| `active_weeks_90` | INTEGER | 0 | 近 90 天有消费的自然周数 | `0` ~ `13` |
|
||||
| `daily_spend_ewma_90` | NUMERIC(14,2) | 0 | 日消费 EWMA(指数加权移动平均) | `0.00` ~ 金额值 |
|
||||
| `score_level_raw` | NUMERIC(10,4) | 0 | Level 子分原始分(消费水平) | ≥ 0 |
|
||||
| `score_speed_raw` | NUMERIC(10,4) | 0 | Speed 子分原始分(消费速度) | ≥ 0 |
|
||||
| `score_stability_raw` | NUMERIC(10,4) | 0 | Stability 子分原始分(消费稳定性) | `0.0000` ~ `1.0000` |
|
||||
| `score_level_display` | NUMERIC(5,2) | 0 | Level 子分展示分 | `0.00` ~ `10.00` |
|
||||
| `score_speed_display` | NUMERIC(5,2) | 0 | Speed 子分展示分 | `0.00` ~ `10.00` |
|
||||
| `score_stability_display` | NUMERIC(5,2) | 0 | Stability 子分展示分 | `0.00` ~ `10.00` |
|
||||
| `raw_score` | NUMERIC(10,4) | 0 | SPI 总分原始分(加权合成) | ≥ 0 |
|
||||
| `display_score` | NUMERIC(5,2) | 0 | SPI 总分展示分 | `0.00` ~ `10.00` |
|
||||
| `calc_time` | TIMESTAMPTZ | NOW() | 本次计算时间 | ISO 时间戳 |
|
||||
| `created_at` | TIMESTAMPTZ | NOW() | 记录创建时间 | ISO 时间戳 |
|
||||
| `updated_at` | TIMESTAMPTZ | NOW() | 记录最后更新时间 | ISO 时间戳 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 主键与索引
|
||||
|
||||
| 名称 | 类型 | 列 | 说明 |
|
||||
|------|------|----|------|
|
||||
| `dws_member_spending_power_index_pkey` | PRIMARY KEY | `spi_id` | 物理主键(自增序列) |
|
||||
| `idx_spi_site_member` | UNIQUE INDEX | `(site_id, member_id)` | 业务主键:每个门店每个会员唯一一条记录 |
|
||||
| `idx_spi_display_score` | INDEX | `(site_id, display_score DESC)` | 按门店查询展示分排名,支持 TOP-N 查询 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据写入策略
|
||||
|
||||
- **delete-before-insert**:每次执行按 `site_id` 全量刷新
|
||||
1. `DELETE FROM dws.dws_member_spending_power_index WHERE site_id = %s`
|
||||
2. 批量 `INSERT` 新计算结果
|
||||
- 无数据时跳过(不删除、不插入),返回 `{'status': 'skipped', 'reason': 'no_data'}`
|
||||
|
||||
---
|
||||
|
||||
## 4. 算法概要
|
||||
|
||||
### 4.1 数据来源
|
||||
|
||||
| 来源表 | 筛选条件 | 提取内容 |
|
||||
|--------|---------|---------|
|
||||
| `dwd.dwd_settlement_head` | 近 90 天,`settle_type IN (1, 3)` | 消费金额、笔数、消费日数、周覆盖、日消费序列 |
|
||||
| `dwd.dwd_recharge_order` | 近 90 天,`settle_type = 5` | 充值总额 |
|
||||
|
||||
### 4.2 子分公式
|
||||
|
||||
- **Level**(消费水平,权重 0.60):
|
||||
`L = w_s30 × ln(1 + spend_30/M30) + w_s90 × ln(1 + spend_90/M90) + w_ticket × ln(1 + avg_ticket_90/T0) + w_r90 × ln(1 + recharge_90/R90)`
|
||||
|
||||
- **Speed**(消费速度,权重 0.30):
|
||||
`S = w_abs × V_abs + w_rel × max(0, V_rel) + w_ewma × V_ewma`
|
||||
- `V_abs = ln(1 + spend_30 / (max(visit_days_30, 1) × V0))`
|
||||
- `V_rel = ln((v_30 + ε) / (v_90 + ε))`,仅加速加分
|
||||
- `V_ewma = ln(1 + daily_spend_ewma_90 / E0)`
|
||||
|
||||
- **Stability**(消费稳定性,权重 0.10):
|
||||
`P = active_weeks_90 / 13`,取值 [0, 1]
|
||||
|
||||
### 4.3 总分合成
|
||||
|
||||
`SPI_raw = w_L × L + w_S × S + w_P × P`(默认 0.60 / 0.30 / 0.10)
|
||||
|
||||
### 4.4 展示分映射
|
||||
|
||||
Raw → Winsorize(P5, P95) → 可选压缩(log1p/asinh) → MinMax [0, 10] → 可选 EWMA 平滑
|
||||
|
||||
子分(Level/Speed/Stability)各自独立映射,使用 `SPI_LEVEL` / `SPI_SPEED` / `SPI_STABILITY` 隔离分位历史。
|
||||
|
||||
---
|
||||
|
||||
## 5. 配置参数
|
||||
|
||||
所有参数存储在 `dws.cfg_index_parameters`(`index_type='SPI'`),缺失时回退到代码中 `DEFAULT_PARAMS`。
|
||||
|
||||
| 参数名 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `spend_window_short_days` | 30 | 短窗口天数 |
|
||||
| `spend_window_long_days` | 90 | 长窗口天数 |
|
||||
| `ewma_alpha_daily_spend` | 0.3 | 日消费 EWMA 平滑系数 |
|
||||
| `amount_base_spend_30` | 500.0 | 30 天消费金额压缩基数 |
|
||||
| `amount_base_spend_90` | 1500.0 | 90 天消费金额压缩基数 |
|
||||
| `amount_base_ticket_90` | 200.0 | 客单价压缩基数 |
|
||||
| `amount_base_recharge_90` | 1000.0 | 充值金额压缩基数 |
|
||||
| `amount_base_speed_abs` | 100.0 | 绝对速度压缩基数 |
|
||||
| `amount_base_ewma_90` | 50.0 | EWMA 速度压缩基数 |
|
||||
| `w_level_spend_30` | 0.30 | Level 子分中 spend_30 权重 |
|
||||
| `w_level_spend_90` | 0.35 | Level 子分中 spend_90 权重 |
|
||||
| `w_level_ticket_90` | 0.20 | Level 子分中 avg_ticket_90 权重 |
|
||||
| `w_level_recharge_90` | 0.15 | Level 子分中 recharge_90 权重 |
|
||||
| `w_speed_abs` | 0.50 | Speed 子分中绝对速度权重 |
|
||||
| `w_speed_rel` | 0.30 | Speed 子分中相对速度权重 |
|
||||
| `w_speed_ewma` | 0.20 | Speed 子分中 EWMA 速度权重 |
|
||||
| `weight_level` | 0.60 | 总分中 Level 权重 |
|
||||
| `weight_speed` | 0.30 | 总分中 Speed 权重 |
|
||||
| `weight_stability` | 0.10 | 总分中 Stability 权重 |
|
||||
| `stability_window_days` | 90 | 稳定性计算窗口 |
|
||||
| `use_stability` | 1 | 是否启用稳定性子分(0=禁用) |
|
||||
| `percentile_lower` | 5 | Winsorize 下分位 |
|
||||
| `percentile_upper` | 95 | Winsorize 上分位 |
|
||||
| `compression_mode` | 1 | 压缩模式:0=无,1=log1p,2=asinh |
|
||||
| `use_smoothing` | 1 | 是否启用 EWMA 分位平滑 |
|
||||
| `ewma_alpha` | 0.2 | 分位平滑 EWMA 系数 |
|
||||
| `speed_epsilon` | 1e-6 | 速度计算防除零小量 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 前置依赖
|
||||
|
||||
- 任务依赖:`DWS_MEMBER_CONSUMPTION`(需先完成会员消费汇总)
|
||||
- 数据源表:`dwd.dwd_settlement_head`、`dwd.dwd_recharge_order` 必须已有数据
|
||||
- 配置表:`dws.cfg_index_parameters` 中 `index_type='SPI'` 种子数据已插入(缺失时使用默认值)
|
||||
- 分位历史表:`dws.dws_index_percentile_history`(首次执行时无历史,不平滑)
|
||||
|
||||
---
|
||||
|
||||
## 7. 验证 SQL
|
||||
|
||||
### 7.1 检查表是否存在且有数据
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
COUNT(*) AS total_rows,
|
||||
COUNT(DISTINCT site_id) AS site_count,
|
||||
MIN(calc_time) AS earliest_calc,
|
||||
MAX(calc_time) AS latest_calc
|
||||
FROM dws.dws_member_spending_power_index;
|
||||
```
|
||||
|
||||
### 7.2 检查展示分范围是否合规(应全部在 [0, 10])
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE display_score < 0 OR display_score > 10) AS spi_out_of_range,
|
||||
COUNT(*) FILTER (WHERE score_level_display < 0 OR score_level_display > 10) AS level_out_of_range,
|
||||
COUNT(*) FILTER (WHERE score_speed_display < 0 OR score_speed_display > 10) AS speed_out_of_range,
|
||||
COUNT(*) FILTER (WHERE score_stability_display < 0 OR score_stability_display > 10) AS stability_out_of_range
|
||||
FROM dws.dws_member_spending_power_index;
|
||||
-- 预期:所有列均为 0
|
||||
```
|
||||
|
||||
### 7.3 检查业务主键唯一性(不应有重复)
|
||||
|
||||
```sql
|
||||
SELECT site_id, member_id, COUNT(*) AS cnt
|
||||
FROM dws.dws_member_spending_power_index
|
||||
GROUP BY site_id, member_id
|
||||
HAVING COUNT(*) > 1;
|
||||
-- 预期:无结果返回
|
||||
```
|
||||
|
||||
### 7.4 按门店查看 SPI 分布概况
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
site_id,
|
||||
COUNT(*) AS member_count,
|
||||
ROUND(AVG(display_score), 2) AS avg_spi,
|
||||
ROUND(MIN(display_score), 2) AS min_spi,
|
||||
ROUND(MAX(display_score), 2) AS max_spi,
|
||||
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY display_score), 2) AS median_spi
|
||||
FROM dws.dws_member_spending_power_index
|
||||
GROUP BY site_id
|
||||
ORDER BY site_id;
|
||||
```
|
||||
|
||||
### 7.5 检查 Stability 子分原始分范围(应在 [0, 1])
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) AS out_of_range
|
||||
FROM dws.dws_member_spending_power_index
|
||||
WHERE score_stability_raw < 0 OR score_stability_raw > 1;
|
||||
-- 预期:0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 兼容性说明
|
||||
|
||||
| 影响范围 | 说明 |
|
||||
|---------|------|
|
||||
| ETL 任务 | 新增任务 `DWS_SPENDING_POWER_INDEX`,依赖 `DWS_MEMBER_CONSUMPTION`。不影响现有 WBI/NCI/RS/OS/MS/ML 指数任务 |
|
||||
| 后端 API | 当前无 API 直接读取此表。后续如需暴露 SPI 数据,需新增接口 |
|
||||
| 管理后台 | 当前无前端页面展示 SPI。后续可在会员详情页新增 SPI 展示 |
|
||||
| 小程序 | 无影响 |
|
||||
| 其他指数 | SPI 独立于现有指数体系,不修改任何已有表或任务逻辑 |
|
||||
| 分位历史 | SPI 会向 `dws.dws_index_percentile_history` 写入 `index_type='SPI'`/`SPI_LEVEL`/`SPI_SPEED`/`SPI_STABILITY` 的分位记录 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 回滚策略
|
||||
|
||||
### 9.1 删除数据(保留表结构)
|
||||
|
||||
```sql
|
||||
DELETE FROM dws.dws_member_spending_power_index;
|
||||
DELETE FROM dws.dws_index_percentile_history WHERE index_type LIKE 'SPI%';
|
||||
DELETE FROM dws.cfg_index_parameters WHERE index_type = 'SPI';
|
||||
```
|
||||
|
||||
### 9.2 完整回滚(删除表)
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS dws.idx_spi_display_score;
|
||||
DROP INDEX IF EXISTS dws.idx_spi_site_member;
|
||||
DROP TABLE IF EXISTS dws.dws_member_spending_power_index;
|
||||
DROP SEQUENCE IF EXISTS dws.dws_member_spending_power_index_spi_id_seq;
|
||||
```
|
||||
|
||||
### 9.3 回滚任务注册
|
||||
|
||||
从 `orchestration/task_registry.py` 中移除 `DWS_SPENDING_POWER_INDEX` 注册行,并从 `tasks/dws/index/__init__.py` 和 `tasks/dws/__init__.py` 中移除 `SpendingPowerIndexTask` 导出。
|
||||
|
||||
---
|
||||
|
||||
## 10. 代码引用
|
||||
|
||||
- 任务类:`tasks/dws/index/spending_power_index_task.py` → `SpendingPowerIndexTask`
|
||||
- 继承:`BaseIndexTask`(`tasks/dws/index/base_index_task.py`)
|
||||
- 任务注册:`orchestration/task_registry.py` → `DWS_SPENDING_POWER_INDEX`
|
||||
- 属性测试:`tests/test_spi_properties.py`
|
||||
- 单元测试:`apps/etl/connectors/feiqiu/tests/unit/test_spi_task.py`
|
||||
- 迁移脚本:`db/etl_feiqiu/migrations/2026-02-23_create_dws_member_spending_power_index.sql`
|
||||
- 种子数据:`db/etl_feiqiu/seeds/seed_index_parameters.sql`
|
||||
@@ -0,0 +1,78 @@
|
||||
# BD 手册:dws_assistant_project_tag / dws_member_project_tag(项目标签表)
|
||||
|
||||
> 变更日期:2026-03-12
|
||||
> 变更类型:补充任务注册(seed 脚本遗漏修复)
|
||||
|
||||
## 变更说明
|
||||
|
||||
两张项目标签表(`dws.dws_assistant_project_tag`、`dws.dws_member_project_tag`)于 2026-03-07 创建,代码层(task class + TaskRegistry)已完整实现,但 `db/etl_feiqiu/seeds/seed_scheduler_tasks.sql` 遗漏了这两个任务码,导致 `meta.etl_task` 表中无记录,调度时报"未启用或不存在"。
|
||||
|
||||
本次修复:
|
||||
- 在 seed 脚本中补充 `DWS_ASSISTANT_PROJECT_TAG` 和 `DWS_MEMBER_PROJECT_TAG`
|
||||
- 在测试库 `test_etl_feiqiu` 的 `meta.etl_task` 表中插入两条记录(`enabled = TRUE`)
|
||||
|
||||
## 表概览
|
||||
|
||||
| 表名 | Schema | 粒度 | 时间窗口数 | 数据来源 |
|
||||
|------|--------|------|-----------|----------|
|
||||
| `dws_assistant_project_tag` | dws | 助教 + 时间窗口 + 项目类型 | 6 | `dwd_assistant_service_log` + `dim_table` + `cfg_area_category` |
|
||||
| `dws_member_project_tag` | dws | 会员 + 时间窗口 + 项目类型 | 2 | `dwd_table_fee_log` + `dim_table` + `cfg_area_category` |
|
||||
|
||||
两表共享相同的标签计算逻辑:按时间窗口聚合各项目时长,计算占比,≥25% 分配标签。
|
||||
|
||||
## 兼容性影响
|
||||
|
||||
- ETL 调度:修复后两个任务可正常被 admin 后台调度执行
|
||||
- 后端 API:无影响(API 层直接查询 DWS 表,表结构未变)
|
||||
- 小程序/管理后台:无影响(前端查询不依赖 `meta.etl_task`)
|
||||
- 其他 ETL 任务:无影响(两个任务独立运行,不被其他任务依赖)
|
||||
|
||||
## 回滚策略
|
||||
|
||||
```sql
|
||||
-- 1. 从 meta.etl_task 移除注册
|
||||
DELETE FROM meta.etl_task
|
||||
WHERE task_code IN ('DWS_ASSISTANT_PROJECT_TAG', 'DWS_MEMBER_PROJECT_TAG');
|
||||
|
||||
-- 2. 还原 seed 脚本:从 task_codes 数组中移除这两行
|
||||
-- 'DWS_ASSISTANT_PROJECT_TAG',
|
||||
-- 'DWS_MEMBER_PROJECT_TAG',
|
||||
```
|
||||
|
||||
## 验证 SQL
|
||||
|
||||
```sql
|
||||
-- 1. 确认 meta.etl_task 中两个任务已注册且启用
|
||||
SELECT task_code, store_id, enabled, created_at
|
||||
FROM meta.etl_task
|
||||
WHERE task_code IN ('DWS_ASSISTANT_PROJECT_TAG', 'DWS_MEMBER_PROJECT_TAG');
|
||||
-- 期望:2 行,enabled = TRUE
|
||||
|
||||
-- 2. 确认 seed 脚本与 task_registry 一致(无遗漏)
|
||||
-- 手动比对 seed_scheduler_tasks.sql 中的 task_codes 与 task_registry.py 中的注册列表
|
||||
|
||||
-- 3. 确认两张 DWS 表存在且结构正确
|
||||
SELECT table_name, column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'dws'
|
||||
AND table_name IN ('dws_assistant_project_tag', 'dws_member_project_tag')
|
||||
ORDER BY table_name, ordinal_position;
|
||||
-- 期望:每张表 15 个字段
|
||||
|
||||
-- 4. 确认调度可正常执行(在 admin 后台触发后检查)
|
||||
SELECT task_code, status, started_at, finished_at
|
||||
FROM meta.etl_run r
|
||||
JOIN meta.etl_task t ON r.task_id = t.task_id
|
||||
WHERE t.task_code IN ('DWS_ASSISTANT_PROJECT_TAG', 'DWS_MEMBER_PROJECT_TAG')
|
||||
ORDER BY r.started_at DESC
|
||||
LIMIT 4;
|
||||
```
|
||||
|
||||
## 详细 BD 手册
|
||||
|
||||
两张表的完整字段说明、索引、数据链路、业务规则见模块级 BD 手册:
|
||||
- `apps/etl/connectors/feiqiu/docs/database/DWS/main/BD_manual_dws_assistant_project_tag.md`
|
||||
- `apps/etl/connectors/feiqiu/docs/database/DWS/main/BD_manual_dws_member_project_tag.md`
|
||||
|
||||
ETL 任务详细说明见:
|
||||
- `apps/etl/connectors/feiqiu/docs/etl_tasks/dws_tasks.md`(项目标签域章节)
|
||||
@@ -1,6 +1,6 @@
|
||||
# cfg_area_category 台区分类映射表
|
||||
|
||||
> 生成时间:2026-02-03 | 更新时间:2026-03-07
|
||||
> 生成时间:2026-02-03 | 更新时间:2026-03-09
|
||||
|
||||
## 表信息
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
| 表名 | cfg_area_category |
|
||||
| 主键 | category_id |
|
||||
| 唯一约束 | (source_area_name, COALESCE(source_table_name, '')) |
|
||||
| 数据来源 | 手工维护/seed脚本(基于dim_table实际数据) |
|
||||
| 说明 | 将dim_table的台区/台桌映射到项目分类,支持台桌级细分 |
|
||||
| 数据来源 | 手工维护/seed脚本(基于用户提供的完整台桌清单) |
|
||||
| 说明 | 纯台桌级精确映射,每台桌一行 (区域, 台桌名) → 分类代码 |
|
||||
|
||||
## 字段说明
|
||||
|
||||
@@ -19,47 +19,124 @@
|
||||
|------|--------|------|------|------|------|
|
||||
| 1 | category_id | SERIAL | NO | PK | 分类ID(自增) |
|
||||
| 2 | source_area_name | VARCHAR(100) | NO | UK | 源区域名称(来自dim_table.site_table_area_name) |
|
||||
| 3 | source_table_name | VARCHAR(100) | YES | UK | 源台桌名称(来自dim_table.table_name),NULL表示区域级映射 |
|
||||
| 3 | source_table_name | VARCHAR(100) | YES | UK | 源台桌名称(来自dim_table.table_name),NULL仅用于DEFAULT兜底 |
|
||||
| 4 | category_code | VARCHAR(20) | NO | | 分类代码。**枚举值**: BILLIARD, SNOOKER, MAHJONG, KTV, SPECIAL, OTHER |
|
||||
| 5 | category_name | VARCHAR(50) | NO | | 分类名称(含emoji) |
|
||||
| 6 | display_name | VARCHAR(50) | YES | | 显示名称(用于筛选器) |
|
||||
| 7 | short_name | VARCHAR(20) | YES | | 简写(用于列表标签) |
|
||||
| 8 | match_type | VARCHAR(10) | NO | | 匹配类型。**枚举值**: EXACT(精确), LIKE(模糊), DEFAULT(兜底) |
|
||||
| 9 | match_priority | INTEGER | NO | | 匹配优先级(数字越小优先级越高) |
|
||||
| 8 | match_type | VARCHAR(10) | NO | | 匹配类型。**枚举值**: EXACT(精确), DEFAULT(兜底)。LIKE 已废弃 |
|
||||
| 9 | match_priority | INTEGER | NO | | 匹配优先级(统一为10,兜底999) |
|
||||
| 10 | is_active | BOOLEAN | NO | | 是否启用 |
|
||||
| 11 | description | TEXT | YES | | 说明 |
|
||||
| 12 | created_at | TIMESTAMPTZ | NO | | 创建时间 |
|
||||
| 13 | updated_at | TIMESTAMPTZ | NO | | 更新时间 |
|
||||
|
||||
## 变更说明(2026-03-07)
|
||||
## 变更说明(2026-03-09)
|
||||
|
||||
### 新增字段
|
||||
- `source_table_name`:支持台桌级细分映射(如 VIP包厢 V5 → SNOOKER)
|
||||
- `display_name`:前端筛选器显示名称
|
||||
- `short_name`:列表中的简写标签
|
||||
### 架构变更:纯台桌级精确映射
|
||||
- 删除所有 LIKE 模糊匹配规则(%VIP%、%斯诺克%、%麻将%、%K包%、%KTV%)
|
||||
- 删除所有区域级映射(source_table_name IS NULL 的 EXACT 记录)
|
||||
- 改为每台桌一行精确映射:source_area_name=区域, source_table_name=台桌名
|
||||
- 仅保留 DEFAULT 兜底规则(source_table_name IS NULL)
|
||||
|
||||
### 删除类型
|
||||
- `BILLIARD_VIP` 已废弃,VIP包厢 V1-V4 归入 `BILLIARD`,V5 归入 `SNOOKER`
|
||||
### 新增台桌
|
||||
- KTV:888、常乐、幸会(纯k)、虚拟188、大包、小包、纯k
|
||||
- MAHJONG:董事办、大包麻将房、666(台桌)
|
||||
- BILLIARD:VIP1-VIP3(台桌级)、TV、A1-A18、B1-B15、C1-C6
|
||||
- SNOOKER:S1-S4、VIP5
|
||||
- SPECIAL:补时长2-7、虚拟台1号
|
||||
|
||||
### 唯一约束变更
|
||||
- 从 `(source_area_name)` 改为 `(source_area_name, COALESCE(source_table_name, ''))`
|
||||
### ETL 代码变更
|
||||
- `get_area_category()` 去掉区域级精确匹配和 LIKE 模糊匹配分支
|
||||
- 仅保留台桌级精确匹配 + DEFAULT 兜底
|
||||
|
||||
## 匹配优先级
|
||||
## 匹配规则
|
||||
|
||||
| 优先级 | 匹配方式 | 说明 |
|
||||
|--------|---------|------|
|
||||
| 5 | 台桌级精确 | source_area_name + source_table_name 都匹配 |
|
||||
| 10 | 区域级精确 | source_area_name 匹配,source_table_name 为 NULL |
|
||||
| 50 | 模糊匹配 | source_area_name 包含模式匹配 |
|
||||
| 999 | 兜底 | 无法匹配的区域归入 OTHER |
|
||||
| 10 | 台桌级精确 | source_area_name + source_table_name 都匹配 |
|
||||
| 999 | 兜底 | 无法匹配的归入 OTHER |
|
||||
|
||||
## 分类映射
|
||||
## 分类映射(完整台桌清单)
|
||||
|
||||
| 分类代码 | 显示名称 | 简写 | 源区域 |
|
||||
|----------|---------|------|--------|
|
||||
| BILLIARD | 🎱 中式/追分 | 🎱 | A区、B区、C区、TV台、VIP包厢(V1-V4) |
|
||||
| SNOOKER | 斯诺克 | 斯 | 斯诺克区、VIP包厢(V5) |
|
||||
| MAHJONG | 🀄 麻将/棋牌 | 🀄 | 麻将房、M7、M8、666、发财 |
|
||||
| KTV | 🎤 团建/K歌 | 🎤 | K包、k包活动区、幸会158 |
|
||||
| SPECIAL | 补时长 | 补 | 补时长 |
|
||||
| OTHER | 其他 | 他 | 兜底 |
|
||||
### BILLIARD 🎱 中式/追分(43台)
|
||||
| 区域 | 台桌 |
|
||||
|------|------|
|
||||
| A区 | A1-A18(18台) |
|
||||
| B区 | B1-B15(15台) |
|
||||
| C区 | C1-C6(6台) |
|
||||
| VIP包厢 | VIP1, VIP2, VIP3 |
|
||||
| TV台 | TV |
|
||||
|
||||
### SNOOKER 斯诺克(5台)
|
||||
| 区域 | 台桌 |
|
||||
|------|------|
|
||||
| 斯诺克区 | S1, S2, S3, S4 |
|
||||
| VIP包厢 | VIP5 |
|
||||
|
||||
### MAHJONG 🀄 麻将/棋牌(11台)
|
||||
| 区域 | 台桌 |
|
||||
|------|------|
|
||||
| 麻将房 | M1, M2, M3, M4, M5 |
|
||||
| M7 | M7, 大包麻将房 |
|
||||
| M8 | M8 |
|
||||
| 666 | 董事办, 666 |
|
||||
| 发财 | 发财 |
|
||||
|
||||
### KTV 🎤 团建/K歌(7台)
|
||||
| 区域 | 台桌 |
|
||||
|------|------|
|
||||
| K包 | 常乐, 幸会(纯k), 虚拟188, 888 |
|
||||
| k包活动区 | 大包, 小包 |
|
||||
| 幸会158 | 纯k |
|
||||
|
||||
### SPECIAL 补时长(8台)
|
||||
| 区域 | 台桌 |
|
||||
|------|------|
|
||||
| 补时长 | 补时长, 补时长2-7 |
|
||||
| 虚拟台 | 虚拟台1号 |
|
||||
|
||||
### OTHER 其他
|
||||
| 匹配 | 说明 |
|
||||
|------|------|
|
||||
| DEFAULT | 兜底规则,无法匹配的归入其他 |
|
||||
|
||||
## 历史变更
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-02-03 | 初始创建,区域级精确 + LIKE 模糊匹配 |
|
||||
| 2026-03-07 | 新增 source_table_name 支持台桌级细分;废弃 BILLIARD_VIP |
|
||||
| 2026-03-09 | 改为纯台桌级精确映射,删除所有 LIKE 和区域级映射 |
|
||||
|
||||
## 验证 SQL
|
||||
|
||||
```sql
|
||||
-- 1. 确认无 LIKE 匹配记录
|
||||
SELECT COUNT(*) AS like_count FROM dws.cfg_area_category WHERE match_type = 'LIKE';
|
||||
-- 期望: 0
|
||||
|
||||
-- 2. 确认所有 EXACT 记录都有 source_table_name
|
||||
SELECT COUNT(*) AS null_table_count
|
||||
FROM dws.cfg_area_category
|
||||
WHERE match_type = 'EXACT' AND source_table_name IS NULL;
|
||||
-- 期望: 0
|
||||
|
||||
-- 3. 确认 KTV 大类包含 888
|
||||
SELECT source_area_name, source_table_name, category_code
|
||||
FROM dws.cfg_area_category
|
||||
WHERE category_code = 'KTV' AND is_active = true
|
||||
ORDER BY source_area_name, source_table_name;
|
||||
-- 期望: 7 行(常乐, 幸会(纯k), 虚拟188, 888, 大包, 小包, 纯k)
|
||||
|
||||
-- 4. 确认总记录数
|
||||
SELECT COUNT(*) AS total FROM dws.cfg_area_category;
|
||||
-- 期望: 75(74 台桌 + 1 DEFAULT 兜底)
|
||||
|
||||
-- 5. 按分类汇总
|
||||
SELECT category_code, COUNT(*) AS cnt
|
||||
FROM dws.cfg_area_category
|
||||
WHERE match_type = 'EXACT'
|
||||
GROUP BY category_code ORDER BY category_code;
|
||||
-- 期望: BILLIARD=43, KTV=7, MAHJONG=11, SNOOKER=5, SPECIAL=8
|
||||
```
|
||||
|
||||
@@ -62,18 +62,27 @@
|
||||
|
||||
### 按区域汇总台费
|
||||
```sql
|
||||
-- 实际 ETL 通过 Python get_area_category(area_name, table_name) 映射
|
||||
-- 以下为等效 SQL 示意(台桌级精确匹配 + DEFAULT 兜底)
|
||||
SELECT
|
||||
DATE(tfl.ledger_end_time) AS stat_date,
|
||||
COALESCE(ac.category_code, 'OTHER') AS category_code,
|
||||
COALESCE(ac.category_name, '其他') AS category_name,
|
||||
COALESCE(ac.category_code, def.category_code, 'OTHER') AS category_code,
|
||||
COALESCE(ac.category_name, def.category_name, '其他') AS category_name,
|
||||
SUM(tfl.ledger_amount) AS income_amount,
|
||||
SUM(tfl.ledger_count) AS duration_seconds,
|
||||
COUNT(DISTINCT tfl.order_settle_id) AS order_count
|
||||
FROM dwd.dwd_table_fee_log tfl
|
||||
LEFT JOIN dwd.dim_table dt ON dt.table_id = tfl.site_table_id
|
||||
LEFT JOIN dws.cfg_area_category ac ON dt.site_table_area_name = ac.source_area_name
|
||||
WHERE tfl.is_delete = 0
|
||||
GROUP BY DATE(tfl.ledger_end_time), COALESCE(ac.category_code, 'OTHER'), COALESCE(ac.category_name, '其他');
|
||||
LEFT JOIN dwd.dim_table dt ON dt.table_id = tfl.site_table_id AND dt.scd2_is_current = 1
|
||||
LEFT JOIN dws.cfg_area_category ac
|
||||
ON ac.source_area_name = dt.site_table_area_name
|
||||
AND ac.source_table_name = dt.table_name
|
||||
AND ac.match_type = 'EXACT'
|
||||
LEFT JOIN dws.cfg_area_category def
|
||||
ON def.match_type = 'DEFAULT'
|
||||
WHERE COALESCE(tfl.is_delete, 0) = 0
|
||||
GROUP BY DATE(tfl.ledger_end_time),
|
||||
COALESCE(ac.category_code, def.category_code, 'OTHER'),
|
||||
COALESCE(ac.category_name, def.category_name, '其他');
|
||||
```
|
||||
|
||||
## 使用说明
|
||||
|
||||
@@ -12,11 +12,14 @@ database/
|
||||
│ └── mappings/ — API JSON → ODS 字段映射(mapping_*.md)
|
||||
├── DWD/
|
||||
│ ├── main/ — DWD 主表文档
|
||||
│ └── Ex/ — DWD 扩展表文档
|
||||
│ ├── Ex/ — DWD 扩展表文档
|
||||
│ └── changes/ — DWD 层变更记录
|
||||
├── DWS/
|
||||
│ └── main/ — DWS 汇总表文档
|
||||
│ ├── main/ — DWS 汇总表文档
|
||||
│ └── changes/ — DWS 层变更记录
|
||||
├── ETL_Admin/
|
||||
│ └── main/ — meta schema 表文档
|
||||
├── cross_layer/ — ODS→DWD 跨层字段映射(从 docs/database/ 迁入)
|
||||
└── _archived/ — 过时的变更记录、DDL 对比报告、已删除表文档
|
||||
```
|
||||
|
||||
@@ -27,12 +30,15 @@ database/
|
||||
| 表级文档 | `BD_manual_{表名}.md` | 字段说明、主键、业务含义 |
|
||||
| 扩展表文档 | `BD_manual_{表名}_ex.md` | SCD2 扩展字段、溢出字段 |
|
||||
| 字段映射 | `mapping_{API端点}_{ODS表名}.md` | API JSON 字段 → ODS 列的映射关系 |
|
||||
| 跨层映射 | `BD_Manual_{ODS表名}.md` | ODS → DWD 字段映射、装载方式、业务含义 |
|
||||
|
||||
## 与项目级文档的关系
|
||||
|
||||
| 内容 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| DDL 基线 | `docs/database/ddl/` | 从数据库自动导出,按 schema 分文件 |
|
||||
| ODS→DWD 字段映射 | `docs/database/BD_Manual_*.md` | 跨层映射(ODS 表 → DWD 表) |
|
||||
| 业务库 BD_Manual | `docs/database/BD_Manual_*.md` | zqyy_app 表结构、FDW、RLS 视图 |
|
||||
| ODS→DWD 跨层映射 | 本目录 `cross_layer/BD_Manual_*.md` | ODS 表 → DWD 表字段映射 |
|
||||
| 表级字段说明 | 本目录 `*/main/BD_manual_*.md` | 单表字段详情 |
|
||||
| API→ODS 字段映射 | 本目录 `ODS/mappings/` | API JSON → ODS 列映射 |
|
||||
| DWD/DWS 层变更记录 | 本目录 `DWD/changes/`、`DWS/changes/` | 加列、新表等变更说明 |
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# BD_Manual:assistant_accounts_master(助教账号档案)
|
||||
|
||||
> ODS 表:`ods.assistant_accounts_master`
|
||||
> DWD 表:`dwd.dim_assistant`(主表)、`dwd.dim_assistant_ex`(扩展表)
|
||||
> API 接口:助教账号列表
|
||||
> JSON 路径:`assistant_accounts_master.json → data.assistantInfos`
|
||||
> 装载方式:SCD2 维度合并(`DwdLoadTask`)
|
||||
> 代码位置:`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. dim_assistant(主表)
|
||||
|
||||
| DWD 列名 | 类型 | ODS 源列 | 映射方式 | 业务含义 | 取值范围/示例 |
|
||||
|----------|------|---------|---------|---------|-------------|
|
||||
| `assistant_id` | BIGINT | `id` | FACT_MAPPINGS | 助教唯一标识(PK 之一) | 飞球系统雪花 ID,如 `2947562271297029` |
|
||||
| `user_id` | BIGINT | `user_id` | FACT_MAPPINGS | 关联的系统用户 ID,0 表示未绑定用户账号 | `0` 或飞球用户 ID |
|
||||
| `assistant_no` | TEXT | `assistant_no` | 自动映射 | 助教编号(门店内序号),用于排班和展示 | 如 `31`、`1` |
|
||||
| `real_name` | TEXT | `real_name` | 自动映射 | 助教真实姓名 | 如 `张静然` |
|
||||
| `nickname` | TEXT | `nickname` | 自动映射 | 助教昵称,用于小程序端展示 | 如 `小然` |
|
||||
| `mobile` | TEXT | `mobile` | 自动映射 | 助教手机号 | 11 位手机号 |
|
||||
| `tenant_id` | BIGINT | `tenant_id` | 自动映射 | 所属租户 ID | 飞球租户 ID |
|
||||
| `site_id` | BIGINT | `site_id` | 自动映射 | 所属门店 ID | 飞球门店 ID |
|
||||
| `team_id` | BIGINT | `team_id` | 自动映射 | 所属团队 ID,0 表示未分组 | `0` 或团队 ID |
|
||||
| `team_name` | TEXT | `team_name` | 自动映射 | 团队名称 | 如 `1组`,NULL 表示未分组 |
|
||||
| `level` | INTEGER | `level` | 自动映射 | 助教等级(技能等级编号) | 如 `20`(对应"高级"等) |
|
||||
| `entry_time` | TIMESTAMPTZ | `entry_time` | 自动映射 | 入职时间 | ISO 时间戳 |
|
||||
| `resign_time` | TIMESTAMPTZ | `resign_time` | 自动映射 | 离职时间,NULL 表示在职 | ISO 时间戳或 NULL |
|
||||
| `leave_status` | INTEGER | `leave_status` | 自动映射 | 在职状态:0=在职,1=已离职 | `0` / `1` |
|
||||
| `assistant_status` | INTEGER | `assistant_status` | 自动映射 | 助教状态:1=正常,2=停用 | `1` / `2` |
|
||||
| `scd2_start_time` | TIMESTAMPTZ | — | DWD 元数据 | SCD2 版本生效起点 | — |
|
||||
| `scd2_end_time` | TIMESTAMPTZ | — | DWD 元数据 | SCD2 版本失效时间,`9999-12-31` 表示当前版本 | — |
|
||||
| `scd2_is_current` | INT | — | DWD 元数据 | 当前版本标记:1=当前,0=历史 | `0` / `1` |
|
||||
| `scd2_version` | INT | — | DWD 元数据 | SCD2 版本号(自增) | — |
|
||||
|
||||
---
|
||||
|
||||
## 2. dim_assistant_ex(扩展表)
|
||||
|
||||
| DWD 列名 | 类型 | ODS 源列 | 映射方式 | 业务含义 | 取值范围/示例 |
|
||||
|----------|------|---------|---------|---------|-------------|
|
||||
| `assistant_id` | BIGINT | `id` | FACT_MAPPINGS | 助教唯一标识(PK 之一) | 同主表 |
|
||||
| `gender` | INTEGER | `gender` | 自动映射 | 性别:0=未设置 | `0` |
|
||||
| `birth_date` | TIMESTAMPTZ | `birth_date` | 自动映射 | 出生日期 | `0001-01-01` 表示未设置 |
|
||||
| `avatar` | TEXT | `avatar` | 自动映射 | 头像 URL | HTTPS 链接 |
|
||||
| `introduce` | TEXT | `introduce` | FACT_MAPPINGS | 个人简介文本 | 自由文本或 NULL |
|
||||
| `video_introduction_url` | TEXT | `video_introduction_url` | 自动映射 | 视频介绍 URL | HTTPS 链接或 NULL |
|
||||
| `height` | NUMERIC(5,2) | `height` | 自动映射 | 身高(cm),0 表示未填写 | `0.00` 或实际身高 |
|
||||
| `weight` | NUMERIC(5,2) | `weight` | 自动映射 | 体重(kg),0 表示未填写 | `0.00` 或实际体重 |
|
||||
| `shop_name` | TEXT | `shop_name` | 自动映射 | 所属门店名称快照 | 如 `朗朗桌球` |
|
||||
| `group_id` | BIGINT | `group_id` | 自动映射 | 分组 ID,0 表示未分组 | `0` 或分组 ID |
|
||||
| `group_name` | TEXT | `group_name` | FACT_MAPPINGS | 分组名称 | NULL 或分组名 |
|
||||
| `person_org_id` | BIGINT | `person_org_id` | 自动映射 | 人事组织 ID | 飞球组织 ID |
|
||||
| `staff_id` | BIGINT | `staff_id` | 自动映射 | 员工 ID,0 表示未绑定员工档案 | `0` 或员工 ID |
|
||||
| `staff_profile_id` | BIGINT | `staff_profile_id` | 自动映射 | 员工档案 ID,0 表示无档案 | `0` 或档案 ID |
|
||||
| `assistant_grade` | DOUBLE PRECISION | `assistant_grade` | 自动映射 | 助教评分(客户评价均分) | `0.0` ~ `5.0` |
|
||||
| `sum_grade` | DOUBLE PRECISION | `sum_grade` | 自动映射 | 累计评分总和 | `0.0` 或累计值 |
|
||||
| `get_grade_times` | INTEGER | `get_grade_times` | 自动映射 | 被评价次数 | `0` 或正整数 |
|
||||
| `charge_way` | INTEGER | `charge_way` | 自动映射 | 计费方式:2=按时长计费(当前门店全部为 2) | `2` |
|
||||
| `allow_cx` | INTEGER | `allow_cx` | 自动映射 | 是否允许促销服务:1=允许(当前全部为 1) | `1` |
|
||||
| `is_guaranteed` | INTEGER | `is_guaranteed` | 自动映射 | 是否保底:0=不保底,1=保底 | `0` / `1` |
|
||||
| `salary_grant_enabled` | INTEGER | `salary_grant_enabled` | 自动映射 | 工资发放开关:2=启用 | `2` |
|
||||
| `entry_type` | INTEGER | `entry_type` | 自动映射 | 入职类型:1=正常入职 | `1` |
|
||||
| `entry_sign_status` | INTEGER | `entry_sign_status` | 自动映射 | 入职签到状态:0=未签到 | `0` |
|
||||
| `resign_sign_status` | INTEGER | `resign_sign_status` | 自动映射 | 离职签到状态:0=未签到 | `0` |
|
||||
| `work_status` | INTEGER | `work_status` | 自动映射 | 工作状态:1=在岗,2=离岗 | `1` / `2` |
|
||||
| `show_status` | INTEGER | `show_status` | 自动映射 | 展示状态:1=展示 | `1` |
|
||||
| `show_sort` | INTEGER | `show_sort` | 自动映射 | 展示排序序号 | 正整数 |
|
||||
| `online_status` | INTEGER | `online_status` | 自动映射 | 在线状态:1=在线 | `1` |
|
||||
| `is_delete` | INTEGER | `is_delete` | 自动映射 | 软删除标记:0=正常,1=已删除 | `0` / `1` |
|
||||
| `criticism_status` | INTEGER | `criticism_status` | 自动映射 | 差评处理状态:1=正常 | `1` |
|
||||
| `create_time` | TIMESTAMPTZ | `create_time` | 自动映射 | 助教记录创建时间 | ISO 时间戳 |
|
||||
| `update_time` | TIMESTAMPTZ | `update_time` | 自动映射 | 助教记录最后更新时间 | ISO 时间戳 |
|
||||
| `start_time` | TIMESTAMPTZ | `start_time` | 自动映射 | 合同/排班开始时间 | ISO 时间戳 |
|
||||
| `end_time` | TIMESTAMPTZ | `end_time` | 自动映射 | 合同/排班结束时间 | ISO 时间戳 |
|
||||
| `last_table_id` | BIGINT | `last_table_id` | 自动映射 | 最近服务的台桌 ID,0 表示无 | `0` 或台桌 ID |
|
||||
| `last_table_name` | TEXT | `last_table_name` | 自动映射 | 最近服务的台桌名称 | 如 `TV`、`1号台` |
|
||||
| `last_update_name` | TEXT | `last_update_name` | 自动映射 | 最后操作人姓名(带职位前缀) | 如 `管理员:郑丽珊` |
|
||||
| `order_trade_no` | BIGINT | `order_trade_no` | 自动映射 | 当前关联的订单交易号,0 表示空闲 | `0` 或订单号 |
|
||||
| `ding_talk_synced` | INTEGER | `ding_talk_synced` | 自动映射 | 钉钉同步状态:1=已同步 | `1` |
|
||||
| `site_light_cfg_id` | BIGINT | `site_light_cfg_id` | 自动映射 | 门店灯控配置 ID,0 表示未配置 | `0` 或配置 ID |
|
||||
| `light_equipment_id` | TEXT | `light_equipment_id` | FACT_MAPPINGS | 灯控设备 ID | NULL 或设备编号 |
|
||||
| `light_status` | INTEGER | `light_status` | 自动映射 | 台灯状态:2=已开灯 | `1` / `2` |
|
||||
| `is_team_leader` | INTEGER | `is_team_leader` | 自动映射 | 是否组长:0=否,1=是 | `0` / `1` |
|
||||
| `serial_number` | BIGINT | `serial_number` | 自动映射 | 序列号,0 表示未分配 | `0` 或序列号 |
|
||||
| `system_role_id` | BIGINT | `system_role_id` | FACT_MAPPINGS | 系统角色 ID,标识助教在系统中的角色类型。当前门店全部为 10 | `10` |
|
||||
| `job_num` | TEXT | `job_num` | FACT_MAPPINGS | 工号,助教的内部编号标识。当前门店未启用,全部为 NULL | NULL |
|
||||
| `cx_unit_price` | NUMERIC(18,2) | `cx_unit_price` | FACT_MAPPINGS | 促销单价(元),助教提供促销服务时的计费单价。当前门店未在账号表层面设置,全部为 0.00 | `0.00` |
|
||||
| `pd_unit_price` | NUMERIC(18,2) | `pd_unit_price` | FACT_MAPPINGS | 陪打单价(元),助教提供陪打服务时的计费单价。当前门店未在账号表层面设置,全部为 0.00 | `0.00` |
|
||||
| `scd2_*` | — | — | DWD 元数据 | SCD2 慢变维度追踪字段(同主表) | — |
|
||||
|
||||
---
|
||||
|
||||
## 3. 跳过字段说明
|
||||
|
||||
| ODS 字段 | 跳过原因 |
|
||||
|---------|---------|
|
||||
| `siteprofile` | JSONB 嵌套列,已由 `dim_site` / `dim_site_ex` 通过 JSONB 提取映射 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 代码引用
|
||||
|
||||
- FACT_MAPPINGS:`dwd_load_task.py` → `FACT_MAPPINGS["dwd.dim_assistant"]` / `FACT_MAPPINGS["dwd.dim_assistant_ex"]`
|
||||
- TABLE_MAP:`"dwd.dim_assistant" → "ods.assistant_accounts_master"`
|
||||
- DWS 下游:`dws_assistant_daily_task.py`(助教日业绩汇总)、`dws_salary_task.py`(工资计算)
|
||||
@@ -0,0 +1,114 @@
|
||||
# BD_Manual:assistant_service_records(助教服务流水)
|
||||
|
||||
> ODS 表:`ods.assistant_service_records`
|
||||
> DWD 表:`dwd.dwd_assistant_service_log`(主表)、`dwd.dwd_assistant_service_log_ex`(扩展表)
|
||||
> API 接口:助教服务记录列表
|
||||
> JSON 路径:`assistant_service_records.json → data.orderAssistantLedgers`
|
||||
> 装载方式:事实表增量插入(`DwdLoadTask`)
|
||||
> 代码位置:`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. dwd_assistant_service_log(主表,33 列)
|
||||
|
||||
| DWD 列名 | 类型 | ODS 源列 | 映射方式 | 业务含义 | 取值范围/示例 |
|
||||
|----------|------|---------|---------|---------|-------------|
|
||||
| `assistant_service_id` | BIGINT | `id` | FACT_MAPPINGS | 助教服务记录唯一标识(PK) | 飞球雪花 ID |
|
||||
| `order_trade_no` | BIGINT | `order_trade_no` | 自动映射 | 关联的订单交易号 | 飞球订单号 |
|
||||
| `order_settle_id` | BIGINT | `order_settle_id` | 自动映射 | 关联的结算单 ID | 飞球结算单 ID |
|
||||
| `order_pay_id` | BIGINT | `order_pay_id` | 自动映射 | 关联的支付单 ID | 飞球支付单 ID |
|
||||
| `order_assistant_id` | BIGINT | `order_assistant_id` | 自动映射 | 订单级助教明细 ID,标识本次服务在订单中的唯一记录 | 飞球雪花 ID |
|
||||
| `order_assistant_type` | INTEGER | `order_assistant_type` | 自动映射 | 助教服务类型:1=陪打,2=促销 | `1` / `2` |
|
||||
| `tenant_id` | BIGINT | `tenant_id` | 自动映射 | 租户 ID | 飞球租户 ID |
|
||||
| `site_id` | BIGINT | `site_id` | 自动映射 | 门店 ID | 飞球门店 ID |
|
||||
| `site_table_id` | BIGINT | `site_table_id` | 自动映射 | 服务台桌 ID,关联 `dim_table.table_id` | 飞球台桌 ID |
|
||||
| `tenant_member_id` | BIGINT | `tenant_member_id` | 自动映射 | 租户维度会员 ID | 飞球会员 ID 或 0 |
|
||||
| `system_member_id` | BIGINT | `system_member_id` | 自动映射 | 系统维度会员 ID(跨租户唯一) | 飞球会员 ID 或 0 |
|
||||
| `assistant_no` | VARCHAR(64) | `assistantno` | FACT_MAPPINGS | 助教编号(门店内序号) | 如 `31` |
|
||||
| `nickname` | VARCHAR(64) | `nickname` | 自动映射 | 助教昵称 | 如 `小张` |
|
||||
| `site_assistant_id` | BIGINT | `site_assistant_id` | FACT_MAPPINGS | 门店维度助教档案 ID,关联 `dim_assistant.assistant_id`。⚠️ 已于 2026-02-20 修正映射源(原错误映射自 `order_assistant_id`) | 飞球助教 ID |
|
||||
| `user_id` | BIGINT | `user_id` | 自动映射 | 助教用户 ID | 飞球用户 ID |
|
||||
| `assistant_team_id` | BIGINT | `assistant_team_id` | 自动映射 | 助教团队 ID | 飞球团队 ID |
|
||||
| `person_org_id` | BIGINT | `person_org_id` | 自动映射 | 人员组织 ID | 飞球组织 ID |
|
||||
| `assistant_level` | INTEGER | `assistant_level` | 自动映射 | 助教等级编码 | 枚举值 |
|
||||
| `level_name` | VARCHAR(64) | `levelname` | FACT_MAPPINGS | 助教等级名称 | 如 `高级`、`中级` |
|
||||
| `skill_id` | BIGINT | `skill_id` | 自动映射 | 服务技能 ID | 飞球技能 ID |
|
||||
| `skill_name` | VARCHAR(64) | `skillname` | FACT_MAPPINGS | 服务技能名称 | 如 `陪打`、`促销` |
|
||||
| `ledger_unit_price` | NUMERIC(10,2) | `ledger_unit_price` | 自动映射 | 分账单价(元/小时) | 金额值 |
|
||||
| `ledger_amount` | NUMERIC(10,2) | `ledger_amount` | 自动映射 | 分账总金额(元) | 金额值 |
|
||||
| `projected_income` | NUMERIC(10,2) | `projected_income` | 自动映射 | 预计收入(元) | 金额值 |
|
||||
| `coupon_deduct_money` | NUMERIC(10,2) | `coupon_deduct_money` | 自动映射 | 优惠券抵扣金额(元) | `0.00` ~ 金额值 |
|
||||
| `income_seconds` | INTEGER | `income_seconds` | 自动映射 | 计费时长(秒) | 正整数 |
|
||||
| `real_use_seconds` | INTEGER | `real_use_seconds` | 自动映射 | 实际使用时长(秒) | 正整数 |
|
||||
| `add_clock` | INTEGER | `add_clock` | 自动映射 | 加钟次数 | `0` ~ 正整数 |
|
||||
| `create_time` | TIMESTAMPTZ | `create_time` | 自动映射 | 记录创建时间 | ISO 时间戳 |
|
||||
| `start_use_time` | TIMESTAMPTZ | `start_use_time` | 自动映射 | 服务开始时间 | ISO 时间戳 |
|
||||
| `last_use_time` | TIMESTAMPTZ | `last_use_time` | 自动映射 | 最后使用时间(服务结束时间) | ISO 时间戳 |
|
||||
| `is_delete` | INTEGER | `is_delete` | 自动映射 | 是否已删除:0=正常,1=已删除 | `0` / `1` |
|
||||
| `real_service_money` | NUMERIC(18,2) | `real_service_money` | FACT_MAPPINGS | 实际服务金额(元),扣除折扣后的实收 | `0.00` ~ 金额值 |
|
||||
|
||||
---
|
||||
|
||||
## 2. dwd_assistant_service_log_ex(扩展表,33 列)
|
||||
|
||||
| DWD 列名 | 类型 | ODS 源列 | 映射方式 | 业务含义 | 取值范围/示例 |
|
||||
|----------|------|---------|---------|---------|-------------|
|
||||
| `assistant_service_id` | BIGINT | `id` | FACT_MAPPINGS | 助教服务记录唯一标识(PK) | 同主表 |
|
||||
| `table_name` | VARCHAR(64) | `tablename` | FACT_MAPPINGS | 台桌名称快照 | 如 `1号台` |
|
||||
| `assistant_name` | VARCHAR(64) | `assistantname` | FACT_MAPPINGS | 助教姓名快照 | 如 `张静然` |
|
||||
| `ledger_name` | VARCHAR(128) | `ledger_name` | 自动映射 | 分账项名称 | 如 `陪打费` |
|
||||
| `ledger_group_name` | VARCHAR(128) | `ledger_group_name` | FACT_MAPPINGS | 分账组名称 | 分账组名或 NULL |
|
||||
| `ledger_count` | INTEGER | `ledger_count` | 自动映射 | 分账数量 | 正整数 |
|
||||
| `member_discount_amount` | NUMERIC(10,2) | `member_discount_amount` | 自动映射 | 会员折扣金额(元) | `0.00` ~ 金额值 |
|
||||
| `manual_discount_amount` | NUMERIC(10,2) | `manual_discount_amount` | 自动映射 | 手动折扣金额(元) | `0.00` ~ 金额值 |
|
||||
| `service_money` | NUMERIC(10,2) | `service_money` | 自动映射 | 服务原价金额(元) | 金额值 |
|
||||
| `returns_clock` | INTEGER | `returns_clock` | 自动映射 | 退钟次数 | `0` ~ 正整数 |
|
||||
| `ledger_start_time` | TIMESTAMPTZ | `ledger_start_time` | 自动映射 | 分账开始时间 | ISO 时间戳 |
|
||||
| `ledger_end_time` | TIMESTAMPTZ | `ledger_end_time` | 自动映射 | 分账结束时间 | ISO 时间戳 |
|
||||
| `ledger_status` | INTEGER | `ledger_status` | 自动映射 | 分账状态 | 枚举值 |
|
||||
| `is_confirm` | INTEGER | `is_confirm` | 自动映射 | 是否已确认:0=未确认,1=已确认 | `0` / `1` |
|
||||
| `is_single_order` | INTEGER | `is_single_order` | 自动映射 | 是否单独订单 | `0` / `1` |
|
||||
| `is_not_responding` | INTEGER | `is_not_responding` | 自动映射 | 是否未响应 | `0` / `1` |
|
||||
| `is_trash` | INTEGER | `is_trash` | 自动映射 | 是否已废除:0=正常,1=已废除 | `0` / `1` |
|
||||
| `trash_applicant_id` | BIGINT | `trash_applicant_id` | 自动映射 | 废除申请人 ID | 员工 ID 或 NULL |
|
||||
| `trash_applicant_name` | VARCHAR(64) | `trash_applicant_name` | FACT_MAPPINGS | 废除申请人姓名 | 姓名或 NULL |
|
||||
| `trash_reason` | VARCHAR(255) | `trash_reason` | FACT_MAPPINGS | 废除原因 | 自由文本或 NULL |
|
||||
| `salesman_user_id` | BIGINT | `salesman_user_id` | 自动映射 | 销售员用户 ID | 用户 ID 或 NULL |
|
||||
| `salesman_name` | VARCHAR(64) | `salesman_name` | FACT_MAPPINGS | 销售员姓名 | 姓名或 NULL |
|
||||
| `salesman_org_id` | BIGINT | `salesman_org_id` | 自动映射 | 销售员组织 ID | 组织 ID 或 NULL |
|
||||
| `skill_grade` | INTEGER | `skill_grade` | 自动映射 | 技能评分 | 评分值 |
|
||||
| `service_grade` | INTEGER | `service_grade` | 自动映射 | 服务评分 | 评分值 |
|
||||
| `composite_grade` | NUMERIC(5,2) | `composite_grade` | 自动映射 | 综合评分 | 如 `4.50` |
|
||||
| `sum_grade` | NUMERIC(10,2) | `sum_grade` | 自动映射 | 累计评分 | 累计值 |
|
||||
| `get_grade_times` | INTEGER | `get_grade_times` | 自动映射 | 获评次数 | 正整数 |
|
||||
| `grade_status` | INTEGER | `grade_status` | 自动映射 | 评分状态 | 枚举值 |
|
||||
| `composite_grade_time` | TIMESTAMPTZ | `composite_grade_time` | 自动映射 | 综合评分时间 | ISO 时间戳 |
|
||||
| `assistant_team_name` | TEXT | `assistantteamname` | FACT_MAPPINGS | 助教团队名称快照 | 如 `1组` |
|
||||
| `operator_id` | BIGINT | `operator_id` | FACT_MAPPINGS | 操作员 ID,录入/结算这条助教服务的员工。0 表示系统自动 | `0` 或员工 ID |
|
||||
| `operator_name` | TEXT | `operator_name` | FACT_MAPPINGS | 操作员姓名(带职位前缀),便于直接阅读 | 如 `收银员:郑丽珊`,NULL 表示系统自动 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 映射修正记录
|
||||
|
||||
| 日期 | 字段 | 修正内容 |
|
||||
|------|------|---------|
|
||||
| 2026-02-20 | `site_assistant_id` | ODS 源从 `order_assistant_id`(订单级 ID)修正为 `site_assistant_id`(助教档案 ID) |
|
||||
| 2026-02-26 | (下游)`table_area_name` | DWS 任务 `_extract_service_records()` 原错误引用 `asl.table_area_name`(本表无此列),改为 JOIN `dwd.dim_table` 取 `site_table_area_name`。详见 `BD_Manual_fix_dws_assistant_daily_table_area.md` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 跳过字段说明
|
||||
|
||||
| ODS 字段 | 跳过原因 |
|
||||
|---------|---------|
|
||||
| `siteprofile` | JSONB 嵌套列,已由 `dim_site` / `dim_site_ex` 通过 JSONB 提取映射 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 代码引用
|
||||
|
||||
- FACT_MAPPINGS:`dwd_load_task.py` → `FACT_MAPPINGS["dwd.dwd_assistant_service_log"]` / `FACT_MAPPINGS["dwd.dwd_assistant_service_log_ex"]`
|
||||
- TABLE_MAP:`"dwd.dwd_assistant_service_log" → "ods.assistant_service_records"`
|
||||
- DWS 下游:`dws_assistant_daily_task.py`(助教日业绩汇总)
|
||||
- 迁移脚本:`db/_archived/ddl_baseline_2026-02-22/db/etl_feiqiu/migrations/2026-02-20__fix_assistant_service_site_assistant_id.sql`(已归档)、`db/_archived/ddl_baseline_2026-02-22/db/etl_feiqiu/migrations/2026-02-20__add_assistant_service_log_ex_fields.sql`(已归档)
|
||||
@@ -0,0 +1,62 @@
|
||||
# BD_Manual:goods_stock_movements(库存变动流水)
|
||||
|
||||
> ODS 表:`ods.goods_stock_movements`
|
||||
> DWD 表:`dwd.dwd_goods_stock_movement`(事实表,无 ex 扩展表)
|
||||
> API 接口:库存变动记录列表
|
||||
> JSON 路径:`goods_stock_movements.json → data.goodsStockList`
|
||||
> 装载方式:事实表按 `create_time` 增量加载(`DwdLoadTask`)
|
||||
> 代码位置:`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. dwd_goods_stock_movement
|
||||
|
||||
| DWD 列名 | 类型 | ODS 源列 | 映射方式 | 业务含义 | 取值范围/示例 |
|
||||
|----------|------|---------|---------|---------|-------------|
|
||||
| `site_goods_stock_id` | BIGINT | `sitegoodsstockid`(ODS 驼峰 → PG 小写) | FACT_MAPPINGS (cast bigint) | 库存变动记录唯一标识(PK) | 飞球雪花 ID |
|
||||
| `tenant_id` | BIGINT | `tenantid` | FACT_MAPPINGS (cast bigint) | 租户 ID | 飞球租户 ID |
|
||||
| `site_id` | BIGINT | `siteid` | FACT_MAPPINGS (cast bigint) | 门店 ID | 飞球门店 ID |
|
||||
| `site_goods_id` | BIGINT | `sitegoodsid` | FACT_MAPPINGS (cast bigint) | 门店商品 ID,关联 `dim_store_goods.site_goods_id` | 飞球商品 ID |
|
||||
| `goods_name` | TEXT | `goodsname` | FACT_MAPPINGS | 商品名称 | 如 `百威啤酒` |
|
||||
| `goods_category_id` | BIGINT | `goodscategoryid` | FACT_MAPPINGS (cast bigint) | 一级商品分类 ID | 飞球分类 ID |
|
||||
| `goods_second_category_id` | BIGINT | `goodssecondcategoryid` | FACT_MAPPINGS (cast bigint) | 二级商品分类 ID | 飞球分类 ID |
|
||||
| `unit` | TEXT | `unit` | FACT_MAPPINGS | 计量单位 | 如 `瓶`、`包`、`张` |
|
||||
| `price` | NUMERIC(18,4) | `price` | FACT_MAPPINGS (cast numeric) | 商品单价(元) | 金额值 |
|
||||
| `stock_type` | INTEGER | `stocktype` | FACT_MAPPINGS (cast integer) | 库存变动类型枚举(详见下方枚举表) | `1`/`2`/`4`/`7`/`8`/`9` |
|
||||
| `change_num` | NUMERIC(18,4) | `changenum` | FACT_MAPPINGS (cast numeric) | 变动数量(正数为增加,负数为减少) | 正/负数值 |
|
||||
| `start_num` | NUMERIC(18,4) | `startnum` | FACT_MAPPINGS (cast numeric) | 变动前库存量 | `0.0000` ~ 正数 |
|
||||
| `end_num` | NUMERIC(18,4) | `endnum` | FACT_MAPPINGS (cast numeric) | 变动后库存量 | `0.0000` ~ 正数 |
|
||||
| `change_num_a` | NUMERIC(18,4) | `changenuma` | FACT_MAPPINGS (cast numeric) | 辅助单位变动量(用于双单位商品,如"箱→瓶"换算) | 数值或 0 |
|
||||
| `start_num_a` | NUMERIC(18,4) | `startnuma` | FACT_MAPPINGS (cast numeric) | 辅助单位变动前库存 | 数值或 0 |
|
||||
| `end_num_a` | NUMERIC(18,4) | `endnuma` | FACT_MAPPINGS (cast numeric) | 辅助单位变动后库存 | 数值或 0 |
|
||||
| `remark` | TEXT | `remark` | FACT_MAPPINGS | 备注说明 | 如 `结账退货`、`采购退货`、`系统自动领用`,或 NULL |
|
||||
| `operator_name` | TEXT | `operatorname` | FACT_MAPPINGS | 操作人姓名 | 姓名或 NULL |
|
||||
| `create_time` | TIMESTAMPTZ | `createtime` | FACT_MAPPINGS (cast timestamptz) | 变动发生时间 | ISO 时间戳 |
|
||||
| `fetched_at` | TIMESTAMPTZ | `fetched_at` | 自动映射 | ETL 采集时间戳 | ISO 时间戳 |
|
||||
|
||||
---
|
||||
|
||||
## 2. stock_type 枚举值详解
|
||||
|
||||
| stock_type | 含义 | 典型 remark | 数据量 |
|
||||
|-----------|------|------------|--------|
|
||||
| 1 | 销售出库 | NULL(系统自动扣减) | 29,573 条 |
|
||||
| 2 | 采购入库 | NULL | 1,033 条 |
|
||||
| 4 | 退货入库 | `结账退货` | 3,300 条 |
|
||||
| 7 | 采购退货(出库) | `采购退货` | 33 条 |
|
||||
| 8 | 领用出库 | `系统自动领用` | 1,033 条 |
|
||||
| 9 | 领用退回(入库) | `系统自动领用退回` | 33 条 |
|
||||
|
||||
---
|
||||
|
||||
## 3. ODS 列名映射说明
|
||||
|
||||
ODS DDL 中列名使用驼峰式(如 `siteGoodsStockId`),PostgreSQL 在无引号时自动小写化。FACT_MAPPINGS 中使用带引号的 ODS 列名以正确引用。部分列(`unit`、`price`、`remark`)在 ODS 中已是小写,无需引号。
|
||||
|
||||
---
|
||||
|
||||
## 4. 代码引用
|
||||
|
||||
- FACT_MAPPINGS:`dwd_load_task.py` → `FACT_MAPPINGS["dwd.dwd_goods_stock_movement"]`
|
||||
- TABLE_MAP:`"dwd.dwd_goods_stock_movement" → "ods.goods_stock_movements"`
|
||||
- DWS 下游:暂无直接 DWS 汇总(库存汇总基于 `dwd_goods_stock_summary`)
|
||||
@@ -0,0 +1,62 @@
|
||||
# BD_Manual:goods_stock_summary(库存汇总)
|
||||
|
||||
> ODS 表:`ods.goods_stock_summary`
|
||||
> DWD 表:`dwd.dwd_goods_stock_summary`(事实表,无 ex 扩展表)
|
||||
> API 接口:库存汇总查询(支持 `startTime`/`endTime` 时间窗口参数)
|
||||
> JSON 路径:`goods_stock_summary.json → data.goodsStockSummaryList`
|
||||
> 装载方式:事实表按时间窗口增量加载(`DwdLoadTask`)
|
||||
> ODS 配置:`requires_window=True`,`time_fields=("startTime", "endTime")`
|
||||
> 代码位置:`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. dwd_goods_stock_summary
|
||||
|
||||
| DWD 列名 | 类型 | ODS 源列 | 映射方式 | 业务含义 | 取值范围/示例 |
|
||||
|----------|------|---------|---------|---------|-------------|
|
||||
| `site_goods_id` | BIGINT | `sitegoodsid`(ODS DDL 驼峰 → PG 小写) | FACT_MAPPINGS (cast bigint) | 门店商品 ID(PK 之一),关联 `dim_store_goods.site_goods_id` | 飞球商品 ID,如 `3028609051954117` |
|
||||
| `goods_name` | TEXT | `goodsname` | FACT_MAPPINGS | 商品名称 | 如 `酱香爆珠槟榔`、`百威啤酒` |
|
||||
| `goods_unit` | TEXT | `goodsunit` | FACT_MAPPINGS | 计量单位 | 如 `包`、`瓶`、`张`、`罐` |
|
||||
| `goods_category_id` | BIGINT | `goodscategoryid` | FACT_MAPPINGS (cast bigint) | 一级商品分类 ID | 飞球分类 ID |
|
||||
| `goods_category_second_id` | BIGINT | `goodscategorysecondid` | FACT_MAPPINGS (cast bigint) | 二级商品分类 ID | 飞球分类 ID |
|
||||
| `category_name` | TEXT | `categoryname` | FACT_MAPPINGS | 一级分类名称 | 如 `槟榔`、`酒水`、`其他` |
|
||||
| `range_start_stock` | NUMERIC(18,4) | `rangestartstock` | FACT_MAPPINGS (cast numeric) | 期初库存(查询时间窗口起点的库存量) | 如 `100.0000`、`0.0000` |
|
||||
| `range_end_stock` | NUMERIC(18,4) | `rangeendstock` | FACT_MAPPINGS (cast numeric) | 期末库存(查询时间窗口终点的库存量) | 如 `100.0000`、`0.0000` |
|
||||
| `range_in` | NUMERIC(18,4) | `rangein` | FACT_MAPPINGS (cast numeric) | 入库数量(时间窗口内的采购/调拨入库总量) | `0.0000` ~ 正数 |
|
||||
| `range_out` | NUMERIC(18,4) | `rangeout` | FACT_MAPPINGS (cast numeric) | 出库数量(时间窗口内的调拨出库/报损总量) | `0.0000` ~ 正数 |
|
||||
| `range_sale` | NUMERIC(18,4) | `rangesale` | FACT_MAPPINGS (cast numeric) | 销售数量(时间窗口内的销售出库总量) | `0.0000` ~ 正数 |
|
||||
| `range_sale_money` | NUMERIC(18,2) | `rangesalemoney` | FACT_MAPPINGS (cast numeric) | 销售金额(元),时间窗口内的销售总金额 | `0.00` ~ 金额值 |
|
||||
| `range_inventory` | NUMERIC(18,4) | `rangeinventory` | FACT_MAPPINGS (cast numeric) | 盘点调整量(时间窗口内的盘盈/盘亏净量) | 正数(盘盈)或负数(盘亏) |
|
||||
| `current_stock` | NUMERIC(18,4) | `currentstock` | FACT_MAPPINGS (cast numeric) | 当前库存(API 返回时的实时库存量) | `0.0000` ~ 正数 |
|
||||
| `site_id` | BIGINT | `site_id` | 自动映射 | 门店 ID(ETL 注入) | 飞球门店 ID |
|
||||
| `tenant_id` | BIGINT | `tenant_id` | 自动映射 | 租户 ID(ETL 注入) | 飞球租户 ID |
|
||||
| `fetched_at` | TIMESTAMPTZ | `fetched_at` | 自动映射 | ETL 采集时间戳(PK 之一),标识本次快照的采集时间 | ISO 时间戳 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 主键说明
|
||||
|
||||
主键为 `(site_goods_id, fetched_at)` 复合键。同一商品在不同采集时间窗口会产生多条记录,每条记录代表该时间窗口内的库存汇总快照。
|
||||
|
||||
---
|
||||
|
||||
## 3. ODS 列名映射说明
|
||||
|
||||
ODS DDL 中列名使用驼峰式(如 `siteGoodsId`),但 PostgreSQL 在无引号时自动小写化为 `sitegoodsid`。FACT_MAPPINGS 中使用带引号的 ODS 列名(如 `"siteGoodsId"`)以正确引用。
|
||||
|
||||
---
|
||||
|
||||
## 4. DWS 下游
|
||||
|
||||
- `dws.dws_goods_stock_daily_summary`:日度库存汇总
|
||||
- `dws.dws_goods_stock_weekly_summary`:周度库存汇总(ISO 周)
|
||||
- `dws.dws_goods_stock_monthly_summary`:月度库存汇总
|
||||
|
||||
---
|
||||
|
||||
## 5. 代码引用
|
||||
|
||||
- FACT_MAPPINGS:`dwd_load_task.py` → `FACT_MAPPINGS["dwd.dwd_goods_stock_summary"]`
|
||||
- TABLE_MAP:`"dwd.dwd_goods_stock_summary" → "ods.goods_stock_summary"`
|
||||
- ODS 任务配置:`tasks/ods/ods_tasks.py` → `OdsTaskSpec("ODS_GOODS_STOCK_SUMMARY", requires_window=True, time_fields=("startTime", "endTime"))`
|
||||
- DWS 任务:`tasks/dws/goods_stock_daily_task.py`、`goods_stock_weekly_task.py`、`goods_stock_monthly_task.py`
|
||||
@@ -0,0 +1,102 @@
|
||||
# BD Manual: goodsStockWarningInfo(库存预警信息)
|
||||
|
||||
## 变更概述
|
||||
|
||||
- 日期:2026-02-24
|
||||
- 触发:一致性检查报告发现 API 独有嵌套字段 `goodsStockWarningInfo` 未映射到 ODS/DWD
|
||||
- 迁移脚本:`db/etl_feiqiu/migrations/2026-02-24__add_goods_stock_warning_info.sql`
|
||||
|
||||
## 字段来源
|
||||
|
||||
API 端点 `/TenantGoods/GetGoodsInventoryList` 返回的 `store_goods_master` 记录中包含嵌套对象:
|
||||
|
||||
```json
|
||||
{
|
||||
"goodsStockWarningInfo": {
|
||||
"tenant_goods_id": 0, // 冗余,已有同名顶层字段
|
||||
"site_goods_id": 0, // 冗余,对应顶层 id
|
||||
"sales_day": 0.0, // → warning_sales_day
|
||||
"warning_day_max": 0, // → warning_day_max
|
||||
"warning_day_min": 0 // → warning_day_min
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
仅提取 3 个有效字段,冗余 ID 不重复收录。
|
||||
|
||||
## 新增字段
|
||||
|
||||
| 层 | 表 | 列名 | 类型 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| ODS | `ods.store_goods_master` | `warning_sales_day` | NUMERIC(18,2) | 库存预警参考的日均销量 |
|
||||
| ODS | `ods.store_goods_master` | `warning_day_max` | INTEGER | 预警天数上限 |
|
||||
| ODS | `ods.store_goods_master` | `warning_day_min` | INTEGER | 预警天数下限 |
|
||||
| DWD | `dwd.dim_store_goods_ex` | `warning_sales_day` | NUMERIC(18,2) | 同 ODS,直接映射 |
|
||||
| DWD | `dwd.dim_store_goods_ex` | `warning_day_max` | INTEGER | 同 ODS,直接映射 |
|
||||
| DWD | `dwd.dim_store_goods_ex` | `warning_day_min` | INTEGER | 同 ODS,直接映射 |
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
API goodsStockWarningInfo (嵌套 JSON)
|
||||
↓ _merge_record_layers 扁平化(_STOCK_WARNING_FIELD_MAP)
|
||||
ODS ods.store_goods_master (warning_sales_day / warning_day_max / warning_day_min)
|
||||
↓ DWD FACT_MAPPINGS 直接映射
|
||||
DWD dwd.dim_store_goods_ex (同名列)
|
||||
```
|
||||
|
||||
## 代码变更
|
||||
|
||||
| 文件 | 变更 |
|
||||
|---|---|
|
||||
| `apps/etl/connectors/feiqiu/tasks/ods/ods_tasks.py` | `_merge_record_layers` 增加 `goodsStockWarningInfo` 扁平化逻辑 |
|
||||
| `apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py` | `FACT_MAPPINGS["dwd.dim_store_goods_ex"]` 增加 3 个映射 |
|
||||
| `db/_archived/ddl_baseline_2026-02-22/db/etl_feiqiu/schemas/ods.sql` | baseline 同步 |
|
||||
| `db/_archived/ddl_baseline_2026-02-22/db/etl_feiqiu/schemas/dwd.sql` | baseline 同步 |
|
||||
|
||||
## 兼容性
|
||||
|
||||
- 后端 API:无影响(后端不直接读取 ODS/DWD 层这些字段)
|
||||
- 小程序:无影响
|
||||
- ETL:ODS schema-aware 插入自动识别新列;DWD 通过 FACT_MAPPINGS 映射
|
||||
- 管理后台:如需展示库存预警信息,可从 `dwd.dim_store_goods_ex` 读取
|
||||
|
||||
## 回滚策略
|
||||
|
||||
```sql
|
||||
ALTER TABLE ods.store_goods_master
|
||||
DROP COLUMN IF EXISTS warning_sales_day,
|
||||
DROP COLUMN IF EXISTS warning_day_max,
|
||||
DROP COLUMN IF EXISTS warning_day_min;
|
||||
ALTER TABLE dwd.dim_store_goods_ex
|
||||
DROP COLUMN IF EXISTS warning_sales_day,
|
||||
DROP COLUMN IF EXISTS warning_day_max,
|
||||
DROP COLUMN IF EXISTS warning_day_min;
|
||||
```
|
||||
|
||||
## 验证 SQL
|
||||
|
||||
```sql
|
||||
-- 1. 确认 ODS 新列存在
|
||||
SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_schema = 'ods' AND table_name = 'store_goods_master'
|
||||
AND column_name IN ('warning_sales_day', 'warning_day_max', 'warning_day_min')
|
||||
ORDER BY column_name;
|
||||
-- 预期:3 行
|
||||
|
||||
-- 2. 确认 DWD 新列存在
|
||||
SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_schema = 'dwd' AND table_name = 'dim_store_goods_ex'
|
||||
AND column_name IN ('warning_sales_day', 'warning_day_max', 'warning_day_min')
|
||||
ORDER BY column_name;
|
||||
-- 预期:3 行
|
||||
|
||||
-- 3. 确认注释已设置
|
||||
SELECT c.column_name, pgd.description
|
||||
FROM information_schema.columns c
|
||||
JOIN pg_catalog.pg_statio_all_tables st ON st.schemaname = c.table_schema AND st.relname = c.table_name
|
||||
JOIN pg_catalog.pg_description pgd ON pgd.objoid = st.relid AND pgd.objsubid = c.ordinal_position
|
||||
WHERE c.table_schema = 'ods' AND c.table_name = 'store_goods_master'
|
||||
AND c.column_name LIKE 'warning_%';
|
||||
-- 预期:3 行,description 非空
|
||||
```
|
||||
@@ -0,0 +1,92 @@
|
||||
# BD_Manual:ods.group_buy_package_details 团购套餐详情表
|
||||
|
||||
> 日期:2026-03-05
|
||||
> 涉及库:`etl_feiqiu` / `test_etl_feiqiu`
|
||||
> DDL 路径:`db/etl_feiqiu/ods/group_buy_package_details.sql`
|
||||
> 直接原因:整合团购详情接口(QueryPackageCouponInfo),新建 ODS 详情表存储每个团购套餐的详情数据
|
||||
> Prompt 摘要:etl-coupon-detail spec — 需求 3 验收标准 1-4
|
||||
|
||||
---
|
||||
|
||||
## 1. 变更说明
|
||||
|
||||
### 变更内容
|
||||
|
||||
新建 `ods.group_buy_package_details` 表,用于存储 `QueryPackageCouponInfo` 详情接口的原始数据。
|
||||
|
||||
| Schema | 表 | 操作 | 说明 |
|
||||
|--------|-----|------|------|
|
||||
| ods | group_buy_package_details | 新建 | 团购套餐详情,主键 `coupon_id`,含 12 个结构化字段 + 6 个 JSONB 数组字段 + 3 个 ETL 元数据字段 |
|
||||
|
||||
### 数据获取方式
|
||||
|
||||
通过 `ODS_GROUP_PACKAGE` 任务的 `detail_endpoint` 二级详情拉取子流程:
|
||||
- 主流程拉取团购列表 → `ods.group_buy_packages`
|
||||
- 子流程遍历每个 `id`,串行调用 `QueryPackageCouponInfo` → 本表
|
||||
- 全量快照模式,UPSERT on `coupon_id`
|
||||
|
||||
### 关键字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `coupon_id` | BIGINT PK | 团购套餐 ID(= group_buy_packages.id) |
|
||||
| `table_area_ids` | JSONB | 可用台区 ID 列表 |
|
||||
| `table_area_names` | JSONB | 可用台区名称列表 |
|
||||
| `assistant_services` | JSONB | 助教服务关联数组 |
|
||||
| `groupon_site_infos` | JSONB | 关联门店信息数组 |
|
||||
| `package_services` | JSONB | 套餐服务数组(待调研) |
|
||||
| `coupon_details_list` | JSONB | 券明细数组(待调研) |
|
||||
| `content_hash` | TEXT | 内容哈希,用于变更检测 |
|
||||
| `payload` | JSONB | 完整原始 JSON 响应 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 兼容性影响
|
||||
|
||||
| 组件 | 影响 | 说明 |
|
||||
|------|------|------|
|
||||
| ETL ODS 层 | 新增表 | `ODS_GROUP_PACKAGE` 任务通过 `detail_endpoint` 配置自动写入 |
|
||||
| ETL DWD 层 | 需配合修改 | `dwd_load_task.py` 需 LEFT JOIN 本表将 4 个 JSONB 字段合并到 `dim_groupbuy_package_ex` |
|
||||
| 后端 API | 无影响 | 当前无接口直接查询本表 |
|
||||
| 小程序 | 无影响 | 不直接使用 ODS 层表 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 回滚策略
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS ods.group_buy_package_details;
|
||||
```
|
||||
|
||||
回滚后需同步移除 `ODS_GROUP_PACKAGE` 任务中的 `detail_endpoint` 相关配置。
|
||||
|
||||
---
|
||||
|
||||
## 4. 验证 SQL
|
||||
|
||||
```sql
|
||||
-- 1. 确认表存在且主键正确
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'ods'
|
||||
AND table_name = 'group_buy_package_details'
|
||||
ORDER BY ordinal_position;
|
||||
-- 预期:22 列,coupon_id 为 BIGINT NOT NULL
|
||||
|
||||
-- 2. 确认主键约束
|
||||
SELECT constraint_name, constraint_type
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = 'ods'
|
||||
AND table_name = 'group_buy_package_details'
|
||||
AND constraint_type = 'PRIMARY KEY';
|
||||
-- 预期:1 行,pk_group_buy_package_details
|
||||
|
||||
-- 3. 确认 JSONB 列存在
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'ods'
|
||||
AND table_name = 'group_buy_package_details'
|
||||
AND data_type = 'jsonb'
|
||||
ORDER BY ordinal_position;
|
||||
-- 预期:8 行(table_area_ids, table_area_names, assistant_services, groupon_site_infos, package_services, coupon_details_list, payload)
|
||||
```
|
||||
@@ -0,0 +1,74 @@
|
||||
# BD_Manual:member_balance_changes(会员余额变动)
|
||||
|
||||
> ODS 表:`ods.member_balance_changes`
|
||||
> DWD 表:`dwd.dwd_member_balance_change`(主表)、`dwd.dwd_member_balance_change_ex`(扩展表)
|
||||
> API 接口:会员余额变动记录列表
|
||||
> JSON 路径:`member_balance_changes.json → data.memberAccountChanges`
|
||||
> 装载方式:事实表增量插入(`DwdLoadTask`)
|
||||
> 代码位置:`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. dwd_member_balance_change(主表,22 列)
|
||||
|
||||
| DWD 列名 | 类型 | ODS 源列 | 映射方式 | 业务含义 | 取值范围/示例 |
|
||||
|----------|------|---------|---------|---------|-------------|
|
||||
| `balance_change_id` | BIGINT | `id` | FACT_MAPPINGS | 余额变动记录唯一标识(PK) | 飞球雪花 ID |
|
||||
| `tenant_id` | BIGINT | `tenant_id` | 自动映射 | 租户 ID | 飞球租户 ID |
|
||||
| `site_id` | BIGINT | `site_id` | 自动映射 | 门店 ID | 飞球门店 ID |
|
||||
| `register_site_id` | BIGINT | `register_site_id` | 自动映射 | 会员注册门店 ID | 飞球门店 ID |
|
||||
| `tenant_member_id` | BIGINT | `tenant_member_id` | 自动映射 | 租户维度会员 ID | 飞球会员 ID |
|
||||
| `system_member_id` | BIGINT | `system_member_id` | 自动映射 | 系统维度会员 ID(跨租户唯一) | 飞球会员 ID |
|
||||
| `tenant_member_card_id` | BIGINT | `tenant_member_card_id` | 自动映射 | 会员卡 ID | 飞球会员卡 ID |
|
||||
| `card_type_id` | BIGINT | `card_type_id` | 自动映射 | 会员卡类型 ID | 飞球卡类型 ID |
|
||||
| `card_type_name` | VARCHAR(32) | `membercardtypename` | FACT_MAPPINGS | 会员卡类型名称快照 | 如 `普通会员卡` |
|
||||
| `member_name` | VARCHAR(64) | `membername` | FACT_MAPPINGS | 会员姓名快照 | 姓名 |
|
||||
| `member_mobile` | VARCHAR(20) | `membermobile` | FACT_MAPPINGS | 会员手机号快照 | 11 位手机号 |
|
||||
| `balance_before` | NUMERIC(18,2) | `before` | FACT_MAPPINGS | 变动前余额(元) | 金额值 |
|
||||
| `change_amount` | NUMERIC(18,2) | `account_data` | FACT_MAPPINGS | 变动金额(元),正数为充入,负数为扣减 | 正/负金额 |
|
||||
| `balance_after` | NUMERIC(18,2) | `after` | FACT_MAPPINGS | 变动后余额(元) | 金额值 |
|
||||
| `from_type` | INTEGER | `from_type` | 自动映射 | 变动来源类型:1=结算扣款,2=充值,3=退款返还,4=系统调整,7=转账,9=其他 | `1`/`2`/`3`/`4`/`7`/`9` |
|
||||
| `payment_method` | INTEGER | `payment_method` | 自动映射 | 支付方式 | 枚举值 |
|
||||
| `change_time` | TIMESTAMPTZ | `create_time` | FACT_MAPPINGS | 变动发生时间 | ISO 时间戳 |
|
||||
| `is_delete` | INTEGER | `is_delete` | 自动映射 | 是否已删除:0=正常,1=已删除 | `0` / `1` |
|
||||
| `remark` | VARCHAR(255) | `remark` | 自动映射 | 备注说明 | 自由文本或 NULL |
|
||||
| `principal_before` | NUMERIC(18,2) | `principal_before` | FACT_MAPPINGS | 变动前本金余额(元),不含赠送金 | 金额值 |
|
||||
| `principal_after` | NUMERIC(18,2) | `principal_after` | FACT_MAPPINGS | 变动后本金余额(元) | 金额值 |
|
||||
| `principal_change_amount` | NUMERIC(18,2) | *计算列* | FACT_MAPPINGS | 本金变动金额(元),= `principal_after - principal_before` | 正/负金额 |
|
||||
|
||||
---
|
||||
|
||||
## 2. dwd_member_balance_change_ex(扩展表,8 列)
|
||||
|
||||
| DWD 列名 | 类型 | ODS 源列 | 映射方式 | 业务含义 | 取值范围/示例 |
|
||||
|----------|------|---------|---------|---------|-------------|
|
||||
| `balance_change_id` | BIGINT | `id` | FACT_MAPPINGS | 余额变动记录唯一标识(PK) | 同主表 |
|
||||
| `pay_site_name` | VARCHAR(64) | `paysitename` | FACT_MAPPINGS | 支付门店名称快照 | 如 `朗朗桌球` |
|
||||
| `register_site_name` | VARCHAR(64) | `registersitename` | FACT_MAPPINGS | 注册门店名称快照 | 如 `朗朗桌球` |
|
||||
| `refund_amount` | NUMERIC(18,2) | `refund_amount` | 自动映射 | 退款金额(元) | `0.00` ~ 金额值 |
|
||||
| `operator_id` | BIGINT | `operator_id` | 自动映射 | 操作员 ID,执行本次余额变动的员工。0 表示系统自动 | `0` 或员工 ID |
|
||||
| `operator_name` | VARCHAR(64) | `operator_name` | 自动映射 | 操作员姓名 | 姓名或 NULL |
|
||||
| `principal_data` | TEXT | `principal_data` | FACT_MAPPINGS | 本金变动金额(元),正数为充入本金,负数为扣减本金 | 正/负金额 |
|
||||
| `relate_id` | BIGINT | `relate_id` | FACT_MAPPINGS | 关联业务单据 ID,指向触发本次余额变动的业务记录。按 `from_type` 不同指向不同表:1→结算单 ID,2→充值单 ID,3→退款单 ID,7→转账单 ID。`from_type=4`(系统调整)和 `9`(其他)时为 0 | `0` 或业务单据 ID |
|
||||
|
||||
---
|
||||
|
||||
## 3. from_type 枚举值详解
|
||||
|
||||
| from_type | 含义 | relate_id 指向 | 数据量 |
|
||||
|-----------|------|---------------|--------|
|
||||
| 1 | 结算扣款(消费) | 结算单 ID(`dwd_settlement_head.order_settle_id`) | 5637 条 |
|
||||
| 2 | 充值 | 充值单 ID(`dwd_recharge_order.recharge_order_id`) | 110 条 |
|
||||
| 3 | 退款返还 | 退款单 ID(`dwd_refund.refund_id`) | 650 条 |
|
||||
| 4 | 系统调整 | 0(无关联单据) | 775 条 |
|
||||
| 7 | 转账 | 转账单 ID | 15 条 |
|
||||
| 9 | 其他 | 0(无关联单据) | 179 条 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 代码引用
|
||||
|
||||
- FACT_MAPPINGS:`dwd_load_task.py` → `FACT_MAPPINGS["dwd.dwd_member_balance_change"]` / `FACT_MAPPINGS["dwd.dwd_member_balance_change_ex"]`
|
||||
- TABLE_MAP:`"dwd.dwd_member_balance_change" → "ods.member_balance_changes"`
|
||||
- DWS 下游:`dws_member_analysis_task.py`(会员消费分析)
|
||||
- 迁移脚本:`db/_archived/ddl_baseline_2026-02-22/db/etl_feiqiu/migrations/2026-02-20__add_member_balance_change_ex_relate_id.sql`(已归档)
|
||||
@@ -0,0 +1,103 @@
|
||||
# BD_Manual:recharge_settlements(充值结算)
|
||||
|
||||
> ODS 表:`ods.recharge_settlements`
|
||||
> DWD 表:`dwd.dwd_recharge_order`(主表)、`dwd.dwd_recharge_order_ex`(扩展表)
|
||||
> API 接口:充值结算记录列表
|
||||
> JSON 路径:`recharge_settlements.json → data.orderSettles`
|
||||
> 装载方式:事实表增量插入(`DwdLoadTask`)
|
||||
> 代码位置:`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. dwd_recharge_order(主表)
|
||||
|
||||
| DWD 列名 | 类型 | ODS 源列 | 映射方式 | 业务含义 | 取值范围/示例 |
|
||||
|----------|------|---------|---------|---------|-------------|
|
||||
| `recharge_order_id` | BIGINT | `id` | FACT_MAPPINGS | 充值结算记录唯一标识(PK) | 飞球雪花 ID |
|
||||
| `tenant_id` | BIGINT | `tenantid` | FACT_MAPPINGS | 租户 ID | 飞球租户 ID |
|
||||
| `site_id` | BIGINT | `siteid` | FACT_MAPPINGS | 门店 ID | 飞球门店 ID |
|
||||
| `member_id` | BIGINT | `memberid` | FACT_MAPPINGS | 会员 ID | 飞球会员 ID |
|
||||
| `member_name_snapshot` | TEXT | `membername` | FACT_MAPPINGS | 会员姓名快照 | 姓名 |
|
||||
| `member_phone_snapshot` | TEXT | `memberphone` | FACT_MAPPINGS | 会员手机号快照 | 11 位手机号 |
|
||||
| `tenant_member_card_id` | BIGINT | `tenantmembercardid` | FACT_MAPPINGS | 会员卡 ID | 飞球会员卡 ID |
|
||||
| `member_card_type_name` | TEXT | `membercardtypename` | FACT_MAPPINGS | 会员卡类型名称 | 如 `普通会员卡` |
|
||||
| `settle_relate_id` | BIGINT | `settlerelateid` | FACT_MAPPINGS | 关联结算单 ID | 飞球结算 ID |
|
||||
| `settle_type` | INTEGER | `settletype` | FACT_MAPPINGS | 结算类型枚举 | 枚举值 |
|
||||
| `settle_name` | TEXT | `settlename` | FACT_MAPPINGS | 结算类型名称 | 如 `充值` |
|
||||
| `is_first` | INTEGER | `isfirst` | FACT_MAPPINGS | 是否首次充值:0=否,1=是 | `0` / `1` |
|
||||
| `pay_amount` | NUMERIC | `payamount` | FACT_MAPPINGS | 实付金额(元) | 金额值 |
|
||||
| `refund_amount` | NUMERIC | `refundamount` | FACT_MAPPINGS | 退款金额(元) | `0.00` ~ 金额值 |
|
||||
| `point_amount` | NUMERIC | `pointamount` | FACT_MAPPINGS | 积分抵扣金额(元) | `0.00` ~ 金额值 |
|
||||
| `cash_amount` | NUMERIC | `cashamount` | FACT_MAPPINGS | 现金支付金额(元) | `0.00` ~ 金额值 |
|
||||
| `payment_method` | TEXT | `paymentmethod` | FACT_MAPPINGS | 支付方式 | 如 `微信`、`支付宝` |
|
||||
| `create_time` | TIMESTAMPTZ | `createtime` | FACT_MAPPINGS | 充值记录创建时间 | ISO 时间戳 |
|
||||
| `pay_time` | TIMESTAMPTZ | `paytime` | FACT_MAPPINGS | 支付完成时间 | ISO 时间戳 |
|
||||
| `pl_coupon_sale_amount` | NUMERIC | `plcouponsaleamount` | FACT_MAPPINGS | 平台券销售金额(元)。当前门店业务未启用,全部为 0 | `0.00` |
|
||||
| `mervou_sales_amount` | NUMERIC | `mervousalesamount` | FACT_MAPPINGS | 储值券销售金额(元)。当前门店业务未启用,全部为 0 | `0.00` |
|
||||
| `electricity_money` | NUMERIC | `electricitymoney` | FACT_MAPPINGS | 电费金额(元)。当前门店业务未启用,全部为 0 | `0.00` |
|
||||
| `real_electricity_money` | NUMERIC | `realelectricitymoney` | FACT_MAPPINGS | 实际电费金额(元),扣除调整后的电费。当前门店业务未启用,全部为 0 | `0.00` |
|
||||
| `electricity_adjust_money` | NUMERIC | `electricityadjustmoney` | FACT_MAPPINGS | 电费调整金额(元),电费手动调整的差额。当前门店业务未启用,全部为 0 | `0.00` |
|
||||
|
||||
---
|
||||
|
||||
## 2. dwd_recharge_order_ex(扩展表)
|
||||
|
||||
| DWD 列名 | 类型 | ODS 源列 | 映射方式 | 业务含义 | 取值范围/示例 |
|
||||
|----------|------|---------|---------|---------|-------------|
|
||||
| `recharge_order_id` | BIGINT | `id` | FACT_MAPPINGS | 充值结算记录唯一标识(PK) | 同主表 |
|
||||
| `site_name_snapshot` | TEXT | `sitename` | FACT_MAPPINGS | 门店名称快照 | 如 `朗朗桌球` |
|
||||
| `settle_status` | INTEGER | `settlestatus` | FACT_MAPPINGS | 结算状态枚举 | 枚举值 |
|
||||
| `is_bind_member` | BOOLEAN | `isbindmember` | FACT_MAPPINGS (cast boolean) | 是否绑定会员 | `true` / `false` |
|
||||
| `is_activity` | BOOLEAN | `isactivity` | FACT_MAPPINGS (cast boolean) | 是否参与活动 | `true` / `false` |
|
||||
| `is_use_coupon` | BOOLEAN | `isusecoupon` | FACT_MAPPINGS (cast boolean) | 是否使用优惠券 | `true` / `false` |
|
||||
| `is_use_discount` | BOOLEAN | `isusediscount` | FACT_MAPPINGS (cast boolean) | 是否使用折扣 | `true` / `false` |
|
||||
| `can_be_revoked` | BOOLEAN | `canberevoked` | FACT_MAPPINGS (cast boolean) | 是否可撤销 | `true` / `false` |
|
||||
| `online_amount` | NUMERIC | `onlineamount` | FACT_MAPPINGS | 线上支付金额(元) | 金额值 |
|
||||
| `balance_amount` | NUMERIC | `balanceamount` | FACT_MAPPINGS | 余额支付金额(元) | 金额值 |
|
||||
| `card_amount` | NUMERIC | `cardamount` | FACT_MAPPINGS | 银行卡支付金额(元) | 金额值 |
|
||||
| `coupon_amount` | NUMERIC | `couponamount` | FACT_MAPPINGS | 优惠券抵扣金额(元) | 金额值 |
|
||||
| `recharge_card_amount` | NUMERIC | `rechargecardamount` | FACT_MAPPINGS | 充值卡支付金额(元) | 金额值 |
|
||||
| `gift_card_amount` | NUMERIC | `giftcardamount` | FACT_MAPPINGS | 赠送卡支付金额(元) | 金额值 |
|
||||
| `prepay_money` | NUMERIC | `prepaymoney` | FACT_MAPPINGS | 预付金额(元) | 金额值 |
|
||||
| `consume_money` | NUMERIC | `consumemoney` | FACT_MAPPINGS | 消费总金额(元) | 金额值 |
|
||||
| `goods_money` | NUMERIC | `goodsmoney` | FACT_MAPPINGS | 商品金额(元) | 金额值 |
|
||||
| `real_goods_money` | NUMERIC | `realgoodsmoney` | FACT_MAPPINGS | 实收商品金额(元) | 金额值 |
|
||||
| `table_charge_money` | NUMERIC | `tablechargemoney` | FACT_MAPPINGS | 台费金额(元) | 金额值 |
|
||||
| `service_money` | NUMERIC | `servicemoney` | FACT_MAPPINGS | 服务费金额(元) | 金额值 |
|
||||
| `activity_discount` | NUMERIC | `activitydiscount` | FACT_MAPPINGS | 活动折扣金额(元) | 金额值 |
|
||||
| `all_coupon_discount` | NUMERIC | `allcoupondiscount` | FACT_MAPPINGS | 全部优惠券折扣金额(元) | 金额值 |
|
||||
| `goods_promotion_money` | NUMERIC | `goodspromotionmoney` | FACT_MAPPINGS | 商品促销金额(元) | 金额值 |
|
||||
| `assistant_promotion_money` | NUMERIC | `assistantpromotionmoney` | FACT_MAPPINGS | 助教促销金额(元) | 金额值 |
|
||||
| `assistant_pd_money` | NUMERIC | `assistantpdmoney` | FACT_MAPPINGS | 助教陪打金额(元) | 金额值 |
|
||||
| `assistant_cx_money` | NUMERIC | `assistantcxmoney` | FACT_MAPPINGS | 助教促销服务金额(元) | 金额值 |
|
||||
| `assistant_manual_discount` | NUMERIC | `assistantmanualdiscount` | FACT_MAPPINGS | 助教手动折扣金额(元) | 金额值 |
|
||||
| `coupon_sale_amount` | NUMERIC | `couponsaleamount` | FACT_MAPPINGS | 券销售金额(元) | 金额值 |
|
||||
| `member_discount_amount` | NUMERIC | `memberdiscountamount` | FACT_MAPPINGS | 会员折扣金额(元) | 金额值 |
|
||||
| `point_discount_price` | NUMERIC | `pointdiscountprice` | FACT_MAPPINGS | 积分折扣价格(元) | 金额值 |
|
||||
| `point_discount_cost` | NUMERIC | `pointdiscountcost` | FACT_MAPPINGS | 积分折扣成本(元) | 金额值 |
|
||||
| `adjust_amount` | NUMERIC | `adjustamount` | FACT_MAPPINGS | 调整金额(元) | 金额值 |
|
||||
| `rounding_amount` | NUMERIC | `roundingamount` | FACT_MAPPINGS | 抹零金额(元) | 金额值 |
|
||||
| `operator_id` | BIGINT | `operatorid` | FACT_MAPPINGS | 操作员 ID | 员工 ID |
|
||||
| `operator_name_snapshot` | TEXT | `operatorname` | FACT_MAPPINGS | 操作员姓名快照 | 如 `郑丽珊` |
|
||||
| `salesman_user_id` | BIGINT | `salesmanuserid` | FACT_MAPPINGS | 销售员用户 ID | 用户 ID |
|
||||
| `salesman_name` | TEXT | `salesmanname` | FACT_MAPPINGS | 销售员姓名 | 姓名或 NULL |
|
||||
| `order_remark` | TEXT | `orderremark` | FACT_MAPPINGS | 订单备注 | 自由文本或 NULL |
|
||||
| `table_id` | BIGINT | `tableid` | FACT_MAPPINGS | 台桌 ID | 飞球台桌 ID |
|
||||
| `serial_number` | INTEGER | `serialnumber` | FACT_MAPPINGS | 流水号 | 正整数 |
|
||||
| `revoke_order_id` | BIGINT | `revokeorderid` | FACT_MAPPINGS | 撤销关联的订单 ID | 订单 ID 或 0 |
|
||||
| `revoke_order_name` | TEXT | `revokeordername` | FACT_MAPPINGS | 撤销关联的订单名称 | 名称或 NULL |
|
||||
| `revoke_time` | TIMESTAMPTZ | `revoketime` | FACT_MAPPINGS | 撤销时间 | ISO 时间戳或 NULL |
|
||||
|
||||
---
|
||||
|
||||
## 3. B 类表说明
|
||||
|
||||
本表属于 B 类(仅补 FACT_MAPPINGS),5 个电费/券字段的 DWD 列在 DDL 中已存在,但之前缺少 FACT_MAPPINGS 条目导致数据未从 ODS 流入 DWD。2026-02-20 补充映射后,这 5 个字段的数据可正常流转。当前门店这 5 个字段的 ODS/DWD 数据全部为 0(业务未启用电费和券销售功能)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 代码引用
|
||||
|
||||
- FACT_MAPPINGS:`dwd_load_task.py` → `FACT_MAPPINGS["dwd.dwd_recharge_order"]` / `FACT_MAPPINGS["dwd.dwd_recharge_order_ex"]`
|
||||
- TABLE_MAP:`"dwd.dwd_recharge_order" → "ods.recharge_settlements"`
|
||||
- DWS 下游:`dws_finance_daily_task.py`(财务日报,充值汇总)
|
||||
@@ -0,0 +1,61 @@
|
||||
# BD_Manual:site_tables_master(台桌维表)
|
||||
|
||||
> ODS 表:`ods.site_tables_master`
|
||||
> DWD 表:`dwd.dim_table`(主表)、`dwd.dim_table_ex`(扩展表)
|
||||
> API 接口:门店台桌列表
|
||||
> JSON 路径:`site_tables_master.json → data.siteTables`
|
||||
> 装载方式:SCD2 维度合并(`DwdLoadTask`)
|
||||
> 代码位置:`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. dim_table(主表)
|
||||
|
||||
| DWD 列名 | 类型 | ODS 源列 | 映射方式 | 业务含义 | 取值范围/示例 |
|
||||
|----------|------|---------|---------|---------|-------------|
|
||||
| `table_id` | BIGINT | `id` | FACT_MAPPINGS | 台桌唯一标识(PK 之一) | 飞球雪花 ID |
|
||||
| `site_id` | BIGINT | `site_id` | 自动映射 | 门店 ID | 飞球门店 ID |
|
||||
| `table_name` | TEXT | `table_name` | 自动映射 | 台桌名称 | 如 `1号台`、`VIP1` |
|
||||
| `site_table_area_id` | BIGINT | `site_table_area_id` | 自动映射 | 门店台桌区域 ID | 飞球区域 ID |
|
||||
| `site_table_area_name` | TEXT | `areaname` | FACT_MAPPINGS | 台桌区域名称 | 如 `大厅`、`VIP区` |
|
||||
| `tenant_table_area_id` | BIGINT | `site_table_area_id` | FACT_MAPPINGS | 租户级台桌区域 ID | 飞球区域 ID |
|
||||
| `table_price` | NUMERIC | `table_price` | 自动映射 | 台费单价(元/小时) | 金额值 |
|
||||
| `order_id` | BIGINT | `order_id` | FACT_MAPPINGS | 当前关联的订单 ID,0 表示空闲 | `0` 或订单 ID |
|
||||
| `scd2_*` | — | — | DWD 元数据 | SCD2 慢变维度追踪字段 | — |
|
||||
|
||||
---
|
||||
|
||||
## 2. dim_table_ex(扩展表)
|
||||
|
||||
| DWD 列名 | 类型 | ODS 源列 | 映射方式 | 业务含义 | 取值范围/示例 |
|
||||
|----------|------|---------|---------|---------|-------------|
|
||||
| `table_id` | BIGINT | `id` | FACT_MAPPINGS | 台桌唯一标识(PK 之一) | 同主表 |
|
||||
| `show_status` | INTEGER | `show_status` | 自动映射 | 展示状态 | 枚举值 |
|
||||
| `is_online_reservation` | INTEGER | `is_online_reservation` | 自动映射 | 是否支持在线预约:0=否,1=是 | `0` / `1` |
|
||||
| `table_cloth_use_time` | INTEGER | `table_cloth_use_time` | FACT_MAPPINGS | 台布使用时间(累计使用次数或时长) | 整数 |
|
||||
| `table_cloth_use_cycle` | INTEGER | `table_cloth_use_cycle` | 自动映射 | 台布更换周期 | 整数 |
|
||||
| `table_status` | INTEGER | `table_status` | 自动映射 | 台桌状态枚举 | 枚举值 |
|
||||
| `create_time` | TIMESTAMPTZ | `create_time` | FACT_MAPPINGS | 台桌配置的创建时间或最近一次创建/复制时间 | ISO 时间戳 |
|
||||
| `light_status` | INTEGER | `light_status` | FACT_MAPPINGS | 台灯状态:1=已关灯,2=已开灯 | `1` / `2` |
|
||||
| `tablestatusname` | TEXT | `tablestatusname` | FACT_MAPPINGS | 台桌状态中文名称(如"空闲中""使用中"),仅展示用途。ODS 列 `tableStatusName` 在 PG 中小写化 | 如 `空闲中`、`使用中` |
|
||||
| `sitename` | TEXT | `sitename` | FACT_MAPPINGS | 门店名称快照,冗余字段。ODS 列 `siteName` 在 PG 中小写化 | 如 `朗朗桌球` |
|
||||
| `applet_qr_code_url` | TEXT | `"appletQrCodeUrl"` | FACT_MAPPINGS | 小程序二维码 URL,用于扫码开台等场景。ODS 列用双引号保留驼峰大小写 | HTTPS 链接或 NULL |
|
||||
| `audit_status` | INTEGER | `audit_status` | FACT_MAPPINGS | 审核状态:2=已审核通过(当前全部为 2) | `2` |
|
||||
| `charge_free` | INTEGER | `charge_free` | FACT_MAPPINGS | 是否免费台:0=收费,1=免费(当前全部为 0) | `0` / `1` |
|
||||
| `delay_lights_time` | INTEGER | `delay_lights_time` | FACT_MAPPINGS | 台灯熄灭延迟时间(秒),结账后延时关灯的秒数 | 正整数 |
|
||||
| `is_rest_area` | INTEGER | `is_rest_area` | FACT_MAPPINGS | 是否休息区台桌:0=否,1=是(当前全部为 0) | `0` / `1` |
|
||||
| `only_allow_groupon` | INTEGER | `only_allow_groupon` | FACT_MAPPINGS | 是否仅允许团购开台:0=不限制,1=仅团购,2=不限制(当前全部为 2) | `0` / `1` / `2` |
|
||||
| `order_delay_time` | INTEGER | `order_delay_time` | FACT_MAPPINGS | 订单自动延时时长(秒),超时未结账自动延长的时间 | 正整数 |
|
||||
| `self_table` | INTEGER | `self_table` | FACT_MAPPINGS | 是否自有台桌:1=自有(当前全部为 1) | `1` |
|
||||
| `temporary_light_second` | INTEGER | `temporary_light_second` | FACT_MAPPINGS | 临时开灯秒数,临时开灯持续的时间 | 正整数 |
|
||||
| `virtual_table` | INTEGER | `virtual_table` | FACT_MAPPINGS | 是否虚拟台桌:0=实体台,1=虚拟台(当前全部为 0) | `0` / `1` |
|
||||
| `scd2_*` | — | — | DWD 元数据 | SCD2 慢变维度追踪字段 | — |
|
||||
|
||||
---
|
||||
|
||||
## 3. 代码引用
|
||||
|
||||
- FACT_MAPPINGS:`dwd_load_task.py` → `FACT_MAPPINGS["dwd.dim_table"]` / `FACT_MAPPINGS["dwd.dim_table_ex"]`
|
||||
- TABLE_MAP:`"dwd.dim_table" → "ods.site_tables_master"`
|
||||
- DWS 下游:`dws_finance_daily_task.py`(财务日报,台费汇总按区域分组)
|
||||
- 迁移脚本:`db/_archived/ddl_baseline_2026-02-22/db/etl_feiqiu/migrations/2026-02-20__add_dim_table_ex_fields.sql`(已归档)
|
||||
@@ -0,0 +1,103 @@
|
||||
# BD_Manual:store_goods_master(门店商品档案)
|
||||
|
||||
> ODS 表:`ods.store_goods_master`
|
||||
> DWD 表:`dwd.dim_store_goods`(主表)、`dwd.dim_store_goods_ex`(扩展表)
|
||||
> API 接口:门店商品列表
|
||||
> JSON 路径:`store_goods_master.json → data.orderGoodsList`
|
||||
> 装载方式:SCD2 维度合并(`DwdLoadTask`)
|
||||
> 代码位置:`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. dim_store_goods(主表)
|
||||
|
||||
| DWD 列名 | 类型 | ODS 源列 | 映射方式 | 业务含义 | 取值范围/示例 |
|
||||
|----------|------|---------|---------|---------|-------------|
|
||||
| `site_goods_id` | BIGINT | `id` | FACT_MAPPINGS | 门店商品唯一标识(PK 之一) | 飞球雪花 ID |
|
||||
| `tenant_id` | BIGINT | `tenant_id` | 自动映射 | 租户 ID | 飞球租户 ID |
|
||||
| `site_id` | BIGINT | `site_id` | 自动映射 | 门店 ID | 飞球门店 ID |
|
||||
| `tenant_goods_id` | BIGINT | `tenant_goods_id` | 自动映射 | 租户商品 ID,关联 `dim_tenant_goods` | 飞球商品 ID |
|
||||
| `goods_name` | TEXT | `goods_name` | 自动映射 | 商品名称 | 如 `百威啤酒` |
|
||||
| `goods_category_id` | BIGINT | `goods_category_id` | 自动映射 | 一级分类 ID | 飞球分类 ID |
|
||||
| `goods_second_category_id` | BIGINT | `goods_second_category_id` | 自动映射 | 二级分类 ID | 飞球分类 ID |
|
||||
| `category_level1_name` | TEXT | `onecategoryname` | FACT_MAPPINGS | 一级分类名称 | 如 `酒水` |
|
||||
| `category_level2_name` | TEXT | `twocategoryname` | FACT_MAPPINGS | 二级分类名称 | 如 `啤酒` |
|
||||
| `batch_stock_qty` | INTEGER | `batch_stock_quantity` | FACT_MAPPINGS | 批次库存数量(按批次管理的库存)。⚠️ 2026-02-20 修正映射源(原错误映射自 `stock`,即当前库存) | 数值 |
|
||||
| `sale_qty` | INTEGER | `sale_num` | FACT_MAPPINGS | 累计销售数量 | 数值 |
|
||||
| `total_sales_qty` | INTEGER | `total_sales` | FACT_MAPPINGS | 累计销售总额 | 数值 |
|
||||
| `sale_price` | NUMERIC(18,2) | `sale_price` | 自动映射 | 商品售价(元) | 金额值 |
|
||||
| `created_at` | TIMESTAMPTZ | `create_time` | FACT_MAPPINGS | 商品创建时间 | ISO 时间戳 |
|
||||
| `updated_at` | TIMESTAMPTZ | `update_time` | FACT_MAPPINGS | 商品最后更新时间 | ISO 时间戳 |
|
||||
| `avg_monthly_sales` | NUMERIC(18,4) | `average_monthly_sales` | FACT_MAPPINGS | 月均销量 | 数值 |
|
||||
| `goods_state` | INTEGER | `goods_state` | 自动映射 | 商品状态 | 枚举值 |
|
||||
| `enable_status` | INTEGER | `enable_status` | 自动映射 | 启用状态 | 枚举值 |
|
||||
| `send_state` | INTEGER | `send_state` | 自动映射 | 配送状态 | 枚举值 |
|
||||
| `is_delete` | INTEGER | `is_delete` | 自动映射 | 是否已删除:0=正常,1=已删除 | `0` / `1` |
|
||||
| `commodity_code` | TEXT | `commodity_code` | FACT_MAPPINGS | 商品编码 | 编码字符串 |
|
||||
| `not_sale` | INTEGER | `not_sale` | FACT_MAPPINGS | 是否停售:0=在售,1=停售 | `0` / `1` |
|
||||
| `scd2_*` | — | — | DWD 元数据 | SCD2 慢变维度追踪字段 | — |
|
||||
|
||||
---
|
||||
|
||||
## 2. dim_store_goods_ex(扩展表)
|
||||
|
||||
| DWD 列名 | 类型 | ODS 源列 | 映射方式 | 业务含义 | 取值范围/示例 |
|
||||
|----------|------|---------|---------|---------|-------------|
|
||||
| `site_goods_id` | BIGINT | `id` | FACT_MAPPINGS | 门店商品唯一标识(PK 之一) | 同主表 |
|
||||
| `site_name` | TEXT | `sitename` | FACT_MAPPINGS | 门店名称快照 | 如 `朗朗桌球` |
|
||||
| `unit` | TEXT | `unit` | 自动映射 | 计量单位 | 如 `瓶`、`包` |
|
||||
| `goods_barcode` | TEXT | `goods_bar_code` | FACT_MAPPINGS | 商品条码 | 条码字符串或 NULL |
|
||||
| `goods_cover_url` | TEXT | `goods_cover` | FACT_MAPPINGS | 商品封面图 URL | HTTPS 链接或 NULL |
|
||||
| `pinyin_initial` | TEXT | `pinyin_initial` | 自动映射 | 拼音首字母(用于搜索) | 如 `BWPJ` |
|
||||
| `stock_qty` | INTEGER | `stock` | FACT_MAPPINGS | 当前库存数量(实时库存) | 数值 |
|
||||
| `stock_secondary_qty` | INTEGER | `stock_a` | FACT_MAPPINGS | 辅助单位库存数量(双单位商品) | 数值 |
|
||||
| `safety_stock_qty` | INTEGER | `safe_stock` | FACT_MAPPINGS | 安全库存数量(低于此值触发预警) | 数值 |
|
||||
| `cost_price` | NUMERIC(18,4) | `cost_price` | 自动映射 | 成本价(元) | 金额值 |
|
||||
| `cost_price_type` | INTEGER | `cost_price_type` | 自动映射 | 成本价类型 | 枚举值 |
|
||||
| `provisional_total_cost` | NUMERIC(18,2) | `provisional_total_cost` | FACT_MAPPINGS | 暂估总成本(元),按暂估价计算的库存成本。⚠️ 2026-02-20 修正映射源(原错误映射自 `total_purchase_cost`,即实际采购成本) | 金额值 |
|
||||
| `total_purchase_cost` | NUMERIC(18,2) | `total_purchase_cost` | 自动映射 | 实际采购总成本(元) | 金额值 |
|
||||
| `min_discount_price` | NUMERIC(18,2) | `min_discount_price` | 自动映射 | 最低折扣价(元) | 金额值 |
|
||||
| `is_discountable` | INTEGER | `able_discount` | FACT_MAPPINGS | 是否可打折:0=不可,1=可 | `0` / `1` |
|
||||
| `days_on_shelf` | INTEGER | `days_available` | FACT_MAPPINGS | 上架天数 | 正整数 |
|
||||
| `audit_status` | INTEGER | `audit_status` | 自动映射 | 审核状态 | 枚举值 |
|
||||
| `sale_channel` | INTEGER | `sale_channel` | 自动映射 | 销售渠道 | 枚举值 |
|
||||
| `is_warehousing` | INTEGER | `is_warehousing` | 自动映射 | 是否入库管理:0=否,1=是 | `0` / `1` |
|
||||
| `freeze_status` | INTEGER | `freeze` | FACT_MAPPINGS | 冻结状态:0=正常,1=冻结 | `0` / `1` |
|
||||
| `forbid_sell_status` | INTEGER | `forbid_sell_status` | 自动映射 | 禁售状态 | 枚举值 |
|
||||
| `able_site_transfer` | INTEGER | `able_site_transfer` | 自动映射 | 是否允许门店间调拨:0=否,1=是 | `0` / `1` |
|
||||
| `custom_label_type` | INTEGER | `custom_label_type` | 自动映射 | 自定义标签类型 | 枚举值 |
|
||||
| `option_required` | INTEGER | `option_required` | 自动映射 | 是否必选规格:0=否,1=是 | `0` / `1` |
|
||||
| `remark` | TEXT | `remark` | FACT_MAPPINGS | 商品备注 | 自由文本或 NULL |
|
||||
| `sort_order` | INTEGER | `sort` | FACT_MAPPINGS | 排序序号 | 正整数 |
|
||||
| `batch_stock_quantity` | NUMERIC | `batch_stock_quantity` | 自动映射 | 批次库存数量(冗余,与主表 `batch_stock_qty` 同源) | 数值 |
|
||||
| `time_slot_sale` | INTEGER | `time_slot_sale` | FACT_MAPPINGS | 分时段销售标记(当前观测全部为 2)。2026-02-21 新增 | `2` |
|
||||
| `scd2_*` | — | — | DWD 元数据 | SCD2 慢变维度追踪字段 | — |
|
||||
|
||||
---
|
||||
|
||||
## 3. 映射修正记录
|
||||
|
||||
| 日期 | 字段 | 修正内容 |
|
||||
|------|------|---------|
|
||||
| 2026-02-20 | `batch_stock_qty` | ODS 源从 `stock`(当前库存)修正为 `batch_stock_quantity`(批次库存)。验证:仅 7.3% 行两列值相等 |
|
||||
| 2026-02-20 | `provisional_total_cost` | ODS 源从 `total_purchase_cost`(实际采购成本)修正为 `provisional_total_cost`(暂估成本)。验证:93.5% 行相等但 113 行不同 |
|
||||
| 2026-02-21 | `time_slot_sale` | 新增字段。ODS `ods.store_goods_master` + DWD `dwd.dim_store_goods_ex` 同步新增 `time_slot_sale INTEGER`。API 返回值当前全部为 `2` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 跳过字段说明
|
||||
|
||||
| ODS 字段 | 跳过原因 |
|
||||
|---------|---------|
|
||||
| ~~`time_slot_sale`~~ | ~~ODS 列不存在~~ → 2026-02-21 已新增,见映射修正记录 |
|
||||
| `goodsStockWarningInfo` | JSONB 嵌套对象,展开不在本次范围内 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 代码引用
|
||||
|
||||
- FACT_MAPPINGS:`dwd_load_task.py` → `FACT_MAPPINGS["dwd.dim_store_goods"]` / `FACT_MAPPINGS["dwd.dim_store_goods_ex"]`
|
||||
- TABLE_MAP:`"dwd.dim_store_goods" → "ods.store_goods_master"`
|
||||
- DWS 下游:`dws_finance_daily_task.py`(财务日报,商品维度关联)
|
||||
- 迁移脚本:`db/_archived/ddl_baseline_2026-02-22/db/etl_feiqiu/migrations/2026-02-20__fix_store_goods_master_mapping.sql`(已归档)
|
||||
- 迁移脚本:`db/_archived/ddl_baseline_2026-02-22/db/etl_feiqiu/migrations/2026-02-21__add_time_slot_sale_merge_commodity_code.sql`(已归档)
|
||||
@@ -0,0 +1,93 @@
|
||||
# BD_Manual:store_goods_sales_records(门店商品销售流水)
|
||||
|
||||
> ODS 表:`ods.store_goods_sales_records`
|
||||
> DWD 表:`dwd.dwd_store_goods_sale`(主表)、`dwd.dwd_store_goods_sale_ex`(扩展表)
|
||||
> API 接口:门店商品销售记录列表
|
||||
> JSON 路径:`store_goods_sales_records.json → data.orderGoodsLedgers`
|
||||
> 装载方式:事实表增量插入(`DwdLoadTask`)
|
||||
> 代码位置:`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. dwd_store_goods_sale(主表,25 列)
|
||||
|
||||
| DWD 列名 | 类型 | ODS 源列 | 映射方式 | 业务含义 | 取值范围/示例 |
|
||||
|----------|------|---------|---------|---------|-------------|
|
||||
| `store_goods_sale_id` | BIGINT | `id` | FACT_MAPPINGS | 销售记录唯一标识(PK) | 飞球雪花 ID |
|
||||
| `order_trade_no` | BIGINT | `order_trade_no` | 自动映射 | 关联的订单交易号 | 飞球订单号 |
|
||||
| `order_settle_id` | BIGINT | `order_settle_id` | 自动映射 | 关联的结算单 ID | 飞球结算单 ID |
|
||||
| `order_pay_id` | BIGINT | `order_pay_id` | 自动映射 | 关联的支付单 ID | 飞球支付单 ID |
|
||||
| `order_goods_id` | BIGINT | `order_goods_id` | 自动映射 | 订单商品明细 ID | 飞球雪花 ID |
|
||||
| `site_id` | BIGINT | `site_id` | 自动映射 | 门店 ID | 飞球门店 ID |
|
||||
| `tenant_id` | BIGINT | `tenant_id` | 自动映射 | 租户 ID | 飞球租户 ID |
|
||||
| `site_goods_id` | BIGINT | `site_goods_id` | 自动映射 | 门店商品 ID,关联 `dim_store_goods` | 飞球商品 ID |
|
||||
| `tenant_goods_id` | BIGINT | `tenant_goods_id` | 自动映射 | 租户商品 ID,关联 `dim_tenant_goods` | 飞球商品 ID |
|
||||
| `tenant_goods_category_id` | BIGINT | `tenant_goods_category_id` | 自动映射 | 商品分类 ID | 飞球分类 ID |
|
||||
| `tenant_goods_business_id` | BIGINT | `tenant_goods_business_id` | 自动映射 | 商品业务类型 ID | 飞球业务 ID |
|
||||
| `site_table_id` | BIGINT | `site_table_id` | 自动映射 | 消费台桌 ID,关联 `dim_table.table_id` | 飞球台桌 ID |
|
||||
| `ledger_name` | VARCHAR(200) | `ledger_name` | 自动映射 | 分账项名称(商品名称快照) | 如 `百威啤酒` |
|
||||
| `ledger_group_name` | VARCHAR(100) | `ledger_group_name` | 自动映射 | 分账组名称 | 分账组名或 NULL |
|
||||
| `ledger_unit_price` | NUMERIC(18,2) | `ledger_unit_price` | 自动映射 | 分账单价(元/单位) | 金额值 |
|
||||
| `ledger_count` | INTEGER | `ledger_count` | 自动映射 | 分账数量(销售数量) | 正整数 |
|
||||
| `ledger_amount` | NUMERIC(18,2) | `ledger_amount` | 自动映射 | 分账总金额(元),= 单价 × 数量 | 金额值 |
|
||||
| `discount_money` | NUMERIC(18,2) | `discount_money` | FACT_MAPPINGS | 折扣金额(元),会员折扣减免的金额。⚠️ 2026-02-20 由原 `discount_price` 重命名而来,修正列名误导 | `0.00` ~ 金额值 |
|
||||
| `real_goods_money` | NUMERIC(18,2) | `real_goods_money` | 自动映射 | 实收商品金额(元),扣除折扣后 | 金额值 |
|
||||
| `cost_money` | NUMERIC(18,2) | `cost_money` | 自动映射 | 成本金额(元) | 金额值 |
|
||||
| `ledger_status` | INTEGER | `ledger_status` | 自动映射 | 分账状态 | 枚举值 |
|
||||
| `is_delete` | INTEGER | `is_delete` | 自动映射 | 是否已删除:0=正常,1=已删除 | `0` / `1` |
|
||||
| `create_time` | TIMESTAMPTZ | `create_time` | 自动映射 | 销售记录创建时间 | ISO 时间戳 |
|
||||
| `coupon_share_money` | NUMERIC(18,2) | `coupon_share_money` | FACT_MAPPINGS | 优惠券分摊金额(元),该商品分摊的优惠券减免 | `0.00` ~ 金额值 |
|
||||
| `discount_price` | NUMERIC(18,2) | `discount_price` | FACT_MAPPINGS | 折后单价(元),会员折扣后的商品单价。⚠️ 2026-02-20 新增,映射自 ODS 真正的 `discount_price` | `0.00` ~ 金额值 |
|
||||
|
||||
---
|
||||
|
||||
## 2. dwd_store_goods_sale_ex(扩展表,28 列)
|
||||
|
||||
| DWD 列名 | 类型 | ODS 源列 | 映射方式 | 业务含义 | 取值范围/示例 |
|
||||
|----------|------|---------|---------|---------|-------------|
|
||||
| `store_goods_sale_id` | BIGINT | `id` | FACT_MAPPINGS | 销售记录唯一标识(PK) | 同主表 |
|
||||
| `legacy_order_goods_id` | BIGINT | `ordergoodsid` | FACT_MAPPINGS | 旧版订单商品 ID(兼容字段) | 飞球 ID |
|
||||
| `site_name` | TEXT | `sitename` | FACT_MAPPINGS | 门店名称快照 | 如 `朗朗桌球` |
|
||||
| `legacy_site_id` | BIGINT | `siteid` | FACT_MAPPINGS | 旧版门店 ID(兼容字段) | 飞球门店 ID |
|
||||
| `goods_remark` | TEXT | `goods_remark` | 自动映射 | 商品备注 | 自由文本或 NULL |
|
||||
| `option_value_name` | TEXT | `option_value_name` | FACT_MAPPINGS | 商品规格选项名称 | 如 `大杯`、NULL |
|
||||
| `operator_name` | TEXT | `operator_name` | 自动映射 | 操作员姓名 | 姓名或 NULL |
|
||||
| `open_salesman_flag` | INTEGER | `opensalesman` | FACT_MAPPINGS (cast integer) | 是否开启销售员提成:0=否,1=是 | `0` / `1` |
|
||||
| `salesman_user_id` | BIGINT | `salesman_user_id` | 自动映射 | 销售员用户 ID | 用户 ID 或 NULL |
|
||||
| `salesman_name` | TEXT | `salesman_name` | FACT_MAPPINGS | 销售员姓名 | 姓名或 NULL |
|
||||
| `salesman_role_id` | BIGINT | `salesman_role_id` | 自动映射 | 销售员角色 ID | 角色 ID 或 NULL |
|
||||
| `salesman_org_id` | BIGINT | `sales_man_org_id` | FACT_MAPPINGS | 销售员组织 ID | 组织 ID 或 NULL |
|
||||
| `discount_money` | NUMERIC(18,2) | `discount_money` | 自动映射 | 折扣金额(元),扩展表中的折扣明细 | `0.00` ~ 金额值 |
|
||||
| `returns_number` | INTEGER | `returns_number` | 自动映射 | 退货数量 | `0` ~ 正整数 |
|
||||
| `coupon_deduct_money` | NUMERIC(18,2) | `coupon_deduct_money` | 自动映射 | 优惠券抵扣金额(元) | `0.00` ~ 金额值 |
|
||||
| `member_discount_amount` | NUMERIC(18,2) | `member_discount_amount` | 自动映射 | 会员折扣金额(元) | `0.00` ~ 金额值 |
|
||||
| `point_discount_money` | NUMERIC(18,2) | `point_discount_money` | 自动映射 | 积分抵扣金额(元) | `0.00` ~ 金额值 |
|
||||
| `point_discount_money_cost` | NUMERIC(18,2) | `point_discount_money_cost` | 自动映射 | 积分抵扣成本(元) | `0.00` ~ 金额值 |
|
||||
| `package_coupon_id` | BIGINT | `package_coupon_id` | 自动映射 | 套餐券 ID | 券 ID 或 NULL |
|
||||
| `order_coupon_id` | BIGINT | `order_coupon_id` | 自动映射 | 订单券 ID | 券 ID 或 NULL |
|
||||
| `member_coupon_id` | BIGINT | `member_coupon_id` | 自动映射 | 会员券 ID | 券 ID 或 NULL |
|
||||
| `option_price` | NUMERIC(18,2) | `option_price` | 自动映射 | 规格选项加价(元) | `0.00` ~ 金额值 |
|
||||
| `option_member_discount_money` | NUMERIC(18,2) | `option_member_discount_money` | 自动映射 | 规格选项会员折扣金额(元) | `0.00` ~ 金额值 |
|
||||
| `option_coupon_deduct_money` | NUMERIC(18,2) | `option_coupon_deduct_money` | 自动映射 | 规格选项优惠券抵扣金额(元) | `0.00` ~ 金额值 |
|
||||
| `push_money` | NUMERIC(18,2) | `push_money` | 自动映射 | 推销提成金额(元) | `0.00` ~ 金额值 |
|
||||
| `is_single_order` | INTEGER | `is_single_order` | 自动映射 | 是否单独订单 | `0` / `1` |
|
||||
| `sales_type` | INTEGER | `sales_type` | 自动映射 | 销售类型 | 枚举值 |
|
||||
| `operator_id` | BIGINT | `operator_id` | 自动映射 | 操作员 ID | 员工 ID 或 NULL |
|
||||
|
||||
---
|
||||
|
||||
## 3. 映射修正记录
|
||||
|
||||
| 日期 | 字段 | 修正内容 |
|
||||
|------|------|---------|
|
||||
| 2026-02-20 | `discount_price` → `discount_money` | 原 DWD `discount_price` 实际映射自 ODS `discount_money`(折扣金额),列名与语义不符,重命名为 `discount_money` |
|
||||
| 2026-02-20 | `discount_price`(新增) | 新增 DWD 列,映射自 ODS 真正的 `discount_price`(折后单价) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 代码引用
|
||||
|
||||
- FACT_MAPPINGS:`dwd_load_task.py` → `FACT_MAPPINGS["dwd.dwd_store_goods_sale"]` / `FACT_MAPPINGS["dwd.dwd_store_goods_sale_ex"]`
|
||||
- TABLE_MAP:`"dwd.dwd_store_goods_sale" → "ods.store_goods_sales_records"`
|
||||
- DWS 下游:`dws_finance_daily_task.py`(财务日报,商品销售汇总)
|
||||
- 迁移脚本:`db/_archived/ddl_baseline_2026-02-22/db/etl_feiqiu/migrations/2026-02-20__fix_store_goods_sale_discount_price.sql`(已归档)
|
||||
@@ -0,0 +1,80 @@
|
||||
# BD_Manual:tenant_goods_master(租户商品档案)
|
||||
|
||||
> ODS 表:`ods.tenant_goods_master`
|
||||
> DWD 表:`dwd.dim_tenant_goods`(主表)、`dwd.dim_tenant_goods_ex`(扩展表)
|
||||
> API 接口:租户商品列表
|
||||
> JSON 路径:`tenant_goods_master.json → data.tenantGoodsList`
|
||||
> 装载方式:SCD2 维度合并(`DwdLoadTask`)
|
||||
> 代码位置:`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. dim_tenant_goods(主表)
|
||||
|
||||
| DWD 列名 | 类型 | ODS 源列 | 映射方式 | 业务含义 | 取值范围/示例 |
|
||||
|----------|------|---------|---------|---------|-------------|
|
||||
| `tenant_goods_id` | BIGINT | `id` | FACT_MAPPINGS | 租户商品唯一标识(PK 之一) | 飞球雪花 ID |
|
||||
| `tenant_id` | BIGINT | `tenant_id` | 自动映射 | 租户 ID | 飞球租户 ID |
|
||||
| `supplier_id` | BIGINT | `supplier_id` | 自动映射 | 供应商 ID | 飞球供应商 ID 或 0 |
|
||||
| `category_name` | VARCHAR | `categoryname` | FACT_MAPPINGS | 一级分类名称 | 如 `酒水` |
|
||||
| `goods_category_id` | BIGINT | `goods_category_id` | 自动映射 | 一级分类 ID | 飞球分类 ID |
|
||||
| `goods_second_category_id` | BIGINT | `goods_second_category_id` | 自动映射 | 二级分类 ID | 飞球分类 ID |
|
||||
| `goods_name` | VARCHAR | `goods_name` | 自动映射 | 商品名称 | 如 `百威啤酒` |
|
||||
| `goods_number` | VARCHAR | `goods_number` | 自动映射 | 商品编号 | 编号字符串 |
|
||||
| `unit` | VARCHAR | `unit` | 自动映射 | 计量单位 | 如 `瓶`、`包` |
|
||||
| `market_price` | NUMERIC | `market_price` | 自动映射 | 市场价/建议售价(元) | 金额值 |
|
||||
| `goods_state` | INTEGER | `goods_state` | 自动映射 | 商品状态 | 整数枚举 |
|
||||
| `create_time` | TIMESTAMPTZ | `create_time` | 自动映射 | 商品创建时间 | ISO 时间戳 |
|
||||
| `update_time` | TIMESTAMPTZ | `update_time` | 自动映射 | 商品最后更新时间 | ISO 时间戳 |
|
||||
| `is_delete` | INTEGER | `is_delete` | 自动映射 | 软删除标记:0=正常,1=已删除 | `0` / `1` |
|
||||
| `not_sale` | INTEGER | `not_sale` | FACT_MAPPINGS | 是否停售:0=在售,1=停售 | `0` / `1` |
|
||||
| `scd2_*` | — | — | DWD 元数据 | SCD2 慢变维度追踪字段 | — |
|
||||
|
||||
---
|
||||
|
||||
## 2. dim_tenant_goods_ex(扩展表)
|
||||
|
||||
| DWD 列名 | 类型 | ODS 源列 | 映射方式 | 业务含义 | 取值范围/示例 |
|
||||
|----------|------|---------|---------|---------|-------------|
|
||||
| `tenant_goods_id` | BIGINT | `id` | FACT_MAPPINGS | 租户商品唯一标识(PK 之一) | 同主表 |
|
||||
| `remark_name` | VARCHAR(128) | `remark_name` | FACT_MAPPINGS | 商品备注名称 | 自由文本或 NULL |
|
||||
| `pinyin_initial` | VARCHAR | `pinyin_initial` | 自动映射 | 商品名称拼音首字母,用于快速检索 | 如 `BWPJ` |
|
||||
| `goods_cover` | VARCHAR | `goods_cover` | 自动映射 | 商品封面图 URL | HTTPS 链接或 NULL |
|
||||
| `goods_bar_code` | VARCHAR | `goods_bar_code` | FACT_MAPPINGS | 商品条码 | 条码字符串或 NULL |
|
||||
| `commodity_code` | VARCHAR | `commodity_code` | 自动映射 | 商品编码(单值,旧字段) | 编码字符串或 NULL |
|
||||
| `commodity_code_list` | TEXT[] | `commoditycode` | FACT_MAPPINGS (cast TEXT[]) | 商品编码数组。ODS `commoditycode` 存储 PG 数组格式 `{CODE1}`,CAST 为 `TEXT[]`。2026-02-21 从 VARCHAR(256) 改为 TEXT[] | `['1234571']` |
|
||||
| `min_discount_price` | NUMERIC | `min_discount_price` | 自动映射 | 最低折扣价(元) | 金额值 |
|
||||
| `cost_price` | NUMERIC | `cost_price` | 自动映射 | 成本价(元) | 金额值 |
|
||||
| `cost_price_type` | INTEGER | `cost_price_type` | 自动映射 | 成本价类型 | 整数枚举 |
|
||||
| `able_discount` | INTEGER | `able_discount` | 自动映射 | 是否允许折扣:0=不允许,1=允许 | `0` / `1` |
|
||||
| `sale_channel` | INTEGER | `sale_channel` | 自动映射 | 销售渠道 | 整数枚举 |
|
||||
| `is_warehousing` | INTEGER | `is_warehousing` | 自动映射 | 是否入库管理:0=否,1=是 | `0` / `1` |
|
||||
| `is_in_site` | BOOLEAN | `isinsite` | FACT_MAPPINGS (cast boolean) | 是否已分配到门店 | `true` / `false` |
|
||||
| `able_site_transfer` | INTEGER | `able_site_transfer` | 自动映射 | 是否允许门店间调拨 | `0` / `1` |
|
||||
| `common_sale_royalty` | INTEGER | `common_sale_royalty` | 自动映射 | 普通销售提成(分) | 整数 |
|
||||
| `point_sale_royalty` | INTEGER | `point_sale_royalty` | 自动映射 | 积分销售提成(分) | 整数 |
|
||||
| `out_goods_id` | BIGINT | `out_goods_id` | 自动映射 | 外部商品 ID(第三方系统关联) | 外部 ID 或 0 |
|
||||
| `scd2_*` | — | — | DWD 元数据 | SCD2 慢变维度追踪字段 | — |
|
||||
|
||||
## 3. 映射修正记录
|
||||
|
||||
| 日期 | 字段 | 修正内容 |
|
||||
|------|------|---------|
|
||||
| 2026-02-21 | `commodity_code_list` | 类型从 `VARCHAR(256)` 改为 `TEXT[]`。映射源从 `commodity_code`(单值)改为 `commoditycode`(PG 数组格式 `{xxx}`),通过 `::TEXT[]` CAST。现有数据通过 `ARRAY[commodity_code_list]` 迁移 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 跳过字段说明
|
||||
|
||||
所有 ODS 业务字段均已映射到 DWD 主表或扩展表,无跳过字段。
|
||||
|
||||
> `commoditycode`(ODS 列)已作为 `commodity_code_list` 的映射源(`commoditycode::TEXT[]`),同时 `commodity_code`(单值旧字段)也保留在扩展表中。
|
||||
|
||||
---
|
||||
|
||||
## 5. 代码引用
|
||||
|
||||
- FACT_MAPPINGS:`dwd_load_task.py` → `FACT_MAPPINGS["dwd.dim_tenant_goods"]` / `FACT_MAPPINGS["dwd.dim_tenant_goods_ex"]`
|
||||
- TABLE_MAP:`"dwd.dim_tenant_goods" → "ods.tenant_goods_master"`
|
||||
- DWS 下游:无直接 DWS 汇总引用
|
||||
- 迁移脚本:`db/_archived/ddl_baseline_2026-02-22/db/etl_feiqiu/migrations/2026-02-21__add_time_slot_sale_merge_commodity_code.sql`(已归档)
|
||||
@@ -199,7 +199,7 @@ class ConfigCache:
|
||||
| `performance_tiers` | `dws.cfg_performance_tier` | 绩效档位(小时阈值 → 抽成/休假) |
|
||||
| `level_prices` | `dws.cfg_assistant_level_price` | 助教等级单价(基础课/附加课) |
|
||||
| `bonus_rules` | `dws.cfg_bonus_rules` | 奖金规则(冲刺奖金/Top 排名奖金) |
|
||||
| `area_categories` | `dws.cfg_area_category` | 区域分类映射(精确/模糊/兜底) |
|
||||
| `area_categories` | `dws.cfg_area_category` | 台桌级分类映射(台桌精确匹配/兜底) |
|
||||
| `skill_types` | `dws.cfg_skill_type` | 技能 → 课程类型映射(BASE/BONUS/ROOM) |
|
||||
|
||||
#### 生效期过滤
|
||||
@@ -214,7 +214,7 @@ class ConfigCache:
|
||||
| `get_performance_tier_by_id(tier_id, date)` | 按档位 ID 直接获取 | 精确匹配 `tier_id` |
|
||||
| `get_level_price(level_code, date)` | 获取助教等级单价 | 按 `level_code` 匹配 |
|
||||
| `get_course_type(skill_id)` | 技能 → 课程类型 | 查 `skill_types` 映射,默认 `BASE` |
|
||||
| `get_area_category(area_name)` | 区域名 → 分类 | 精确匹配 → 模糊匹配 → 兜底 `OTHER` |
|
||||
| `get_area_category(area_name, table_name)` | 台桌 → 分类 | 台桌级精确匹配 → 兜底 `OTHER`(2026-03-09 改版,无 LIKE) |
|
||||
| `calculate_sprint_bonus(hours, date)` | 冲刺奖金 | 不累计,取满足阈值的最高档 |
|
||||
| `calculate_top_rank_bonus(rank, date)` | Top 排名奖金 | 第 1/2/3 名分别对应配置金额,>3 返回 0 |
|
||||
|
||||
@@ -996,7 +996,7 @@ total_discount = adjust_amount + member_discount_amount + rounding_amount
|
||||
|
||||
**8. 区域分类**
|
||||
|
||||
通过 `ConfigCache` 加载 `cfg_area_category` 配置,调用 `get_area_category(area_name)` 将台桌区域名称映射为分类标签。匹配逻辑:精确匹配 → 模糊匹配 → 兜底 `OTHER`。
|
||||
通过 `ConfigCache` 加载 `cfg_area_category` 配置,调用 `get_area_category(area_name, table_name)` 将台桌映射为分类标签。匹配逻辑:台桌级精确匹配(`source_area_name` + `source_table_name`)→ 兜底 `OTHER`(2026-03-09 改版,已移除 LIKE 模糊匹配和区域级映射)。
|
||||
|
||||
**9. 手机号脱敏**
|
||||
|
||||
@@ -1270,9 +1270,9 @@ total_card_balance = cash_card_balance + gift_card_balance
|
||||
|
||||
**维度 2:按区域(`structure_type = 'AREA'`)**
|
||||
|
||||
通过 CTE 合并台费流水和助教服务流水,关联 `dim_table` 获取 `site_table_area_name`,再通过 `get_area_category(area_name)` 映射到分类代码。
|
||||
通过 CTE 合并台费流水和助教服务流水,关联 `dim_table` 获取 `site_table_area_name` 和 `table_name`,再通过 `get_area_category(area_name, table_name)` 映射到分类代码。
|
||||
|
||||
区域映射逻辑(与 `DWS_MEMBER_VISIT` 相同):精确匹配 → 模糊匹配 → 兜底 `OTHER`。
|
||||
区域映射逻辑(与 `DWS_MEMBER_VISIT` 相同):台桌级精确匹配 → 兜底 `OTHER`(2026-03-09 改版,无 LIKE)。
|
||||
|
||||
相同 `category_code` 的不同区域名称会被合并聚合。每条记录额外输出 `duration_minutes`(台费秒数 + 助教服务秒数,转换为分钟)。
|
||||
|
||||
|
||||
@@ -223,9 +223,12 @@ class AssistantOrderContributionTask(BaseDwsTask):
|
||||
def _extract_settlements(
|
||||
self, site_id: int, start_date: date, end_date: date
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""提取台桌结账订单的结算主表
|
||||
"""提取消费结算订单的结算主表
|
||||
|
||||
settle_type=1 为台桌结账,包含台费、酒水食品等金额。
|
||||
settle_type IN (1, 3):
|
||||
- 1 = 台桌结账(含台费、商品、陪打、超休)
|
||||
- 3 = 商城订单(含纯超休/激励课 477 笔、商品、超休+商品混合)
|
||||
settle_type=3 中陪打(pd)为 0、台费为 0,但超休(cx)占全口径 85%+。
|
||||
"""
|
||||
# CHANGE 2026-03-01 | business-day-cutoff 6.2: DATE(pay_time) → 营业日归属表达式
|
||||
cutoff = self.config.get("app.business_day_start_hour", 8)
|
||||
@@ -240,7 +243,7 @@ class AssistantOrderContributionTask(BaseDwsTask):
|
||||
{biz_expr} AS stat_date
|
||||
FROM dwd.dwd_settlement_head
|
||||
WHERE site_id = %s
|
||||
AND settle_type = 1
|
||||
AND settle_type IN (1, 3)
|
||||
AND {biz_expr} >= %s
|
||||
AND {biz_expr} <= %s
|
||||
"""
|
||||
|
||||
@@ -207,8 +207,7 @@ class AssistantProjectTagTask(BaseDwsTask):
|
||||
|
||||
# 全量删除该门店的标签数据后重建
|
||||
delete_sql = "DELETE FROM dws.dws_assistant_project_tag WHERE site_id = %s"
|
||||
self.db.execute(delete_sql, (site_id,))
|
||||
deleted = self.db.cursor.rowcount if hasattr(self.db, "cursor") else 0
|
||||
deleted = self.db.execute(delete_sql, (site_id,))
|
||||
|
||||
insert_sql = """
|
||||
INSERT INTO dws.dws_assistant_project_tag (
|
||||
|
||||
@@ -735,11 +735,14 @@ class BaseDwsTask(BaseTask):
|
||||
|
||||
def get_area_category(self, area_name: Optional[str], table_name: Optional[str] = None) -> Dict[str, str]:
|
||||
"""
|
||||
获取区域分类(支持台桌级精确 > 区域精确 > 模糊 > 兜底)
|
||||
获取区域分类(纯台桌级精确匹配 + 兜底)
|
||||
|
||||
2026-03-09 改为纯台桌级精确映射,去掉 LIKE 模糊匹配和区域级映射。
|
||||
每台桌一行 (area_name, table_name) → category_code。
|
||||
|
||||
Args:
|
||||
area_name: 原始区域名称(dim_table.site_table_area_name)
|
||||
table_name: 台桌名称(dim_table.table_name),用于台桌级细分映射
|
||||
table_name: 台桌名称(dim_table.table_name),必须提供以精确匹配
|
||||
|
||||
Returns:
|
||||
包含 category_code, category_name, display_name, short_name 的字典
|
||||
@@ -766,19 +769,7 @@ class BaseDwsTask(BaseTask):
|
||||
if key in cats and cats[key].get('match_type') == 'EXACT':
|
||||
return _pick(cats[key])
|
||||
|
||||
# 2. 区域级精确匹配(area_name + 空 table_name)
|
||||
key = f"{area_name}\x00"
|
||||
if key in cats and cats[key].get('match_type') == 'EXACT':
|
||||
return _pick(cats[key])
|
||||
|
||||
# 3. 模糊匹配(按优先级,已排序)
|
||||
for k, cat in cats.items():
|
||||
if cat.get('match_type') == 'LIKE':
|
||||
pattern = cat['source_area_name'].replace('%', '')
|
||||
if pattern and pattern in area_name:
|
||||
return _pick(cat)
|
||||
|
||||
# 4. 兜底
|
||||
# 2. 兜底
|
||||
fallback_key = f"DEFAULT\x00"
|
||||
if fallback_key in cats:
|
||||
return _pick(cats[fallback_key])
|
||||
|
||||
@@ -195,8 +195,7 @@ class MemberProjectTagTask(BaseDwsTask):
|
||||
site_id = transformed[0]["site_id"]
|
||||
|
||||
delete_sql = "DELETE FROM dws.dws_member_project_tag WHERE site_id = %s"
|
||||
self.db.execute(delete_sql, (site_id,))
|
||||
deleted = self.db.cursor.rowcount if hasattr(self.db, "cursor") else 0
|
||||
deleted = self.db.execute(delete_sql, (site_id,))
|
||||
|
||||
insert_sql = """
|
||||
INSERT INTO dws.dws_member_project_tag (
|
||||
|
||||
2
apps/miniprogram - 副本/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
miniprogram_npm
|
||||
@@ -1,202 +0,0 @@
|
||||
# apps/miniprogram — 微信小程序
|
||||
|
||||
微信小程序前端项目,基于 Donut 多端框架 + TDesign 组件库,为台球门店会员提供移动端服务入口。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 微信小程序原生 + Donut 多端(`projectArchitecture: multiPlatform`)
|
||||
- TDesign 小程序版(`tdesign-miniprogram ^1.12.2`)
|
||||
- TypeScript
|
||||
- 类型定义:`miniprogram-api-typings`
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
apps/miniprogram/
|
||||
├── miniprogram/ # 小程序主体代码
|
||||
│ ├── app.ts # 应用入口(wx.login 获取 code)
|
||||
│ ├── app.json # 全局配置(页面路由、窗口样式)
|
||||
│ ├── app.wxss # 全局样式
|
||||
│ ├── pages/ # 页面目录
|
||||
│ │ ├── mvp/ # MVP 全链路验证页
|
||||
│ │ ├── index/ # 首页
|
||||
│ │ ├── login/ # 登录页
|
||||
│ │ ├── apply/ # 入驻申请页
|
||||
│ │ ├── reviewing/ # 审核中等待页
|
||||
│ │ ├── no-permission/ # 无权限提示页
|
||||
│ │ ├── dev-tools/ # 开发调试面板(仅 develop 环境)
|
||||
│ │ └── logs/ # 日志页
|
||||
│ ├── components/ # 全局组件
|
||||
│ │ └── dev-fab/ # 浮动调试按钮(仅 develop 环境显示)
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ ├── config.ts # 环境配置(API 地址自动切换)
|
||||
│ │ └── util.ts # 通用工具(日期格式化等)
|
||||
│ ├── miniprogram_npm/ # 构建后的 npm 包(TDesign 组件)
|
||||
│ ├── i18n/ # 国际化资源
|
||||
│ └── miniapp/ # Donut 多端原生资源
|
||||
├── typings/ # TypeScript 类型定义
|
||||
├── project.config.json # 微信开发者工具项目配置
|
||||
├── project.miniapp.json # Donut 多端配置
|
||||
├── tsconfig.json # TypeScript 编译配置
|
||||
├── package.json # npm 依赖声明
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 环境准备
|
||||
|
||||
1. 安装微信开发者工具
|
||||
2. 打开本目录(`apps/miniprogram/`)
|
||||
3. 首次打开后,在工具中执行"构建 npm"以生成 `miniprogram_npm/`
|
||||
4. AppID:`wx7c07793d82732921`
|
||||
|
||||
### 页面路由
|
||||
|
||||
当前注册页面(`app.json`):
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `pages/mvp/mvp` | MVP 全链路验证(从后端读取测试数据) |
|
||||
| `pages/index/index` | 首页(待开发) |
|
||||
| `pages/login/login` | 登录页 |
|
||||
| `pages/apply/apply` | 入驻申请页 |
|
||||
| `pages/reviewing/reviewing` | 审核中等待页 |
|
||||
| `pages/no-permission/no-permission` | 无权限提示页 |
|
||||
| `pages/dev-tools/dev-tools` | 开发调试面板(仅 develop 环境,通过 dev-fab 浮动按钮进入) |
|
||||
| `pages/logs/logs` | 日志页(框架默认) |
|
||||
|
||||
## 后端 API 集成
|
||||
|
||||
### API 地址配置
|
||||
|
||||
`utils/config.ts` 根据小程序运行环境自动切换 API 地址:
|
||||
|
||||
| 环境 | API 地址 |
|
||||
|------|----------|
|
||||
| develop(开发版) | `http://127.0.0.1:8000` |
|
||||
| trial(体验版) | `https://api.langlangzhuoqiu.cn` |
|
||||
| release(正式版) | `https://api.langlangzhuoqiu.cn` |
|
||||
|
||||
### 认证流程
|
||||
|
||||
小程序用户的完整生命周期:
|
||||
|
||||
```
|
||||
wx.login() 获取 code
|
||||
↓
|
||||
POST /api/xcx-auth/login → 获取 JWT(受限令牌,status=new)
|
||||
↓
|
||||
POST /api/xcx-auth/apply → 提交入驻申请(球房ID + 身份 + 手机号,status → pending)
|
||||
↓
|
||||
管理员在后台审批
|
||||
↓
|
||||
GET /api/xcx-auth/status → 查询审批结果
|
||||
↓
|
||||
POST /api/xcx-auth/login → 重新登录获取完整令牌(含 site_id + roles)
|
||||
↓
|
||||
正常使用业务功能
|
||||
```
|
||||
|
||||
用户状态流转:
|
||||
- `new`:新用户,尚未提交申请
|
||||
- `pending`:已提交申请,等待审批
|
||||
- `approved`:审批通过,可正常使用
|
||||
- `rejected`:审批拒绝,可重新申请
|
||||
- `disabled`:账号禁用
|
||||
|
||||
令牌类型:
|
||||
- 受限令牌(`limited=True`):new/pending/rejected 用户,仅可访问申请和状态查询端点
|
||||
- 完整令牌:approved 用户,包含 `user_id` + `site_id` + `roles`
|
||||
|
||||
### 开发模式
|
||||
|
||||
后端支持开发模式(`WX_DEV_MODE=true`),提供 mock 登录端点跳过微信 code2Session:
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/xcx-auth/dev-login` | POST | 开发模式 mock 登录(仅开发环境) |
|
||||
|
||||
参数:
|
||||
- `openid`:模拟的微信 openid
|
||||
- `status`:可选,指定用户状态(new/pending/approved/rejected)
|
||||
|
||||
### 关键 API 端点
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/xcx-auth/login` | POST | 微信登录(code → JWT) |
|
||||
| `/api/xcx-auth/dev-login` | POST | 开发模式 mock 登录(仅开发环境) |
|
||||
| `/api/xcx-auth/apply` | POST | 提交入驻申请 |
|
||||
| `/api/xcx-auth/status` | GET | 查询用户状态和申请记录 |
|
||||
| `/api/xcx-auth/sites` | GET | 获取关联门店列表 |
|
||||
| `/api/xcx-auth/switch-site` | POST | 切换当前门店 |
|
||||
| `/api/xcx-auth/refresh` | POST | 刷新令牌 |
|
||||
| `/api/xcx/tasks` | GET | 获取任务列表 |
|
||||
| `/api/xcx/tasks/{task_id}/pin` | POST | 置顶/取消置顶任务 |
|
||||
| `/api/xcx/tasks/{task_id}/abandon` | POST | 放弃/取消放弃任务 |
|
||||
| `/api/xcx/notes` | GET/POST/DELETE | 备注 CRUD |
|
||||
| `/api/xcx-test` | GET | MVP 全链路验证 |
|
||||
|
||||
> 完整 API 文档见 [`apps/backend/docs/API-REFERENCE.md`](../backend/docs/API-REFERENCE.md)
|
||||
|
||||
## MVP 页面
|
||||
|
||||
`pages/mvp/mvp` 是全链路验证页面,从后端 `/api/xcx-test` 读取 `test."xcx-test"` 表数据并显示,用于验证:
|
||||
- 小程序 → 后端 API → 数据库 的完整链路
|
||||
- 网络请求、错误处理、加载状态
|
||||
|
||||
## 权限模型
|
||||
|
||||
小程序用户通过 RBAC 模型控制功能访问:
|
||||
|
||||
| 角色 | 可见功能 |
|
||||
|------|----------|
|
||||
| coach(助教) | 查看任务、助教看板 |
|
||||
| staff(员工) | 查看任务、数据看板 |
|
||||
| site_admin(店铺管理员) | 全部看板 |
|
||||
| tenant_admin(租户管理员) | 全部权限 |
|
||||
|
||||
多门店支持:用户可关联多个门店,通过 `/api/xcx-auth/switch-site` 切换。
|
||||
|
||||
## 与 Monorepo 的关系
|
||||
|
||||
- 本项目为独立前端工程,不参与 Python uv workspace
|
||||
- 通过 FastAPI 后端(`apps/backend/`)与数据层交互
|
||||
- H5 原型设计稿位于 `docs/h5_ui/`
|
||||
- 认证数据存储在 `zqyy_app` 数据库的 `auth` Schema
|
||||
|
||||
## 开发调试面板(dev-tools)
|
||||
|
||||
仅在 develop 环境可用的调试工具,通过页面底部浮动按钮(dev-fab 组件)进入。
|
||||
|
||||
功能:
|
||||
- 展示当前用户上下文(角色、权限、绑定关系、门店信息)
|
||||
- 一键切换角色(coach / staff / site_admin / tenant_admin),后端真实修改 `user_site_roles` 并重签 token
|
||||
- 一键切换用户状态(new / pending / approved / rejected / disabled),后端真实修改 `users.status` 并重签 token
|
||||
- 页面跳转列表,点击可跳转到任意已注册页面
|
||||
|
||||
安全保障:
|
||||
- dev-fab 组件通过 `wx.getAccountInfoSync().miniProgram.envVersion` 判断环境,仅 `develop` 时渲染
|
||||
- 后端 dev 端点仅在 `WX_DEV_MODE=true` 时注册路由,生产环境不可访问
|
||||
|
||||
依赖的后端端点(均需 JWT):
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/xcx/dev-context` | GET | 获取当前用户调试上下文 |
|
||||
| `/api/xcx/dev-switch-role` | POST | 切换角色 |
|
||||
| `/api/xcx/dev-switch-status` | POST | 切换用户状态 |
|
||||
| `/api/xcx/dev-switch-binding` | POST | 切换绑定关系 |
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] 完善认证流程页面(登录 → 申请 → 等待审批 → 首页)
|
||||
- [ ] 任务管理页面(任务列表、置顶、放弃、备注)
|
||||
- [ ] 数据看板页面(助教业绩、客户分析)
|
||||
- [ ] 会员中心页面
|
||||
- [ ] 助教预约功能
|
||||
- [ ] 订单查询功能
|
||||
- [ ] 多门店切换 UI
|
||||
- [ ] 消息通知(微信订阅消息)
|
||||
- [ ] CI/CD(代码检查、自动上传体验版)
|
||||
@@ -1,214 +0,0 @@
|
||||
# 小程序认证系统联调指南
|
||||
|
||||
本文档说明如何在本地环境中完成小程序认证系统的前后端联调测试,覆盖从微信登录到管理端审核的完整流程。
|
||||
|
||||
---
|
||||
|
||||
## 1. 环境准备
|
||||
|
||||
### 1.1 后端服务
|
||||
|
||||
1. 在项目根目录 `.env.local` 中添加以下配置:
|
||||
|
||||
```env
|
||||
WX_DEV_MODE=true # 开启开发模式,跳过真实微信 code2Session 调用
|
||||
JWT_SECRET_KEY=dev-secret # 开发环境 JWT 密钥(生产环境请使用强随机值)
|
||||
```
|
||||
|
||||
2. 确保数据库 `test_zqyy_app` 已执行认证相关迁移脚本:
|
||||
|
||||
```bash
|
||||
# 建表脚本
|
||||
psql -d test_zqyy_app -f db/zqyy_app/migrations/2026-02-26__p3_create_auth_tables.sql
|
||||
|
||||
# 种子数据(角色、权限、角色-权限映射)
|
||||
psql -d test_zqyy_app -f db/zqyy_app/migrations/2026-02-26__p3_seed_roles_permissions.sql
|
||||
```
|
||||
|
||||
3. 启动后端服务:
|
||||
|
||||
```bash
|
||||
cd apps/backend && uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
服务默认监听 `http://localhost:8000`。
|
||||
|
||||
### 1.2 微信开发者工具
|
||||
|
||||
1. **导入项目**:打开微信开发者工具 → 导入项目 → 选择 `apps/miniprogram/` 目录
|
||||
2. **AppID**:使用测试号,或在项目设置中选择「不使用云服务」
|
||||
3. **项目设置**:
|
||||
- 勾选「不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书」(开发环境必须)
|
||||
- 勾选「不校验 HTTPS 证书」
|
||||
4. **编译模式**(可选):点击编译模式下拉 → 添加编译模式 → 指定启动页面(如 `pages/login/login`)方便调试特定页面
|
||||
|
||||
---
|
||||
|
||||
## 2. 联调测试流程
|
||||
|
||||
### Step 1:Mock 登录
|
||||
|
||||
开发模式下使用 `POST /api/xcx/dev-login` 端点,绕过微信 `code2Session` 调用。
|
||||
|
||||
```bash
|
||||
# 默认创建 pending 状态用户
|
||||
curl -X POST http://localhost:8000/api/xcx/dev-login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"openid": "test_user_001"}'
|
||||
|
||||
# 指定用户状态(可选值:pending / approved / rejected / disabled)
|
||||
curl -X POST http://localhost:8000/api/xcx/dev-login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"openid": "test_user_001", "status": "pending"}'
|
||||
```
|
||||
|
||||
返回示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJ...",
|
||||
"refresh_token": "eyJ...",
|
||||
"token_type": "bearer",
|
||||
"user_status": "pending",
|
||||
"user_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
小程序端会自动将 token 存储到 Storage 和 globalData 中。
|
||||
|
||||
### Step 2:提交申请
|
||||
|
||||
在申请页面(`pages/apply/apply`)填写以下信息:
|
||||
|
||||
| 字段 | 示例值 | 说明 |
|
||||
|------|--------|------|
|
||||
| 球房ID(site_code) | `AB123` | 格式:2 字母 + 3 数字 |
|
||||
| 申请身份 | `助教` 或 `员工` | 下拉选择 |
|
||||
| 手机号 | `13800138000` | 11 位数字 |
|
||||
| 编号(选填) | `EMP001` | 员工编号,用于辅助匹配 |
|
||||
| 昵称 | `张三` | 显示名称 |
|
||||
|
||||
提交后自动跳转到审核等待页(`pages/reviewing/reviewing`)。
|
||||
|
||||
也可通过 API 直接提交:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/xcx/apply \
|
||||
-H "Authorization: Bearer <access_token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"site_code": "AB123",
|
||||
"applied_role_text": "助教",
|
||||
"phone": "13800138000",
|
||||
"nickname": "张三"
|
||||
}'
|
||||
```
|
||||
|
||||
### Step 3:管理端审核
|
||||
|
||||
使用管理端 API 审核申请:
|
||||
|
||||
```bash
|
||||
# 1. 查看申请列表
|
||||
curl http://localhost:8000/api/admin/applications \
|
||||
-H "Authorization: Bearer <admin_token>"
|
||||
|
||||
# 2. 查看申请详情(含候选匹配)
|
||||
curl http://localhost:8000/api/admin/applications/1 \
|
||||
-H "Authorization: Bearer <admin_token>"
|
||||
|
||||
# 3a. 批准申请(需提供 role_id,可选 binding 信息)
|
||||
curl -X POST http://localhost:8000/api/admin/applications/1/approve \
|
||||
-H "Authorization: Bearer <admin_token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"role_id": 1,
|
||||
"binding": {"assistant_id": 100, "binding_type": "assistant"},
|
||||
"review_note": "信息核实通过"
|
||||
}'
|
||||
|
||||
# 3b. 拒绝申请(需提供拒绝原因)
|
||||
curl -X POST http://localhost:8000/api/admin/applications/1/reject \
|
||||
-H "Authorization: Bearer <admin_token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"review_note": "信息不匹配,请重新申请"}'
|
||||
```
|
||||
|
||||
> **提示**:管理端 API 需要 `site_admin` 或 `tenant_admin` 角色的 token。可通过 `dev-login` 创建一个 approved 状态的管理员用户,再手动在数据库中为其分配管理角色。
|
||||
|
||||
### Step 4:重新登录验证
|
||||
|
||||
审核通过后,在小程序中重新登录(或使用 `dev-login`):
|
||||
|
||||
- 用户状态应变为 `approved`
|
||||
- 小程序应自动跳转到主页(mvp 页面)
|
||||
- 可通过 `GET /api/xcx/me` 验证用户状态和关联信息
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/xcx/me \
|
||||
-H "Authorization: Bearer <access_token>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. API 端点汇总
|
||||
|
||||
### 小程序端
|
||||
|
||||
| 端点 | 方法 | 说明 | 认证要求 |
|
||||
|------|------|------|---------|
|
||||
| `/api/xcx/login` | POST | 微信登录 | 无 |
|
||||
| `/api/xcx/dev-login` | POST | 开发模式 mock 登录 | 无(仅 `WX_DEV_MODE=true`) |
|
||||
| `/api/xcx/apply` | POST | 提交申请 | JWT(含 pending) |
|
||||
| `/api/xcx/me` | GET | 查询用户状态 | JWT(含 pending) |
|
||||
| `/api/xcx/me/sites` | GET | 查询关联店铺 | JWT(approved) |
|
||||
| `/api/xcx/switch-site` | POST | 切换店铺 | JWT(approved) |
|
||||
| `/api/xcx/refresh` | POST | 刷新令牌 | refresh_token |
|
||||
|
||||
### 管理端
|
||||
|
||||
| 端点 | 方法 | 说明 | 认证要求 |
|
||||
|------|------|------|---------|
|
||||
| `/api/admin/applications` | GET | 查询申请列表 | JWT + site_admin/tenant_admin |
|
||||
| `/api/admin/applications/{id}` | GET | 申请详情 + 候选匹配 | JWT + site_admin/tenant_admin |
|
||||
| `/api/admin/applications/{id}/approve` | POST | 批准申请 | JWT + site_admin/tenant_admin |
|
||||
| `/api/admin/applications/{id}/reject` | POST | 拒绝申请 | JWT + site_admin/tenant_admin |
|
||||
|
||||
---
|
||||
|
||||
## 4. 常见问题排查
|
||||
|
||||
### 401 Unauthorized
|
||||
|
||||
- **原因**:token 过期或无效
|
||||
- **排查**:检查小程序 Storage 中的 `access_token` 是否存在;尝试使用 `dev-login` 重新获取 token
|
||||
- **注意**:access_token 默认有效期较短,过期后小程序会自动尝试用 refresh_token 刷新
|
||||
|
||||
### 403 Forbidden
|
||||
|
||||
- **原因**:用户状态为 `disabled`,或权限不足
|
||||
- **排查**:通过 `GET /api/xcx/me` 检查用户 `status` 字段;确认用户在目标店铺下有对应角色
|
||||
|
||||
### 409 Conflict
|
||||
|
||||
- **原因**:已有待审核申请(重复提交),或审核目标申请状态非 `pending`
|
||||
- **排查**:通过 `GET /api/xcx/me` 查看现有申请列表及状态
|
||||
|
||||
### 422 Validation Error
|
||||
|
||||
- **原因**:表单数据格式错误
|
||||
- **常见情况**:
|
||||
- `site_code` 不符合 `2字母+3数字` 格式(如 `AB123`)
|
||||
- `phone` 不是 11 位纯数字
|
||||
- 必填字段为空
|
||||
|
||||
### 网络请求失败
|
||||
|
||||
- **检查后端是否启动**:访问 `http://localhost:8000/docs` 确认 Swagger 文档可访问
|
||||
- **检查域名配置**:小程序 `utils/request.ts` 中的 `BASE_URL` 应指向 `http://localhost:8000`
|
||||
- **「不校验合法域名」未勾选**:开发环境必须在微信开发者工具项目设置中勾选此选项,否则 `localhost` 请求会被拦截
|
||||
|
||||
### Mock 登录不可用
|
||||
|
||||
- **原因**:`WX_DEV_MODE` 未设置为 `true`
|
||||
- **排查**:检查 `.env.local` 中是否有 `WX_DEV_MODE=true`;重启后端服务使配置生效
|
||||
@@ -1,404 +0,0 @@
|
||||
# H5 → 小程序转换:输入材料准备指南
|
||||
|
||||
> 本文档规范了每次将 H5 原型页面转换为微信小程序时,应提供的输入材料清单、格式要求和操作步骤。
|
||||
> 目标:提高 AI 转换还原度,减少颜色/间距换算错误、交互逻辑遗漏、图标处理失误。
|
||||
|
||||
---
|
||||
|
||||
## 一、HTML 文件规范
|
||||
|
||||
### 问题背景
|
||||
|
||||
当前 H5 原型是 Tailwind CDN + 内联 SVG + 原生 JS 的单文件结构,存在以下转换障碍:
|
||||
|
||||
- Tailwind 工具类需逐个手动展开为 WXSS,容易遗漏或换算错误
|
||||
- 内联 SVG 在小程序中完全不可用,但混在 HTML 里容易被"直译"
|
||||
- JS 交互逻辑和 DOM 操作对小程序没有参考价值,反而干扰判断
|
||||
|
||||
### 推荐做法
|
||||
|
||||
1. **提供渲染后的最终 DOM**(而非模板源码)
|
||||
- 浏览器中打开页面 → 右键 `<body>` → Copy → Copy outerHTML
|
||||
- 原因:Tailwind `tailwind.config` 中自定义颜色/间距在源码中只是类名,无法确定最终计算值
|
||||
- 保存为 `docs/h5_ui/rendered/<page-name>.html`
|
||||
|
||||
2. **去掉所有 `<script>` 标签**
|
||||
- JS 交互逻辑应单独用结构化文本描述(见第三节"交互描述规范")
|
||||
- 不要让 AI 从 DOM 操作代码中反推意图
|
||||
|
||||
3. **内联关键样式**(二选一)
|
||||
|
||||
**方式 A:手动记录(少量元素时)**
|
||||
```
|
||||
Chrome DevTools 操作:
|
||||
1. 打开 H5 页面
|
||||
2. 右键目标元素 → 检查
|
||||
3. 在 Styles 面板中,点击 Computed 标签
|
||||
4. 勾选 "Show all"
|
||||
5. 记录以下关键属性的计算值:
|
||||
- width / height / padding / margin / gap
|
||||
- font-size / font-weight / line-height / color
|
||||
- background / border-radius / box-shadow
|
||||
- display / flex-direction / align-items / justify-content
|
||||
```
|
||||
|
||||
**方式 B:脚本批量导出(推荐)**
|
||||
在 Chrome Console 中执行以下脚本,批量导出关键元素的计算样式:
|
||||
|
||||
```javascript
|
||||
function exportStyles() {
|
||||
const props = [
|
||||
'width','height','padding','margin','gap',
|
||||
'fontSize','fontWeight','lineHeight','color',
|
||||
'background','backgroundColor','borderRadius',
|
||||
'boxShadow','display','flexDirection','alignItems',
|
||||
'justifyContent','position','opacity'
|
||||
];
|
||||
const elements = document.querySelectorAll('[class]');
|
||||
const result = [];
|
||||
elements.forEach((el, i) => {
|
||||
const cs = getComputedStyle(el);
|
||||
const styles = {};
|
||||
props.forEach(p => {
|
||||
const v = cs[p];
|
||||
if (v && v !== 'none' && v !== 'normal' && v !== '0px' && v !== 'auto') {
|
||||
styles[p] = v;
|
||||
}
|
||||
});
|
||||
if (Object.keys(styles).length > 0) {
|
||||
result.push({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
classes: el.className,
|
||||
text: el.textContent?.trim().slice(0, 30),
|
||||
styles
|
||||
});
|
||||
}
|
||||
});
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
exportStyles();
|
||||
```
|
||||
|
||||
将输出追加到 `docs/h5_ui/computed-styles.json`(按页面名分 key 存放)
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 二、CSS / 样式规范
|
||||
|
||||
### 当前状况
|
||||
|
||||
CSS 分两部分:Tailwind 工具类(占 90%)+ 少量自定义 `<style>` 块。无 SCSS/LESS 预处理器。
|
||||
|
||||
### 设计 Token 文件(做一次,全局复用)
|
||||
|
||||
将 Tailwind 自定义主题提取为设计 Token,保存为 `docs/h5_ui/design-tokens.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"colors": {
|
||||
"primary": "#0052d9",
|
||||
"primary-light": "#ecf2fe",
|
||||
"success": "#00a870",
|
||||
"warning": "#ed7b2f",
|
||||
"error": "#e34d59",
|
||||
"gray-1": "#f3f3f3",
|
||||
"gray-2": "#eeeeee",
|
||||
"gray-3": "#e7e7e7",
|
||||
"gray-4": "#dcdcdc",
|
||||
"gray-5": "#c5c5c5",
|
||||
"gray-6": "#a6a6a6",
|
||||
"gray-7": "#8b8b8b",
|
||||
"gray-8": "#777777",
|
||||
"gray-9": "#5e5e5e",
|
||||
"gray-10": "#4b4b4b",
|
||||
"gray-11": "#393939",
|
||||
"gray-12": "#2c2c2c",
|
||||
"gray-13": "#242424"
|
||||
},
|
||||
"spacing": {
|
||||
"comment": "Tailwind 默认 1 unit = 4px = 8rpx(基于 750rpx 设计稿)",
|
||||
"base": 8,
|
||||
"unit": "rpx"
|
||||
},
|
||||
"borderRadius": {
|
||||
"sm": "8rpx",
|
||||
"md": "16rpx",
|
||||
"lg": "24rpx",
|
||||
"xl": "32rpx",
|
||||
"2xl": "32rpx",
|
||||
"3xl": "48rpx"
|
||||
},
|
||||
"fontSize": {
|
||||
"xs": "24rpx",
|
||||
"sm": "28rpx",
|
||||
"base": "32rpx",
|
||||
"lg": "36rpx",
|
||||
"xl": "40rpx",
|
||||
"2xl": "48rpx"
|
||||
},
|
||||
"shadows": {
|
||||
"lg": "0 8rpx 32rpx rgba(0,0,0,0.06)",
|
||||
"xl": "0 16rpx 48rpx rgba(0,0,0,0.08)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 自定义动画标注
|
||||
|
||||
对 `<style>` 块中的自定义 CSS,需标注处理方式:
|
||||
|
||||
| CSS 特性 | 小程序支持 | 处理方式 |
|
||||
|----------|-----------|----------|
|
||||
| `@keyframes` + `animation` | ✅ 支持 | 直接迁移 |
|
||||
| `transition` | ✅ 支持 | 直接迁移 |
|
||||
| `linear-gradient` | ✅ 支持 | 直接迁移 |
|
||||
| `backdrop-filter: blur()` | ❌ 不支持 | 改为半透明背景色 `rgba()` |
|
||||
| `blur-xl`(Tailwind) | ❌ 不支持 | 去掉或改为纯色 |
|
||||
| CSS 变量 `var()` | ✅ 支持 | 直接迁移(TDesign 大量使用) |
|
||||
|
||||
### 不需要做的事
|
||||
|
||||
- 不需要展开 SCSS/LESS(当前未使用预处理器)
|
||||
- 不需要合并 CSS 文件(小程序每个页面有独立 `.wxss`)
|
||||
|
||||
---
|
||||
|
||||
## 三、交互描述规范
|
||||
|
||||
### 为什么需要单独描述
|
||||
|
||||
H5 原型中的 JS 交互(`addEventListener`、`classList.toggle`、`innerHTML` 等)在小程序中完全不可用。AI 需要从"用户意图"层面理解交互,而非从"DOM 操作"层面翻译。
|
||||
|
||||
### 格式模板
|
||||
|
||||
每个页面提供一份交互说明,保存为 `docs/h5_ui/interactions/<page-name>.md`:
|
||||
|
||||
```markdown
|
||||
# 页面名:<page-name>
|
||||
|
||||
## 状态变量
|
||||
| 变量名 | 类型 | 初始值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| agreed | boolean | false | 协议勾选状态 |
|
||||
| loading | boolean | false | 登录请求中 |
|
||||
|
||||
## 用户操作 → 响应
|
||||
| 操作 | 触发条件 | 响应行为 | 目标状态 |
|
||||
|------|----------|----------|----------|
|
||||
| 点击协议勾选框 | 无 | 切换 agreed | agreed=!agreed |
|
||||
| 点击"使用微信登录" | agreed=true | 调用登录API | loading=true → 跳转 |
|
||||
| 点击"使用微信登录" | agreed=false | 无响应(按钮禁用态) | 不变 |
|
||||
| 登录成功 | API返回 | 按用户状态跳转 | 见流程7.1 |
|
||||
| 登录失败 | API报错 | Toast提示错误 | loading=false |
|
||||
|
||||
## 页面状态枚举
|
||||
| 状态名 | 视觉表现 | 触发条件 |
|
||||
|--------|----------|----------|
|
||||
| 默认态 | 按钮灰色禁用 | agreed=false |
|
||||
| 可登录态 | 按钮蓝色渐变+阴影 | agreed=true |
|
||||
| 加载中 | 按钮显示loading | loading=true |
|
||||
```
|
||||
|
||||
### 必须覆盖的状态(每个页面都要考虑)
|
||||
|
||||
| 状态 | 说明 | 视觉表现参考 |
|
||||
|------|------|-------------|
|
||||
| 正常态(有数据) | 页面主要功能正常展示 | 默认截图 |
|
||||
| 空数据态 | 列表/卡片区域无数据 | 居中文案 `暂无数据` / `暂无任务` |
|
||||
| 加载中态 | 数据请求中 | 区域文案 `加载中...` |
|
||||
| 错误态 | 接口请求失败 | 文案 `加载失败,请点击重试` + 重试按钮 |
|
||||
| 登录态差异 | 如果页面因登录状态有不同表现 | 按实际描述 |
|
||||
|
||||
---
|
||||
|
||||
## 四、视觉截图规范
|
||||
|
||||
### 截图是校验还原度的唯一视觉参考
|
||||
|
||||
### 分辨率
|
||||
|
||||
- 使用 iPhone 6/7/8 尺寸(375×667)
|
||||
- 这是小程序 750rpx 设计基准的 1:1 对应
|
||||
- Chrome DevTools → Toggle device toolbar → 选择 iPhone 6/7/8
|
||||
|
||||
### 每个页面至少截以下状态
|
||||
|
||||
| 状态 | 文件命名示例 | 说明 |
|
||||
|------|-------------|------|
|
||||
| 默认/正常态 | `login--default.png` | 有数据的主要展示 |
|
||||
| 关键交互态 | `login--agreed.png` | 勾选协议后按钮变化 |
|
||||
| 空数据态 | `task-list--empty.png` | 如果适用 |
|
||||
| 弹窗/浮层 | `task-list--longpress-menu.png` | 弹窗打开状态 |
|
||||
| 筛选展开 | `board-customer--filter-open.png` | 下拉筛选展开 |
|
||||
|
||||
### 命名规范
|
||||
|
||||
```
|
||||
<page-name>--<state>.png
|
||||
```
|
||||
|
||||
- 页面名用小写连字符:`board-customer`、`task-detail`、`my-profile`
|
||||
- 状态名用小写连字符:`default`、`empty`、`loading`、`error`、`filter-open`、`longpress-menu`
|
||||
|
||||
### 存放目录
|
||||
|
||||
```
|
||||
docs/h5_ui/screenshots/
|
||||
```
|
||||
|
||||
### 长页面处理
|
||||
|
||||
如果页面有滚动内容,提供长截图:
|
||||
- Chrome DevTools → Ctrl+Shift+P → 输入 "Capture full size screenshot"
|
||||
|
||||
### 不需要的截图
|
||||
|
||||
- 不需要暗色模式截图(PRD 无暗色模式需求)
|
||||
- 不需要多设备截图(仅面向手机竖屏)
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 五、资源文件规范
|
||||
|
||||
### 当前状况
|
||||
|
||||
`docs/h5_ui/img/` 只有 2 张图片,大部分图标是内联 SVG。
|
||||
|
||||
### 图标处理优先级
|
||||
|
||||
1. **优先使用 TDesign 内置图标**(`<t-icon name="xxx">`),不需要提供文件
|
||||
2. TDesign 没有的图标,提取为独立 SVG 文件
|
||||
3. 复杂图形/Logo 使用 PNG/JPG 图片
|
||||
|
||||
### 图标文件规范
|
||||
|
||||
| 项目 | 规范 |
|
||||
|------|------|
|
||||
| 格式 | SVG(图标)、PNG(带透明图片)、JPG(照片类) |
|
||||
| 命名 | `<category>-<name>.svg`,如 `icon-billiard.svg`、`icon-wechat.svg` |
|
||||
| 目录 | `apps/miniprogram/miniprogram/assets/icons/`(图标) |
|
||||
| | `apps/miniprogram/miniprogram/assets/images/`(图片) |
|
||||
| 尺寸 | 图片提供 2x 版本(750px 宽设计稿对应的实际像素) |
|
||||
|
||||
### 图标映射表(每个页面提供)
|
||||
|
||||
```markdown
|
||||
| H5 中的图标描述 | 处理方式 | 小程序引用 |
|
||||
|----------------|----------|-----------|
|
||||
| Logo 台球图标 | 自定义SVG | /assets/icons/icon-billiard.svg |
|
||||
| 任务管理图标 | TDesign | <t-icon name="task" /> |
|
||||
| 数据看板图标 | TDesign | <t-icon name="chart-bar" /> |
|
||||
| 智能助手图标 | TDesign | <t-icon name="chat" /> |
|
||||
| 微信图标 | 自定义SVG | /assets/icons/icon-wechat.svg |
|
||||
| 返回箭头 | TDesign | <t-icon name="chevron-left" /> |
|
||||
| 右箭头 | TDesign | <t-icon name="chevron-right" /> |
|
||||
```
|
||||
|
||||
保存为 `docs/h5_ui/icon-mapping.md`,或在每个页面的交互说明中附带。
|
||||
|
||||
---
|
||||
|
||||
## 六、目录结构总览
|
||||
|
||||
准备完成后,`docs/h5_ui/` 目录结构应如下:
|
||||
|
||||
```
|
||||
docs/h5_ui/
|
||||
├── css/ # 原有:自定义 CSS 文件
|
||||
├── img/ # 原有:图片资源
|
||||
├── js/ # 原有:JS 文件
|
||||
├── pages/ # 原有:H5 原型页面
|
||||
│ ├── login.html
|
||||
│ ├── board-customer.html
|
||||
│ └── ...
|
||||
├── rendered/ # 新增:渲染后的 DOM(可选)
|
||||
│ ├── login.html
|
||||
│ └── ...
|
||||
├── computed-styles.json # 新增:计算样式(可选,按页面名分 key)
|
||||
├── screenshots/ # 新增:页面截图(必须)
|
||||
│ ├── login--default.png
|
||||
│ ├── login--agreed.png
|
||||
│ ├── task-list--default.png
|
||||
│ ├── task-list--empty.png
|
||||
│ └── ...
|
||||
├── interactions/ # 新增:交互说明(必须)
|
||||
│ ├── login.md
|
||||
│ ├── task-list.md
|
||||
│ └── ...
|
||||
├── design-tokens.json # 新增:设计 Token(必须,做一次)
|
||||
├── icon-mapping.md # 新增:图标映射表(必须,做一次)
|
||||
└── index.html # 原有:入口页
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、操作步骤——"怎么喂料"
|
||||
|
||||
### 一次性准备(全局)
|
||||
|
||||
1. 创建 `docs/h5_ui/design-tokens.json`(从 `tailwind.config` 提取,见第二节)
|
||||
2. 创建 `docs/h5_ui/icon-mapping.md`(全局图标映射表,见第五节)
|
||||
3. 创建目录:`screenshots/`、`interactions/`、`rendered/`(可选)
|
||||
|
||||
### 每个页面的准备流程
|
||||
|
||||
#### Step 1:准备 HTML + 计算样式
|
||||
1. 在 Chrome 中打开 `docs/h5_ui/pages/<page>.html`
|
||||
2. 切换到 iPhone 6/7/8 设备模式(375×667)
|
||||
3. 在 Console 中运行 `exportStyles()` 脚本(见第一节)
|
||||
4. 将输出追加到 `docs/h5_ui/computed-styles.json`(以页面名为 key)
|
||||
5. (可选)复制渲染后 DOM,保存为 `docs/h5_ui/rendered/<page>.html`
|
||||
|
||||
#### Step 2:截图
|
||||
1. Chrome DevTools → Ctrl+Shift+P → "Capture screenshot"
|
||||
2. 每个状态截一张,命名为 `<page>--<state>.png`
|
||||
3. 放入 `docs/h5_ui/screenshots/`
|
||||
|
||||
#### Step 3:写交互说明
|
||||
1. 按第三节格式模板,写出状态变量、操作→响应表、状态枚举
|
||||
2. 保存为 `docs/h5_ui/interactions/<page>.md`
|
||||
|
||||
#### Step 4:标注图标/图片
|
||||
1. 检查页面中的所有图标和图片
|
||||
2. 在图标映射表中补充新图标的处理方式
|
||||
3. 自定义资源放入小程序 `assets/` 对应目录
|
||||
|
||||
#### Step 5:喂给 AI
|
||||
在对话中提供以下信息:
|
||||
|
||||
```
|
||||
请将 <page-name> 页面转换为小程序。
|
||||
- HTML:docs/h5_ui/pages/<page>.html
|
||||
- 计算样式:docs/h5_ui/computed-styles.json(页面 key: <page>)
|
||||
- 截图:docs/h5_ui/screenshots/<page>__default.png(拖入对话)
|
||||
- 交互说明:docs/h5_ui/interactions/<page>.md
|
||||
- PRD 参考:P<N> spec <章节号>
|
||||
- 图标映射:全部使用 TDesign / 见 icon-mapping.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、最低限度清单
|
||||
|
||||
如果时间有限,以下三项是"必须提供"的底线:
|
||||
|
||||
| 优先级 | 材料 | 作用 | 频率 |
|
||||
|--------|------|------|------|
|
||||
| P0 | 截图(默认态,375px 宽) | 校验还原度的唯一视觉参考 | 每页 |
|
||||
| P0 | 交互说明(状态变量 + 操作响应表) | 避免逻辑错误 | 每页 |
|
||||
| P0 | 设计 Token JSON | 避免颜色/间距换算错误 | 一次 |
|
||||
| P1 | 计算样式 JSON | 显著提高还原度 | 每页(可选) |
|
||||
| P1 | 图标映射表 | 避免图标处理失误 | 一次 + 增量 |
|
||||
| P2 | 渲染后 DOM | 复杂页面时有帮助 | 按需 |
|
||||
|
||||
---
|
||||
|
||||
## 附录:相关文档
|
||||
|
||||
- [H5 → 小程序转换避坑指南](./h5-to-miniprogram-pitfalls.md) — 标签映射、样式差异、事件系统、高频踩坑清单
|
||||
- [小程序认证系统联调指南](./auth-integration-guide.md) — 前后端联调测试流程
|
||||
- [产品需求文档](./prd.md) — 完整 PRD(页面级需求)
|
||||
- PRD Specs — `docs/prd/specs/P1~P11`
|
||||
- H5 原型 — `docs/h5_ui/pages/`
|
||||
@@ -1,476 +0,0 @@
|
||||
# H5 → 微信小程序转换避坑指南
|
||||
|
||||
> 基于本项目 `docs/h5_ui/` 原型与 `apps/miniprogram/miniprogram/pages/` 已转换页面的实际对比,结合微信小程序官方文档整理。
|
||||
> 适用于后续页面开发(如 board-coach、task-list、customer-detail 等)的快速参考。
|
||||
|
||||
---
|
||||
|
||||
## 一、WXML vs HTML — 标签与结构
|
||||
|
||||
### 1.1 标签映射表
|
||||
|
||||
| H5 (HTML) | 小程序 (WXML) | 说明 |
|
||||
|---------------------|----------------------|------|
|
||||
| `<div>` | `<view>` | 最基础的容器 |
|
||||
| `<span>` / `<p>` | `<text>` | 文本必须用 `<text>` 包裹才可选中/换行 |
|
||||
| `<a href="...">` | `<navigator url="">` | 或用 `wx.navigateTo()` 编程式跳转 |
|
||||
| `<img src="...">` | `<image src="" mode="">` | 必须指定 `mode`,默认 320×240 |
|
||||
| `<input>` | `<input>` 或 `<t-input>` | 原生 input 事件名不同;推荐 TDesign |
|
||||
| `<textarea>` | `<textarea>` 或 `<t-textarea>` | 同上 |
|
||||
| `<button>` | `<button>` 或 `<t-button>` | 小程序 button 有 `open-type` 能力 |
|
||||
| `<ul>/<li>` | `<view wx:for="{{list}}">` | 没有列表语义标签 |
|
||||
| `<select>` | `<picker>` 或 `<t-picker>` | 完全不同的交互模式 |
|
||||
| `<label for="id">` | `<label for="id">` | 支持,但 for 只能绑定 checkbox/radio/switch |
|
||||
| `<svg>` 内联 | `<image src="xx.svg">` | 不支持内联 SVG,只能作为图片引用 |
|
||||
| `<iframe>` | `<web-view>` | 需配置业务域名白名单 |
|
||||
|
||||
### 1.2 实际对比:login 页面
|
||||
|
||||
**H5 原型** — 内联 SVG 图标:
|
||||
```html
|
||||
<svg class="w-14 h-14 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="10" .../>
|
||||
</svg>
|
||||
```
|
||||
|
||||
**小程序转换** — 改为 image 引用:
|
||||
```xml
|
||||
<image class="logo-icon" src="/assets/icons/logo-billiard.svg" mode="aspectFit" />
|
||||
```
|
||||
|
||||
> **坑**:小程序不支持内联 SVG。所有 SVG 图标需提取为独立 `.svg` 文件放到 `assets/icons/`,通过 `<image>` 引用。
|
||||
|
||||
### 1.3 不存在的标签/属性
|
||||
|
||||
| H5 特性 | 小程序替代方案 |
|
||||
|---------|---------------|
|
||||
| `<h1>`~`<h6>` | `<text>` + 样式类 |
|
||||
| `<table>` | `<view>` 手动布局 |
|
||||
| `<form>` + `<input name>` | 小程序 `<form>` 或直接 `setData` 收集 |
|
||||
| `onclick="fn()"` | `bindtap="fn"` 或 `bind:tap="fn"` |
|
||||
| `class="a b c"` 动态 | `class="base {{condition ? 'a' : 'b'}}"` |
|
||||
| `innerHTML` | `<rich-text nodes="{{html}}">` |
|
||||
|
||||
---
|
||||
|
||||
## 二、WXSS vs CSS — 样式差异
|
||||
|
||||
### 2.1 支持的选择器(有限)
|
||||
|
||||
| 选择器 | 支持 | 说明 |
|
||||
|--------|------|------|
|
||||
| `.class` | ✅ | |
|
||||
| `#id` | ✅ | |
|
||||
| `element` (如 `view`) | ✅ | |
|
||||
| `element, element` | ✅ | 群组选择器 |
|
||||
| `::before` / `::after` | ✅ | |
|
||||
| `*` 通配符 | ❌ | Tailwind 的 `*` reset 全部失效 |
|
||||
| `>` 子选择器 | ⚠️ | 部分版本支持,不推荐依赖 |
|
||||
| `+` / `~` 兄弟选择器 | ⚠️ | 同上 |
|
||||
| `:nth-child()` | ⚠️ | 部分支持 |
|
||||
| `@media` | ✅ | 支持,但用 rpx 更好 |
|
||||
|
||||
### 2.2 rpx 单位 — 最大差异
|
||||
|
||||
H5 用 `px`/`rem`/`vw`,小程序用 `rpx`(responsive pixel):
|
||||
- 屏幕宽度固定 = 750rpx
|
||||
- iPhone6 上 1rpx = 0.5px,即 1px = 2rpx
|
||||
- 设计稿以 750px 宽为基准时,数值直接写 rpx
|
||||
|
||||
**实际对比:login 页面**
|
||||
|
||||
H5 原型(Tailwind):
|
||||
```html
|
||||
<div class="w-24 h-24 rounded-3xl"> <!-- 96px × 96px -->
|
||||
```
|
||||
|
||||
小程序转换:
|
||||
```css
|
||||
.logo-box {
|
||||
width: 192rpx; /* 96px × 2 = 192rpx */
|
||||
height: 192rpx;
|
||||
border-radius: 48rpx;
|
||||
}
|
||||
```
|
||||
|
||||
> **换算规则**:H5 的 px 值 × 2 = rpx 值(基于 750 宽设计稿)。
|
||||
|
||||
### 2.2.1 H5 → 小程序全局缩放比例(87.5%)
|
||||
|
||||
H5 原型基于 375px 视口设计(iPhone SE/6/7/8),直接 ×2 转 rpx 后在大屏手机(iPhone 15 Pro Max,430pt 宽)上元素偏大。经实测对比,对所有 rpx 值统一乘以 0.875 缩放系数后视觉效果最佳。
|
||||
|
||||
**规则**:
|
||||
- 尺寸、间距、圆角、阴影偏移:`H5 px × 2 × 0.875` = 最终 rpx,取偶数
|
||||
- 字号:同上规则,如 `text-2xl`(24px) → 48rpx × 0.875 = 42rpx
|
||||
- t-icon size:同上规则,如 `w-5`(20px) → 40rpx × 0.875 = 35rpx
|
||||
- `max-width` 等约束宽度:同上规则
|
||||
- 背景纹理间距(如十字纹 `bg-pattern`):不缩放,保持原值
|
||||
|
||||
**换算速查**(常用 Tailwind 值):
|
||||
|
||||
| Tailwind | H5 px | 原始 rpx | ×0.875 | 取整 rpx |
|
||||
|----------|-------|----------|--------|----------|
|
||||
| `text-xs` (12px) | 12 | 24 | 21 | 22 |
|
||||
| `text-sm` (14px) | 14 | 28 | 24.5 | 24 |
|
||||
| `text-base` (16px) | 16 | 32 | 28 | 28 |
|
||||
| `text-lg` (18px) | 18 | 36 | 31.5 | 32 |
|
||||
| `text-xl` (20px) | 20 | 40 | 35 | 36 |
|
||||
| `text-2xl` (24px) | 24 | 48 | 42 | 42 |
|
||||
| `gap-2` / `p-2` (8px) | 8 | 16 | 14 | 14 |
|
||||
| `gap-3` / `p-3` (12px) | 12 | 24 | 21 | 22 |
|
||||
| `gap-4` / `p-4` (16px) | 16 | 32 | 28 | 28 |
|
||||
| `gap-5` / `p-5` (20px) | 20 | 40 | 35 | 36 |
|
||||
| `gap-6` / `p-6` (24px) | 24 | 48 | 42 | 42 |
|
||||
| `gap-8` / `p-8` (32px) | 32 | 64 | 56 | 56 |
|
||||
| `w-10` / `h-10` (40px) | 40 | 80 | 70 | 70 |
|
||||
| `w-14` / `h-14` (56px) | 56 | 112 | 98 | 98 |
|
||||
| `w-24` / `h-24` (96px) | 96 | 192 | 168 | 168 |
|
||||
| `w-28` / `h-28` (112px) | 112 | 224 | 196 | 196 |
|
||||
| `rounded-xl` (12px) | 12 | 24 | 21 | 22 |
|
||||
| `rounded-2xl` (16px) | 16 | 32 | 28 | 28 |
|
||||
| `rounded-3xl` (24px) | 24 | 48 | 42 | 42 |
|
||||
|
||||
> **来源**:no-permission 页面实测确定。先后尝试了非统一缩放、80%、87.5% 三种方案,87.5% 在 iPhone 15 Pro Max 上与 H5 原型视觉一致度最高。后续所有页面转换统一使用此系数。
|
||||
|
||||
### 2.3 Tailwind CSS → 手写 WXSS
|
||||
|
||||
小程序不支持 Tailwind CSS(无构建链集成),所有 Tailwind 工具类必须手写为 WXSS。
|
||||
|
||||
| Tailwind 类 | WXSS 等价写法 |
|
||||
|-------------|--------------|
|
||||
| `flex flex-col items-center` | `display: flex; flex-direction: column; align-items: center;` |
|
||||
| `gap-4` | `gap: 32rpx;`(4 × 8px × 2rpx) |
|
||||
| `p-5` | `padding: 40rpx;` |
|
||||
| `rounded-2xl` | `border-radius: 32rpx;` |
|
||||
| `text-sm` | `font-size: 28rpx;` |
|
||||
| `text-gray-7` | `color: #8b8b8b;` |
|
||||
| `bg-white/60` | `background: rgba(255,255,255,0.6);` |
|
||||
| `backdrop-blur-sm` | ❌ 不支持 `backdrop-filter` |
|
||||
| `shadow-lg` | `box-shadow: 0 8rpx 32rpx rgba(0,0,0,0.06);` |
|
||||
| `min-h-screen` | `min-height: 100vh;` |
|
||||
|
||||
### 2.4 不支持/有差异的 CSS 特性
|
||||
|
||||
| CSS 特性 | 小程序支持情况 | 替代方案 |
|
||||
|----------|---------------|---------|
|
||||
| `backdrop-filter: blur()` | ❌ 不支持 | 用半透明背景色模拟 |
|
||||
| `position: fixed` | ⚠️ 部分场景异常 | 用 `position: sticky` 或组件自带吸顶 |
|
||||
| `@import url()` 远程 | ❌ | 只支持本地 `@import "path.wxss"` |
|
||||
| `@font-face` 远程字体 | ⚠️ | 需 `wx.loadFontFace()` 动态加载 |
|
||||
| CSS 变量 `var()` | ✅ 支持 | TDesign 大量使用 |
|
||||
| `linear-gradient` | ✅ 支持 | 正常使用 |
|
||||
| `animation` / `@keyframes` | ✅ 支持 | 正常使用 |
|
||||
| `transition` | ✅ 支持 | 正常使用 |
|
||||
| `env(safe-area-inset-*)` | ✅ 支持 | 刘海屏适配必用 |
|
||||
|
||||
### 2.5 样式作用域
|
||||
|
||||
- `app.wxss` = 全局样式
|
||||
- 页面 `.wxss` = 仅当前页面生效(自动隔离)
|
||||
- 组件 `.wxss` = 默认隔离(`styleIsolation: 'isolated'`)
|
||||
|
||||
> **坑**:H5 的全局 CSS reset(如 `* { box-sizing: border-box; }`)在小程序中无效。需要在每个需要的元素上手动设置 `box-sizing`。
|
||||
|
||||
---
|
||||
|
||||
## 三、事件系统 — 最容易踩坑
|
||||
|
||||
### 3.1 事件绑定对比
|
||||
|
||||
| H5 | 小程序 | 说明 |
|
||||
|----|--------|------|
|
||||
| `onclick="fn()"` | `bindtap="fn"` | 不能传参! |
|
||||
| `onclick="fn(1)"` | `data-id="1" bindtap="fn"` | 通过 dataset 传参 |
|
||||
| `addEventListener` | 不支持 | 只能在 WXML 中声明式绑定 |
|
||||
| `event.target.value` | `e.detail.value` | 取值路径不同 |
|
||||
| `event.preventDefault()` | `catchtap` | 用 catch 前缀阻止冒泡 |
|
||||
| `event.stopPropagation()` | `catchtap` | 同上 |
|
||||
|
||||
### 3.2 传参方式
|
||||
|
||||
**H5**:
|
||||
```html
|
||||
<button onclick="handleClick(item.id, item.name)">点击</button>
|
||||
```
|
||||
|
||||
**小程序**:
|
||||
```xml
|
||||
<view data-id="{{item.id}}" data-name="{{item.name}}" bindtap="handleClick">点击</view>
|
||||
```
|
||||
```typescript
|
||||
handleClick(e: WechatMiniprogram.TouchEvent) {
|
||||
const { id, name } = e.currentTarget.dataset
|
||||
}
|
||||
```
|
||||
|
||||
> **坑**:`data-` 属性名会自动转换 — 连字符转驼峰(`data-user-id` → `dataset.userId`),大写转小写(`data-userId` → `dataset.userid`)。
|
||||
|
||||
### 3.3 实际对比:login 页面的协议勾选
|
||||
|
||||
**H5 原型**:
|
||||
```html
|
||||
<input type="checkbox" id="agreeCheckbox" onchange="updateButtonState()">
|
||||
<script>
|
||||
checkbox.addEventListener('change', updateButtonState);
|
||||
</script>
|
||||
```
|
||||
|
||||
**小程序转换**:
|
||||
```xml
|
||||
<view class="agreement" bindtap="onAgreeChange">
|
||||
<view class="checkbox {{agreed ? 'checkbox--checked' : ''}}">
|
||||
<t-icon wx:if="{{agreed}}" name="check" size="20rpx" color="#fff" />
|
||||
</view>
|
||||
</view>
|
||||
```
|
||||
```typescript
|
||||
onAgreeChange() {
|
||||
this.setData({ agreed: !this.data.agreed })
|
||||
}
|
||||
```
|
||||
|
||||
> **坑**:小程序没有原生 checkbox 的 `checked` 双向绑定,需要手动用 `setData` + 条件样式类模拟。
|
||||
|
||||
---
|
||||
|
||||
## 四、数据绑定与渲染
|
||||
|
||||
### 4.1 模板语法对比
|
||||
|
||||
| 功能 | H5 (原生/框架) | 小程序 WXML |
|
||||
|------|----------------|-------------|
|
||||
| 插值 | `${variable}` / `{{variable}}` | `{{variable}}` |
|
||||
| 条件渲染 | `if/else` + DOM 操作 | `wx:if` / `wx:elif` / `wx:else` |
|
||||
| 列表渲染 | `forEach` + `innerHTML` | `wx:for="{{list}}" wx:key="id"` |
|
||||
| 显示/隐藏 | `style.display = 'none'` | `hidden="{{!show}}"` 或 `wx:if` |
|
||||
| 动态 class | `classList.toggle()` | `class="base {{active ? 'on' : ''}}"` |
|
||||
| 动态 style | `element.style.color = 'red'` | `style="color: {{color}};"` |
|
||||
|
||||
### 4.2 wx:if vs hidden
|
||||
|
||||
```xml
|
||||
<!-- wx:if:条件为 false 时不渲染 DOM,切换时销毁/重建 -->
|
||||
<view wx:if="{{status === 'pending'}}">审核中</view>
|
||||
|
||||
<!-- hidden:始终渲染,只切换 display -->
|
||||
<view hidden="{{status !== 'pending'}}">审核中</view>
|
||||
```
|
||||
|
||||
- 频繁切换 → 用 `hidden`(避免重复创建销毁)
|
||||
- 初始条件不太可能变 → 用 `wx:if`(减少初始渲染量)
|
||||
|
||||
### 4.3 实际对比:reviewing 页面的条件渲染
|
||||
|
||||
**H5 原型**:只有一种状态(审核中),用静态 HTML。
|
||||
|
||||
**小程序转换**:支持 pending/rejected 两种状态,用 `wx:if` 动态切换:
|
||||
```xml
|
||||
<view class="top-gradient top-gradient--{{status}}"></view>
|
||||
<t-icon wx:if="{{status === 'pending'}}" name="time" size="112rpx" color="#fff" />
|
||||
<t-icon wx:else name="close-circle" size="112rpx" color="#fff" />
|
||||
<text class="main-title">{{status === 'pending' ? '申请审核中' : '申请未通过'}}</text>
|
||||
```
|
||||
|
||||
> **坑**:`wx:if` 中的表达式必须在 `{{}}` 内,且不支持复杂 JS 表达式(如函数调用)。需要复杂逻辑时用 WXS 或在 JS 中预处理好数据。
|
||||
|
||||
---
|
||||
|
||||
## 五、路由与导航
|
||||
|
||||
### 5.1 对比
|
||||
|
||||
| H5 | 小程序 | 说明 |
|
||||
|----|--------|------|
|
||||
| `window.location.href = 'xx.html'` | `wx.navigateTo({ url: '/pages/xx/xx' })` | 保留当前页,跳新页 |
|
||||
| `window.location.replace()` | `wx.redirectTo()` | 关闭当前页,跳新页 |
|
||||
| `history.back()` | `wx.navigateBack()` | 返回上一页 |
|
||||
| 无直接等价 | `wx.reLaunch()` | 关闭所有页面,打开新页 |
|
||||
| 无直接等价 | `wx.switchTab()` | 跳转 tabBar 页面 |
|
||||
|
||||
### 5.2 实际对比:reviewing 页面的"更换账号"
|
||||
|
||||
**H5 原型**:
|
||||
```javascript
|
||||
function switchAccount() {
|
||||
localStorage.clear();
|
||||
window.location.href = 'login.html';
|
||||
}
|
||||
```
|
||||
|
||||
**小程序转换**:
|
||||
```typescript
|
||||
onSwitchAccount() {
|
||||
const app = getApp<IAppOption>()
|
||||
app.globalData.token = undefined
|
||||
wx.removeStorageSync("token")
|
||||
wx.removeStorageSync("refreshToken")
|
||||
wx.reLaunch({ url: "/pages/login/login" }) // 清空页面栈
|
||||
}
|
||||
```
|
||||
|
||||
> **坑**:
|
||||
> - 页面栈最多 10 层,`navigateTo` 超过会静默失败
|
||||
> - 跳转 tabBar 页面必须用 `switchTab`,用 `navigateTo` 会报错
|
||||
> - 路径必须以 `/` 开头,且不带 `.wxml` 后缀
|
||||
> - `reLaunch` 会销毁所有页面,适合登录/登出等场景
|
||||
|
||||
---
|
||||
|
||||
## 六、存储与网络
|
||||
|
||||
### 6.1 本地存储
|
||||
|
||||
| H5 | 小程序 | 说明 |
|
||||
|----|--------|------|
|
||||
| `localStorage.setItem(k, v)` | `wx.setStorageSync(k, v)` | 同步写入 |
|
||||
| `localStorage.getItem(k)` | `wx.getStorageSync(k)` | 同步读取 |
|
||||
| `localStorage.removeItem(k)` | `wx.removeStorageSync(k)` | 同步删除 |
|
||||
| `localStorage.clear()` | `wx.clearStorageSync()` | 清空全部 |
|
||||
| 上限 ~5MB | 上限 10MB | 小程序更大 |
|
||||
|
||||
> **坑**:小程序没有 Cookie,登录态必须自行通过 Storage + header token 管理。
|
||||
|
||||
### 6.2 网络请求
|
||||
|
||||
| H5 | 小程序 | 说明 |
|
||||
|----|--------|------|
|
||||
| `fetch()` / `XMLHttpRequest` | `wx.request()` | 需配置域名白名单 |
|
||||
| 无限制 | 并发上限 10 个 | 超出排队 |
|
||||
| 任意域名 | 必须 HTTPS + 白名单 | 开发时可关闭校验 |
|
||||
|
||||
---
|
||||
|
||||
## 七、TDesign 组件替代 H5 原生元素
|
||||
|
||||
本项目使用 TDesign 小程序组件库,以下是常见替代关系:
|
||||
|
||||
| H5 原型元素 | TDesign 组件 | 注意事项 |
|
||||
|-------------|-------------|---------|
|
||||
| `<button>` | `<t-button>` | 用 CSS 变量定制样式,如 `--td-button-large-height` |
|
||||
| `<input>` | `<t-input>` | 事件是 `bind:change` 而非 `bindinput` |
|
||||
| `<textarea>` | `<t-textarea>` | 同上 |
|
||||
| `<select>` | `<t-picker>` | 完全不同的交互 |
|
||||
| `<checkbox>` | `<t-checkbox>` | 或手动实现(如 login 页) |
|
||||
| `<radio>` | `<t-radio>` / `<t-radio-group>` | `bind:change` 取值 |
|
||||
| SVG 图标 | `<t-icon name="xxx">` | TDesign 内置图标库 |
|
||||
| 加载动画 | `<t-loading>` | 替代 CSS spinner |
|
||||
| 弹窗 | `<t-dialog>` / `<t-toast>` | 替代 `alert()` / `confirm()` |
|
||||
| 下拉刷新 | 页面 `onPullDownRefresh` | 在 page.json 中 `enablePullDownRefresh: true` |
|
||||
|
||||
### TDesign 样式覆盖 4 种方式
|
||||
|
||||
1. **CSS 变量**(推荐):`--td-button-large-height: 96rpx`
|
||||
2. **外部样式类**:`t-class="my-class"` + `.my-class { ... !important }`
|
||||
3. **解除隔离**:TDesign 已开启 `addGlobalClass`,页面样式可直接覆盖
|
||||
4. **style 属性**:`style="background: #f5f5f5; border-radius: 16rpx;"`
|
||||
|
||||
**实际示例**(login 页面按钮定制):
|
||||
```css
|
||||
.login-btn {
|
||||
--td-button-large-height: 96rpx !important;
|
||||
--td-button-large-font-size: 32rpx !important;
|
||||
--td-button-border-radius: 24rpx !important;
|
||||
}
|
||||
.login-btn--active {
|
||||
background: linear-gradient(135deg, #0052d9, #3b82f6) !important;
|
||||
box-shadow: 0 12rpx 32rpx rgba(0, 82, 217, 0.3);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、高频踩坑清单
|
||||
|
||||
### 8.1 结构层
|
||||
|
||||
| # | 坑 | 说明 | 解决方案 |
|
||||
|---|-----|------|---------|
|
||||
| 1 | 内联 SVG 不支持 | WXML 不能写 `<svg>` 标签 | 提取为 `.svg` 文件,用 `<image>` 或 `<t-icon>` |
|
||||
| 2 | 没有 DOM API | `document.getElementById` 等全部不可用 | 用 `this.setData()` 驱动视图更新 |
|
||||
| 3 | `<text>` 内只能嵌套 `<text>` | 不能在 `<text>` 内放 `<view>` | 需要块级布局时外层用 `<view>` |
|
||||
| 4 | `checked="false"` 是 true | 字符串 `"false"` 是 truthy | 必须写 `checked="{{false}}"` |
|
||||
| 5 | `wx:key` 必须提供 | 列表渲染不加 key 会警告且性能差 | `wx:key="id"` 或 `wx:key="*this"` |
|
||||
| 6 | `<block>` 不渲染 DOM | 只是逻辑包裹,不产生真实节点 | 需要样式时改用 `<view>` |
|
||||
|
||||
### 8.2 样式层
|
||||
|
||||
| # | 坑 | 说明 | 解决方案 |
|
||||
|---|-----|------|---------|
|
||||
| 7 | `*` 选择器无效 | 全局 reset 失效 | 逐个元素设置 `box-sizing` |
|
||||
| 8 | `backdrop-filter` 不支持 | 毛玻璃效果无法实现 | 用半透明背景色 `rgba()` 近似 |
|
||||
| 9 | `image` 默认 320×240 | 不设宽高会变形 | 始终指定 `width`/`height` + `mode` |
|
||||
| 10 | rpx 小数精度 | 1rpx 在某些设备上不显示 | 边框最小用 2rpx |
|
||||
| 11 | 组件样式隔离 | 页面样式穿不进自定义组件 | 用外部样式类或 `styleIsolation: 'shared'` |
|
||||
| 12 | `!important` 滥用 | TDesign 组件内部样式优先级高 | 优先用 CSS 变量覆盖 |
|
||||
|
||||
### 8.3 逻辑层
|
||||
|
||||
| # | 坑 | 说明 | 解决方案 |
|
||||
|---|-----|------|---------|
|
||||
| 13 | `setData` 性能 | 大数据量传输卡顿 | 只传变化字段:`'list[2].name': 'new'` |
|
||||
| 14 | 没有 Cookie | 登录态不能靠 Cookie | Storage + header token |
|
||||
| 15 | `eval()` 不可用 | 动态代码执行被禁止 | 预编译逻辑 |
|
||||
| 16 | 页面栈 10 层限制 | `navigateTo` 超过 10 层静默失败 | 合理使用 `redirectTo` / `reLaunch` |
|
||||
| 17 | `alert()` 不存在 | 没有浏览器弹窗 API | `wx.showToast()` / `wx.showModal()` |
|
||||
| 18 | `window` / `document` 不存在 | 所有 Web API 不可用 | 用 `wx.*` API 替代 |
|
||||
|
||||
### 8.4 TDesign 相关
|
||||
|
||||
| # | 坑 | 说明 | 解决方案 |
|
||||
|---|-----|------|---------|
|
||||
| 19 | `style: v2` 冲突 | app.json 中的 `"style": "v2"` 导致样式错乱 | 删除该配置 |
|
||||
| 20 | npm 构建遗忘 | 安装新包后忘记构建 npm | 每次 `npm install` 后在开发者工具中"构建 npm" |
|
||||
| 21 | 事件名差异 | TDesign 用 `bind:change`,原生用 `bindinput` | 查阅组件文档确认事件名 |
|
||||
| 22 | 外部样式类命名 | `t-class` / `t-class-input` 等各组件不同 | 查阅组件文档的 External Classes |
|
||||
|
||||
---
|
||||
|
||||
## 九、转换 Checklist(新页面开发用)
|
||||
|
||||
开发新页面时,按此清单逐项检查:
|
||||
|
||||
- [ ] HTML 标签全部替换为 WXML 组件(`div→view`、`span→text`、`img→image`)
|
||||
- [ ] 内联 SVG 提取为文件,改用 `<image>` 或 `<t-icon>`
|
||||
- [ ] Tailwind 类全部手写为 WXSS(px × 2 × 0.875 = rpx,见 §2.2.1 缩放规则)
|
||||
- [ ] `backdrop-filter` 等不支持的 CSS 改为替代方案
|
||||
- [ ] 事件绑定改为 `bindtap` / `bind:change`,传参用 `data-*`
|
||||
- [ ] `alert/confirm` 改为 `wx.showToast` / `wx.showModal`
|
||||
- [ ] `localStorage` 改为 `wx.setStorageSync`
|
||||
- [ ] 路由跳转改为 `wx.navigateTo` / `wx.reLaunch` 等
|
||||
- [ ] 表单收集改为 `setData` + 事件回调
|
||||
- [ ] 图片设置 `mode` 属性(`aspectFit` / `aspectFill` / `widthFix`)
|
||||
- [ ] 列表渲染加 `wx:key`
|
||||
- [ ] 布尔属性用 `{{}}` 包裹(`checked="{{true}}"`)
|
||||
- [ ] TDesign 组件在页面 `.json` 中注册 `usingComponents`
|
||||
- [ ] 安全区适配:`padding-top: env(safe-area-inset-top)`
|
||||
- [ ] 页面配置:`enablePullDownRefresh`、`navigationBarTitleText` 等
|
||||
|
||||
---
|
||||
|
||||
## 十、board-customer 迁移经验补充
|
||||
|
||||
> 来源:board-customer 页面 8 维度卡片迁移实战(2026-03-07)
|
||||
|
||||
### 10.1 复杂维度用独立布局
|
||||
|
||||
最专一维度的助教明细表不适合用通用的 `card-mid-row` 或 `card-grid`,直接用独立的 `loyal-table` 布局(左侧竖线 `border-left: 4rpx solid #eee` + 表头 + 数据行)。同时在助教行的 `wx:if` 中排除 `dimType !== 'loyal'`,避免信息重复。
|
||||
|
||||
**关键**:当某个维度的卡片结构与其他维度差异过大时,不要硬套通用模板,直接写独立布局更清晰。
|
||||
|
||||
### 10.2 heart-icon 组件:TS observer 替代 WXS
|
||||
|
||||
小程序 WXS 不支持 emoji surrogate pair(如 `\uD83D\uDC96`),渲染为乱码。解决方案:
|
||||
- 用 TS `observers` 监听 `score` 属性变化,计算对应 emoji 字符串
|
||||
- WXML 中用 `{{heartEmoji}}` 数据绑定渲染
|
||||
- 样式:`font-size: 22rpx; line-height: 1; position: relative; top: -4rpx` 和文字对齐
|
||||
|
||||
### 10.3 助教字体颜色三态 + badge 渐变
|
||||
|
||||
助教名字颜色规则(通过 CSS class 控制):
|
||||
- 跟 badge → `.assistant--assignee`:`color: #e34d59; font-weight: 700`(红色加粗)
|
||||
- 弃 badge → `.assistant--abandoned`:`color: #a6a6a6`(灰色)
|
||||
- 无 badge → `.assistant--normal`:`color: #242424`(黑色)
|
||||
|
||||
Badge 样式(白字 + 渐变背景 + 阴影):
|
||||
- 跟:`background: linear-gradient(135deg, #e34d59, #f26a76); box-shadow: 0 2rpx 6rpx rgba(227,77,89,0.28)`
|
||||
- 弃:`background: linear-gradient(135deg, #d4d4d4, #b6b6b6); box-shadow: 0 2rpx 6rpx rgba(0,0,0,0.14)`
|
||||
@@ -1,97 +0,0 @@
|
||||
|
||||
|
||||
1) 迁移前审计与准备(强烈建议先跑这个)
|
||||
你是一名资深微信小程序前端架构师+CSS布局专家。我要把一套 Web 页面(HTML/CSS/少量JS)迁移为“原生微信小程序”(WXML/WXSS/JS/JSON),要求结构与样式细节还原度尽可能高。
|
||||
|
||||
输入(我将分段提供):
|
||||
- 页面HTML(可含多页面)
|
||||
- 全量CSS(含reset/公共样式)
|
||||
- 资源清单(图片/字体/图标svg等路径)
|
||||
- 若有:目标效果截图/设计稿
|
||||
|
||||
请先不要写最终代码,先做《迁移审计与准备报告》,输出必须包含以下部分,并使用清晰的编号标题:
|
||||
|
||||
A. 页面结构清单:列出每个页面的主要区域(header/hero/list/card/footer等)及建议组件化边界(哪些抽组件、哪些保留页面内)
|
||||
B. CSS复杂度审计:列出会影响小程序还原的高风险点(如 position: sticky、复杂选择器、伪元素、滤镜、背景混合、滚动容器、字体/line-height差异、overflow裁切、z-index层叠等),并给出在小程序里的替代策略
|
||||
C. HTML→WXML映射规则:语义标签映射、事件绑定映射、表单控件映射、列表渲染(wx:for)策略
|
||||
D. CSS→WXSS映射规则:单位策略(px→rpx换算方案、断点策略)、选择器扁平化策略、样式隔离策略(页面/组件wxss组织)、必要时是否引入原子化类(如仅用基础class,不引第三方)
|
||||
E. 资源处理:图片/字体/图标(svg转png或iconfont方案)、2x/3x图、网络图片策略、包体与分包建议
|
||||
F. 产物目录规划:给出建议的最终目录树(pages/、components/、styles/、assets/),并说明每个文件职责
|
||||
G. 最小补充信息清单:如果缺少信息,列出“为了高还原必须补充的最小信息”(例如:页面宽度基准、设计稿字号、是否允许自适应裁切等)
|
||||
|
||||
输出要求:表格+要点并存;每个高风险点给出“原因 + 影响 + 处理方案 + 验收方式”。
|
||||
|
||||
2) 生成第一版代码(要求目录树+逐文件输出)
|
||||
基于你刚才的《迁移审计与准备报告》,现在开始生成可运行的小程序第一版代码(优先保证结构与样式还原、其次是交互完整度)。
|
||||
|
||||
硬性要求:
|
||||
A.输出完整目录树(含 pages、components、styles、assets)
|
||||
B.按文件逐个输出内容:.wxml / .wxss / .js / .json
|
||||
C.样式组织:公共样式抽到 styles/common.wxss(或等价方案),页面只放页面差异
|
||||
D.尽量避免依赖第三方库;如必须引入,必须说明理由、替代方案、以及如何降低包体影响
|
||||
E.对所有“Web里有但小程序不完全支持”的效果:必须写清替代实现与预期差异
|
||||
|
||||
我将提供:
|
||||
- <PAGE_NAME_1> 的HTML:<<<...>>>
|
||||
- 全量CSS:<<<...>>>
|
||||
- 可选:截图/设计稿要点:<<<...>>>
|
||||
|
||||
请输出:
|
||||
I. 目录树
|
||||
II. 逐文件代码(从 app.json/app.wxss/app.js 开始)
|
||||
III. 编译与运行说明(微信开发者工具中需注意的设置项)
|
||||
IV. 第一版“已知差异清单”(按严重度排序:P0/P1/P2)
|
||||
|
||||
3) 高保真样式“对齐增强”专用提示词(第二轮开始用)
|
||||
现在进入“高保真样式对齐”阶段。目标:在不破坏现有结构的前提下,把视觉细节尽量贴近 Web/设计稿。
|
||||
|
||||
我会提供:
|
||||
- 当前小程序代码片段(相关 wxml/wxss)
|
||||
- Web 参考(对应HTML/CSS片段)或截图差异描述
|
||||
- 具体差异点列表(例如:间距、对齐、字体、行高、圆角、阴影、图片裁切、列表间距、按钮高度等)
|
||||
|
||||
你的任务:
|
||||
A. 对每个差异点,给出:根因判断(布局/单位/行高/盒模型/层叠/渲染差异)+ 最小修改方案 + 修改后风险
|
||||
B. 输出“补丁式修改”:只给出需要改动的文件与改动段落(像 git diff 一样),不要整文件重贴
|
||||
C. 给出验收步骤:在微信开发者工具与真机预览分别怎么验证
|
||||
|
||||
约束:
|
||||
- 优先使用 flex/盒模型的确定性方案,尽量减少依赖“碰运气”的魔法数
|
||||
- px→rpx 的换算要统一,严禁同一类间距一会儿 rpx 一会儿 px
|
||||
4) 结构还原与组件化质量提升(防止“越改越乱”)
|
||||
请对当前小程序实现做一次“结构与可维护性重构”,目标是在不改变 UI 效果的情况下,提高组件化与样式可控性,从而提升后续对齐效率。
|
||||
|
||||
输出必须包含:
|
||||
A.现状问题清单(重复样式、选择器过深、耦合、命名不一致、难以复用的结构)
|
||||
B.组件拆分方案(组件名、props、slot策略、事件、数据流)
|
||||
C.样式治理方案(BEM/命名规范、公共变量、间距/字号/圆角/阴影的设计token化)
|
||||
D.重构后的目录树
|
||||
E.逐文件补丁(diff风格),并说明每个改动为何不影响UI
|
||||
|
||||
约束:不引第三方UI库;不改变页面路由与业务数据接口(若有)。
|
||||
|
||||
|
||||
5) 细致对比与验收清单(你要的“对比更细致”)
|
||||
请为本次迁移输出《像素级对比与验收清单》,用于我逐项对照 Web 版本与小程序版本。
|
||||
|
||||
要求:
|
||||
- 按页面输出(每页一个小节)
|
||||
- 每页包含:布局结构、间距系统、字体系统、颜色、边框/圆角、阴影、图片裁切、交互态(hover/active/disabled等在小程序里等价态)、滚动与吸顶、列表与空状态
|
||||
- 每条验收项必须可操作:说明“怎么看”“合格标准是什么”“常见失败表现是什么”
|
||||
- 额外输出《差异追踪表》:字段包括【差异描述|截图/位置|严重度P0/P1/P2|可能原因|建议修复方案|修复代价|回归点】
|
||||
|
||||
输出用表格为主,保证我可以直接复制到文档里当验收用例。
|
||||
|
||||
|
||||
6) 编译报错/真机差异快速修复(把 Opus 当“修复代理”)
|
||||
你是微信小程序调试与兼容性专家。我会贴出:
|
||||
- 微信开发者工具编译报错/警告日志
|
||||
- 真机预览与模拟器表现差异描述
|
||||
- 相关代码片段
|
||||
|
||||
请你:
|
||||
A.先定位根因(按概率排序列出 1~3 个最可能原因)
|
||||
B.给出最小修复补丁(diff风格)
|
||||
C.给出回归测试点(防止修A坏B)
|
||||
D.如果是基础库兼容问题,说明需要的最低基础库版本或降级方案
|
||||
|
||||
@@ -1,560 +0,0 @@
|
||||
# H5 → 微信小程序迁移实施指南
|
||||
|
||||
> 本文档是"规则化迁移 + AI 辅助 + 视觉验收"的完整实施方案。
|
||||
> 适用于将 `docs/h5_ui/pages/*.html`(Tailwind CSS + 原生 JS)迁移为原生微信小程序页面。
|
||||
> 试点页面:`notes`(备注记录)— 中低复杂度,验证流程可行性。
|
||||
|
||||
---
|
||||
|
||||
## 一、核心原则
|
||||
|
||||
**这是迁移工程,不是生成工程。**
|
||||
|
||||
- 规则先行:标签映射、样式换算、事件转换有明确规则表,AI 按规则执行
|
||||
- 原型忠实:H5 源码 + computed-styles + 截图是唯一视觉真相,不凭想象
|
||||
- 组件化承接:TDesign 组件优先,自定义组件按 design.md 接口定义
|
||||
- 视觉回归兜底:每个页面转换后必须与 H5 截图逐项对比
|
||||
- 增量验证:一个页面走完全流程再推进下一个
|
||||
|
||||
---
|
||||
|
||||
## 二、迁移流水线(6 步)
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Step 1 │ │ Step 2 │ │ Step 3 │
|
||||
│ 输入物冻结 │───▶│ 迁移审计 │───▶│ 规则化转换 │
|
||||
│ (HTML/CSS/ │ │ (风险点+ │ │ (标签/样式/ │
|
||||
│ 截图/交互) │ │ 替代方案) │ │ 事件/路由) │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
│
|
||||
┌─────────────┐ ┌─────────────┐ ▼
|
||||
│ Step 6 │ │ Step 5 │ ┌─────────────┐
|
||||
│ 验收签收 │◀───│ 差异修复 │◀───│ Step 4 │
|
||||
│ (通过/打回) │ │ (截图diff+ │ │ 编译验证 │
|
||||
│ │ │ 定点修复) │ │ (开发者工具) │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、Step 1:输入物冻结
|
||||
|
||||
每个页面转换前,AI 必须加载以下材料(缺一不可):
|
||||
|
||||
| 序号 | 材料 | 路径 | 作用 |
|
||||
|------|------|------|------|
|
||||
| 1 | H5 源码 | `docs/h5_ui/pages/<page>.html` | 结构与样式的唯一真相 |
|
||||
| 2 | 自定义 CSS | `docs/h5_ui/css/<page>.css`(如有) | 非 Tailwind 的自定义样式 |
|
||||
| 3 | 交互说明 | `docs/h5_ui/interactions/<page>.md` | 状态变量、操作响应、页面状态枚举 |
|
||||
| 4 | 设计 Token | `docs/h5_ui/design-tokens.json` | 颜色、间距、圆角、字号、阴影 |
|
||||
| 5 | 图标映射表 | `docs/h5_ui/icon-mapping.md` | 图标处理方案(TDesign/自定义/Emoji) |
|
||||
| 6 | 计算样式 | `docs/h5_ui/computed-styles.json` 中对应 key | 精确的 px 数值(如有) |
|
||||
| 7 | 截图 | `docs/h5_ui/screenshots/<page>--*.png` | 视觉校验基线 |
|
||||
| 8 | 转换规范 | `.kiro/steering/miniprogram-h5-conversion.md` | 强制规则 |
|
||||
| 9 | 避坑指南 | `apps/miniprogram/doc/h5-to-miniprogram-pitfalls.md` | 22 个高频坑点 |
|
||||
|
||||
### 加载顺序
|
||||
|
||||
```
|
||||
1. 转换规范 + 避坑指南(规则层)
|
||||
2. 设计 Token + 图标映射(全局资源层)
|
||||
3. H5 源码 + 自定义 CSS + 计算样式(页面源码层)
|
||||
4. 交互说明(行为层)
|
||||
5. 截图(视觉校验层)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、Step 2:迁移审计
|
||||
|
||||
读取 H5 源码后,先输出《迁移审计报告》,不写代码。
|
||||
|
||||
### 审计清单
|
||||
|
||||
| 审计项 | 输出内容 |
|
||||
|--------|---------|
|
||||
| A. 页面结构 | 主要区域划分(header/list/card/footer),组件化边界建议 |
|
||||
| B. CSS 风险点 | 不支持的 CSS 特性清单 + 替代方案(伪元素、backdrop-filter、clip-path 等) |
|
||||
| C. Tailwind 展开 | 关键元素的 Tailwind 类 → WXSS 属性映射(取 computed-styles 精确值) |
|
||||
| D. SVG/图标 | 内联 SVG 清单 + 处理方式(TDesign/导出图片/Emoji) |
|
||||
| E. JS 交互 | DOM 操作 → setData 映射表 |
|
||||
| F. 外部依赖 | CDN 资源(Tailwind/Google Fonts)的本地化方案 |
|
||||
| G. 缺失信息 | 需要用户补充的材料清单 |
|
||||
|
||||
### 审计报告格式
|
||||
|
||||
```markdown
|
||||
## <page-name> 迁移审计报告
|
||||
|
||||
### A. 页面结构
|
||||
- 顶部导航栏(sticky,含返回按钮 + 标题)
|
||||
- Tab 切换区(客户备注 / 助教备注)
|
||||
- 备注列表(wx:for 渲染,每条含文本 + 标签 + 时间)
|
||||
|
||||
### B. CSS 风险点
|
||||
| 风险点 | 原因 | 影响 | 替代方案 | 验收方式 |
|
||||
|--------|------|------|---------|---------|
|
||||
| ... | ... | ... | ... | ... |
|
||||
|
||||
### C. 关键样式映射
|
||||
| 元素 | Tailwind 类 | computed 值 | WXSS |
|
||||
|------|------------|-------------|------|
|
||||
| 备注卡片 | bg-white rounded-2xl p-4 shadow-sm | ... | ... |
|
||||
|
||||
### D. 图标处理
|
||||
| 图标 | H5 实现 | 小程序方案 |
|
||||
|------|---------|-----------|
|
||||
| 返回箭头 | 内联 SVG | <t-icon name="chevron-left" /> |
|
||||
|
||||
### E. 交互映射
|
||||
| H5 操作 | DOM 实现 | 小程序实现 |
|
||||
|---------|---------|-----------|
|
||||
| Tab 切换 | classList.toggle | setData({ activeTab }) + wx:if |
|
||||
|
||||
### F. 外部依赖
|
||||
- Tailwind CDN → 手写 WXSS
|
||||
- Google Fonts → 系统字体(-apple-system)
|
||||
|
||||
### G. 缺失信息
|
||||
- (列出需要用户补充的内容)
|
||||
```
|
||||
|
||||
用户确认审计报告后,才进入 Step 3。
|
||||
|
||||
---
|
||||
|
||||
## 五、Step 3:规则化转换
|
||||
|
||||
### 5.0 SVG 导出规则(强制,在转换前执行)
|
||||
|
||||
H5 原型中所有内联 `<svg>` 必须单独导出为 `.svg` 文件,小程序中通过 `<view>` + `<image>` 引用。
|
||||
|
||||
#### 规则
|
||||
|
||||
1. 扫描目标页面 H5 源码中的所有 `<svg>` 标签
|
||||
2. 每个 SVG 导出为独立文件,存放路径:`apps/miniprogram/miniprogram/assets/icons/<name>.svg`
|
||||
3. 命名规则:`icon-<用途>.svg`(如 `icon-wechat.svg`、`icon-clipboard.svg`);Logo 类用 `logo-<名称>.svg`
|
||||
4. 导出时保留原始 `viewBox`、`fill`、`path` 等属性,确保渲染一致
|
||||
5. 小程序中引用方式:`<image src="/assets/icons/<name>.svg" mode="aspectFit" />`,必须指定宽高
|
||||
6. 如果 TDesign 图标库有语义等价的图标(如返回箭头 → `chevron-left`),优先用 `<t-icon>`,不导出 SVG
|
||||
7. 审计报告的「D. 图标处理」栏必须列出每个 SVG 的处理决策(TDesign / 导出 SVG / Emoji)
|
||||
|
||||
#### 为什么不用 TDesign icon 属性
|
||||
|
||||
TDesign `<t-button icon="xxx">` 的 `icon` 属性只支持 TDesign 内置图标名。微信 logo 等第三方品牌图标不在 TDesign 图标库中,传入无效名称会导致图标不显示。因此品牌图标、复杂自定义图标一律导出 SVG 文件,用 `<image>` 引用。
|
||||
|
||||
#### 已导出清单
|
||||
|
||||
| 文件名 | 来源页面 | 说明 |
|
||||
|--------|---------|------|
|
||||
| `logo-billiard.svg` | login | 台球 Logo(已有) |
|
||||
| `icon-wechat.svg` | login | 微信品牌图标 |
|
||||
| `icon-clock-circle.svg` | reviewing | 时钟主图标(stroke 风格,TDesign 无等价) |
|
||||
| `icon-forbidden.svg` | no-permission | 禁止符号主图标(stroke 风格,TDesign 无等价) |
|
||||
|
||||
> 每次迁移新页面时,将新导出的 SVG 追加到此清单。
|
||||
|
||||
### 5.1 标签映射(硬性规则)
|
||||
|
||||
| HTML | WXML | 说明 |
|
||||
|------|------|------|
|
||||
| `<div>` | `<view>` | 容器 |
|
||||
| `<span>` / `<p>` | `<text>` | 文本必须用 `<text>` 包裹 |
|
||||
| `<a>` | `<navigator>` 或 `bindtap` + `wx.navigateTo` | |
|
||||
| `<img>` | `<image mode="">` | 必须指定 mode 和宽高 |
|
||||
| `<svg>` 内联 | `<image src="xx.svg">` 或 `<t-icon>` | 不支持内联 SVG |
|
||||
| `<ul>/<li>` | `<view wx:for>` | 无列表语义标签 |
|
||||
| `<button>` | `<t-button>` | TDesign 优先 |
|
||||
| `<input>` | `<t-input>` | TDesign 优先 |
|
||||
| `<select>` | `<t-picker>` | 完全不同的交互 |
|
||||
| `<h1>`~`<h6>` | `<text>` + 样式类 | 无语义标题标签 |
|
||||
|
||||
**严禁在 WXML 中使用 HTML 标签。**
|
||||
|
||||
### 5.2 样式转换规则
|
||||
|
||||
#### 一屏页面布局模式(强制)
|
||||
|
||||
对于 H5 原型中一屏显示完毕、不需要滚动的页面(如 login、reviewing、no-permission 等),必须使用以下布局模式:
|
||||
|
||||
```css
|
||||
.page {
|
||||
height: 100vh; /* 固定一屏高度,不用 min-height */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box; /* padding-top 从 100vh 中扣除 */
|
||||
overflow: hidden; /* 防止内容溢出产生滚动 */
|
||||
/* padding-top 由 JS statusBarHeight 动态设置 */
|
||||
}
|
||||
|
||||
.hero { flex: 1; } /* 主内容区域占满剩余空间,内部垂直居中 */
|
||||
.bottom-area { /* 固定高度,不参与 flex 伸缩 */ }
|
||||
```
|
||||
|
||||
关键点:
|
||||
- `height: 100vh` + `box-sizing: border-box` → padding-top(状态栏)从总高度中扣除,不会导致底部溢出
|
||||
- 主内容区域用 `flex: 1` 自适应剩余空间,内部用 `justify-content: center` 垂直居中
|
||||
- 底部操作区固定高度,不设 `flex`
|
||||
- 如果页面内容可能超过一屏(如 apply 的表单页),改用 `min-height: 100vh` + 允许滚动
|
||||
|
||||
#### 状态栏适配(强制)
|
||||
|
||||
所有 `navigationStyle: "custom"` 的页面,必须:
|
||||
1. TS 中 `onLoad` 获取 `wx.getSystemInfoSync().statusBarHeight`
|
||||
2. WXML 中 `.page` 加 `style="padding-top: {{statusBarHeight}}px;"`
|
||||
3. WXSS 中 `.page` 加 `box-sizing: border-box;`(确保 padding 不增加总高度)
|
||||
4. 禁止使用 `env(safe-area-inset-top)`(部分机型不生效)
|
||||
|
||||
#### rpx 换算
|
||||
```
|
||||
H5 px 值 × 2 = rpx 值(基于 375px → 750rpx)
|
||||
Tailwind spacing: 1 unit = 4px = 8rpx
|
||||
```
|
||||
|
||||
#### Tailwind → WXSS 速查表
|
||||
|
||||
| Tailwind | WXSS |
|
||||
|----------|------|
|
||||
| `p-4` | `padding: 32rpx;` |
|
||||
| `px-4` | `padding-left: 32rpx; padding-right: 32rpx;` |
|
||||
| `m-3` | `margin: 24rpx;` |
|
||||
| `gap-3` | `gap: 24rpx;` |
|
||||
| `space-y-3` | 子元素 `margin-top: 24rpx;`(首个除外) |
|
||||
| `rounded-2xl` | `border-radius: 32rpx;` |
|
||||
| `text-sm` | `font-size: 28rpx;` |
|
||||
| `text-base` | `font-size: 32rpx;` |
|
||||
| `text-xs` | `font-size: 24rpx;` |
|
||||
| `font-medium` | `font-weight: 500;` |
|
||||
| `font-semibold` | `font-weight: 600;` |
|
||||
| `leading-relaxed` | `line-height: 1.625;` |
|
||||
| `shadow-sm` | `box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);` |
|
||||
| `flex` | `display: flex;` |
|
||||
| `flex-col` | `flex-direction: column;` |
|
||||
| `items-center` | `align-items: center;` |
|
||||
| `justify-between` | `justify-content: space-between;` |
|
||||
| `flex-1` | `flex: 1;` |
|
||||
| `min-h-screen` | `min-height: 100vh;` |
|
||||
| `sticky top-0` | `position: sticky; top: 0;` |
|
||||
| `z-10` | `z-index: 10;` |
|
||||
| `bg-white` | `background-color: #ffffff;` |
|
||||
| `bg-gray-1` | `background-color: #f3f3f3;` |
|
||||
| `text-gray-13` | `color: #242424;` |
|
||||
| `text-gray-6` | `color: #a6a6a6;` |
|
||||
| `border-b border-gray-2` | `border-bottom: 2rpx solid #eeeeee;` |
|
||||
| `backdrop-blur-sm` | ❌ 不支持,改为 `rgba()` 半透明 |
|
||||
|
||||
#### 颜色值参照 design-tokens.json
|
||||
|
||||
```
|
||||
primary: #0052d9 primary-light: #ecf2fe
|
||||
success: #00a870 warning: #ed7b2f error: #e34d59
|
||||
gray-1 ~ gray-13: 见 design-tokens.json
|
||||
```
|
||||
|
||||
#### 不支持的 CSS 替代方案
|
||||
|
||||
| CSS 特性 | 替代方案 |
|
||||
|----------|---------|
|
||||
| `backdrop-filter: blur()` | `background: rgba(255,255,255,0.95);` |
|
||||
| `*` 通配符选择器 | 逐个元素设置 |
|
||||
| `::before` / `::after` | 额外 `<view>` 元素模拟(小程序部分支持伪元素,复杂场景用 DOM) |
|
||||
| 远程 `@font-face` | `wx.loadFontFace()` 或系统字体 |
|
||||
| `clip-path` | 改为图片或 CSS 渐变近似 |
|
||||
| `blur-xl`(Tailwind) | 去掉或改为纯色 |
|
||||
|
||||
### 5.3 事件转换规则
|
||||
|
||||
| H5 | 小程序 | 说明 |
|
||||
|----|--------|------|
|
||||
| `onclick="fn()"` | `bindtap="fn"` | 不能传参 |
|
||||
| `onclick="fn(id)"` | `data-id="{{id}}" bindtap="fn"` | dataset 传参 |
|
||||
| `addEventListener` | 不支持 | 只能声明式绑定 |
|
||||
| `event.target.value` | `e.detail.value` | 取值路径不同 |
|
||||
| `event.preventDefault()` | `catchtap` | catch 前缀阻止冒泡 |
|
||||
| `classList.toggle('active')` | `setData({ active: !this.data.active })` + `class="{{active ? 'on' : ''}}"` | |
|
||||
| `innerHTML = '...'` | `setData({ content: '...' })` + WXML 数据绑定 | |
|
||||
| `history.back()` | `wx.navigateBack()` | |
|
||||
| `window.location.href` | `wx.navigateTo({ url: '...' })` | |
|
||||
| `localStorage.setItem` | `wx.setStorageSync` | |
|
||||
| `alert()` / `confirm()` | `wx.showToast()` / `wx.showModal()` | |
|
||||
|
||||
### 5.4 路由转换规则
|
||||
|
||||
| 场景 | 小程序 API | 说明 |
|
||||
|------|-----------|------|
|
||||
| 普通页面跳转 | `wx.navigateTo` | 保留当前页,页面栈 +1 |
|
||||
| TabBar 页面跳转 | `wx.switchTab` | 必须用 switchTab,navigateTo 会报错 |
|
||||
| 替换当前页 | `wx.redirectTo` | 关闭当前页 |
|
||||
| 清空页面栈 | `wx.reLaunch` | 登录/登出场景 |
|
||||
| 返回上一页 | `wx.navigateBack` | 页面栈 -1 |
|
||||
|
||||
**TabBar 页面:task-list、board-finance、my-profile**
|
||||
|
||||
### 5.5 转换执行顺序
|
||||
|
||||
```
|
||||
1. 创建页面 4 文件骨架(.wxml / .wxss / .ts / .json)
|
||||
2. 在 .json 中注册 usingComponents(TDesign 组件 + 自定义组件)
|
||||
3. 转换 WXML 结构(HTML 标签 → WXML 标签,保持层级一致)
|
||||
4. 转换 WXSS 样式(Tailwind → 手写 WXSS,取 computed-styles 精确值)
|
||||
5. 转换 TS 逻辑(DOM 操作 → setData,事件绑定 → bindtap)
|
||||
6. 设置 Mock 数据(贴近真实 API 格式,标记 TODO)
|
||||
7. 处理三态(loading / empty / normal / error)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、Step 4:编译验证
|
||||
|
||||
在微信开发者工具中检查:
|
||||
|
||||
| 检查项 | 合格标准 |
|
||||
|--------|---------|
|
||||
| WXML 编译 | 无编译错误(特别注意 `.toFixed()` 等 JS 方法不能在 WXML 中使用) |
|
||||
| WXSS 编译 | 无警告(检查不支持的选择器) |
|
||||
| 控制台 | 无 JS 运行时错误 |
|
||||
| 图片加载 | 无 404/500 错误(所有 `/assets/` 引用的文件必须存在) |
|
||||
| 组件注册 | 无 "component not found" 警告 |
|
||||
| 路由跳转 | 无 "navigateTo:fail" 错误 |
|
||||
|
||||
---
|
||||
|
||||
## 七、Step 5:差异修复
|
||||
|
||||
### 7.1 截图对比
|
||||
|
||||
用户在微信开发者工具中截图,与 `docs/h5_ui/screenshots/<page>--*.png` 逐项对比。
|
||||
|
||||
### 7.2 差异追踪表
|
||||
|
||||
| 差异描述 | 位置 | 严重度 | 可能原因 | 修复方案 | 修复代价 |
|
||||
|---------|------|--------|---------|---------|---------|
|
||||
| 卡片圆角偏小 | 备注卡片 | P1 | rpx 换算错误 | 改为 32rpx | 低 |
|
||||
| 标签颜色偏差 | tag-coach | P1 | 颜色值不准确 | 取 computed-styles 精确值 | 低 |
|
||||
| 间距不一致 | 列表间距 | P0 | space-y-3 未正确转换 | 改为 margin-top: 24rpx | 低 |
|
||||
|
||||
### 7.3 修复原则
|
||||
|
||||
- 只输出需要改动的文件和改动段落(diff 风格),不整文件重贴
|
||||
- 优先使用 flex/盒模型的确定性方案,不用"碰运气"的魔法数
|
||||
- rpx 换算统一,严禁同一类间距混用 rpx 和 px
|
||||
- 每次修复后重新编译验证,防止修 A 坏 B
|
||||
|
||||
---
|
||||
|
||||
## 八、Step 6:验收签收
|
||||
|
||||
### 逐项验收清单
|
||||
|
||||
| 验收项 | 怎么看 | 合格标准 | 常见失败表现 |
|
||||
|--------|--------|---------|-------------|
|
||||
| 布局结构 | 对比截图整体布局 | 区域划分、层级关系一致 | 元素错位、层级混乱 |
|
||||
| 间距系统 | 对比元素间距 | 与 H5 截图一致(±4rpx 容差) | 间距过大/过小 |
|
||||
| 字体系统 | 对比字号、字重、行高 | 与 design-tokens 一致 | 字号偏差、行高不对 |
|
||||
| 颜色 | 对比背景色、文字色、边框色 | 与 design-tokens 一致 | 颜色偏差 |
|
||||
| 圆角 | 对比卡片、按钮圆角 | 与 design-tokens 一致 | 圆角过大/过小 |
|
||||
| 阴影 | 对比卡片阴影 | 有阴影且不突兀 | 无阴影或阴影过重 |
|
||||
| 图标 | 对比图标位置、大小、颜色 | TDesign 图标正确显示 | 图标缺失或错位 |
|
||||
| 交互完整性 | 按交互说明逐项操作 | 所有操作有正确响应 | 点击无反应、状态不切换 |
|
||||
| 三态处理 | 切换 loading/empty/error | 三种状态均有对应 UI | 缺少空状态或加载态 |
|
||||
| 安全区 | 刘海屏设备检查 | 内容不被刘海遮挡 | 顶部内容被裁切 |
|
||||
|
||||
---
|
||||
|
||||
## 九、试点页面:notes(备注记录)
|
||||
|
||||
### 选择理由
|
||||
- 中低复杂度:简单列表 + Tab 切换 + 标签样式
|
||||
- 覆盖核心转换场景:Tailwind → WXSS、事件绑定、列表渲染、三态处理
|
||||
- 有完整材料:HTML + CSS + 交互说明 + 截图
|
||||
- 验证周期短:预计 1 轮即可完成
|
||||
|
||||
### 需要加载的材料
|
||||
```
|
||||
docs/h5_ui/pages/notes.html
|
||||
docs/h5_ui/css/notes.css
|
||||
docs/h5_ui/interactions/notes.md
|
||||
docs/h5_ui/design-tokens.json
|
||||
docs/h5_ui/icon-mapping.md
|
||||
docs/h5_ui/screenshots/notes--*.png
|
||||
docs/h5_ui/computed-styles.json(notes key,如有)
|
||||
```
|
||||
|
||||
### 预期产出
|
||||
```
|
||||
miniprogram/pages/notes/notes.wxml
|
||||
miniprogram/pages/notes/notes.wxss
|
||||
miniprogram/pages/notes/notes.ts
|
||||
miniprogram/pages/notes/notes.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、页面迁移优先级
|
||||
|
||||
按模块分组,每组内按复杂度从低到高排列:
|
||||
|
||||
| 批次 | 页面 | 复杂度 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| 试点 | notes | 低 | 验证流程 |
|
||||
| 第 1 批 | login, apply, reviewing, no-permission | 低 | 认证流程(已有实现,需重写) |
|
||||
| 第 2 批 | task-list, task-detail | 中-高 | 任务模块核心 |
|
||||
| 第 3 批 | task-detail-callback, task-detail-priority, task-detail-relationship | 中 | 任务详情变体 |
|
||||
| 第 4 批 | performance, performance-records | 中 | 绩效模块 |
|
||||
| 第 5 批 | board-finance, board-customer, board-coach | 高 | 看板模块(筛选+吸顶+复杂布局) |
|
||||
| 第 6 批 | customer-detail, customer-service-records, coach-detail | 中 | 详情模块 |
|
||||
| 第 7 批 | chat, chat-history | 中 | 对话模块 |
|
||||
| 第 8 批 | my-profile | 中 | 个人中心 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、需要用户补充的内容
|
||||
|
||||
### P0(阻塞试点)
|
||||
|
||||
| 材料 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| notes 页面截图 | ⚠️ 待确认 | 确认 `docs/h5_ui/screenshots/notes--*.png` 是否存在 |
|
||||
| notes 的 computed-styles | ⚠️ 待确认 | 确认 `computed-styles.json` 中是否有 notes key |
|
||||
|
||||
### P1(提升还原度)
|
||||
|
||||
| 材料 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| Banner 背景图片 | ❌ 缺失 | icon-mapping.md 规划了用 Playwright 截取 Banner,但实际图片未导出 |
|
||||
| AI 图标图片 | ❌ 缺失 | icon-ai-float.png / icon-ai-inline.png / icon-ai-badge.png 需要从 H5 截取 |
|
||||
| 其他页面的 computed-styles | ⚠️ 部分缺失 | 当前仅 5 个页面有数据,其余 19 个缺失 |
|
||||
|
||||
### P2(后续批次需要)
|
||||
|
||||
| 材料 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| home-settings 交互说明 | ❌ 缺失 | |
|
||||
| ai-icon-demo 交互说明 | ❌ 缺失 | |
|
||||
|
||||
---
|
||||
|
||||
## 十二、与现有文档的关系
|
||||
|
||||
| 文档 | 职责 | 本指南的关系 |
|
||||
|------|------|-------------|
|
||||
| `miniprogram-h5-conversion.md`(steering) | 强制转换规则 | 本指南引用其规则,不重复 |
|
||||
| `h5-to-miniprogram-pitfalls.md` | 避坑清单 | 本指南引用其坑点,不重复 |
|
||||
| `h5-input-material-guide.md` | 输入材料准备规范 | 本指南引用其格式要求 |
|
||||
| `howtodo.md` | 6 阶段工作流提示词 | 本指南是其具体化实施版本 |
|
||||
| `design.md`(spec) | 组件接口 + 数据模型 | 本指南引用其组件定义 |
|
||||
|
||||
---
|
||||
|
||||
## 十三、实战踩坑记录
|
||||
|
||||
> 本节记录迁移过程中实际遇到的坑和解决方案,按发现时间倒序排列。每次迁移新页面遇到新坑时追加。
|
||||
|
||||
### P1:WXML 中不能调用 JS 方法
|
||||
|
||||
- 触发页面:所有页面
|
||||
- 现象:`{{price.toFixed(2)}}` 编译报错
|
||||
- 原因:WXML 模板表达式不支持 JS 方法调用(`.toFixed()`、`.map()`、`.filter()` 等)
|
||||
- 解决:创建 WXS 模块 `utils/format.wxs`,在 WXML 中 `<wxs src="..." module="fmt" />`,用 `{{fmt.toFixed(price, 2)}}`
|
||||
|
||||
### P2:TabBar 页面不能用 navigateTo
|
||||
|
||||
- 触发页面:task-list、board-finance、my-profile
|
||||
- 现象:`navigateTo:fail` 静默失败
|
||||
- 原因:TabBar 页面必须用 `wx.switchTab`,`navigateTo` / `redirectTo` 均无效
|
||||
- 解决:跳转前判断目标是否 TabBar 页面,是则用 `switchTab`
|
||||
|
||||
### P3:图片 500 错误(资源文件不存在)
|
||||
|
||||
- 触发页面:所有引用 `/assets/images/*.png` 和 `/assets/icons/icon-ai-*.png` 的页面
|
||||
- 现象:控制台大量 500 错误
|
||||
- 原因:代码引用了不存在的图片文件
|
||||
- 解决:用 CSS 渐变、emoji 文本、`<t-icon>` 替代所有不存在的图片引用;仅保留确实存在的文件(如 `logo-billiard.svg`)
|
||||
|
||||
### P4:env(safe-area-inset-top) 部分机型不生效
|
||||
|
||||
- 触发页面:notes、login(所有 `navigationStyle: "custom"` 的页面)
|
||||
- 现象:iPhone 刘海屏顶部内容被状态栏遮挡,部分安卓机型也有此问题
|
||||
- 原因:`env(safe-area-inset-top)` 在部分机型/基础库版本下返回 0
|
||||
- 解决:TS 中 `onLoad` 获取 `wx.getSystemInfoSync().statusBarHeight`,WXML 中动态设置 `style="padding-top: {{statusBarHeight}}px;"`
|
||||
- 标准模式:见 5.2 节「状态栏适配」
|
||||
|
||||
### P5:statusBarHeight padding 导致一屏页面底部溢出
|
||||
|
||||
- 触发页面:login(所有一屏不滚动的页面)
|
||||
- 现象:底部按钮和协议文字被推到屏幕外
|
||||
- 原因:`min-height: 100vh` + `padding-top: Xpx` = 实际高度 > 100vh
|
||||
- 解决:改为 `height: 100vh` + `box-sizing: border-box`,padding 从总高度中扣除
|
||||
- 标准模式:见 5.2 节「一屏页面布局模式」
|
||||
|
||||
### P6:TDesign Button icon 属性不支持自定义图标
|
||||
|
||||
- 触发页面:login
|
||||
- 现象:`<t-button icon="logo-wechat">` 微信图标不显示
|
||||
- 原因:TDesign `icon` 属性只接受内置图标名,微信 logo 不在内置库中
|
||||
- 解决:放弃 `t-button`,改为原生 `<view>` + `<image src="/assets/icons/icon-wechat.svg">` 手动组合按钮
|
||||
- 规则:品牌图标、复杂自定义图标一律导出 SVG 文件,用 `<image>` 引用(见 5.0 节)
|
||||
|
||||
### P7:TDesign Button 默认样式覆盖自定义 WXSS
|
||||
|
||||
- 触发页面:login
|
||||
- 现象:按钮圆角过大(接近胶囊形)、禁用态颜色不是预期的 `#dcdcdc`
|
||||
- 原因:TDesign Button 内部样式优先级高于外部 `t-class` 覆盖,`!important` 也不一定生效
|
||||
- 解决:对于需要高度定制的按钮,直接用原生 `<view>` 实现,完全绕开 TDesign 样式干扰
|
||||
- 原则:TDesign 组件适合"接近默认样式"的场景;与原型差异大时,原生实现更可控
|
||||
|
||||
### P8:小程序 WXSS 不支持 `::before` / `::after` 伪元素
|
||||
|
||||
- 触发页面:reviewing
|
||||
- 现象:尝试用 `::before` 添加背景图案层,编译无报错但不渲染
|
||||
- 原因:微信小程序 WXSS 不支持 CSS 伪元素
|
||||
- 解决:用实际的 `<view class="bg-pattern">` 替代伪元素,absolute 定位覆盖全屏
|
||||
- 规则:所有需要伪元素的场景(装饰层、分隔线、角标等),一律用 WXML `<view>` 实现
|
||||
|
||||
### P9:小程序 WXSS 不支持 `url("data:image/svg+xml,...")` 内联 SVG
|
||||
|
||||
- 触发页面:reviewing
|
||||
- 现象:H5 用 `background-image: url("data:image/svg+xml,...")` 实现十字纹背景,小程序中不渲染
|
||||
- 原因:小程序 WXSS 的 `background-image` 不支持 data URI(仅支持网络图片和 base64 图片)
|
||||
- 解决:用 `repeating-linear-gradient` 组合模拟纹理图案;或用实际图片文件
|
||||
- 规则:H5 中的 SVG 背景图案,迁移时优先用 CSS 渐变模拟;效果不佳时导出为 PNG/base64
|
||||
|
||||
### P10:小程序不支持 `filter: blur()` CSS 属性
|
||||
|
||||
- 触发页面:reviewing
|
||||
- 现象:H5 用 `blur-xl`(Tailwind)实现图标背景光晕的模糊效果,小程序中无效
|
||||
- 原因:微信小程序 WXSS 不支持 `filter` 属性
|
||||
- 解决:用更大尺寸的 `radial-gradient` 模拟模糊扩散效果(扩大元素尺寸 + 调整渐变衰减曲线)
|
||||
- 参数参考:原始 256rpx → 扩大到 320rpx,gradient 从 `0.18 → 0.06 → transparent` 三段衰减
|
||||
|
||||
### P11:Tailwind `max-w-sm` 在小程序中的换算需考虑容器 padding
|
||||
|
||||
- 触发页面:reviewing
|
||||
- 现象:进度卡片 `max-w-sm = 384px = 768rpx` 直接换算后卡片过宽
|
||||
- 原因:H5 中 `max-w-sm` 受外层 `px-8`(32px padding)约束,实际可用宽度 ≈ 320px;小程序中 content 区域的 padding 和 max-width 叠加效果不同
|
||||
- 解决:实测调整 `max-width` 值(本例最终为 550rpx),不能机械换算
|
||||
- 规则:涉及 `max-w-*` 的元素,迁移后必须在真机/模拟器中目测确认宽度,按实际效果微调
|
||||
|
||||
### P12:H5 Tailwind 颜色变体需逐一核对(如 `bg-amber-300` ≠ `bg-warning`)
|
||||
|
||||
- 触发页面:reviewing
|
||||
- 现象:装饰点 dot-2 在 H5 中用 `bg-amber-300`(#fcd34d),迁移时误用了 `bg-warning`(#ed7b2f)
|
||||
- 原因:Tailwind 的 amber/orange/yellow 色系有多个变体,不能一律映射为 design-tokens 中的 `warning`
|
||||
- 解决:逐个检查 H5 中的颜色类名,查 Tailwind 色板取精确 hex 值
|
||||
- 规则:迁移时遇到非 design-tokens 定义的 Tailwind 颜色,必须查 Tailwind 官方色板确认精确值
|
||||
|
||||
---
|
||||
|
||||
## 附录:相关文档索引
|
||||
|
||||
- 转换规范:`.kiro/steering/miniprogram-h5-conversion.md`
|
||||
- 避坑指南:`apps/miniprogram/doc/h5-to-miniprogram-pitfalls.md`
|
||||
- 输入材料指南:`apps/miniprogram/doc/h5-input-material-guide.md`
|
||||
- 工作流提示词:`apps/miniprogram/doc/howtodo.md`
|
||||
- 设计文档:`.kiro/specs/p52-miniapp-fe-all-pages/design.md`
|
||||
- 设计 Token:`docs/h5_ui/design-tokens.json`
|
||||
- 图标映射:`docs/h5_ui/icon-mapping.md`
|
||||
- H5 原型:`docs/h5_ui/pages/`
|
||||
- 交互说明:`docs/h5_ui/interactions/`
|
||||
- 截图:`docs/h5_ui/screenshots/`
|
||||
@@ -1,337 +0,0 @@
|
||||
# H5 → 微信小程序迁移:实战全路径方法论
|
||||
|
||||
> 基于 board-finance 等 7 个页面的实际迁移经验总结。
|
||||
> 本文档是 `migration-guide.md`(理论流水线)的实战补充,聚焦工具链和操作细节。
|
||||
> 更新日期:2026-03-08
|
||||
|
||||
---
|
||||
|
||||
## 一、全路径概览
|
||||
|
||||
```
|
||||
H5 原型 HTML/CSS
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Phase 1: 结构转换(wxml + ts + wxss 三件套) │
|
||||
│ 输入: H5 源码 + 交互说明 + design-tokens │
|
||||
│ 规则: 标签映射 + 87.5% 缩放 + 事件转换 │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Phase 2: 编译验证(getDiagnostics + IDE 检查) │
|
||||
│ 消除 TS 类型错误、wxss 语法警告 │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Phase 3: 像素级视觉对比(自动化工具链) │
|
||||
│ H5 截图 ←→ 小程序截图 → diff → 定点修复 → 循环 │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Phase 4: 交互态验证(3 种交互态截图对比) │
|
||||
│ filter-dropdown / tip-modal / toc-panel 等 │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Phase 5: 收尾(tracker 更新 + dev-tools 状态同步) │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、Phase 1: 结构转换
|
||||
|
||||
### 2.1 输入物
|
||||
|
||||
| 材料 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| H5 源码 | `docs/h5_ui/pages/<page>.html` | 结构与样式唯一真相 |
|
||||
| 交互说明 | `docs/h5_ui/interactions/<page>.md` | 状态变量、事件响应 |
|
||||
| Design Tokens | `docs/h5_ui/design-tokens.json` | 颜色、间距、字号 |
|
||||
| 共享组件规范 | `apps/miniprogram/doc/shared-component-specs.md` | filter-dropdown 等 |
|
||||
| 避坑文档 | `apps/miniprogram/doc/h5-to-miniprogram-pitfalls.md` | 22+ 个已知坑点 |
|
||||
|
||||
### 2.2 核心缩放公式
|
||||
|
||||
```
|
||||
最终 rpx = H5 px × 2 × 0.875
|
||||
```
|
||||
|
||||
- 结果取偶数(向最近偶数取整)
|
||||
- 来源:iPhone 15 Pro Max 430px 宽 → 小程序 750rpx 基准,87.5% = 750/430/2
|
||||
- 示例:`16px → 16 × 2 × 0.875 = 28rpx`,`12px → 12 × 2 × 0.875 = 21 → 22rpx`
|
||||
|
||||
### 2.3 标签映射速查
|
||||
|
||||
| H5 | 小程序 | 注意 |
|
||||
|----|--------|------|
|
||||
| `<div>` | `<view>` | |
|
||||
| `<span>` | `<text>` | 小程序 text 不支持嵌套 view |
|
||||
| `<img>` | `<image>` | 必须设 mode,默认 scaleToFill |
|
||||
| `<a>` | `<navigator>` 或 `bindtap` | 小程序无超链接 |
|
||||
| `<input>` | `<input>` 或 `<t-input>` | TDesign 优先 |
|
||||
| `<button>` | `<t-button>` | TDesign 优先 |
|
||||
| `<select>` | `<t-picker>` | 无原生 select |
|
||||
| `<svg>` | `<t-icon>` 或 `<image>` | 小程序不支持内联 SVG |
|
||||
| `<section>` | `<view>` | 语义标签统一用 view |
|
||||
| `scroll 容器` | `<scroll-view>` | 必须设固定高度 |
|
||||
|
||||
### 2.4 样式转换要点
|
||||
|
||||
- Tailwind class → 手写 wxss(小程序不支持 Tailwind)
|
||||
- `vh/vw` → `rpx` 或 `calc()`
|
||||
- `position: fixed` → 正常工作,但注意安全区 `env(safe-area-inset-bottom)`
|
||||
- `backdrop-filter` → 小程序不支持,用纯色半透明替代
|
||||
- `gap` → flexbox gap 在小程序基础库 2.30+ 支持,低版本用 margin
|
||||
- CSS 变量 `var(--xxx)` → 小程序不支持,直接写值
|
||||
- `::before/::after` → 支持,但不能用 `content: attr()`
|
||||
- 渐变 → `linear-gradient` 支持,`radial-gradient` 部分支持
|
||||
|
||||
### 2.5 事件转换
|
||||
|
||||
| H5 | 小程序 | 说明 |
|
||||
|----|--------|------|
|
||||
| `onclick` | `bindtap` | |
|
||||
| `onchange` | `bind:change` | TDesign 组件用 `bind:change` |
|
||||
| `oninput` | `bindinput` | |
|
||||
| `onscroll` | `bindscroll` | 在 scroll-view 上 |
|
||||
| `addEventListener` | 无 | 用 wxml 声明式绑定 |
|
||||
| `e.target.dataset` | `e.currentTarget.dataset` | 注意 currentTarget |
|
||||
| `e.preventDefault()` | `catchtap`(阻止冒泡) | |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 三、Phase 2: 编译验证
|
||||
|
||||
### 3.1 TypeScript 检查
|
||||
|
||||
```
|
||||
getDiagnostics → board-finance.ts
|
||||
```
|
||||
|
||||
- 确保 `Page<IData>()` 类型定义完整
|
||||
- data 中所有字段有初始值
|
||||
- 事件处理函数签名正确(`e: WechatMiniprogram.TouchEvent`)
|
||||
|
||||
### 3.2 常见 TS 问题
|
||||
|
||||
- TDesign 组件事件类型:用 `WechatMiniprogram.CustomEvent` 而非 `TouchEvent`
|
||||
- `wx:if` 条件中的变量必须在 data 中声明
|
||||
- `setData` 的 key 路径必须与 data 结构一致
|
||||
|
||||
### 3.3 wxss 检查
|
||||
|
||||
微信 IDE 会报 CSS 警告(非阻塞),常见:
|
||||
- 内联 style 中的空格(`style="width: 100%"` → `style="width:100%"`)
|
||||
- 不支持的属性(`backdrop-filter` 等)
|
||||
|
||||
---
|
||||
|
||||
## 四、Phase 3: 像素级视觉对比(核心工具链)
|
||||
|
||||
这是整个迁移流程中最关键的环节。我们建立了一套完整的自动化对比工具链。
|
||||
|
||||
### 4.1 前置准备
|
||||
|
||||
#### H5 截图(一次性)
|
||||
|
||||
1. 用户启动 Go Live(VS Code 插件),端口 5500
|
||||
2. Playwright MCP 导航到 `http://127.0.0.1:5500/docs/h5_ui/pages/<page>.html`
|
||||
3. 设置视口 `430×932`(iPhone 15 Pro Max)
|
||||
4. 全页面截图 → `docs/h5_ui/screenshots/<page>.png`(1290px 宽,DPR 3)
|
||||
5. 交互态截图(下拉、弹窗、面板等)→ `<page>--<state>.png`
|
||||
|
||||
#### 微信开发者工具连接
|
||||
|
||||
```bash
|
||||
# 用户手动启动自动化端口(或 AI 用 controlPwshProcess)
|
||||
& "C:\dev\WechatDevtools\cli.bat" auto --project "C:\NeoZQYY\apps\miniprogram" --auto-port 9420
|
||||
```
|
||||
|
||||
连接规范:
|
||||
- 只能用 `wsEndpoint` 策略,`ws://127.0.0.1:9420`
|
||||
- 禁止 auto/launch/connect/discover 策略
|
||||
- 导航到 tabbar 页面必须用 `relaunch`,路径前加 `/`
|
||||
|
||||
### 4.2 对比流程(每轮迭代)
|
||||
|
||||
```
|
||||
Step 1: 截图
|
||||
mcp_weixin_devtools_mcp_relaunch → /pages/<page>/<page>
|
||||
mcp_weixin_devtools_mcp_waitFor → 2000ms
|
||||
mcp_weixin_devtools_mcp_screenshot → mp-<page>.png
|
||||
|
||||
Step 2: 尺寸统一
|
||||
python scripts/ops/resize_and_compare_v2.py
|
||||
→ H5 保持 1290px 宽
|
||||
→ MP 截图 ×2 缩放到 1290px
|
||||
→ H5 裁剪到 MP 逻辑高度对应的物理高度
|
||||
→ 输出 cmp-h5.png + cmp-mp.png(同尺寸)
|
||||
|
||||
Step 3: 像素对比
|
||||
mcp_image_compare_compare_images
|
||||
→ image1: cmp-h5.png
|
||||
→ image2: cmp-mp.png
|
||||
→ threshold: 0.1
|
||||
→ 输出 diff 图 + 差异百分比
|
||||
|
||||
Step 4: 差异分析
|
||||
python scripts/ops/analyze_diff.py
|
||||
→ 按 150px 条带分析差异密度
|
||||
→ 定位差异最大的 5 个区域
|
||||
|
||||
Step 5: 精确位置对比
|
||||
mcp_weixin_devtools_mcp_get_page_snapshot(MP 元素位置)
|
||||
Playwright browser_evaluate(H5 元素位置)
|
||||
→ 逐元素对比 Y 坐标和高度
|
||||
|
||||
Step 6: wxss 微调
|
||||
根据位置差异调整 padding/margin/gap
|
||||
→ 回到 Step 1 循环
|
||||
```
|
||||
|
||||
### 4.3 DPR 换算关系
|
||||
|
||||
| 平台 | 物理宽度 | DPR | 逻辑宽度 |
|
||||
|------|---------|-----|---------|
|
||||
| H5 截图 | 1290px | 3 | 430px |
|
||||
| MP 截图 | 645px | 1.5 | 430px |
|
||||
|
||||
统一对比宽度:1290px(MP ×2 缩放)
|
||||
|
||||
高度对齐:
|
||||
- MP 逻辑高度 = MP 物理高度 / 1.5
|
||||
- H5 裁剪高度 = MP 逻辑高度 × 3
|
||||
|
||||
### 4.4 差异阈值参考
|
||||
|
||||
| 差异% | 评价 | 行动 |
|
||||
|-------|------|------|
|
||||
| < 5% | 优秀 | 字体渲染级差异,可接受 |
|
||||
| 5-10% | 良好 | 检查是否有结构性差异(底部内容不同) |
|
||||
| 10-15% | 需调整 | 定位差异区域,微调间距 |
|
||||
| > 15% | 较大 | 可能有布局错误,需逐元素排查 |
|
||||
|
||||
注意:底部区域(MP 只截一屏)的差异是结构性的,不算样式问题。评估时应关注前半屏的差异。
|
||||
|
||||
### 4.5 工具脚本
|
||||
|
||||
| 脚本 | 路径 | 功能 |
|
||||
|------|------|------|
|
||||
| resize_and_compare_v2.py | `scripts/ops/` | 统一尺寸 + 裁剪 |
|
||||
| analyze_diff.py | `scripts/ops/` | 按条带分析差异分布 |
|
||||
|
||||
### 4.6 实战数据(board-finance)
|
||||
|
||||
| 轮次 | 差异% | 主要调整 |
|
||||
|------|-------|---------|
|
||||
| 初始 | 15.29% | 首次截图对比 |
|
||||
| 第一轮 | 12.56% | 10 处间距微调(padding/margin/gap) |
|
||||
| 第二轮 | 9.04% | 2 处调整(header→sub-label 间距、divider 非对称 margin) |
|
||||
|
||||
前半屏(Y<450px)最终差异约 3.5%,达到字体渲染级别。
|
||||
|
||||
---
|
||||
|
||||
## 五、Phase 4: 交互态验证
|
||||
|
||||
### 5.1 常见交互态
|
||||
|
||||
| 交互态 | 触发方式 | 截图命名 |
|
||||
|--------|---------|---------|
|
||||
| 筛选下拉 | 点击 filter-dropdown | `<page>--filter-dropdown.png` |
|
||||
| 指标弹窗 | 点击 help-icon | `<page>--tip-modal.png` |
|
||||
| 目录导航 | 点击 toc-btn | `<page>--toc-panel.png` |
|
||||
|
||||
### 5.2 小程序交互态截图方法
|
||||
|
||||
```
|
||||
# 打开 toc-panel
|
||||
mcp_weixin_devtools_mcp_click → uid=view.toc-btn
|
||||
mcp_weixin_devtools_mcp_waitFor → 500ms
|
||||
mcp_weixin_devtools_mcp_screenshot → mp-<page>--toc-panel.png
|
||||
|
||||
# 关闭(通过 evaluate_script 或点击 overlay)
|
||||
mcp_weixin_devtools_mcp_evaluate_script → page.setData({ tocVisible: false })
|
||||
|
||||
# 打开 tip-modal
|
||||
mcp_weixin_devtools_mcp_click → uid=view.help-icon-light
|
||||
mcp_weixin_devtools_mcp_waitFor → 500ms
|
||||
mcp_weixin_devtools_mcp_screenshot → mp-<page>--tip-modal.png
|
||||
```
|
||||
|
||||
### 5.3 交互态对比
|
||||
|
||||
交互态的对比更多是视觉检查(弹窗位置、遮罩透明度、动画效果),不需要像默认态那样精确到像素。重点关注:
|
||||
- 弹窗/面板的圆角、阴影、背景色
|
||||
- 遮罩层透明度
|
||||
- 列表项的间距和对齐
|
||||
- 关闭按钮的位置
|
||||
|
||||
---
|
||||
|
||||
## 六、Phase 5: 收尾
|
||||
|
||||
### 6.1 更新 migration-tracker.md
|
||||
|
||||
- 将页面从"正在迁移"移到"已完成"
|
||||
- 记录最终差异百分比和关键调整
|
||||
|
||||
### 6.2 更新 dev-tools 页面
|
||||
|
||||
- `dev-tools.ts` 中将页面从 `MIGRATING_PAGES` 移到 `DONE_PAGES`
|
||||
|
||||
### 6.3 截图归档
|
||||
|
||||
所有截图保存在 `docs/h5_ui/screenshots/`:
|
||||
- `<page>.png` — H5 默认态
|
||||
- `<page>--<state>.png` — H5 交互态
|
||||
- `mp-<page>.png` — 小程序默认态
|
||||
- `mp-<page>--<state>.png` — 小程序交互态
|
||||
- `diff-<page>-v2.png` — 最终 diff 图
|
||||
- `cmp-h5.png` / `cmp-mp.png` — 对比用中间文件
|
||||
|
||||
---
|
||||
|
||||
## 七、经验教训
|
||||
|
||||
### 7.1 高频坑点(详见 h5-to-miniprogram-pitfalls.md)
|
||||
|
||||
1. `scroll-view` 必须设固定高度,否则不滚动
|
||||
2. `wx:if` vs `hidden`:频繁切换用 hidden,否则用 wx:if
|
||||
3. 小程序 text 组件不支持嵌套 view
|
||||
4. CSS `gap` 需要基础库 2.30+
|
||||
5. 内联 style 中的空格会触发 IDE 警告
|
||||
6. tabbar 页面只能用 `switchTab` 或 `reLaunch` 导航
|
||||
|
||||
### 7.2 效率提升点
|
||||
|
||||
- 先完成整体结构转换,再做像素级微调(不要边转边调)
|
||||
- 用 `get_page_snapshot` 获取精确元素位置,比目测高效 10 倍
|
||||
- `evaluate_script` 可以直接操作页面 data,比 UI 操作更可靠
|
||||
- 每轮微调控制在 2-5 处修改,避免一次改太多难以定位效果
|
||||
|
||||
### 7.3 差异收敛规律
|
||||
|
||||
- 第一轮调整通常能降 3-5 个百分点(修复明显的间距错误)
|
||||
- 第二轮再降 2-3 个百分点(精细间距对齐)
|
||||
- 低于 10% 后继续调整收益递减(剩余差异多为字体渲染和平台差异)
|
||||
- 前半屏 < 5% 即可视为达标
|
||||
|
||||
---
|
||||
|
||||
## 八、文档索引
|
||||
|
||||
| 文档 | 路径 | 内容 |
|
||||
|------|------|------|
|
||||
| 迁移指南(理论) | `apps/miniprogram/doc/migration-guide.md` | 6 步流水线 |
|
||||
| 本文档(实战) | `apps/miniprogram/doc/migration-method-full-path.md` | 工具链全路径 |
|
||||
| 避坑文档 | `apps/miniprogram/doc/h5-to-miniprogram-pitfalls.md` | 22+ 个坑点 |
|
||||
| 共享组件规范 | `apps/miniprogram/doc/shared-component-specs.md` | 组件接口定义 |
|
||||
| 迁移追踪 | `apps/miniprogram/doc/migration-tracker.md` | 页面进度 |
|
||||
@@ -1,65 +0,0 @@
|
||||
# 小程序页面迁移追踪
|
||||
|
||||
> 更新日期:2026-03-07
|
||||
> 迁移规范:`migration-guide.md` + `h5-to-miniprogram-pitfalls.md` §2.2.1(87.5% 缩放)
|
||||
> H5 原型目录:`docs/h5_ui/pages/`
|
||||
> 交互说明目录:`docs/h5_ui/interactions/`
|
||||
|
||||
## 已完成(5 页)
|
||||
|
||||
| 页面 | 小程序路径 | H5 原型 | 状态 | 备注 |
|
||||
|------|-----------|---------|------|------|
|
||||
| no-permission | `pages/no-permission/` | `no-permission.html` | ✅ 已完成 | 参考实现,87.5% 缩放基准页 |
|
||||
| login | `pages/login/` | `login.html` | ✅ 已完成 | 87.5% 缩放已应用 |
|
||||
| reviewing | `pages/reviewing/` | `reviewing.html` | ✅ 已完成 | 87.5% 缩放已应用 |
|
||||
| apply | `pages/apply/` | `apply.html` | ✅ 已完成 | 87.5% 缩放 + 像素级微调 |
|
||||
| board-coach | `pages/board-coach/` | `board-coach.html` | ✅ 已完成 | 87.5% 缩放 + 4 维度卡片 + 组件规范见 `shared-component-specs.md` |
|
||||
| board-customer | `pages/board-customer/` | `board-customer.html` | ✅ 已完成 | 8 维度卡片 + 最专一独立表格布局 + heart-icon 组件 |
|
||||
|
||||
## 正在迁移(1 页)
|
||||
|
||||
| 页面 | 小程序路径 | H5 原型 | 状态 | 备注 |
|
||||
|------|-----------|---------|------|------|
|
||||
| board-finance | `pages/board-finance/` | `board-finance.html` | 🔧 迁移中 | 6 板块完整重写:经营一览(深色)+预收资产(储值卡+赠送卡表格)+应计收入(收入结构+损益链)+现金流入+现金流出(4类网格)+助教分析(基础课+激励课表格) |
|
||||
|
||||
## 待迁移(14 页)
|
||||
|
||||
| 页面 | 小程序路径 | H5 原型 | 交互说明 | 优先级 | 备注 |
|
||||
|------|-----------|---------|----------|--------|------|
|
||||
| task-list | `pages/task-list/` | `task-list.html` | ✅ | 高 | 任务列表(主页 Tab) |
|
||||
| my-profile | `pages/my-profile/` | `my-profile.html` | ✅ | 高 | 个人中心(主页 Tab) |
|
||||
| task-detail | `pages/task-detail/` | `task-detail.html` | ✅ | 高 | 任务详情 |
|
||||
| task-detail-callback | `pages/task-detail-callback/` | `task-detail-callback.html` | ✅ | 中 | 任务详情-回访 |
|
||||
| task-detail-priority | `pages/task-detail-priority/` | `task-detail-priority.html` | ✅ | 中 | 任务详情-优先级 |
|
||||
| task-detail-relationship | `pages/task-detail-relationship/` | `task-detail-relationship.html` | ✅ | 中 | 任务详情-关系 |
|
||||
| coach-detail | `pages/coach-detail/` | `coach-detail.html` | ✅ | 中 | 助教详情 |
|
||||
| customer-detail | `pages/customer-detail/` | `customer-detail.html` | ✅ | 中 | 客户详情 |
|
||||
| customer-service-records | `pages/customer-service-records/` | `customer-service-records.html` | ✅ | 中 | 客户服务记录 |
|
||||
| performance | `pages/performance/` | `performance.html` | ✅ | 中 | 业绩总览 |
|
||||
| performance-records | `pages/performance-records/` | `performance-records.html` | ✅ | 中 | 业绩明细 |
|
||||
| chat | `pages/chat/` | `chat.html` | ✅ | 中 | AI 对话 |
|
||||
| chat-history | `pages/chat-history/` | `chat-history.html` | ✅ | 中 | 对话历史 |
|
||||
| notes | `pages/notes/` | `notes.html` | ✅ | 低 | 备忘录 |
|
||||
|
||||
## 无需 H5 迁移的页面
|
||||
|
||||
| 页面 | 小程序路径 | 说明 |
|
||||
|------|-----------|------|
|
||||
| mvp | `pages/mvp/` | 临时入口/路由分发页,无 H5 原型 |
|
||||
| index | `pages/index/` | 框架默认页,无 H5 原型 |
|
||||
| logs | `pages/logs/` | 框架默认日志页,无 H5 原型 |
|
||||
| dev-tools | `pages/dev-tools/` | 开发调试工具页,无 H5 原型 |
|
||||
|
||||
|
||||
| H5 原型 | 交互说明 | 说明 |
|
||||
|---------|----------|------|
|
||||
| `home-settings.html` | 无 | 首页设置,待确认是否需要 |
|
||||
|
||||
## 迁移流程参考
|
||||
|
||||
1. 输入物冻结(H5 html + interactions md + design-tokens.json)
|
||||
2. 迁移审计(对比当前小程序代码与 H5 原型差异)
|
||||
3. 规则化转换(标签映射 + 87.5% 缩放 + 事件绑定)
|
||||
4. 编译验证(开发者工具无报错)
|
||||
5. 真机差异修复(截图对比像素级微调)
|
||||
6. 验收签收(用户确认)
|
||||
@@ -1,997 +0,0 @@
|
||||
|
||||
|
||||
# 一、文档信息
|
||||
|
||||
* 产品名称:球房运营助手(微信小程序)
|
||||
* 版本:V1.0(原型版,全权限视角)
|
||||
* 撰写日期:YYYY-MM-DD
|
||||
* 适用平台:微信小程序(iOS / Android 手机竖屏)
|
||||
* 文档范围:仅描述小程序前端界面与交互行为,不包含后端服务和接口字段定义。
|
||||
|
||||
---
|
||||
|
||||
# 二、背景与目标
|
||||
|
||||
本小程序用于提升台球厅经营管理效率,为店长、助教管理、助教等内部人员提供任务管理、业绩查看、运营看板和智能助手对话能力。
|
||||
|
||||
当前阶段目标:
|
||||
|
||||
* 交付一套基于“全功能、全权限角色视角”的微信小程序前端原型。
|
||||
* 明确各页面布局、组件及交互行为,便于前端和原型工具直接实现。
|
||||
* 角色权限控制、数据口径、字段来源均由后端与后续迭代处理,原型仅展示有权限时的页面样式。
|
||||
|
||||
---
|
||||
|
||||
# 三、范围与约束说明
|
||||
|
||||
1. **设备与环境**
|
||||
|
||||
* 仅面向手机端微信小程序(iOS / Android),竖屏使用。
|
||||
* 暂不考虑 iPad 等大屏适配。
|
||||
|
||||
2. **门店范围**
|
||||
|
||||
* 当前仅支持一个店铺场景,后端如扩展多门店,在后续版本处理。
|
||||
|
||||
3. **权限与角色**
|
||||
|
||||
* 原型以“全功能视角”展示所有模块与入口。
|
||||
* 实际上线时,不同角色(店长/管理层/助教管理/助教)的权限由后端接口控制,对无权限功能采取“入口隐藏”的方式。
|
||||
* 原型中不绘制模块级“无权限访问”占位状态。
|
||||
|
||||
4. **接口与数据**
|
||||
|
||||
* 本文不描述具体接口、字段名、数据结构。
|
||||
* 各类展示字段以接口实际返回为准,本文若举例字段,仅为示意,不代表完整字段列表。
|
||||
|
||||
5. **登录/申请流程的权限提示**
|
||||
|
||||
* 登录后如账号未通过审核或无访问权限,将展示对应状态页(审核中、无权限),这属于整体访问控制,不属于“模块权限占位”,在原型中需要体现。
|
||||
|
||||
---
|
||||
|
||||
# 四、角色说明(仅用于理解,不做权限逻辑)
|
||||
|
||||
* 店长 / 公司管理层:实际场景中拥有全功能权限。
|
||||
* 助教管理:看板中财务板块不可见(上线时通过隐藏入口实现)。
|
||||
* 助教:看板中财务板块和助教板块不可见(上线时通过隐藏入口实现)。
|
||||
|
||||
原型中统一以“全功能视角”展示,不做差异。
|
||||
|
||||
---
|
||||
|
||||
# 五、全局设计规范
|
||||
|
||||
## 5.1 语言与格式
|
||||
|
||||
* 语言:简体中文。
|
||||
* 金额单位:
|
||||
|
||||
* 元:取整,不显示小数。
|
||||
* 万元:保留两位小数。
|
||||
* 时间显示格式:
|
||||
|
||||
* 标准格式:`YYYY-MM-DD HH:mm:ss`
|
||||
* 在不影响理解情况下,可根据页面需要简化为 `YYYY-MM-DD` 或 `MM-DD HH:mm` 等,具体由设计与前端协商。
|
||||
|
||||
## 5.2 导航与返回规则
|
||||
|
||||
* 底部一级导航(TabBar):
|
||||
|
||||
* Tab 顺序:任务 / 看板 / 我的
|
||||
* 文字:`任务`、`看板`、`我的`
|
||||
* 每个 Tab 对应一个一级页面,点击 Tab 时:
|
||||
|
||||
* 若当前已在该 Tab 内的子页面,点击 Tab 返回该 Tab 的根页面,并滚动至顶部。
|
||||
* 顶部导航:
|
||||
|
||||
* 除特别说明外,二级/详情页隐藏微信原生导航栏,使用自定义头部,左上角为返回图标,行为为返回上一页面。
|
||||
* 弹窗与浮层:
|
||||
|
||||
* 使用标准底部弹出或中部弹窗,与微信交互习惯一致。
|
||||
|
||||
## 5.3 悬浮助手按钮
|
||||
|
||||
* 悬浮按钮在所有业务页面(任务、看板、我的及其子页面)显示,不在“登录/申请/审核中/无权限”页面显示。
|
||||
* 默认位置:页面右下角(不遮挡底部 TabBar),随页面滚动悬浮。
|
||||
* 点击行为:进入“助手对话页面”,默认打开最近一次会话(若有)。
|
||||
|
||||
## 5.4 提示、错误与加载状态
|
||||
|
||||
* **网络异常 / 接口错误(列表/卡片区域)**
|
||||
|
||||
* 在对应数据区域显示文字:`加载失败,请点击重试`
|
||||
* 下方提供“重试”按钮,点击重新请求该区域数据。
|
||||
* 作为所有列表/卡片区域的统一错误样式。
|
||||
|
||||
* **空数据状态**
|
||||
|
||||
* 统一使用简单文字:
|
||||
|
||||
* 列表类统一为:`暂无数据` 或根据场景显示 `暂无任务` 等。
|
||||
* 不使用插画或占位图。
|
||||
|
||||
* **加载状态**
|
||||
|
||||
* 使用区域加载:在列表或卡片区域显示文字:`加载中...`
|
||||
* 不做骨架屏和复杂动画。
|
||||
|
||||
---
|
||||
|
||||
# 六、信息架构与页面列表
|
||||
|
||||
## 6.1 顶层结构
|
||||
|
||||
* 登录相关
|
||||
|
||||
* 登录页
|
||||
* 账号申请页
|
||||
* 审核中页
|
||||
* 无权限页
|
||||
* Tab 1:任务
|
||||
|
||||
* 任务列表页(默认首页)
|
||||
* 任务详情页
|
||||
* 业绩详情页
|
||||
* Tab 2:看板
|
||||
|
||||
* 看板首页(含:财务 / 客户 / 助教 三级视图)
|
||||
* 客户详情页
|
||||
* 助教详情页
|
||||
* Tab 3:我的
|
||||
|
||||
* 我的首页
|
||||
* 备注记录页
|
||||
* 助手对话记录页
|
||||
* 首页设置页
|
||||
* 退出账号(确认弹窗)
|
||||
* 全局
|
||||
|
||||
* 助手对话页
|
||||
|
||||
---
|
||||
|
||||
# 七、关键流程说明
|
||||
|
||||
## 7.1 登录与申请流程
|
||||
|
||||
1. 用户打开小程序 → 登录页。
|
||||
2. 点击“使用微信登录”,完成微信授权。
|
||||
3. 登录后:
|
||||
|
||||
* 若查无此用户,也无此用户提交过申请 → 进入账号申请页。
|
||||
* 若查到该用户提交过申请,状态为“审核中” → 进入“审核中”状态页。
|
||||
* 若查到该用户提交过申请,状态为“拒绝/未通过” → 进入“无权限”状态页。
|
||||
* 若查到该用户申请已通过 → 跳转至用户设置的默认首页(初始为“任务”页)。
|
||||
|
||||
## 7.2 默认首页配置流程
|
||||
|
||||
* 初始默认首页为“任务”。
|
||||
* 用户可在“我的 → 首页设置”中将首页设置为:任务 / 看板。
|
||||
* 设置为“切换即保存”,与账号绑定(不因退出登录而重置)。
|
||||
|
||||
---
|
||||
|
||||
# 八、页面级需求
|
||||
|
||||
以下各页面按【页面名称 / 入口 / 布局结构 / 功能与交互 / 状态】描述。
|
||||
|
||||
---
|
||||
|
||||
## 8.1 登录与访问控制相关
|
||||
|
||||
### 8.1.1 登录页
|
||||
|
||||
**入口**
|
||||
|
||||
* 小程序启动未登录状态。
|
||||
|
||||
**布局结构**
|
||||
|
||||
* 顶部:App Logo 占位 + 应用名称(球房运营助手)。
|
||||
* 中部:一句产品描述文案,例如:
|
||||
|
||||
* `为台球厅提升运营效率的内部管理工具`
|
||||
* 底部区域:
|
||||
|
||||
* 主按钮:`使用微信登录`(微信授权登录入口)。
|
||||
* 下方文案 + 勾选框:
|
||||
|
||||
* 复选框 + 文案:`我已阅读并同意《用户协议》和《隐私政策》`
|
||||
* 协议名称为可点击文本(具体跳转页面可后续补充)。
|
||||
|
||||
**功能与交互**
|
||||
|
||||
* 用户必须勾选协议复选框才能点击“使用微信登录”,否则按钮为禁用态。
|
||||
* 点击“使用微信登录”调用微信授权流程,登录成功后进入流程 7.1 所述分支。
|
||||
* 登录失败时,在底部弹出错误提示(Toast),重试留在本页。
|
||||
|
||||
---
|
||||
|
||||
### 8.1.2 账号申请页
|
||||
|
||||
**入口**
|
||||
|
||||
* 登录成功后,系统查无该用户及其申请记录时。
|
||||
|
||||
**布局结构**
|
||||
|
||||
* 顶部:标题 `申请访问权限`。
|
||||
* 主体:
|
||||
|
||||
* 文本说明:简单说明需要申请原因,例如:
|
||||
|
||||
* `请填写申请说明,审核通过后即可使用小程序功能。`
|
||||
* 表单区:
|
||||
|
||||
* 字段 1:
|
||||
|
||||
* 标签:`申请说明`,带红色星号(必填)。
|
||||
* 多行文本输入框,用于填写自我介绍、岗位、所属门店等说明(具体内容由用户自由填写)。
|
||||
* 底部:
|
||||
|
||||
* 主按钮:`提交申请`
|
||||
|
||||
**功能与交互**
|
||||
|
||||
* “申请说明”为必填,如为空则点击“提交申请”时在输入框下方显示错误提示:`申请说明不能为空`。
|
||||
* 提交成功后,进入“审核中”页。
|
||||
* 接口错误时,弹出错误提示,停留在本页。
|
||||
|
||||
---
|
||||
|
||||
### 8.1.3 审核中页
|
||||
|
||||
**入口**
|
||||
|
||||
* 登录后发现该用户有申请记录,状态为“审核中”。
|
||||
|
||||
**布局结构**
|
||||
|
||||
* 居中展示:
|
||||
|
||||
* 图标(等待/审核中占位图标)。
|
||||
* 标题文案:`申请审核中`
|
||||
* 说明文案:例如:`您的访问申请正在审核中,请稍后再试或联系管理员。`
|
||||
* 不提供其他操作按钮,保持不可操作状态。
|
||||
|
||||
**功能与交互**
|
||||
|
||||
* 用户可关闭小程序或退出;再次进入时仍按登录逻辑判断状态。
|
||||
|
||||
---
|
||||
|
||||
### 8.1.4 无权限页
|
||||
|
||||
**入口**
|
||||
|
||||
* 登录后发现该用户申请状态为“拒绝/未通过”,或无访问权限。
|
||||
|
||||
**布局结构**
|
||||
|
||||
* 居中展示:
|
||||
|
||||
* 图标(禁止/无权限占位图标)。
|
||||
* 标题文案:`无访问权限`
|
||||
* 说明文案:例如:`您的访问申请未通过,或当前账号无访问权限。如需使用,请联系管理员。`
|
||||
* 不提供操作按钮,不可操作状态。
|
||||
|
||||
**功能与交互**
|
||||
|
||||
* 用户可关闭小程序或退出;如后续权限变更,再次登录时可进入首页。
|
||||
|
||||
---
|
||||
|
||||
## 8.2 Tab:任务
|
||||
|
||||
### 8.2.1 任务列表页(默认首页)
|
||||
|
||||
**入口**
|
||||
|
||||
* 底部 TabBar 点击“任务”。
|
||||
* 登录通过后,如未设置其他默认首页,则默认进入本页。
|
||||
|
||||
**整体布局**
|
||||
|
||||
* 顶部:自定义导航栏(标题:`任务`),左侧无返回按钮。
|
||||
* Banner 区:当前用户信息与业绩概览。
|
||||
* 任务列表:按紧急程度排序的任务列表。
|
||||
* 悬浮助手按钮:右下角。
|
||||
|
||||
**Banner 区内容**
|
||||
|
||||
* 展示内容示例:
|
||||
|
||||
* 第一行:`用户名` + `身份`(例如:张三 / 助教)
|
||||
* 第二行:一句聚合文案,例如:
|
||||
`本月目标 5 万,已完成 3 万,任务 50 个,完成进度 60%`
|
||||
* 第三行:`X 月预计收入:12345 元`(单位为元,取整)
|
||||
* Banner 整块区域可点击,跳转至“业绩详情页”。
|
||||
|
||||
**任务列表结构**
|
||||
|
||||
* 列表为单列列表,不按任务类型分组,仅通过排序和颜色区分。
|
||||
* 排序规则:按紧急程度从高到低排序,类型依次为:
|
||||
|
||||
* 高优先召回(红)
|
||||
* 优先召回(橙)
|
||||
* 关系构建(粉)
|
||||
* 客户回访(蓝)
|
||||
|
||||
**单条任务卡片布局**
|
||||
|
||||
* 第一行(标题行):
|
||||
|
||||
* 左侧:任务类型标签(带背景色的颜色块或 icon),颜色按类型区分(红/橙/粉/蓝)。
|
||||
* 紧随其后:客户姓名。
|
||||
* 右侧:`>` 箭头图标,提示可点击进入详情。
|
||||
* 第二行(补充行):
|
||||
|
||||
* 核心信息 + 召回说明,具体字段根据当前任务类型与接口返回内容展示,例如:最近到店时间、召回原因、优先级说明等。
|
||||
* 其他:
|
||||
|
||||
* 不提供搜索框和筛选组件,任务集合由接口控制。
|
||||
|
||||
**交互说明**
|
||||
|
||||
* 点击整条任务卡片:进入“任务详情页”。
|
||||
* 长按任务卡片:在长按位置上方弹出黑底浮层菜单,样式类似微信对话长按菜单,菜单项:
|
||||
|
||||
* `任务置底`
|
||||
* `问问助手`
|
||||
* `备注`
|
||||
* “任务置底”:前端仅调用接口,排序规则由后端控制;前端不单独维护生命周期状态。
|
||||
* “问问助手”:跳转至“助手对话页”,以该任务信息为引用,开启新对话主题。
|
||||
* “备注”:弹出底部浮层,输入备注内容并保存,备注按时间排序纳入“备注记录”。
|
||||
|
||||
**空状态**
|
||||
|
||||
* 当列表为空时,在列表区域居中显示文案:`暂无任务`。
|
||||
|
||||
---
|
||||
|
||||
### 8.2.2 任务详情页
|
||||
|
||||
**入口**
|
||||
|
||||
* 任务列表页点击某条任务。
|
||||
|
||||
**布局结构**
|
||||
|
||||
* 顶部:自定义导航栏
|
||||
|
||||
* 左:返回按钮 `<`
|
||||
* 中:标题,例如 `任务详情`
|
||||
* 主体内容区:
|
||||
|
||||
* 模块一:客户基本信息
|
||||
|
||||
* 示例字段:姓名、手机号、会员编号、性别、标签(如 VIP/新客)、所属门店等(以接口为准)。
|
||||
* 模块二:消费习惯
|
||||
|
||||
* 文本描述形式,例如:“偏好晚间 21:00 后到店,喜欢中式台球,平均消费 300 元/月”等。
|
||||
* 模块三:与我的关系
|
||||
|
||||
* 等级:很好 / 好 / 一般 / 较陌生
|
||||
* 每个等级附带一段文字说明(例如“最近 3 个月每周均有1次课程”等)。
|
||||
* 模块四:任务建议
|
||||
|
||||
* 纯文本内容,给出执行建议、沟通话术提示等。
|
||||
* 底部固定操作栏:
|
||||
|
||||
* 左按钮:`问问助手`
|
||||
* 右按钮:`备注`
|
||||
|
||||
**交互说明**
|
||||
|
||||
* `问问助手`:
|
||||
|
||||
* 跳转至助手对话页。
|
||||
* 以当前任务的关键信息(任务类型、客户名、任务说明等)作为引用内容,显示为灰底卡片,用户在其下输入文本发送。
|
||||
* 通过此入口固定新建一个新对话主题。
|
||||
* `备注`:
|
||||
|
||||
* 底部弹出浮层,包含备注输入框和“保存”按钮。
|
||||
* 保存后生成一条备注记录,类型标记为“任务备注”,记入“备注记录”,按照创建时间倒序展示。
|
||||
|
||||
---
|
||||
|
||||
### 8.2.3 业绩详情页
|
||||
|
||||
**入口**
|
||||
|
||||
* 任务列表页 Banner 区点击。
|
||||
|
||||
**布局结构**
|
||||
|
||||
* 顶部 Banner:
|
||||
|
||||
* 展示:用户名 + 身份 + 本月业绩进度 + 本月预计收入。
|
||||
* 示例:
|
||||
|
||||
* 第一行:`张三(助教)`
|
||||
* 第二行:`本月目标:5 万,已完成:3 万,任务:50 个,完成进度:60%`
|
||||
* 第三行:`本月预计收入:1.23 万元`
|
||||
* 下方内容区:多组指标,以两列卡片布局展示。
|
||||
|
||||
**指标分组示意**
|
||||
|
||||
* 分组一:`收入构成`
|
||||
* 分组二:`台球助教业绩`
|
||||
* 分组三:`充值业绩`
|
||||
* 分组四:`酒水业绩`
|
||||
|
||||
每组都有组标题一行,下面为两列卡片网格。
|
||||
|
||||
**单个指标卡片内容**
|
||||
|
||||
* 布局:
|
||||
|
||||
* 卡片内上下两行,可视为“名称行 + 数据行”。
|
||||
* 字段:
|
||||
|
||||
* 指标名称
|
||||
* 当前值
|
||||
* 目标值
|
||||
* 完成度(百分比)
|
||||
* 对齐与单位:
|
||||
|
||||
* 数值区居中对齐。
|
||||
* 单位规则:
|
||||
|
||||
* 元:整数,无小数。
|
||||
* 万元:保留 2 位小数。
|
||||
* 完成度:使用 `%`。
|
||||
|
||||
**交互**
|
||||
|
||||
* 页面整体可滚动。
|
||||
* 卡片本身无需额外交互(本期不跳转、不长按)。
|
||||
|
||||
**时间范围**
|
||||
|
||||
* 本页仅展示当前“本月”的业绩数据,不提供时间周期切换。
|
||||
|
||||
---
|
||||
|
||||
## 8.3 Tab:看板
|
||||
|
||||
### 8.3.1 看板首页(含财务/客户/助教)
|
||||
|
||||
**入口**
|
||||
|
||||
* 底部 TabBar 点击“看板”。
|
||||
|
||||
**顶部区域**
|
||||
|
||||
* 一级标签(顶部 Tab):
|
||||
|
||||
* `财务` / `客户` / `助教`
|
||||
* 默认选中:`财务`(原型中展示全功能视角)
|
||||
|
||||
**筛选区域**
|
||||
|
||||
* 位置:一级标签下方。
|
||||
* 展示方式:多标签筛选按钮,每个按钮点击后展开下拉菜单,交互类似外卖/点评类应用。
|
||||
* 联动规则:
|
||||
|
||||
* 更改任一筛选条件后,立即刷新当前视图数据(无需额外“确定”按钮)。
|
||||
* 不提供“重置筛选”按钮。
|
||||
|
||||
**滚动行为**
|
||||
|
||||
* 当用户向上滚动列表内容时,筛选区域保持吸顶显示。
|
||||
* 当用户向下快速滚动时,可自动收起/隐藏筛选区域,仅保留一级 Tab,增强可视区域。
|
||||
* 向上滚动时再次展示筛选区域。
|
||||
|
||||
---
|
||||
|
||||
### 8.3.2 看板 – 财务视图
|
||||
|
||||
**入口**
|
||||
|
||||
* 看板顶部 Tab 选择“财务”(默认)。
|
||||
|
||||
**筛选条件**
|
||||
|
||||
* 条件 1:时间月份
|
||||
|
||||
* 选项:
|
||||
|
||||
1. 本月(默认)
|
||||
2. 上个月
|
||||
3. 最近 3 个月
|
||||
4. 最近半年
|
||||
5. 本季度
|
||||
6. 上个季度
|
||||
7. 本周
|
||||
8. 上周
|
||||
9. 指定时间周期
|
||||
* 选择“指定时间周期”时:
|
||||
|
||||
* 打开日期区间选择组件,可选择开始日期与结束日期。
|
||||
* 最大跨度:366 天。
|
||||
* 当用户选择的时间跨度超过 366 天时,非模态提示:例如 `时间跨度不可超过 366 天`,并阻止该选择生效。
|
||||
|
||||
* 条件 2:区域
|
||||
|
||||
* 选项:
|
||||
|
||||
1. 全部(默认)
|
||||
2. 大厅(子级:A 区、B 区、C 区)
|
||||
3. 麻将房
|
||||
4. 团建房
|
||||
5. 具体房间台桌
|
||||
* 选择“具体房间台桌”时:
|
||||
|
||||
* 弹出选择弹窗,列表单选。
|
||||
* 列表按“大厅 / 麻将房 / 团建房”分组展示具体房间或台桌。
|
||||
* 如接口获取失败或为空,在弹窗中显示:`网络错误,请重试`,并提供“重试”入口。
|
||||
|
||||
**财务汇总行**
|
||||
|
||||
* 展示位置:筛选区域下方第一行。
|
||||
* 分为三列:
|
||||
|
||||
* 当前筛选条件下实际收入
|
||||
* 当前筛选条件下实际支出
|
||||
* 当前筛选条件下净利润
|
||||
* 显隐与“预计”字样:
|
||||
|
||||
* 某些筛选条件下不显示支出与净利润,由接口控制。
|
||||
* 某些时间维度(例如本月、本周等)可显示“预计”字样:`12345 元(预计)`,由接口在数据中标记。
|
||||
|
||||
**内容分区**
|
||||
|
||||
分为四个部分,依次:
|
||||
|
||||
1. 营业数据
|
||||
2. 收入构成
|
||||
3. 支出构成
|
||||
4. 利润构成
|
||||
|
||||
每一部分包含:
|
||||
|
||||
* 标题行:如 `营业数据`
|
||||
* 指标卡片区:每行 3 个卡片,自动换行。
|
||||
|
||||
**指标卡片结构**
|
||||
|
||||
* 每个卡片:
|
||||
|
||||
* 第一行(标题行):左侧图标(简单占位)、右侧为指标名称(例如“总流水”、“客单价”等)。
|
||||
* 第二行(详情行):文字 + 数值,或文字 / 数值单独展示:
|
||||
|
||||
* 例如:`本期:12345 元`,或 `毛利率:35%`。
|
||||
* 指标列表(示意,实际由接口控制):
|
||||
|
||||
* 营业数据:总流水、客单价、开台数、场次、平均停留时长等。
|
||||
* 收入构成:桌费、助教费、酒水、餐饮、包房费、其他。
|
||||
* 支出构成:房租、水电、人工、耗材、推广等。
|
||||
* 利润构成:毛利、净利、毛利率、净利率等。
|
||||
|
||||
**交互**
|
||||
|
||||
* 长按任意指标卡片:
|
||||
|
||||
* 启动助手对话,跳转至“助手对话页”,以该指标为引用内容(来源:财务看板 + 指标名 + 当前数值等),开启新对话主题。
|
||||
* 列表下拉刷新,重新拉取数据。
|
||||
|
||||
---
|
||||
|
||||
### 8.3.3 看板 – 客户视图
|
||||
|
||||
**入口**
|
||||
|
||||
* 看板顶部 Tab 选择“客户”。
|
||||
|
||||
**筛选条件**
|
||||
|
||||
* 条件 1:客户类型
|
||||
|
||||
* 最近到店:按最近到店时间由近到远。
|
||||
* 最应召回:按当天召回因子由高到低(默认)。
|
||||
* 最近充值:按充值时间由近到远。
|
||||
* 最高消费:最近 60 天到店消费金额由高到低(不含充值)。
|
||||
* 最高余额:按单个客户所有会员卡金额总计由高到低。
|
||||
* 最频繁:最近 60 天到店次数由多到少。
|
||||
* 潜力股:最近 60 天到店间隔有缩短趋势的客户。
|
||||
* 最专一:最近 60 天使用助教服务 ≥10 次,且 ≥8 次为同一助教,最近 2 次均为该助教。
|
||||
|
||||
* 条件 2:偏爱项目
|
||||
|
||||
* 不限(默认)
|
||||
* 中式/追分
|
||||
* 斯诺克
|
||||
* 麻将
|
||||
* 团建
|
||||
|
||||
**助教身份默认筛选(后台行为,前端不显式展示)**
|
||||
|
||||
* 当登录用户身份为“助教”时,后台默认增加过滤条件:仅显示最近 14 天内该助教提供过课程服务的客户。
|
||||
* 前端不提供取消或修改该条件的开关,也不在 UI 中单独标识。
|
||||
|
||||
**客户列表卡片布局**
|
||||
|
||||
* 第一行:
|
||||
|
||||
* 左侧:
|
||||
|
||||
* 客户名称
|
||||
* 等级标(如等级图标或字母 A/B/C)
|
||||
* VIP 标识(如“VIP”标签),有则显示。
|
||||
* 右侧:最喜欢的助教列表,文字形式展示,例如:
|
||||
|
||||
* `💖 助教A、💖 助教B、💛 助教C...`
|
||||
* 最多展示前三,超过则以省略号表示。
|
||||
|
||||
* 第二行:
|
||||
|
||||
* 当前排序条件对应的核心指标(如召回因子、储值金额、累计消费等)。
|
||||
* 最近到店时间(副文案)。
|
||||
* 可在末尾增加一句简短说明,例如:`最近 30 天到店 5 次` 等。
|
||||
|
||||
**其他字段**
|
||||
|
||||
* 在“最高余额”等维度时,应显示该客户当前余额字段(由接口提供),格式按金额规则显示。
|
||||
|
||||
**交互**
|
||||
|
||||
* 点击客户卡片:进入“客户详情页”。
|
||||
* 长按客户卡片(可选):可考虑后续扩展为快速备注或助手入口,本期可不实现。
|
||||
|
||||
---
|
||||
|
||||
### 8.3.4 客户详情页
|
||||
|
||||
**入口**
|
||||
|
||||
* 看板 – 客户视图列表点击某客户。
|
||||
|
||||
**布局**
|
||||
|
||||
* 顶部导航:标题 `客户详情`,左侧返回按钮。
|
||||
* 模块一:客户基本信息
|
||||
|
||||
* 示例字段:姓名、手机号、会员编号、性别、等级、VIP 标识、所属门店等。
|
||||
* 手机号可支持点击拨号(后续实现时决定)。
|
||||
* 模块二:消费习惯
|
||||
|
||||
* 标签 + 文本说明的形式:
|
||||
|
||||
* 标签示例:`常来夜场`、`偏爱中式`、`高客单价` 等。
|
||||
* 文本说明:简要描述消费偏好、时段、频率等。
|
||||
* 模块三:与我的关系
|
||||
|
||||
* 等级:很好 / 好 / 一般 / 较陌生。
|
||||
* 等级下附文字说明,描述互动频率、最近服务情况等。
|
||||
* 底部固定操作栏:
|
||||
|
||||
* `问问助手`
|
||||
* `备注`
|
||||
|
||||
**交互**
|
||||
|
||||
* `问问助手`:
|
||||
|
||||
* 跳转到助手对话页。
|
||||
* 引用当前客户的关键信息(客户名、ID、最近消费等)作为灰底引用卡片。
|
||||
* 开启新对话主题。
|
||||
* `备注`:
|
||||
|
||||
* 底部弹出备注输入浮层,类型标记为“客户备注”,保存后进入“备注记录”。
|
||||
|
||||
---
|
||||
|
||||
### 8.3.5 看板 – 助教学视图
|
||||
|
||||
**入口**
|
||||
|
||||
* 看板顶部 Tab 选择“助教”。
|
||||
|
||||
**筛选条件**
|
||||
|
||||
* 条件 1:排序维度
|
||||
|
||||
* 创收最多(默认):按该助教带来的球房流水由高到低。
|
||||
* 创收最低:按球房流水由低到高。
|
||||
* 业绩最高:按业绩完成百分比由高到低。
|
||||
* 业绩最低:按业绩完成百分比由低到高.
|
||||
* 工资最高:按工资由高到低。
|
||||
* 工资最低:按工资由低到高。
|
||||
* 潜在客源储值:按该助教客户关系 >0.7 的所有客户储值金额总和由高到低。
|
||||
|
||||
* 条件 2:擅长项目
|
||||
|
||||
* 不限(默认)
|
||||
* 中式/追分
|
||||
* 斯诺克
|
||||
* 麻将
|
||||
* 团建
|
||||
|
||||
* 条件 3:时间月份
|
||||
|
||||
* 同财务视图:本月(默认)、上月、最近 3 个月、最近半年、本季度、上个季度、本周、上周、指定时间周期。
|
||||
* “指定时间周期”同样使用日期区间选择组件,并限制最大跨度 366 天,超出时非模态提示。
|
||||
|
||||
**助教列表卡片布局**
|
||||
|
||||
* 第一行:
|
||||
|
||||
* 左侧:
|
||||
|
||||
* 助教姓名
|
||||
* 等级标(如星级/等级)
|
||||
* 擅长项目(标签形式,展示主擅长方向)。
|
||||
* 右侧:
|
||||
|
||||
* 关系最好的客户列表,展示客户名称和关系指数(例如:`客户A 0.98、客户B 0.92、客户C 0.89...`),最多展示前三,超过以省略号表示。
|
||||
|
||||
* 第二行:
|
||||
|
||||
* 当前排序维度对应的数值信息,附单位/说明:
|
||||
|
||||
* 如创收最多:`本期流水:12345 元`
|
||||
* 业绩最高:`完成度:87%`
|
||||
* 工资:`本期工资:8000 元`
|
||||
* 上课时长等(小时)。
|
||||
|
||||
* 不显示头像。
|
||||
|
||||
**交互**
|
||||
|
||||
* 点击助教卡片:进入“助教详情页”。
|
||||
|
||||
---
|
||||
|
||||
### 8.3.6 助教详情页
|
||||
|
||||
**入口**
|
||||
|
||||
* 看板 – 助教视图列表点击某助教。
|
||||
|
||||
**布局**
|
||||
|
||||
* 顶部导航:标题 `助教详情`,左侧返回按钮。
|
||||
|
||||
* 模块一:助教基本信息
|
||||
|
||||
* 字段示例:姓名、工号、所属门店、擅长项目、等级等。
|
||||
|
||||
* 模块二:流水与业绩
|
||||
|
||||
* 本月带来的球房流水(数值,单位元或万元)。
|
||||
* 最近 3 个月带来的球房流水(数值)。
|
||||
* 综合业绩完成度(一个总进度百分比)。
|
||||
|
||||
* 模块三:工资与上课时长
|
||||
|
||||
* 本月工资总额。
|
||||
* 对应时间段的上课总时长(小时)。
|
||||
|
||||
* 模块四:前 10 个客户指数最高的客户列表
|
||||
|
||||
* 列表项字段:客户名 + 指数数值(0~1 或百分比展示)。
|
||||
|
||||
* 底部固定操作栏:
|
||||
|
||||
* `问问助手`
|
||||
* `备注`
|
||||
|
||||
**交互**
|
||||
|
||||
* `问问助手`:以助教信息和主要指标为引用,开启新对话主题。
|
||||
* `备注`:对该助教添加备注记录,类型为“助教备注”。
|
||||
|
||||
---
|
||||
|
||||
## 8.4 Tab:我的
|
||||
|
||||
### 8.4.1 我的首页
|
||||
|
||||
**入口**
|
||||
|
||||
* 底部 TabBar 点击“我的”。
|
||||
|
||||
**布局**
|
||||
|
||||
* 顶部:用户信息区域
|
||||
|
||||
* 用户名、身份(店长/助教等)、所属门店等信息。
|
||||
* 列表菜单项:
|
||||
|
||||
* `备注记录`
|
||||
* `助手对话记录`
|
||||
* `首页设置`
|
||||
* `退出账号`
|
||||
|
||||
**交互**
|
||||
|
||||
* 点击各行进入对应子页面或触发弹窗。
|
||||
|
||||
---
|
||||
|
||||
### 8.4.2 备注记录页
|
||||
|
||||
**入口**
|
||||
|
||||
* “我的”首页点击 `备注记录`。
|
||||
|
||||
**布局**
|
||||
|
||||
* 标题:`备注记录`
|
||||
* 列表按时间倒序(由近到远)平铺,不按日期分组。
|
||||
* 每条记录显示:
|
||||
|
||||
* 备注全文(不做截断或只做必要的单行/多行控制)。
|
||||
* 关联对象:例如 `客户:张三` / `任务:XXX` / `助教:李四`。
|
||||
* 创建时间。
|
||||
|
||||
**交互**
|
||||
|
||||
* 点击备注记录:不进入详情页(即本页即为详情展示),不支持编辑/删除。
|
||||
* 列表为空时:显示 `暂无数据`。
|
||||
|
||||
---
|
||||
|
||||
### 8.4.3 助手对话记录页
|
||||
|
||||
**入口**
|
||||
|
||||
* “我的”首页点击 `助手对话记录`。
|
||||
|
||||
**布局**
|
||||
|
||||
* 标题:`助手对话记录`
|
||||
* 列表项字段:
|
||||
|
||||
* 对话标题:由接口返回(一般为首条消息摘要)。
|
||||
* 最近一次对话时间。
|
||||
* 消息条数概览(例如:`共 25 条消息`)。
|
||||
* 列表按最近更新时间倒序。
|
||||
|
||||
**交互**
|
||||
|
||||
* 点击某条记录:
|
||||
|
||||
* 进入“助手对话页”,直接打开该会话。
|
||||
* 默认滚动到该对话的最后一条消息位置。
|
||||
* 不提供删除对话能力。
|
||||
* 列表为空时:显示 `暂无数据`。
|
||||
|
||||
---
|
||||
|
||||
### 8.4.4 首页设置页
|
||||
|
||||
**入口**
|
||||
|
||||
* “我的”首页点击 `首页设置`。
|
||||
|
||||
**布局**
|
||||
|
||||
* 标题:`首页设置`
|
||||
* 内容:
|
||||
|
||||
* 单选列表:
|
||||
|
||||
* `任务`
|
||||
* `看板`
|
||||
* 每项前有单选圆点,当前选中项高亮。
|
||||
* 底部:返回按钮。
|
||||
|
||||
**交互**
|
||||
|
||||
* 用户点击某一选项后立即生效,作为新的默认首页设置(切换即保存,不需要额外保存按钮)。
|
||||
* 退出账号不会清除该设置,再次登录仍使用该默认首页。
|
||||
|
||||
---
|
||||
|
||||
### 8.4.5 退出账号(确认弹窗)
|
||||
|
||||
**入口**
|
||||
|
||||
* “我的”首页点击 `退出账号`。
|
||||
|
||||
**交互**
|
||||
|
||||
* 弹出确认弹窗:
|
||||
|
||||
* 标题:`确认退出`
|
||||
* 文案:`确认退出当前账号吗?`
|
||||
* 按钮:
|
||||
|
||||
* 取消
|
||||
* 退出
|
||||
* 点击“退出”:
|
||||
|
||||
* 清除登录态。
|
||||
* 不清理由于当前账号相关的本地配置(如首页设置、筛选条件等)。
|
||||
* 跳转回“登录页”。
|
||||
* 点击“取消”:关闭弹窗,留在“我的”页。
|
||||
|
||||
---
|
||||
|
||||
## 8.5 全局:助手对话页
|
||||
|
||||
### 8.5.1 入口
|
||||
|
||||
* 悬浮助手按钮。
|
||||
* 任务详情页底部按钮“问问助手”。
|
||||
* 客户详情页底部按钮“问问助手”。
|
||||
* 助教详情页底部按钮“问问助手”。
|
||||
* 看板各视图中长按指标启动助手。
|
||||
* “助手对话记录”页点击某一条历史记录。
|
||||
|
||||
### 8.5.2 聊天形式
|
||||
|
||||
* 对话双方:用户(“我”)与“助手”。
|
||||
* UI 全面仿微信对话界面:
|
||||
|
||||
* 左侧:助手气泡,显示助手头像(固定)和名称。
|
||||
* 右侧:用户气泡,显示用户头像和名称。
|
||||
* 对话记录:
|
||||
|
||||
* 最近 50 条消息默认加载。
|
||||
* 上拉加载更早记录。
|
||||
|
||||
### 8.5.3 引用内容展示
|
||||
|
||||
* 从任务/客户/助教/看板等入口进入助手时,将引用上下文:
|
||||
|
||||
* 引用内容显示为一块灰底小卡片,位于即将发送的消息气泡上方。
|
||||
* 卡片内容包括:
|
||||
|
||||
* 来源类型:任务 / 客户 / 助教 / 看板(具体子模块如财务/客户看板等)。
|
||||
* 标题或名称(例如客户名、任务标题、指标名)。
|
||||
* 部分摘要文案或关键数据。
|
||||
* 引用内容不可编辑,用户只能在下方输入框中补充自己的提问文本后发送。
|
||||
|
||||
### 8.5.4 会话管理
|
||||
|
||||
* 每个“新对话主题”形成一个独立会话,出现在“助手对话记录”列表中。
|
||||
* 来源:
|
||||
|
||||
* 从“助手对话记录”进入:
|
||||
|
||||
* 直接打开对应会话,加载历史记录,滚动到最后一条消息。
|
||||
* 如距最后一条消息超过 1 小时,在输入框区域上方显示横条提示,提供两个按钮:
|
||||
|
||||
* `新对话主题`
|
||||
* `继续对话`
|
||||
* 选择“新对话主题”:清空当前对话展示区域,开始新的对话会话,该会话作为新条目记录在“助手对话记录”中,历史对话仍保留在原会话条目中。
|
||||
* 选择“继续对话”:在当前会话中继续发送消息,对话标题不变。
|
||||
* 从任务/客户/助教/看板入口的“问问助手”或长按启动:
|
||||
|
||||
* 固定开启“新对话主题”,不受 1 小时规则影响,始终新建会话,并带入引用内容。
|
||||
|
||||
### 8.5.5 输入与发送
|
||||
|
||||
* 输入区包含:
|
||||
|
||||
* 文本输入框。
|
||||
* “按住说话”按钮(语音转文字)。
|
||||
* 发送按钮。
|
||||
|
||||
**语音转文字交互**
|
||||
|
||||
* 点击“按住说话”按钮并按住:
|
||||
|
||||
* 显示录音状态动画,松开后结束录音并开始识别。
|
||||
* 识别结果展示在输入框中,用户可编辑后再点击“发送”。
|
||||
* 识别失败时,弹出提示:`识别失败,请重试`,不发送消息。
|
||||
|
||||
**键盘与滚动行为**
|
||||
|
||||
* 键盘弹出时,列表自动滚动到底部,确保最新消息和输入框可见。
|
||||
* 发送消息后,自动滚动到底部。
|
||||
|
||||
---
|
||||
|
||||
# 九、非功能性要求
|
||||
|
||||
* 关键页面(任务列表、看板页、助手对话页)首次可接受加载时间:≤ 10 秒(在普通网络环境下)。
|
||||
* 看板页面数据:
|
||||
|
||||
* 各数据块采用懒加载策略,优先加载当前视图及首屏必要数据,其他部分可在滚动时或后台加载,避免一次性加载过多影响首屏体验。
|
||||
* 本阶段不做埋点与统计需求设计。
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
# 共享组件样式规范(已验证)
|
||||
|
||||
> 来源:board-coach 页面迁移验收通过后提取
|
||||
> 用途:board-customer、board-finance 等看板页面复用
|
||||
> 更新日期:2026-03-07
|
||||
|
||||
## 1. AI 悬浮按钮(ai-float-button)
|
||||
|
||||
- 组件路径:`components/ai-float-button/`
|
||||
- 机器人 SVG icon + 渐变流动动画背景
|
||||
- 默认 bottom 220rpx(在自定义底部导航栏上方)
|
||||
|
||||
## 2. 自定义底部导航栏(board-tab-bar)
|
||||
|
||||
- 组件路径:`components/board-tab-bar/`
|
||||
- 用于非 TabBar 的看板子页面(board-coach、board-customer)
|
||||
- SVG icon 从 H5 原型提取,路径 `/assets/icons/tab-*-nav*.svg`
|
||||
|
||||
### 样式参数
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 高度 | 100rpx |
|
||||
| 背景 | #ffffff |
|
||||
| 边框 | 1rpx solid #eeeeee |
|
||||
| icon 尺寸 | 44rpx × 44rpx |
|
||||
| label 字号 | 20rpx |
|
||||
| label 颜色 | #8b8b8b(默认)/ #0052d9(active) |
|
||||
| active 字重 | 500 |
|
||||
| gap(icon↔label) | 4rpx |
|
||||
| safe-area | padding-bottom: env(safe-area-inset-bottom) |
|
||||
|
||||
## 3. 筛选下拉组件(filter-dropdown)
|
||||
|
||||
- 组件路径:`components/filter-dropdown/`
|
||||
- 全屏宽度面板 + 半透明遮罩 + 动态 top 计算
|
||||
|
||||
### 触发按钮样式
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| padding | 16rpx 20rpx |
|
||||
| 背景 | #ffffff |
|
||||
| 边框 | 2rpx solid var(--color-gray-1) |
|
||||
| 圆角 | var(--radius-md) |
|
||||
| 文字+箭头 | justify-content: center |
|
||||
| label 字号 | 24rpx |
|
||||
| label 字重 | 600 |
|
||||
| active 边框色 | var(--color-primary) |
|
||||
| active 背景 | var(--color-primary-light) |
|
||||
|
||||
### 下拉面板样式
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 定位 | fixed, left:0, right:0 |
|
||||
| 最大高度 | 60vh |
|
||||
| 圆角 | 0 0 28rpx 28rpx |
|
||||
| 阴影 | 0 16rpx 48rpx rgba(0,0,0,0.15) |
|
||||
| 选项 padding | 34rpx 32rpx |
|
||||
| 选项字号 | 28rpx |
|
||||
| 分隔线 | 1rpx solid rgba(0,0,0,0.03) |
|
||||
| active 颜色 | var(--color-primary) |
|
||||
| active 字重 | 500 |
|
||||
|
||||
### 遮罩层
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 背景 | rgba(0,0,0,0.5) |
|
||||
| z-index | 999(遮罩)/ 1000(面板) |
|
||||
|
||||
## 4. 顶部看板 Tab 栏
|
||||
|
||||
- 直接写在页面 wxml 中(非独立组件)
|
||||
- sticky top: 0, z-index: 20
|
||||
|
||||
### 样式参数
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 背景 | #ffffff |
|
||||
| 边框 | 2rpx solid #eeeeee |
|
||||
| tab padding | 24rpx 0 |
|
||||
| tab 字号 | 26rpx |
|
||||
| tab 字重 | 500(默认)/ 600(active) |
|
||||
| tab 颜色 | #8b8b8b(默认)/ #0052d9(active) |
|
||||
| 下划线宽 | 42rpx |
|
||||
| 下划线高 | 5rpx |
|
||||
| 下划线渐变 | linear-gradient(90deg, #0052d9, #5b9cf8) |
|
||||
|
||||
## 5. 筛选栏容器
|
||||
|
||||
- sticky top: 70rpx, z-index: 15
|
||||
- 滚动隐藏/显示(220ms ease 过渡)
|
||||
|
||||
### 样式参数
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 背景 | #f3f3f3 |
|
||||
| padding | 14rpx 28rpx |
|
||||
| 内框背景 | #ffffff |
|
||||
| 内框圆角 | 14rpx |
|
||||
| 内框 padding | 10rpx |
|
||||
| 内框 gap | 14rpx |
|
||||
| 内框边框 | 2rpx solid #eeeeee |
|
||||
| 第一个筛选项 | flex: 1.8 |
|
||||
| 其他筛选项 | flex: 1 |
|
||||
|
||||
## 6. 筛选选项内容(board-coach 专用,供参考)
|
||||
|
||||
### 排序维度
|
||||
- 定档业绩最高 / 定档业绩最低 / 工资最高 / 工资最低 / 客源储值最高 / 任务完成最多
|
||||
|
||||
### 技能筛选
|
||||
- 不限 / 🎱 中式追分 / 斯诺克 / 🀄 麻将棋牌 / 🎤团建K歌
|
||||
|
||||
### 时间筛选
|
||||
- 本月 / 本季度 / 上月 / 前3个月(不含本月) / 上季度 / 最近6个月(不含本月,不支持客源储值最高)
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"ios": {
|
||||
"name": "桌球运营助手"
|
||||
},
|
||||
"android": {
|
||||
"name": "桌球运营助手"
|
||||
},
|
||||
"common": {
|
||||
"name": "桌球运营助手"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
testMatch: ['**/tests/**/*.test.ts'],
|
||||
transform: {
|
||||
'^.+\\.ts$': ['ts-jest', {
|
||||
tsconfig: 'tsconfig.test.json',
|
||||
}],
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/miniprogram/$1',
|
||||
},
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/login/login",
|
||||
"pages/apply/apply",
|
||||
"pages/reviewing/reviewing",
|
||||
"pages/no-permission/no-permission",
|
||||
"pages/task-list/task-list",
|
||||
"pages/board-finance/board-finance",
|
||||
"pages/my-profile/my-profile",
|
||||
"pages/task-detail/task-detail",
|
||||
"pages/task-detail-callback/task-detail-callback",
|
||||
"pages/task-detail-priority/task-detail-priority",
|
||||
"pages/task-detail-relationship/task-detail-relationship",
|
||||
"pages/notes/notes",
|
||||
"pages/performance/performance",
|
||||
"pages/performance-records/performance-records",
|
||||
"pages/board-customer/board-customer",
|
||||
"pages/board-coach/board-coach",
|
||||
"pages/customer-detail/customer-detail",
|
||||
"pages/customer-service-records/customer-service-records",
|
||||
"pages/coach-detail/coach-detail",
|
||||
"pages/chat/chat",
|
||||
"pages/chat-history/chat-history",
|
||||
"pages/index/index",
|
||||
"pages/dev-tools/dev-tools",
|
||||
"pages/logs/logs",
|
||||
"pages/mvp/mvp"
|
||||
],
|
||||
"tabBar": {
|
||||
"color": "#8b8b8b",
|
||||
"selectedColor": "#0052d9",
|
||||
"backgroundColor": "#ffffff",
|
||||
"borderStyle": "white",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/task-list/task-list",
|
||||
"text": "任务",
|
||||
"iconPath": "assets/icons/tab-task.png",
|
||||
"selectedIconPath": "assets/icons/tab-task-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/board-finance/board-finance",
|
||||
"text": "看板",
|
||||
"iconPath": "assets/icons/tab-board.png",
|
||||
"selectedIconPath": "assets/icons/tab-board-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/my-profile/my-profile",
|
||||
"text": "我的",
|
||||
"iconPath": "assets/icons/tab-my.png",
|
||||
"selectedIconPath": "assets/icons/tab-my-active.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"window": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "球房运营助手",
|
||||
"navigationBarBackgroundColor": "#ffffff"
|
||||
},
|
||||
"usingComponents": {
|
||||
"dev-fab": "/components/dev-fab/dev-fab"
|
||||
},
|
||||
"componentFramework": "glass-easel",
|
||||
"lazyCodeLoading": "requiredComponents"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"adapteByMiniprogram": {
|
||||
"userName": "gh_521029c3a9c7"
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// app.ts
|
||||
// 应用入口 — 启动时检查登录状态并路由到对应页面
|
||||
import { request } from "./utils/request"
|
||||
|
||||
App<IAppOption>({
|
||||
globalData: {},
|
||||
|
||||
onLaunch() {
|
||||
// 从 Storage 恢复 token 和用户信息
|
||||
const token = wx.getStorageSync("token")
|
||||
const refreshToken = wx.getStorageSync("refreshToken")
|
||||
const userId = wx.getStorageSync("userId")
|
||||
if (token) {
|
||||
this.globalData.token = token
|
||||
this.globalData.refreshToken = refreshToken
|
||||
if (userId) {
|
||||
this.globalData.authUser = {
|
||||
userId,
|
||||
status: wx.getStorageSync("userStatus") || "new",
|
||||
}
|
||||
}
|
||||
// 有 token → 查询最新用户状态并路由
|
||||
this.checkAuthStatus()
|
||||
}
|
||||
// 无 token → 停留在 login 页(首页已设为 login)
|
||||
},
|
||||
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
const data = await request({
|
||||
url: "/api/xcx/me",
|
||||
method: "GET",
|
||||
needAuth: true,
|
||||
})
|
||||
|
||||
// 持久化用户信息
|
||||
this.globalData.authUser = {
|
||||
userId: data.user_id,
|
||||
status: data.status,
|
||||
nickname: data.nickname,
|
||||
}
|
||||
wx.setStorageSync("userId", data.user_id)
|
||||
wx.setStorageSync("userStatus", data.status)
|
||||
|
||||
// 根据状态路由
|
||||
switch (data.status) {
|
||||
case "approved":
|
||||
wx.reLaunch({ url: "/pages/task-list/task-list" })
|
||||
break
|
||||
case "pending":
|
||||
wx.reLaunch({ url: "/pages/reviewing/reviewing" })
|
||||
break
|
||||
case "new":
|
||||
wx.reLaunch({ url: "/pages/apply/apply" })
|
||||
break
|
||||
case "rejected":
|
||||
wx.reLaunch({ url: "/pages/no-permission/no-permission" })
|
||||
break
|
||||
case "disabled":
|
||||
wx.reLaunch({ url: "/pages/no-permission/no-permission" })
|
||||
break
|
||||
default:
|
||||
wx.reLaunch({ url: "/pages/apply/apply" })
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// token 无效或网络错误 → 停留在 login 页
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,111 +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;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
* 设计 Token 全局变量(基于 design-tokens.json)
|
||||
* ============================================ */
|
||||
page {
|
||||
/* 颜色 */
|
||||
--color-primary: #0052d9;
|
||||
--color-primary-light: #ecf2fe;
|
||||
--color-success: #00a870;
|
||||
--color-warning: #ed7b2f;
|
||||
--color-error: #e34d59;
|
||||
--color-gray-1: #f3f3f3;
|
||||
--color-gray-2: #eeeeee;
|
||||
--color-gray-3: #e7e7e7;
|
||||
--color-gray-4: #dcdcdc;
|
||||
--color-gray-5: #c5c5c5;
|
||||
--color-gray-6: #a6a6a6;
|
||||
--color-gray-7: #8b8b8b;
|
||||
--color-gray-8: #777777;
|
||||
--color-gray-9: #5e5e5e;
|
||||
--color-gray-10: #4b4b4b;
|
||||
--color-gray-11: #393939;
|
||||
--color-gray-12: #2c2c2c;
|
||||
--color-gray-13: #242424;
|
||||
|
||||
/* 字号 */
|
||||
--font-xs: 24rpx;
|
||||
--font-sm: 28rpx;
|
||||
--font-base: 32rpx;
|
||||
--font-lg: 36rpx;
|
||||
--font-xl: 40rpx;
|
||||
--font-2xl: 48rpx;
|
||||
|
||||
/* 圆角 */
|
||||
--radius-sm: 8rpx;
|
||||
--radius-md: 16rpx;
|
||||
--radius-lg: 24rpx;
|
||||
--radius-xl: 32rpx;
|
||||
|
||||
/* 阴影 */
|
||||
--shadow-lg: 0 8rpx 32rpx rgba(0,0,0,0.06);
|
||||
--shadow-xl: 0 16rpx 48rpx rgba(0,0,0,0.08);
|
||||
|
||||
/* 间距基准 */
|
||||
--spacing-base: 8rpx;
|
||||
|
||||
/* TDesign 主题覆盖 */
|
||||
--td-brand-color: #0052d9;
|
||||
--td-brand-color-light: #ecf2fe;
|
||||
--td-success-color: #00a870;
|
||||
--td-warning-color: #ed7b2f;
|
||||
--td-error-color: #e34d59;
|
||||
|
||||
/* 页面默认样式 */
|
||||
background-color: var(--color-gray-1);
|
||||
font-size: var(--font-base);
|
||||
color: var(--color-gray-13);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
* 通用工具类
|
||||
* ============================================ */
|
||||
|
||||
/* 安全区适配 */
|
||||
.safe-area-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.safe-area-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
/* 文本省略 */
|
||||
.text-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Flex 布局 */
|
||||
.flex-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.flex-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.flex-between {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.flex-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 187 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 199 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.32.32 0 0 0 .165-.054l1.9-1.106a.96.96 0 0 1 .465-.116.94.94 0 0 1 .272.04 10.6 10.6 0 0 0 2.822.384c.136 0 .271-.002.405-.009a6.9 6.9 0 0 1-.315-2.053c0-3.694 3.614-6.69 8.076-6.69.233 0 .463.01.691.027C16.964 4.837 13.132 2.188 8.691 2.188zm-2.97 5.28a1.03 1.03 0 1 1 0-2.06 1.03 1.03 0 0 1 0 2.06zm5.96 0a1.03 1.03 0 1 1 0-2.06 1.03 1.03 0 0 1 0 2.06zM24 14.2c0-3.355-3.4-6.08-7.59-6.08s-7.59 2.725-7.59 6.08c0 3.356 3.4 6.08 7.59 6.08.772 0 1.515-.094 2.215-.268a.77.77 0 0 1 .224-.033.79.79 0 0 1 .382.095l1.565.912a.26.26 0 0 0 .135.044c.13 0 .238-.108.238-.242 0-.06-.024-.117-.04-.175l-.32-1.218a.48.48 0 0 1 .175-.547C22.95 17.89 24 16.165 24 14.2zm-10.14-.426a.85.85 0 1 1 0-1.7.85.85 0 0 1 0 1.7zm5.1 0a.85.85 0 1 1 0-1.7.85.85 0 0 1 0 1.7z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 998 B |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white">
|
||||
<circle cx="12" cy="12" r="10" fill="white" opacity="0.3"/>
|
||||
<circle cx="12" cy="12" r="6" fill="white"/>
|
||||
<circle cx="12" cy="12" r="2" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 237 B |
@@ -1,5 +0,0 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="13" width="4" height="8" rx="1" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>
|
||||
<rect x="10" y="8" width="4" height="13" rx="1" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>
|
||||
<rect x="16" y="3" width="4" height="18" rx="1" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 382 B |
@@ -1,5 +0,0 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="13" width="4" height="8" rx="1" stroke="#8b8b8b" stroke-width="1.5"/>
|
||||
<rect x="10" y="8" width="4" height="13" rx="1" stroke="#8b8b8b" stroke-width="1.5"/>
|
||||
<rect x="16" y="3" width="4" height="18" rx="1" stroke="#8b8b8b" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 343 B |
@@ -1,4 +0,0 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="7" r="4" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>
|
||||
<path d="M5.5 21a6.5 6.5 0 0113 0h-13z" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 255 B |
@@ -1,4 +0,0 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="7" r="4" stroke="#8b8b8b" stroke-width="1.5"/>
|
||||
<path d="M5.5 21a6.5 6.5 0 0113 0h-13z" stroke="#8b8b8b" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 229 B |
@@ -1,5 +0,0 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="5" y="4" width="14" height="17" rx="2" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>
|
||||
<path d="M9 12l2 2 4-4" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 387 B |
@@ -1,5 +0,0 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="5" y="4" width="14" height="17" rx="2" stroke="#8b8b8b" stroke-width="1.5"/>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" stroke="#8b8b8b" stroke-width="1.5"/>
|
||||
<path d="M9 12l2 2 4-4" stroke="#8b8b8b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 363 B |
|
Before Width: | Height: | Size: 70 B |
@@ -1,39 +0,0 @@
|
||||
Component({
|
||||
properties: {
|
||||
/** 是否显示 */
|
||||
visible: {
|
||||
type: Boolean,
|
||||
value: true,
|
||||
},
|
||||
/** 跳转目标页面 */
|
||||
targetUrl: {
|
||||
type: String,
|
||||
value: '/pages/chat/chat',
|
||||
},
|
||||
/** 可选:携带客户 ID 参数 */
|
||||
customerId: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
/** 距底部距离(rpx),TabBar 页面用 200,非 TabBar 页面用 120 */
|
||||
bottom: {
|
||||
type: Number,
|
||||
value: 200,
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onTap() {
|
||||
let url = this.data.targetUrl
|
||||
if (this.data.customerId) {
|
||||
url += `?customerId=${this.data.customerId}`
|
||||
}
|
||||
wx.navigateTo({
|
||||
url,
|
||||
fail: () => {
|
||||
wx.showToast({ title: '页面跳转失败', icon: 'none' })
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,14 +0,0 @@
|
||||
<!-- AI 悬浮对话按钮 — SVG 机器人 + 渐变流动背景 -->
|
||||
<view
|
||||
class="ai-float-btn-container"
|
||||
wx:if="{{visible}}"
|
||||
style="bottom: {{bottom}}rpx"
|
||||
bindtap="onTap"
|
||||
>
|
||||
<view class="ai-float-btn">
|
||||
<!-- 高光叠加层 -->
|
||||
<view class="ai-float-btn-highlight"></view>
|
||||
<!-- 机器人 SVG(小程序用 image 引用) -->
|
||||
<image class="ai-icon-svg" src="/assets/icons/ai-robot.svg" mode="aspectFit" />
|
||||
</view>
|
||||
</view>
|
||||
@@ -1,57 +0,0 @@
|
||||
/* AI 悬浮按钮 — 忠于 H5 原型:渐变流动背景 + 机器人 SVG */
|
||||
/* H5: 56px → 56×2×0.875 = 98rpx */
|
||||
|
||||
.ai-float-btn-container {
|
||||
position: fixed;
|
||||
right: 28rpx;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.ai-float-btn {
|
||||
width: 98rpx;
|
||||
height: 98rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* 渐变动画背景 — 忠于 H5 原型 */
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 25%, #f093fb 50%, #f5576c 75%, #667eea 100%);
|
||||
background-size: 400% 400%;
|
||||
animation: gradientShift 8s ease infinite;
|
||||
box-shadow: 0 8rpx 40rpx rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.ai-float-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* 高光叠加层 */
|
||||
.ai-float-btn-highlight {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(145deg, rgba(255,255,255,0.2) 0%, transparent 50%, rgba(0,0,0,0.1) 100%);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 背景渐变流动动画 */
|
||||
@keyframes gradientShift {
|
||||
0% { background-position: 0% 50%; }
|
||||
25% { background-position: 50% 100%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
75% { background-position: 50% 0%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
/* SVG 图标 H5: 30px → 52rpx */
|
||||
.ai-icon-svg {
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
Component({
|
||||
properties: {
|
||||
/** Banner 主题色 */
|
||||
theme: {
|
||||
type: String,
|
||||
value: 'blue',
|
||||
},
|
||||
/** Banner 标题 */
|
||||
title: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
/** 指标列表 [{label, value}] */
|
||||
metrics: {
|
||||
type: Array,
|
||||
value: [],
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
/** 背景图加载失败时降级为纯渐变色(CSS 已处理) */
|
||||
onBgError() {
|
||||
// 背景图加载失败,CSS 渐变色自动降级,无需额外处理
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
<view class="banner banner--{{theme}}">
|
||||
<view class="banner-bg"></view>
|
||||
<view class="banner-overlay">
|
||||
<text class="banner-title">{{title}}</text>
|
||||
<view class="banner-metrics">
|
||||
<view class="metric-item" wx:for="{{metrics}}" wx:key="label">
|
||||
<text class="metric-value">{{item.value}}</text>
|
||||
<text class="metric-label">{{item.label}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -1,66 +0,0 @@
|
||||
.banner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 280rpx;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.banner-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 主题渐变降级(背景图加载失败时) */
|
||||
.banner--blue { background: linear-gradient(135deg, #0052d9, #0080ff); }
|
||||
.banner--red { background: linear-gradient(135deg, #e34d59, #ff6b6b); }
|
||||
.banner--orange { background: linear-gradient(135deg, #ed7b2f, #ffaa44); }
|
||||
.banner--pink { background: linear-gradient(135deg, #d94da0, #ff6bcc); }
|
||||
.banner--teal { background: linear-gradient(135deg, #00a870, #00d68f); }
|
||||
.banner--coral { background: linear-gradient(135deg, #e06c5a, #ff8a7a); }
|
||||
.banner--dark-gold { background: linear-gradient(135deg, #8b6914, #c9a227); }
|
||||
|
||||
.banner-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 0 32rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.banner-title {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.banner-metrics {
|
||||
display: flex;
|
||||
gap: 40rpx;
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: var(--font-2xl);
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: var(--font-xs);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// 自定义底部导航栏 — 非 TabBar 页面模拟系统导航
|
||||
Component({
|
||||
properties: {
|
||||
/** 当前激活的 tab: task / board / my */
|
||||
active: {
|
||||
type: String,
|
||||
value: 'board',
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const tab = e.currentTarget.dataset.tab as string
|
||||
if (tab === this.data.active) return
|
||||
|
||||
if (tab === 'task') {
|
||||
wx.switchTab({ url: '/pages/task-list/task-list' })
|
||||
} else if (tab === 'board') {
|
||||
wx.switchTab({ url: '/pages/board-finance/board-finance' })
|
||||
} else if (tab === 'my') {
|
||||
wx.switchTab({ url: '/pages/my-profile/my-profile' })
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
<!-- 自定义底部导航栏 — 用于非 TabBar 的看板子页面,SVG icon 忠于 H5 原型 -->
|
||||
<view class="tab-bar">
|
||||
<view
|
||||
class="tab-bar-item {{active === 'task' ? 'tab-bar-item--active' : ''}}"
|
||||
bindtap="onTap"
|
||||
data-tab="task"
|
||||
>
|
||||
<image
|
||||
class="tab-bar-icon"
|
||||
src="{{active === 'task' ? '/assets/icons/tab-task-nav-active.svg' : '/assets/icons/tab-task-nav.svg'}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="tab-bar-label">任务</text>
|
||||
</view>
|
||||
<view
|
||||
class="tab-bar-item {{active === 'board' ? 'tab-bar-item--active' : ''}}"
|
||||
bindtap="onTap"
|
||||
data-tab="board"
|
||||
>
|
||||
<image
|
||||
class="tab-bar-icon"
|
||||
src="{{active === 'board' ? '/assets/icons/tab-board-nav-active.svg' : '/assets/icons/tab-board-nav.svg'}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="tab-bar-label">看板</text>
|
||||
</view>
|
||||
<view
|
||||
class="tab-bar-item {{active === 'my' ? 'tab-bar-item--active' : ''}}"
|
||||
bindtap="onTap"
|
||||
data-tab="my"
|
||||
>
|
||||
<image
|
||||
class="tab-bar-icon"
|
||||
src="{{active === 'my' ? '/assets/icons/tab-my-nav-active.svg' : '/assets/icons/tab-my-nav.svg'}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="tab-bar-label">我的</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"component": true
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* 开发调试浮动按钮组件
|
||||
*
|
||||
* 仅在 develop 环境下显示,点击跳转到 dev-tools 页面。
|
||||
* 使用 movable-view 实现可拖拽。
|
||||
*/
|
||||
Component({
|
||||
data: {
|
||||
visible: false,
|
||||
x: 580, // 初始位置:右下角附近(rpx 换算后的 px 近似值)
|
||||
y: 1100,
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
attached() {
|
||||
// 仅 develop 环境显示
|
||||
const accountInfo = wx.getAccountInfoSync()
|
||||
const env = accountInfo.miniProgram.envVersion
|
||||
this.setData({ visible: env === "develop" })
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
goDevTools() {
|
||||
wx.navigateTo({ url: "/pages/dev-tools/dev-tools" })
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
<!--
|
||||
开发调试浮动按钮 — 仅 develop 环境渲染
|
||||
可拖拽,点击跳转到 dev-tools 页面
|
||||
-->
|
||||
<movable-area wx:if="{{visible}}" class="fab-area">
|
||||
<movable-view
|
||||
class="fab-btn"
|
||||
direction="all"
|
||||
x="{{x}}"
|
||||
y="{{y}}"
|
||||
bindtap="goDevTools"
|
||||
>
|
||||
<text class="fab-icon">🛠</text>
|
||||
</movable-view>
|
||||
</movable-area>
|
||||
@@ -1,29 +0,0 @@
|
||||
/* 浮动按钮覆盖全屏,不阻挡页面交互 */
|
||||
.fab-area {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.fab-btn {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(24, 144, 255, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.25);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.fab-icon {
|
||||
font-size: 40rpx;
|
||||
line-height: 96rpx;
|
||||
text-align: center;
|
||||
width: 96rpx;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
Component({
|
||||
properties: {
|
||||
/** 筛选标签文字 */
|
||||
label: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
/** 选项列表 */
|
||||
options: {
|
||||
type: Array,
|
||||
value: [] as Array<{ value: string; text: string }>,
|
||||
},
|
||||
/** 当前选中值 */
|
||||
value: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
|
||||
observers: {
|
||||
'value, options'(val: string, opts: Array<{ value: string; text: string }>) {
|
||||
const matched = (opts || []).find((o) => o.value === val)
|
||||
this.setData({ selectedText: matched ? matched.text : '' })
|
||||
},
|
||||
},
|
||||
|
||||
data: {
|
||||
expanded: false,
|
||||
selectedText: '',
|
||||
panelTop: 0,
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
attached() {
|
||||
const { value, options } = this.data
|
||||
const matched = (options as Array<{ value: string; text: string }>).find(
|
||||
(o) => o.value === value,
|
||||
)
|
||||
this.setData({ selectedText: matched ? matched.text : '' })
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
toggleDropdown() {
|
||||
if (!this.data.options || (this.data.options as any[]).length === 0) return
|
||||
|
||||
if (!this.data.expanded) {
|
||||
// 展开时计算按钮底部位置,作为面板 top
|
||||
this.createSelectorQuery()
|
||||
.select('.filter-dropdown')
|
||||
.boundingClientRect((rect) => {
|
||||
if (rect) {
|
||||
this.setData({
|
||||
panelTop: rect.bottom,
|
||||
expanded: true,
|
||||
})
|
||||
} else {
|
||||
this.setData({ expanded: true })
|
||||
}
|
||||
})
|
||||
.exec()
|
||||
} else {
|
||||
this.setData({ expanded: false })
|
||||
}
|
||||
},
|
||||
|
||||
onSelect(e: WechatMiniprogram.TouchEvent) {
|
||||
const val = e.currentTarget.dataset.value as string
|
||||
const opts = this.data.options as Array<{ value: string; text: string }>
|
||||
const matched = opts.find((o) => o.value === val)
|
||||
this.setData({
|
||||
expanded: false,
|
||||
selectedText: matched ? matched.text : '',
|
||||
})
|
||||
this.triggerEvent('change', { value: val })
|
||||
},
|
||||
|
||||
/** 点击遮罩层关闭 */
|
||||
onMaskTap() {
|
||||
this.setData({ expanded: false })
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,27 +0,0 @@
|
||||
<!-- 筛选下拉组件 — 全屏宽度面板 + 遮罩层 -->
|
||||
<view class="filter-dropdown-wrap" wx:if="{{options && options.length > 0}}">
|
||||
<view class="filter-dropdown {{expanded ? 'filter-dropdown--active' : ''}}" bindtap="toggleDropdown">
|
||||
<text class="filter-label">{{selectedText || label}}</text>
|
||||
<t-icon name="caret-down-small" size="32rpx" class="filter-arrow {{expanded ? 'filter-arrow--up' : ''}}" />
|
||||
</view>
|
||||
|
||||
<!-- 遮罩层 -->
|
||||
<view class="dropdown-mask" wx:if="{{expanded}}" catchtap="onMaskTap" />
|
||||
|
||||
<!-- 全屏宽度下拉面板 -->
|
||||
<view
|
||||
class="dropdown-panel {{expanded ? 'dropdown-panel--show' : ''}}"
|
||||
style="top: {{panelTop}}px"
|
||||
>
|
||||
<view
|
||||
class="dropdown-item {{item.value === value ? 'dropdown-item--active' : ''}}"
|
||||
wx:for="{{options}}"
|
||||
wx:key="value"
|
||||
data-value="{{item.value}}"
|
||||
bindtap="onSelect"
|
||||
>
|
||||
<text>{{item.text}}</text>
|
||||
<t-icon wx:if="{{item.value === value}}" name="check" size="32rpx" color="var(--color-primary)" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -1,107 +0,0 @@
|
||||
.filter-dropdown-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 触发按钮 */
|
||||
.filter-dropdown {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
background: #ffffff;
|
||||
border-radius: var(--radius-md);
|
||||
border: 2rpx solid var(--color-gray-1);
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.filter-dropdown--active {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-gray-12);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-dropdown--active .filter-label {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* 箭头旋转动画 */
|
||||
.filter-arrow {
|
||||
transition: transform 0.25s ease;
|
||||
color: var(--color-gray-6);
|
||||
}
|
||||
|
||||
.filter-arrow--up {
|
||||
transform: rotate(180deg);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* 遮罩层 — 半透明黑色背景,忠于 H5 原型 */
|
||||
.dropdown-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
/* 下拉面板 — 全屏宽度,固定定位,从筛选栏下方展开 */
|
||||
.dropdown-panel {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
background: #ffffff;
|
||||
border-radius: 0 0 28rpx 28rpx;
|
||||
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
transform: translateY(-16rpx);
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
pointer-events: none;
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
|
||||
.dropdown-panel--show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 选项项 — 更大的 padding,忠于 H5 原型 */
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 34rpx 32rpx;
|
||||
font-size: 28rpx;
|
||||
color: var(--color-gray-12, #2c2c2c);
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.dropdown-item:active {
|
||||
background: var(--color-gray-1, #f3f3f3);
|
||||
}
|
||||
|
||||
.dropdown-item--active {
|
||||
color: var(--color-primary, #0052d9);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-item + .dropdown-item {
|
||||
border-top: 1rpx solid rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
Component({
|
||||
properties: {
|
||||
/** 评分 0-10,超出范围自动 clamp */
|
||||
score: {
|
||||
type: Number,
|
||||
value: 0,
|
||||
},
|
||||
},
|
||||
|
||||
data: {
|
||||
heartEmoji: '💙',
|
||||
},
|
||||
|
||||
observers: {
|
||||
score(val: number) {
|
||||
const s = val < 0 ? 0 : val > 10 ? 10 : val
|
||||
let emoji: string
|
||||
if (s > 8.5) {
|
||||
emoji = '💖'
|
||||
} else if (s > 7) {
|
||||
emoji = '🧡'
|
||||
} else if (s > 5) {
|
||||
emoji = '💛'
|
||||
} else {
|
||||
emoji = '💙'
|
||||
}
|
||||
this.setData({ heartEmoji: emoji })
|
||||
},
|
||||
},
|
||||
})
|
||||