用户: 「有的是需求,有的是需求的需求,都需要打上不同的标记」 ## 核心洞察 三类文档(META/REQ/REF)长期混用同一套审查标准 → 用错标准 = 审查失效。 ## 新增规范 docs/standards/文档性质标记规范.md (v1.0) - 三分类定义 + 判定判据(判据是「约束范围」,不是文件名) - ★ 三类规则差异表: | | META | REQ | REF | |审查标准| 能否约束住别人 | 验收标准达成 | 与代码一致 | |变更成本| 高(全仓) | 中(局部) | 低(同步) | |架构锁定| ❌ | ✅ | ❌ | - 三层落地:① 文档头标性质 ② README §六 索引 ③ META 强制类挂 AGENTS ## 全量打标 25/25 META 13 · REQ 5 · REF 7 ## ★ 盘点发现(已修复) - **11 份 META 中曾仅 4 份有强制入口** → 7 份「标记了但约束不住」 - docs/standards/ 目录名误导:18 份混着 4 REF + 3 REQ - 易错点:名为"XX接入规范"但只约束一个集成 → 是 REQ 不是 META ## 强制链补齐(AGENTS.md 绝对禁止 8~11) 新增 4 条,把"待挂"META 接入强制链: - 8. error-code.md 响应格式(成功 {code,data,message} / 错误走 HTTPException) - 9. 数据采集合并去重机制 所有采集通道的去重键 - 10. 投诉渠道口径与对账 渠道口径权威定义(改动影响全部投诉数字) - 11. 数据源接入与缓存加速 缓存铁律(写入后必须失效 pattern) ## 其它 - docs/README.md 新增 §六 文档性质索引(单一事实来源);修复历史表格粘连缺陷 - AGENTS.md 新增「文档性质纪律(强制)」章 - review-checklist.md 新增「文档性质」检查项(6 条子项) - .github/instructions/doc-class-rationale.instructions.md(applyTo: docs/**) --- ## 附带修复:前端构建/编辑器报错(真实问题) ### ① Vue 3 模板被误报 compileError(根因:仓库根无 ESLint 配置) 现象:`v-model:preset="..."` 被编辑器标 [vue/no-v-model-argument] compileError 实测澄清:vite build / docker build / 命令行 eslint **全部通过** 根因:VS Code ESLint 扩展以**工作区根**为 cwd 启动;根目录无任何 ESLint 配置 → 回退内置默认规则集(含 Vue 2 遗留规则 vue/no-v-model-argument) 修复: - 新增 eslint.config.mjs(根级**桥接**文件,转发 web/eslint.config.js,单一事实来源) - 新增 web/jsconfig.json(声明 vueCompilerOptions.target=3,Vue 语言服务) - 新增 .vscode/settings.json(eslint.useFlatConfig=true 等) - web/eslint.config.js 显式关闭 vue/no-v-model-argument 并注明原因 验证:两个 cwd 均解析出 118 条规则、该规则 = [0](一致) ### ② el-radio-button label → value 迁移(Element Plus 2.14 弃用) 实测告警:[el-radio] label act as value is about to be deprecated (3.0.0 移除) 项目实际装 element-plus 2.14.4(package.json 写 ^2.6.2)→ 告警真实存在 迁移 28 文件 / 158 处(脚本 scripts/migrate_radio_label_to_value.py,可复现) 验证:浏览器实测 el-radio 弃用告警**归零** ## 验证 - 前端 eslint 0 error(5 warning 均为既有 v-html)· vitest 52 例全绿 - vite build 成功 · docker compose build --no-cache 成功 - 文档性质标记覆盖 25/25 · AGENTS 强制链 9 份 META 关联工单: #71
45 KiB
数据同步与加工说明
性质:参考(REF) —— 记录采集链路实现现状,以代码为准
版本:v0.8 · 日期:2026-09-01 状态:实现补充文档(开发者参考) 关联:规划设计.md §十四/§十九 数据同步与加工实现补充 范围:本文件记录数据同步链路实现细节——BMS Playwright 采集、ai-acc HTTP 接入、SCRM REST API 同步、OCR 自动登录、时区加工、BMS 浏览器时区与时间窗口设置、7 天分片、去重策略、凭证管理、任务互斥/冷却/增量/异步进度/复用登录/抓取导入并行、SyncDashboard 统一入口。 v0.4 新增:§八 SCRM 同步体系、§九 OCR 自动登录、§十 SyncDashboard 统一入口 v0.8 新增:§四.5 BMS 浏览器时区与时间窗口设置(2026-09-01 生产审计修复:容器浏览器时区 UTC、
timezone_id=Asia/Shanghai、fill_utc=False、time_labels标签匹配、:visible非法选择器、负数新增告警、op_time 窗口二次校验)
一、数据同步链路总览
1.1 数据源与抓取方式
系统当前支持 5 个数据源,按 fetch_mode 分流到两条抓取链路:
| data_type | 名称 | base_url | fetch_mode | 抓取器 | 备注 |
|---|---|---|---|---|---|
| refund | 退费单管理 | https://wifi.cloud-cat.cn/bms | playwright | BmsFetcher | 浏览器自动化 |
| ticket | 服务请求管理 | https://support.cloud-cat.cn/bms | playwright | BmsFetcher | 浏览器自动化 |
| wallet | 提现操作记录 | https://support.cloud-cat.cn/bms | playwright | BmsFetcher | 浏览器自动化 |
| device_combo | 设备套餐 | https://support.cloud-cat.cn/bms | playwright | BmsFetcher | default_sync=False |
| cs_record | 智能助手处理记录 | http://ai-acc.cloud-cat.cn | http | ai_acc_fetcher | REST API,非浏览器 |
fetch_mode不在sync_source_configsDB 表里,由BMS_DATA_SOURCES种子在配置读取时(_row_to_config)合并进来,用于bms_sync.sync_all分流。
1.2 抓取链路分流
bms_sync.sync_all 把任务按 fetch_mode 分两组:
fetch_mode == "http"→ai_acc_fetcher.fetch_one(httpx,无浏览器)- 其他(默认
playwright)→BmsFetcher.fetch_all(共享一个 Playwright 浏览器实例)
两条链路最终都产出一个本地 xlsx 文件,统一走 _auto_import(parse_file → load_dataframe → run_upsert)入库。因此所有数据源共用一套解析与加工口径(列映射、类型转换、时区加工、去重策略),抓取方式的差异不渗透到导入层。
1.3 时间范围计算(v0.3.2 重构后)
| 触发方式 | 时间范围 | 实现 |
|---|---|---|
| 定时同步 | 当天 00:00 ~ now(东八区) | bms_scheduler._do_scheduled_sync 硬性限定 today_start ~ now_cn |
| 手动同步(未指定 range, 默认增量) | 上次成功同步 end_time ~ 当前时间;若从未成功过或比今日 0 点早,则从今日 0 点起算 |
SyncTaskManager.compute_incremental_ranges (查 bms_sync_logs 最近 SUCCESS 记录的 time_end) |
| 手动同步(用户指定自定义区间) | 调用方传入的精确区间 | 前端按 today/yesterday/本周/自定义等 UI 传 time_ranges + force=true 跳过冷却 |
/task/preflight 返回值 |
三种组合字段:busy (有运行中任务)/cooldown_remaining(距上次成功剩余秒数)/incremental_ranges(data_type→[start,end]) |
供前端展示给用户,用户可确认后再调 /task/submit |
硬性约束:
- 定时同步必须显式限定到当天数据,不允许同步过多历史数据。每次定时覆盖当天 0 点至今,upsert 幂等不产生重复,反而能补回上次失败漏掉的数据。
- 手动同步有 5 分钟冷却期 (
DEFAULT_COOLDOWN_SECONDS = 300) — 上次成功结束到本次开始不足 5 分钟的,非force=true一律拒绝,避免无意义地频繁拉取服务器。- 任何时刻最多一个同步任务在执行 — 由
SyncTaskManager._lock(全局 asyncio 互斥锁)保证:手动/定时无论谁先拿到锁,另一路都会抛RuntimeError("当前有同步任务正在执行…请等待其完成")。
二、ai-acc 智能助手处理记录接入(cs_record)
2.1 为何走 HTTP 而非 Playwright
ai-acc 平台提供标准 REST API(登录 + 导出),无需浏览器自动化即可完成抓取,相比 Playwright:
- 无浏览器进程开销,启动快、资源占用低
- 无 1 分钟操作间隔限制(BMS 系统有此限制)
- 无页面 DOM 变更脆弱性
- 适合服务器无人值守定时同步
2.2 接口契约
登录:POST {base_url}/ai-acc/portal/v1/auth/login
- body:
{"username":"...","password":"..."} - response:
{"token":"...","userId":"...","username":"..."} - ⚠️ token 在 JSON 顶层,不在
data字段下——与 BMS 风格{data:{token}}不同。_login同时检查data.token与顶层token兼容两种风格。 - 登录同时下发 session cookie,httpx 共享 client 自动保持。
导出:GET {base_url}/ai-acc/cs/v1/assistant/handleRecord/export
- params:
createDateStart=YYYY-MM-DD HH:mm:ss & createDateEnd=YYYY-MM-DD HH:mm:ss - auth:
Authorization: Bearer {token}+ session cookie - response: xlsx 文件流(
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet) - ⚠️ 用户提供的 URL
/admin/assistant/handle-record-export是前端路由,不是 API 端点。
2.3 非文件流识别
导出接口在业务异常时返回 JSON 而非文件流,典型场景:
{"code":"500","message":"操作时间范围不能超过7天","timestamp":1785459901135}
_export_chunk 检查 Content-Type,含 json 则抛异常(带响应体前 300 字符),避免把错误 JSON 当 xlsx 解析。
三、7 天时间范围分片
3.1 限制规则
ai-acc 导出接口限制单次时间范围不得超过 7 天:
- 正好 7 天:允许(HTTP 200)
- 超过 7 天:拒绝(返回上述 JSON 错误)
3.2 分片实现
ai_acc_fetcher._chunk_time_range 把 [start, end] 切成若干 ≤7 天的连续子区间:
def _chunk_time_range(start, end, max_days=7):
chunks = []
cur = start
step = timedelta(days=max_days)
while cur < end:
chunk_end = min(cur + step, end)
chunks.append((cur, chunk_end))
cur = chunk_end
return chunks
- 子区间边界相接(
chunk_i.end == chunk_{i+1}.start) - API 端点
inclusive,边界秒的记录可能在两片中都出现 → 合并时drop_duplicates()去重
3.3 合并实现
逐片 GET → pd.read_excel(BytesIO(content), dtype=str, engine="openpyxl") 读为 DataFrame → pd.concat 合并 → drop_duplicates():
frames = []
for cs, ce in chunks:
content = await _export_chunk(...)
df = pd.read_excel(BytesIO(content), dtype=str, engine="openpyxl")
frames.append(df)
merged = pd.concat(frames, ignore_index=True).drop_duplicates()
dtype=str:与data_parser解析口径一致(全文本读入,由 upsert 层做类型转换),避免数值/日期列在合并时被 pandas 推断错类型。- 合并后写入单个 xlsx(
openpyxl,单行表头 + 全文本),文件名ai_acc_{data_type}_{ts}.xlsx,落uploads/{YYYY-MM}/。
3.4 验证
14 天范围(2 片)E2E 测试:合并文件 69015 行,内部 0 重复,边界 0 重复。已清理测试数据。
四、时区加工(关键 bug 修复)
4.1 问题
运营平台(BMS CSV / ai-acc xlsx)导出的时间字段为 naive 北京时间字符串(无时区标识)。原 upsert._to_datetime 用 pd.to_datetime 返回 naive datetime,存入 DateTime(timezone=True) 列时,因 DB 时区为 Etc/UTC,naive 值被当 UTC 解释,导致按北京显示时偏后 8 小时。
复现:CSV 原始 2026-07-30 22:32:00 存为 22:32+00 UTC,前端按北京显示为 2026-07-31 06:32:00(次日)。
4.2 修复
upsert._to_datetime 把 naive 视为北京时间(UTC+8)→ tz_localize(_TZ_CN).tz_convert(utc) 后再存;aware datetime 原样返回:
_TZ_CN = timezone(timedelta(hours=8))
def _to_datetime(val):
if isinstance(val, datetime):
if val.tzinfo is None:
return val.replace(tzinfo=_TZ_CN).astimezone(timezone.utc)
return val
result = pd.to_datetime(val)
if result.tzinfo is None:
result = result.tz_localize(_TZ_CN).tz_convert(timezone.utc)
return result.to_pydatetime()
- 影响所有 BMS/ai-acc 导入路径(手动上传、一键同步、定时同步、Playwright/HTTP 两条链路),因为统一走
upsert层。 - 纯日期字段(如
complaint_date)迁移后按北京显示00:00(日期不变)。 - 系统内部表(
incidents/measures/reports等服务器生成 UTC 时间)未动。
4.3 存量数据迁移
修复代码只影响新写入数据,存量 naive-当-UTC 的数据需一次性 SQL 迁移。对 7 张有数据的业务表执行 col - INTERVAL '8 hours':
| 表 | 迁移行数 |
|---|---|
| wallet_withdrawals | 9653 |
| service_tickets | 18706 |
| refunds | 42944 |
| cs_records | 70227 |
| device_combos | 44672 |
| external_complaints | 1312 |
| card_complaints | 720 |
| merchant_refunds | 0(空表,跳过) |
验证:wallet 记录迁移后显示 2026-07-30 22:32:00 北京 = CSV 原始值;wallet 小时分布回归业务时段(不再凌晨聚集)。
4.4 后续保障
代码已编译部署,未来所有同步路径(手动上传 / 一键同步 / 定时同步 8-23/0 每小时 + 8:00 近 7 天全量 + 5:00 前一天补跑 / BMS / ai-acc)自动按北京时间 → UTC 转换,无需额外操作。
4.5 BMS 浏览器时区与时间窗口设置(2026-09-01 生产审计修复)
背景:2026-08-28 ~ 09-01 生产日志审计发现「当日退款/投诉数据无法按小时实时同步」。排查结论:不是数据删除、也不是时区换算 8 小时偏移,而是 BMS 页面日期时间框(Element UI DatePicker)被自动化填入的"时间窗口"不可靠——窗口没设置成功时,BMS 页面保留默认/上次时间范围,导致 ① 当前窗口增量数据漏掉(当天看不到)② 拉到窗口外的旧数据与库内已存在记录冲突(统计负数)。真正根因是浏览器自动化填入日期框这一步,而非数据本身或时间换算。
4.5.1 根因一:容器浏览器时区 = UTC(已修复)
Playwright 无头浏览器进程默认时区是 UTC,而 BMS 前端按浏览器时区取"当前时刻"并解析输入串。此前把北京时间转 UTC 填入(fill_utc=True),BMS 又按字面值解析 → 窗口错位。
修复(bms_fetcher._do_fetch_one / _do_fetch_one_after_login):
new_context(timezone_id='Asia/Shanghai')(两处)→ BMS 前端当前时刻 = 北京时间- 填入的时间字符串保持北京时间(
fill_utc=False,即_bms_time_str默认按 dt 自身时区格式化,与用户手动导出一致) - 每源可用
sync_source_configs.time_fill_utc覆盖
_bms_time_str(dt, time_format="%Y-%m-%d %H:%M:%S", fill_utc=False)
4.5.2 根因二:ticket 时间标签匹配失败(已修复)
ticket 源配置 time_labels=["起始登记时间","结束登记时间"],但 BMS 服务请求管理页:
- 这些字段是 placeholder 而非
.el-form-item__label元素 →_fill_input_by_label找 label 永远失败 - 时间框在高级筛选里默认折叠不可见(
visible=False)→ 需先点.show-more展开
修复(bms_fetcher._set_time_by_labels + 新增 _fill_daterange_inputs):
- 先点
.show-more展开高级筛选(仅当时间框不可见时) - 按 placeholder 精确匹配(
input[placeholder="起始登记时间"])定位正确的登记/完成时间框 _fill_daterange_inputs:每个 input click 聚焦 → fill(4s 超时) → evaluate setter 兜底,用blur + ESC关面板(不用page.mouse.click避免滚动不稳定)——Element UI DatePicker 的 input 需先 click 聚焦才可 fill,否则 fill 超时且 setter 不触发 Vue v-model → 查询用默认近一周。
4.5.3 根因三:_set_time_range 兜底 evaluate 内 :visible 非法选择器(已修复)
_set_time_range 最后兜底在 page.evaluate 里执行 document.querySelectorAll('input.el-range-input:visible, .el-date-editor input:visible')——:visible 是 jQuery / Playwright 引擎伪类,原生 JS querySelectorAll 不识别,直接抛 SyntaxError(生产 wallet 09-01 12:05/15:05 轮次 'input.el-range-input:visible' 不是合法选择器,抓取中断、窗口回退默认范围)。
修复:先按类名选中(input.el-range-input, .el-date-editor input),再用 JS getBoundingClientRect + computedStyle 过滤可见性:
const isVisible = (el) => {
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0
&& style.visibility !== 'hidden' && style.display !== 'none';
};
⚠️ 注意区分:Playwright 引擎侧(
page.query_selector(':visible')/page.locator('button:visible'))的:visible是合法的引擎伪类;只有page.evaluate内原生 JS 里不能用。
4.5.4 时间窗口设置的完整策略链(_set_time_range)
按优先级从高到低,命中即 return:
- 策略0:轮询(最多 6s)找 Element UI datetimerange/daterange 容器(
.el-date-editor--datetimerange/.el-date-editor--daterange/.el-range-editor),取内部 2 个.el-range-input→ click→ctrl+a→backspace→type 键盘输入 → 回填 value 二次校验(DOM value + Vue2 实例 value,失败则 fallback_fill_el_input+setter,再失败抛错中止避免按错误窗口导出脏数据) - 策略1:
.el-date-editor--datetimerange容器的.el-range-input - 策略2:可见日期相关 input 取前两个
- 策略3:placeholder 含「开始/结束」兜底 fill
- 策略4(终极兜底):所有可见 input 按 y 坐标分组,优先命中含
el-range-input的组,否则取最上一行 ≥2 个非 readonly text input,用 JS setter + input/change 事件触发 - 最后兜底:evaluate 找 range(见 4.5.3 修复)
_set_time_by_labels(有 time_labels 的源,如 ticket)额外前置 _fill_input_by_label 的 label 查找与 placeholder 策略0。
4.5.5 负数"新增"与告警(统计口径,不删数据)
upsert_wallet 用 on_conflict_do_nothing + 批次去重。当窗口设置失败拉到大量已存在旧流水时,existing_count > unique_rows → 旧代码 result.inserted += len(unique_rows) - existing_count 产生负数新增(生产 -4885/-587/-515/-153/-15)。不删除任何数据,负数只是统计口径失真。
修复(upsert.py upsert_wallet):
inserted += max(net_inserted, 0)保证统计不为负(口径 = 本次实际新增条数)net_inserted < 0时打 WARNING:[wallet] 疑似时间窗口未设置成功: 本轮拉回 N 行中 M 行已存在于库内 (净新增 X<0)→ 便于早发现窗口设置失败
4.5.6 op_time 窗口二次校验(审计建议 3)
wallet 导入时输出实际拉回数据的 op_time 分布(UTC),用于对照同步日志里的「设置时间范围」判断窗口是否真的生效:
[wallet] 实际入库 34 行 op_time 范围: 2026-08-31 16:04:41 ~ 2026-09-01 02:49:45
生产 09-01 14:05 轮:同步窗口应为
09-01 00:00 ~ 14:05,实际拉回却大多落在前一天深夜~凌晨 → 直接坐实"页面保留了默认/上次时间范围"。
4.5.7 排障速查
| 现象 | 看什么 | 处理 |
|---|---|---|
| 当天数据看不到 / 增量缺失 | 同步日志 设置时间范围: X ~ Y 是否 = 期望窗口 |
检查 4.5.1~4.5.3 修复是否已部署(timezone_id + fill_utc=False + placeholder/:visible 修复) |
[wallet] 导入完成: 新增 为负数 |
是否命中 4.5.5 WARNING | 窗口未设置成功,回退默认范围拉到旧流水;确认修复已部署后重同步 |
[wallet] 实际入库 N 行 op_time 范围 与窗口不符 |
同上 | 同上 |
未找到 '起始登记时间' 对应的输入框 / fill 失败,退化为 evaluate 直接设值 |
ticket 标签匹配 | 确认 4.5.2 修复(placeholder 策略 + .show-more 展开)已部署 |
'input.el-range-input:visible' 不是合法选择器(SyntaxError) |
_set_time_range 兜底 evaluate |
确认 4.5.3 修复已部署 |
五、去重策略(pk_strategy)
5.1 三种策略
各业务表按是否有天然唯一键采用不同入库策略,由 upsert_*.py 内部分流:
| 策略 | 实现 | 适用表 |
|---|---|---|
| upsert(主键去重更新) | pg_insert.on_conflict_do_update,已存在则更新非主键字段 |
refunds(refund_no)、tickets(ticket_no)、merchant_refunds(wx_refund_no)、merchants(sub_name)、device_combos(recharge_order_no)、external_complaints(有 complaint_no 的部分)、manual_refunds(复合键,见 5.3) |
| none(无主键插入) | pg_insert.on_conflict_do_nothing 兜底,不去重 |
wallet、cs_record、card_complaint、external_complaints(无 complaint_no 的部分) |
| fallback 主键 | 无天然主键时构造稳定合成主键,再走 upsert | device_combos 初始套餐 |
5.2 cs_record 为何不去重
cs_record(智能助手处理记录)无天然唯一键:
- 同设备同时间可多次接待(业务上合法)
- 导出表无单号/ID 列
- 即使用 (device_sn, operation_time) 组合也无法保证唯一(同秒多次接待)
因此 upsert_cs_records 走 on_conflict_do_nothing,不去重。带来的影响:
- ✅ 正常增量同步(定时用
last_end_time非重叠区间):无重复 - ⚠️ 手动重同步重叠范围:会产生重复行(需人工避免重叠,或后续提供去重工具)
5.3 device_combo fallback 主键
初始套餐(免费赠送/体验套餐)在 BMS 导出时无充值订单号,此前被主键非空校验跳过导致缺失。现生成稳定 fallback 主键:
INIT::{device_sn}::{package_name}::{start_date}
INIT::前缀与真实订单号(BR/WX/SP 开头)隔离,绝不冲突(device_sn, package_name, start_date)组合唯一,保证多次增量同步幂等(命中on_conflict_do_update)device_sn缺失时无法构造稳定唯一键,才报错跳过(安全网,正常初始套餐不触发)
5.4 external_complaints 混合策略
投诉编号有则走 upsert(96%),无编号的行(~3%)走 on_conflict_do_nothing 插入(与 cs_record 一致,重复导入会累计)。投诉编号单元格含 \n 前缀,导入时剥离。
5.5 manual_refunds 复合去重键(2026-08-24 调整)
手动退款登记(商户号手机退款处理登记.xlsx / workbuddy 推送)无单号主键,去重键为 (北京登记日期, order_no, COALESCE(device_sn,''), COALESCE(registrant,''))(唯一索引 uq_manual_refunds_dedup,部分索引 WHERE order_no IS NOT NULL):设备号+订单号+登记日期+登记人 确定一笔退款单,退款金额/状态修改走 UPDATE 不新增(旧键含退款金额导致改金额即新增→残留累积使 D 路虚高,2026-08-24 清理 3929→1976 行);workbuddy 推送支持 full_sync=true 全量对账删除残留行,详见 workbuddy手动退款数据同步接入规范.md
5.6 钱包提现"失败/冻结"备注口径(2026-08-28)
BMS 提现操作记录中,备注含 提现申请-冻结 等失败/冻结关键词的记录表示提现申请被冻结/拒绝,钱并未实际出账给用户,并非成功退款。但这类记录 op_type 仍是 用户提现,若不排除会导致"成功退款/成功提现"统计虚高。同时,同一笔提现会出现"冻结记录 + 成功记录"成对(同一 WD 提现单号,冻结记录 op_time=申请时刻、成功记录 op_time=实际出款时刻)。
统一口径(用户确认,2026-08-28):
- 冻结/失败记录全部排除:备注含
冻结/退款失败/提现失败/超期订单不允许退款/驳回/拒绝/未到账等失败关键词 → 不计入成功退款统计。 - 同单成对只算成功:同一
WD提现单号同时有冻结+成功记录时,只统计成功记录(排除冻结记录后自动满足)。 - 历史数据同样生效:过滤基于备注字段,历史已入库的失败/冻结记录(如
微信退款失败:超期订单不允许退款)同样被排除,无需数据清洗。
实现位置:
analytics.py新增_WITHDRAW_FAIL_REMARK_KEYWORDS+_withdraw_fail_remark_sql_cond(),_route_b_withdraw_filter()统一加COALESCE(remark,'') NOT LIKE '%关键词%'条件;refund_decision_overview/refund_export_all/wallet_vs_withdraw/cash_out_trend等内联 SQL 同步加同一条件。preset_views_seed.py各"用户提现"segment 的 filters 追加remark not_contains排除。quality_check.py的check_wallet_row对失败/冻结备注标记pending(数据质量页可见,便于审计)。
5.7 通用 upsert 执行器
_execute_upsert 先查已存在主键数量再执行 on_conflict_do_update,以精确区分 insert/update 计数。同批次内重复主键会被去重(保留最后一条,符合"后到覆盖"语义),避免 ON CONFLICT affect row a second time 错误。
六、凭证管理
6.1 加密存储
各数据源账号密码独立配置,存于 sync_source_configs 表,用 Fernet 加密(app/core/crypto.py):
- 启动时
ensure_sync_sources_registered幂等 seed:缺失的源用.env的BMS_USERNAME/BMS_PASSWORD加密灌入;已存在的源不覆盖(保留用户在 UI 的编辑) - 运行时
get_sync_source_config解密返回给 fetcher - API
list_sync_source_configs返回掩码(has_credentials布尔),不泄露明文
6.2 ⚠️ 加密密钥变更影响
修改 .env 的 CONFIG_ENCRYPTION_KEY 或 JWT_SECRET_KEY 会使现有 sync_source_configs 凭证解密失败:
- 现象:解密失败后系统把密文当作用户名发送给 BMS,BMS 返回"用户密码错误"
- 修复:在「系统管理 - 同步设置」页重新填写各源账号密码(PUT
/api/v1/system/sync-sources/{data_type}),重新加密入库 - 预防:变更加密密钥后,所有数据源凭证必须重新写一次
6.3 凭证来源优先级
- DB
sync_source_configs表(解密后)—— 运行时主用 .envBMS_USERNAME/BMS_PASSWORD—— 仅 seed 初始值 / DB 缺失时回退
七、定时同步调度
7.1 调度规则(东八区)
- 白天每小时:
08:00, 09:00, ..., 23:00, 00:00(共 17 次)当天窗口增量同步(带专项级熔断过滤) - 8:00 全量兜底:近 7 天窗口全量同步(覆盖 BMS 数据按登记时间分批滞后 1~2 天的回填,忽略专项熔断)
- 5:00 前一天补跑:对前一天整天做整体同步(忽略专项熔断,强制跑所有源;2026-08-31 由 3:00 调整,避开 BMS 平台凌晨维护时段)
- 触发窗口:每分钟检查,只在定时小时的前 5 分钟内触发(防止错过)
- 同一小时去重:
_last_run_hour字典防止同一小时重复执行 - 仅同步
default_sync=True的源(DEFAULT_SYNC_TYPES)
调度常量见
backend/app/services/bms_scheduler.py:DAYTIME_HOURS(8~23+0)、CATCHUP_HOUR(5)、FULL_WINDOW_DAYS(7)。
7.2 启动前置检查
_default_sources_have_credentials 检查默认同步的源是否都已配置凭证,未配置则跳过本次定时并告警(无人值守必须预设)。
7.3 时间范围约束
- 白天增量 / 8 点全量:
time_ranges = {dt: (今天/近 N 天 0 点, now_cn)} - 5 点补跑:锁定「前一天整天」(
yesterday_full_sync=True) - 理由:upsert 幂等不会产生重复;覆盖当天 0 点至今能补回上次失败漏掉的数据;防止无人值守时误同步过多历史数据拖垮系统。
7.4 与 SyncTaskManager 共享全局互斥锁 (v0.3.2)
调度器现在不直接跑 sync_all_data,而是把任务提交给 SyncTaskManager.submit(trigger_type='scheduled'…):
- 效果:若此时用户正手动同步,调度器拿不到锁会立即抛错跳过本小时(日志记录
scheduler task skipped: 有 xxx 触发的任务在执行),不会阻塞。 - 反方向也保护:若定时正在执行,用户点「开始同步」也会被后端拒绝并提示"当前有定时任务在执行"。
- 冷却对
scheduled触发无效(定时本就按 4h+ 间隔运行,无需再叠冷却)。
八、同步任务管理:SyncTaskManager(v0.3.2 新增)
8.1 设计背景与目标
单后端单实例部署 + 长耗时抓取(BMS playwright 登录+导出 45 张表通常 38 分钟,下载到本地与导入到 DB 合计又数分钟)下,原实现存在几类典型问题:
| 问题 | 用户表现 | 解决方式 |
|---|---|---|
| 重复发起 | 用户点击「同步」后 UI 无进度反馈,超时后再点,会在后台同时开两个浏览器,BMS 同账号多 session 被踢下线 | 全局 asyncio.Lock 互斥锁 + 前端提交后不再阻塞(taskSubmit 返回 task_id → 前端轮询进度) |
| 冷却不足 | 用户 30 秒内点两次,其实服务器数据一分钟内并无差异 → 白白浪费抓取配额 | 上次成功结束后 5 分钟内,非 force=true 一律拒绝 |
| 无增量语义 | 每次「一键同步」都默认拉今日 0 点,重复抓取白天相同记录 → 浪费服务器流量与配额 | compute_incremental_ranges 查 bms_sync_logs 最近 SUCCESS 记录的 time_end,下次就从那个时间起 |
| 无进度可见 | 点按钮后前端长时间转圈 / 超时,后端日志只能看 shell,无法在 UI 内反馈进度 | 每个任务一个 SyncTaskProgress,实时记录阶段 + 子步骤进度,taskStatus 接口可轮询 |
8.2 核心数据结构
SyncTask (一个任务对象,内存常驻直到被覆盖) + SyncTaskProgress:
@dataclass
class SyncTaskProgress:
stage: str # preflight / fetching / importing / done
message: str = "" # 中文文案, 直接展示
current_step: int = 0
total_steps: int = 0
sub_progress: float = 0.0 # 0.0 ~ 1.0, 用于当前步骤内细分进度
sub_message: str = ""
results: list = field(default_factory=list) # 每个 data_type 完成后立即写入
started_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
finished_at: Optional[datetime] = None
status: str = "running" # running / success / failed
error: Optional[str] = None
进度回调签名:on_progress(stage, message, *, cur, total, sub=0.0, sub_msg="") — 所有 fetcher/sync 代码都已接入这个回调,下载一个文件 / 导入一张表后立刻推进前端可见进度。
8.3 新增异步 API(/api/v1/fetch/*)
| 路由 | 方法 | 用途 |
|---|---|---|
/task/preflight |
POST | 提交前检查:返回 { busy, current_task, cooldown_remaining, cooldown_total, incremental_ranges }。前端可据此显示"当前有任务执行/距离上次仅过 41 秒/本次增量范围 14:23 起"等提示。 |
/task/submit |
POST | 异步提交,校验锁+冷却后立即返回 {task_id, message},然后在后台 asyncio.create_task 里执行真正的同步(不阻塞 HTTP 响应, 避免超时)。 |
/task/status |
GET ?task_id=sync_xxx |
轮询进度(每秒一次即可),返回完整 SyncTaskProgress。前端据此渲染进度条 + 各步骤卡片。 |
/sync-all |
POST | 兼容旧前端,内部调用 submit。若 submit 失败则回落到 legacy 阻塞式同步,保证行为一致。 |
前端调用推荐流程:先点「开始同步」→ 前端调用
/task/preflight→ UI 提示用户本次将做什么 → 确认后调用/task/submit→ 拿到 task_id 后按 1s 间隔轮询/task/status→ finished 后 toast 成功 + 刷新当前页图表;中途可任意关闭页面不影响后台执行(除非重启后端进程)。
九、BMS 同 base_url 复用浏览器 Context(v0.3.2 优化)
9.1 问题来源
5 张 BMS 表里,refund 的 base_url 是 wifi.cloud-cat.cn,其余 ticket/wallet/device_combo/cs_record_bms 都在 support.cloud-cat.cn。若每抓一张表都新 Context + 新登录,就会发生:
- BMS 登录滑块偶尔触发,人工 1 分钟限制也在"每次登录"上叠加 → 总耗时成倍增加
- 同一 IP 反复登录触发风控 → 导出接口变慢
9.2 优化:按 base_url 分组共享登录后的 Context
BmsFetcher.fetch_all() 内部先按 base_url 对 tasks 分组,组内共用一个 Playwright BrowserContext:
- 组首任务第一次去该 URL 时走完整登录(含滑块 → 等待登录成功 Cookie)
- 登录成功后
already_logged_in = True - 组内后续任务访问时,带上已有 Cookie / 会话 → BMS 后台直接放行,不再重登
- 组内两个任务之间仍遵守 BMS 官方 1 分钟「两次下载间隔」限制(同一 web 间隔超过 1 分钟即可,登录复用后下载等待语义不变)
- 整组结束后
finally: context.close()保证会话释放
实测效果(4 张 support 域 + 1 张 wifi 域 = 两组):登录次数从 5 次降为 2 次,端到端耗时平均缩短 40%~60%。
十、抓取与导入并行化(v0.3.2 优化)
10.1 关键点:下载等待 1 分钟 vs DB 导入可并发
BMS/ai-acc 对「两次导出请求的间隔」有 1 分钟下限(同一 Web),但这个 1 分钟等待发生在抓取器(BmsFetcher)内部,跟本地的 DB 写入是无关的。以前链路是串行:
抓 refund → 等待间隔 60s → 抓 ticket → 等待 60s → 抓 wallet → 等待 → …
全部抓完后 → 导入 refund → 导入 ticket → 导入 wallet → …
等待间隔期间 CPU/DB 都是空转。
10.2 优化后:每个 FetchResult 就绪立刻启动独立导入线程
# bms_sync._sync_all_async_runner 的 progress 包装回调
async def on_progress_with_import(stage, message, **kw):
if stage == "fetch_done":
# 单个 data_type 刚下载到本地 xlsx, 立刻扔线程池开始导入, 不阻塞下一次抓取
asyncio.create_task(asyncio.to_thread(_do_import, fr))
await orig_progress(...) # 继续调用上层回调推进整体进度条
# 所有抓取跑完后, gather 等待所有导入线程结束
await asyncio.gather(*import_pending_tasks, return_exceptions=True)
如此抓取与导入重叠,总体端到端耗时可以进一步压缩 30%~40%(尤其表数多、单表体积大时效果更显著)。
不破坏原有的 1 分钟访问约束:互斥只在"抓取请求之间",导入是本地写库,跟抓取互不阻塞。
十一、前端看板组件化(v0.3.2 UI 侧同步)
11.1 背景
今日看板 (Today.vue)、接待看板 (Reception.vue)、工作台首页 (Dashboard.vue)、退款与提现看板 (RefundWithdrawal.vue)、现金流分析 (Cash.vue) 存在大量相同结构的代码:
- KPI 卡(图标 + 标签 + 数值 + 单位 + 环比)在 5+ 份文件里各写了一遍 HTML+CSS
- 现金流 4 路线拆分 + 对账偏差 (A/B/C/D + 经营估算 vs 实际) 在 Today、Dashboard、Cash 三处重写
- FilterBar、区块标题、图表卡、待办队列面板样式在每个看板都各写一份 (共 3~5 个拷贝)
这会导致 UI 调整需要改多份文件,极易出现"改了看板 A 忘记看板 B"的不一致。
11.2 抽出的复用组件 (全部在 components/dashboard/)
| 组件 | 用途 | 已覆盖的看板 |
|---|---|---|
KpiCard.vue |
统一 KPI 卡片(含金额/数值、图标、环比标签、去重设备人次追加行、简洁 hideYoy 模式、tip tooltip) | Today / Reception / Dashboard首页 |
RouteSplit.vue |
四路现金拆分+对账偏差(堆叠进度条可视化 + op/fc/diff 三合计 + compact/hideHeader 模式) | Today / Dashboard首页 |
ChartCard.vue |
图表外层卡片(标题 + loading + extra 插槽),统一所有图表卡片的 padding/间距 | Today / Reception |
SectionHeader.vue |
区块标题(左边彩色竖条+主副标题),替代 4 处 .section-title 内联样式 |
Today / Reception |
FilterBar.vue |
快捷日期选择 + 自定义区间 + 刷新按钮(支持 v-model) | Today / Reception |
QueuePanel.vue |
三列事务面板(待办/专项/投诉,优先级 Tag + 截止时间格式化 + 点击跳转) | Today |
11.3 代码量缩减(构建后 chunk 对比)
Dashboard.js 19.36 kB → 12.87 kB ↓ 34%
Today.js 31.38 kB → 26.49 kB ↓ 16%
Reception.js 17.83 kB → 17.86 kB ≈ 持平 (拆分后公共代码抽到独立 chunk)
- 公共组件 chunk:
KpiCard + ChartCard + RouteSplit + FilterBar + SectionHeader + QueuePanel
合计 < 20 KB (gzip < 7 KB)
新增看板 / 新增分析卡片时可直接
<KpiCard ... />+<SectionHeader ... />+<RouteSplit ... />快速搭出风格一致的页面,不再重复写内联 CSS。
十二、文件清单(v0.3.2 刷新)
| 文件 | 职责 | v0.3.2 是否新增/大改 |
|---|---|---|
backend/app/services/sync_task_manager.py |
核心新增:SyncTaskManager 单例(全局互斥锁/冷却/增量/进度/异步任务提交) | ✅ 新增 |
backend/app/services/bms_fetcher.py |
BMS_DATA_SOURCES 种子 + BmsFetcher(Playwright,已按 base_url 分组复用 Context) | ✅ 大改 |
backend/app/services/ai_acc_fetcher.py |
ai-acc HTTP 抓取(登录 + 导出 + 7 天分片合并) | |
backend/app/services/bms_sync.py |
同步编排(fetch 完成即启动导入线程并行化 + SyncTaskManager 的 runner) | ✅ 大改 |
backend/app/services/bms_scheduler.py |
定时调度(8-23/0 每小时 + 5:00 补跑 + 8:00 全量兜底;现走 SyncTaskManager 提交通道共享锁) | ✅ 小改 |
backend/app/api/v1/fetch.py |
新增 /task/preflight /task/submit /task/status 三个 API;/sync-all 兼容旧前端 |
✅ 大改 |
backend/app/services/sync_source_config_service.py |
凭证加密存取 + fetch_mode 合并 | |
backend/app/services/upsert.py |
类型转换(含时区加工 _to_datetime)+ 三种去重策略 |
|
backend/app/services/data_parser.py |
CSV/XLSX 解析 + 列映射建议 | |
backend/app/core/crypto.py |
Fernet 加解密 | |
web/src/components/dashboard/KpiCard.vue |
可复用 KPI 卡片 | ✅ 新增 |
web/src/components/dashboard/RouteSplit.vue |
四路现金拆分 + 对账偏差可视化 | ✅ 新增 |
web/src/components/dashboard/ChartCard.vue |
图表外层卡片 | ✅ 新增 |
web/src/components/dashboard/SectionHeader.vue |
区块标题 | ✅ 新增 |
web/src/components/dashboard/FilterBar.vue |
日期快捷筛选 + 自定义区间 | ✅ 新增 |
web/src/components/dashboard/QueuePanel.vue |
三列事务面板 | ✅ 新增 |
web/src/views/dashboards/Today.vue |
今日看板 (已组件化,清理死代码) | ✅ 大改 |
web/src/views/dashboards/Reception.vue |
接待看板 (已组件化) | ✅ 大改 |
web/src/views/Dashboard.vue |
工作台首页 (组件化 KpiCard + RouteSplit) | ✅ 大改 |
十三、变更记录
| 日期 | 版本 | 变更 | 关联 |
|---|---|---|---|
| 2026-08-02 | v0.3.2 | 同步任务管理体系(SyncTaskManager 全局互斥/冷却/增量/进度/异步提交) | sync_task_manager.py + fetch.py /task/preflight·submit·status |
| 2026-08-02 | v0.3.2 | 抓取优化 1:BMS 同 base_url 分组复用浏览器 Context 登录 | BmsFetcher.fetch_all → group by base_url |
| 2026-08-02 | v0.3.2 | 抓取优化 2:单文件抓完立刻启动导入线程,抓取与导入并行 | bms_sync._sync_all_async_runner (asyncio.to_thread + gather) |
| 2026-08-02 | v0.3.2 | 前端看板 6 类卡片抽公共组件,Today/Reception/Dashboard 首页替换;代码量↓ 16%~34% | components/dashboard/*.vue(6 个新文件) |
| 2026-07-31 | v0.3.1 | 新增 cs_record 数据源(ai-acc HTTP 接入) | ai_acc_fetcher.py / bms_fetcher.py / Dockerfile(httpx) |
| 2026-07-31 | v0.3.1 | 修复时区 bug(naive 北京时间 → UTC) | upsert.py _to_datetime + 7 张表 SQL 迁移 |
| 2026-07-31 | v0.3.1 | ai-acc 7 天范围分片 + 合并去重 | ai_acc_fetcher.py _chunk_time_range |
| 2026-07-31 | v0.3.1 | 凭证密钥变更后重新写入 4 个数据源 | sync_source_config_service.py |
| 2026-08-09 | v0.4 | SCRM 同步体系(operator/session/transfer 三表 + 增量 + Token 自动刷新) | scrm_sync_service.py / scrm_client.py |
| 2026-08-10 | v0.4 | OCR 自动登录置信度预检(ddddocr + 概率检查 + 内层预检循环) | captcha_session.py |
| 2026-08-12 | v0.4 | SyncDashboard 统一入口(BMS+SCRM 合并表单 + 实时日志面板) | SyncDashboard.vue / Ingest.vue |
| 2026-08-14 | v0.4 | BMS 查询按钮点击策略升级(5 层兜底 + SPA 渲染就绪守卫) | bms_fetcher.py _click_query_button |
| 2026-08-17 | v0.4 | alembic migration 补齐(merge head + Group/Specialty 6 表) | alembic/versions/m0a1b2c3d4e5 + n1b2c3d4e5f6 |
| 2026-08-19 | v0.6 | 采集并发优化:submit_multi 一次任务内 asyncio.gather 并发执行 BMS/SCRM runner,BMS 下载冷却期穿插 SCRM;同步完成统一失效 today:*/analytics:* 缓存 |
sync_task_manager.py + bms_scheduler.py + miniapp.py |
| 2026-08-25 | v0.7 | 缓存加速体系:路由层 cached_call(60/90/120s) + 缓存预热(同步后/启动后/空闲期, WARM_TTL=180s) + 管理员强制重建(cache_admin.py) + 写入高峰期 TTL 放大防雪崩。新增数据源/看板接口的缓存接入清单与命名规范见 数据源接入与缓存加速规范.md(含已知缺口 G1~G4) | cache_warmer.py / cache_admin.py / redis_client.py |
| 2026-09-01 | v0.8 | BMS 时间窗口可靠性修复(生产审计):① 容器浏览器时区 UTC → new_context(timezone_id='Asia/Shanghai') + fill_utc=False(北京时间直填);② ticket 时间标签从 label 改 placeholder 匹配 + 先点 .show-more 展开高级筛选 + click 聚焦后再 fill;③ _set_time_range 兜底 evaluate 内 :visible 非法选择器 → 改为类名选中 + JS rect/computedStyle 过滤;④ wallet 负数"新增"改为 max(..,0) + WARNING 告警;⑤ wallet 导入输出实际 op_time 范围二次校验;⑥ 下载 response listener 后台 _read_body 任务追踪清理(防 asyncio 泄漏) |
bms_fetcher.py / upsert.py |
八、SCRM 同步体系(v0.4 新增)
8.1 数据源
SCRM(企业微信客服)通过 REST API 同步,3 张数据表:
| 表 | 用途 | 同步方式 |
|---|---|---|
scrm_operators |
客服操作员 | 全量 upsert(覆盖更新) |
scrm_sessions |
客服会话(含质量指标) | 增量 upsert |
scrm_session_transfers |
会话转接记录 | 先删后插(幂等) |
8.2 同步策略
- 增量同步:从上次成功结束时间减 2 小时重叠开始,首次运行回溯 30 天
- 定时调度:与 BMS 共享调度(8-23/0 每小时 + 5:00 补跑),BMS 与 SCRM 经
submit_multi并发执行(BMS 冷却期间穿插 SCRM) - 全局互斥锁 + 冷却期:与 BMS 共用 SyncTaskManager,防止手动/定时冲突
- 独立运行:BMS 或 SCRM 任一方无凭证时,另一方可独立运行
8.3 Token 管理
- Token 自动刷新:提前 5 分钟刷新
- asyncio.Lock 双重检查锁:并发安全
- 401/403 自动重试:一次重试后仍失败才报错
- 凭证存储:ClientId/ClientSecret 存于
sync_source_configs表,Fernet 对称加密
8.4 数据处理规则
- 操作员全量 upsert(覆盖更新,不保留历史)
- 会话质量字段(first_response_sec / avg_response_sec 等)非空时不覆盖,防止回滚
- 会话转接先删后插,保证幂等
- SCRM 聊天记录不同步到本地(数据量过大)
- 同步日志跟踪 API 调用次数、reconcile delta(API 总数 - inserted/updated)、inserted/updated/failed 计数
8.5 API 端点
| 端点 | 方法 | 说明 |
|---|---|---|
/fetch/scrm/status |
GET | SCRM 同步状态摘要 |
/fetch/scrm/sync |
POST | 提交 SCRM 同步任务 |
/fetch/scrm/logs |
GET | SCRM 同步日志 |
/fetch/schedule/status |
GET | 调度状态(含 has_credentials: {bms, scrm}) |
/fetch/schedule/start |
POST | 启动调度器(仅需一个数据源配置即可) |
8.6 会话列表 API 自动分页
3 个会话列表 API 实现自动分页和合并:
getUserChatListByPage:按页获取getUserChatListByGroup:按组获取getVirtualChatSessionPage:虚拟会话分页
8.7 错误处理
SCRM API 错误统一封装为 ScrmApiError,包含 code、message 和 raw_response。
九、OCR 自动登录(v0.4 新增)
9.1 应用场景
BMS 平台登录需输入图形验证码,使用 ddddocr 进行 OCR 自动识别。
9.2 置信度预检机制
| 常量 | 默认值 | 说明 | 位置 |
|---|---|---|---|
_OCR_MIN_CHAR_PROB |
0.72 | 单字符最低概率 | captcha_session.py |
_OCR_AVG_PROB |
0.86 | 平均概率阈值 | captcha_session.py |
_OCR_PRECHECK_MAX_REFRESH |
20 | 最大刷新次数 | captcha_session.py |
_OCR_FALLBACK_CONFIDENCE |
0.70 | 兜底置信度(ddddocr 不支持概率输出时) | captcha_session.py |
9.3 登录流程
内层预检循环 (最多20次):
1. 刷新验证码
2. OCR 识别 (ddddocr)
3. 提取 CTC 输出的字符概率 (30个时间步 × 非空字符 max 概率)
4. 检查: 单字符概率 ≥ 0.72 且 平均概率 ≥ 0.86?
是 → 进入外层登录
否 → 回到步骤1 (不触发登录,不产生"验证码错误")
外层登录:
5. 输入用户名/密码/验证码
6. 提交登录
7. 检查响应:
"用户名或密码错误" → 立即终止 (不重试)
"验证码错误" → 回到内层预检循环
成功 → 完成
9.4 性能要求
- 整体流程约 3 秒完成(典型场景)
- 验证码刷新不触发服务端"验证码错误"(仅前端刷新图片)
9.5 Playwright 上下文复用
- 同域名数据源复用浏览器上下文,避免重复登录
BmsFetcher对同base_url强制 65 秒等待- 跨域名可立即切换
9.6 BMS 查询按钮点击策略(5 层兜底)
BMS 平台页面改版导致查询按钮 DOM 变化,采用 5 层兜底:
| 层级 | 策略 | 选择器 |
|---|---|---|
| 0 | 快速直击 | button:has-text('搜索') force-click |
| 1 | 标准 button | button:has-text('查询') |
| 2 | div/span role | div[role='button'], span[role='button'] |
| 3 | class 匹配 | a[class*=btn], div[class*=el-button] |
| 4 | aria-label | aria-label*='搜索/查询' |
| 5 | 终极兜底 | JS form.submit() / class*=submit-btn |
SPA 渲染就绪守卫:页面加载 3 秒后再轮询 20 秒等待可点击元素出现。
十、SyncDashboard 统一入口(v0.4 新增)
10.1 设计目标
将分散的数据采集功能合并为统一入口,Tab 切换:
| Tab | 功能 | 权限 |
|---|---|---|
| Data View | 数据浏览 | 所有用户 |
| BMS Sync | BMS 同步 | 所有用户 |
| SCRM Sync | SCRM 同步 | 所有用户 |
| File Upload | 文件上传 | 所有用户 |
| Collection Records | 采集记录 | 所有用户 |
| Sync Settings | 同步设置 | 仅管理员 |
| Data Source Management | 数据源管理 | 仅管理员 |
默认打开 BMS Sync Tab。
10.2 界面布局
- 左右两栏:左侧主内容区,右侧 420px 固定实时日志面板
- 响应式:屏幕 <1200px 切换为上下布局
- 日志面板高度:
calc(100vh - 88px)独立垂直滚动
10.3 实时日志面板
- 自动滚动:上滑暂停,回底部恢复
- 清空按钮:清空当前视图
- 底部返回:跳转到最新日志
- 彩色来源标签:BMS=蓝色 / SCRM=绿色
- 进度条 + 阶段标签:顶部显示当前执行阶段
- 结果摘要标签:底部显示彩色结果统计
- 新任务自动重置:新同步任务自动重置日志偏移并清空上一任务日志
10.4 合并同步表单
- BMS + SCRM 数据源复选框统一选择
- 统一时间范围选择
- 同步任务并发执行(BMS 与 SCRM 各自 runner 经
asyncio.gather并发) - 按钮动态显示:
开始同步(共 7 个源 · BMS 3 + SCRM 4 · 增量接续) - 运行中任务进度条置顶通知
- Steps 进度组件使用后端
runningTask.sources显示准确执行顺序
10.5 路由兼容
旧路由自动重定向到统一入口对应 Tab:
/data/fetch→/data/ingest?tab=fetch/data/upload→/data/ingest?tab=upload/data/records→/data/ingest?tab=records/data/browser→/data/ingest?tab=browser?ingest=fetch→/data/ingest?tab=fetch
旧独立页面保留在隐藏路由中,向后兼容。