Selaa lähdekoodia

feat: 智能工牌录音管道全功能运维技能首次发布(smartbadge-ops)

- SKILL.md: 确认门交互协议(复述→回问→确认后才执行,写操作强制重档)+
  能力矩阵/巡检/故障自诊断/列表诊断/补跑重试/月报年报/部署核验 SOP
- scripts: lib.mjs 共享库(env 回退链/token/httpJson/htmlToText)、api.mjs
  (兼容+env/--raw/--base)、oss.mjs(--json/--env/--find-env+自动发现)、
  check-online.mjs(12 项断言,无害告警语义)、verify-online.py(参数化)
- 新增: report-aggregate.mjs 区间聚合(月报/年报原料:报告+逐日对账+
  缺失清单+补跑建议)、fetch-report-text.mjs 报告正文拉取
- templates: 八段月报分析模板 + HTML 产物模板
- 零依赖(Node>=18);token/out/__pycache__ 已 gitignore

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Huccct 2 viikkoa sitten
commit
8bd2b7199f

+ 16 - 0
.env.example

@@ -0,0 +1,16 @@
+# 智能工牌运维技能环境模板(复制为 .env 或置入系统环境变量,不提交真实凭据)
+# 各脚本的凭据回退链:--env 参数 > SMARTBADGE_ENV_FILE 环境变量 > 自动发现(见 README)
+
+# 接口地址(线上默认; 本地开发用 http://localhost:3002)
+SMARTBADGE_API_BASE=https://recording.sh-lami.com
+
+# 登录账号
+SMARTBADGE_USERNAME=lami123456
+SMARTBADGE_PASSWORD=<填登录密码>
+
+# OSS 直连凭据(可选; 通常由自动发现的 server/.env 提供)
+# OSS_ACCESS_KEY_ID=xxx
+# OSS_ACCESS_KEY_SECRET=xxx
+# OSS_BUCKET=xxx
+# OSS_REGION=xxx
+# OSS_PUBLIC_DOMAIN=xxx

+ 13 - 0
.gitignore

@@ -0,0 +1,13 @@
+# 登录缓存的 token
+.token*
+
+# 本地输出目录
+out/
+
+# Python 缓存
+__pycache__/
+*.pyc
+
+# 敏感配置本地副本
+.env
+.env.local

+ 57 - 0
README.md

@@ -0,0 +1,57 @@
+# skills-smartbadge-ops
+
+智能工牌录音管道(归档 → 清洗 → 讯飞转录 → DeepSeek 报告)的全功能 AI 运维技能(Claude Code Skill)。
+
+一个技能覆盖:巡检、故障自诊断、补跑/重试、OSS 同步、列表排查、报告解读、**月报/年报按需聚合分析**、线上部署核验。
+
+## 快速开始
+
+```bash
+# 1. 安装(Windows): 把本目录链接到 Claude 全局技能目录
+cmd /c mklink /D "C:\Users\11729\.claude\skills\smartbadge-ops" "D:\1\skills-smartbadge-ops"
+
+# 2. 登录(在任意位置,技能会自行定位)
+node <技能目录>/scripts/api.mjs login <密码>
+
+# 3. 一键核查
+node <技能目录>/scripts/check-online.mjs
+```
+
+之后 Claude 中直接提问即可(如「昨天管道有问题吗」「帮我出 8 月月报」),技能通过「确认门」交互流程逐步完成。
+
+## 环境要求
+
+| 项 | 要求 |
+|---|---|
+| Node | >= 18(内建 fetch,零 npm 依赖) |
+| Python | 仅 `verify-online.py` 需要,含 playwright |
+| 网络 | 可达 `recording.sh-lami.com`(或本地后端) |
+| 凭据 | 登录密码;OSS 凭据自动发现自项目 `server/.env` |
+
+## 脚本一览
+
+| 脚本 | 用途 |
+|---|---|
+| `scripts/api.mjs` | 任意接口调用(login/get/post/env、--raw、--base) |
+| `scripts/check-online.mjs` | 线上 12 项断言一键核查(登录/health/错误/归档/列表/实时分流/心跳/设备/互斥) |
+| `scripts/verify-online.py` | 浏览器 5 页走查,0 pageerror 断言 |
+| `scripts/oss.mjs` | OSS 直连列举(顶层统计/前缀明细/--json/--find-env) |
+| `scripts/report-aggregate.mjs` | 区间聚合:窗口内报告 + 逐日对账 → 缺失清单与补跑建议(月报/年报原料) |
+| `scripts/fetch-report-text.mjs` | 报告正文拉取(HTML→纯文本,供 AI 深度分析) |
+| `scripts/lib.mjs` | 共享库(凭据回退链/token/httpJson/htmlToText;内部使用) |
+| `templates/monthly-report.md` | 月报/年报八段分析结构模板 |
+| `templates/report-html.html` | 可选 HTML 产物模板 |
+
+## 凭据与安全
+
+- 本仓库**不含任何密钥**。登录 token 缓存 `.token`;OSS 凭据从项目 `server/.env` 自动发现。
+- 凭据定位回退链:`--env` 参数 > 环境变量 `SMARTBADGE_ENV_FILE` > 自动发现(本仓库同级/兄弟目录中名称含 `device|smartbadge` 且带 `server/.env` 的项目)> 已导出的 `OSS_*` 环境变量。
+- 排查凭据定位:`node scripts/oss.mjs --find-env`。
+
+## 与项目内旧技能的关系
+
+本项目内旧的 `.claude/skills/smartbadge-pipeline-ops` 已被本独立仓库取代(内容迁移并扩展至月报/年报与确认门交互)。安装本技能后,直接以 `smartbadge-ops` 使用即可。
+
+## 版本
+
+v1.0.0 — 2026-09-04,首次发布

+ 216 - 0
SKILL.md

@@ -0,0 +1,216 @@
+---
+name: smartbadge-ops
+description: 智能工牌录音管道全功能运维技能。当用户提到录音归档、录音下载、管道巡检、转录重试、每日录音任务、smartbadge 运维、归档失败、转录失败、录音清洗、讯飞转录、OSS 同步、分析任务状态、线上部署核验、健康检查、列表数据、故障自诊断、月报、年报、周报、日报汇总、报告解读、历史数据对比、区间数据聚合时使用。提供对服务器定时管道(归档→清洗→讯飞转录→DeepSeek 报告)的巡检、手动触发、补跑、重试、故障诊断、列表排查、月报/年报按需聚合分析、线上部署核验与浏览器走查。交互特色:先复述问题与方案,回问用户确认后再执行。
+---
+
+# 智能工牌录音管道运维(smartbadge-ops)
+
+服务器(`server/index.js`)常驻运行每日定时管道,**无需人工值守**;本技能是控制面:巡检、诊断、补跑、重试、部署核验、区间(月报/年报)按需聚合分析。所有作业逻辑在服务器端,技能只调用其 REST API 与直连 OSS。
+
+本技能为独立仓库,脚本全部在 `./scripts/`(零依赖,Node >=18;verify-online.py 需本机 playwright)。
+
+---
+
+## §1 交互协议:确认门(第一优先,不可跳过)
+
+无论用户请求什么,先走确认门:
+
+### 重档(默认,适用于一切「有生产影响」或「参数不明确」的请求)
+
+- **写操作永远重档**:`archive/run`、`analysis/run`、`retry`、`retry-transcribe`、`repair-oss`、`config` PUT、任何 DELETE。
+- 步骤:
+  1. **复述**:用 1-3 句话复述我理解的问题、时间窗、对象(哪些设备/日期)、期望产物(如「月报 Markdown」)。
+  2. **方案**:列出将执行的脚本与命令序列,并一句话说明**是否会写数据**。
+  3. **回问**:"是这个意思吗?" 等待用户确认(或纠正)。
+- 用户纠正后:只用一句更新复述,再确认一次即执行,不重复长篇。
+- 已确认动作链中的后续复查步骤(如补跑后复跑 `report-aggregate`/`reconcile` 归零验证)视为同一确认,无需再问。
+
+### 轻档(预授权:纯只读且参数齐全)
+
+以下场景视为用户已授权,可`一句话备案`后直接执行(备案格式:`我将执行:…(若与预期不符请中断)`,不强制等待回答):
+- `get`/`list` 查询、`check-online.mjs` 巡检、`oss.mjs` 列举、`reconcile` 对账、`report-aggregate` 聚合、`fetch-report-text` 正文拉取、`verify-online.py` 走查。
+- 用户说「巡检」「跑 check-online」等明确指令,直接执行。
+
+### 确认门例外
+
+- 用户明确说「直接跑」「不用问」或已确认过相同模式请求 → 跳过回问,执行并在首句说明。
+
+---
+
+## §2 连接与凭据
+
+```bash
+export MSYS_NO_PATHCONV=1     # Windows Git Bash: 以 / 开头的路径参数先防止被转成 Windows 路径
+```
+- **接口地址**:默认线上 `https://recording.sh-lami.com`;本地开发:环境变量 `SMARTBADGE_API_BASE=http://localhost:3002`(或各脚本 `--base`)。
+- **登录**:`node scripts/api.mjs login <密码>`,token 缓存到 `scripts/.token`(各脚本自动共享)。
+- **凭据回退链**(OSS 等):`--env 参数` > `SMARTBADGE_ENV_FILE` > 自动发现(向上扫本仓库同级/兄弟目录含 `server/.env` 的项目,如 `D:\1\device-management-backend`) > 已存在的 `OSS_*` 进程环境变量。
+- 敏感凭据只存在于项目 `server/.env`(OSS/讯飞/DeepSeek/masterKey),本技能仓库**不含任何密钥**。
+
+## §3 能力矩阵与编排表
+
+| 脚本 | 能力 | 典型参数 |
+|---|---|---|
+| `scripts/api.mjs` | 任意接口调用 | `login` / `get <路径>` / `post <路径> '<JSON>'` / `env` / `--raw` / `--base` |
+| `scripts/check-online.mjs` | 12 项断言一键核查 | `[密码]` 或 `SMARTBADGE_PASSWORD` |
+| `scripts/verify-online.py` | 浏览器 5 页走查 + 0 错误断言 | 环境变量 `PLAYWRIGHT_BASE_URL/SMARTBADGE_*` |
+| `scripts/oss.mjs` | OSS 直连列举统计 | `[prefix]` `--json` `--env <path>` `--find-env` |
+| `scripts/report-aggregate.mjs` | **区间聚合(月报/年报/任意窗口)** | `--start --end [--type day\|week\|all] [--json] [--no-reconcile]` |
+| `scripts/fetch-report-text.mjs` | 报告正文拉取(HTML→文本) | `--id <reportId>` \| `--url <直链>` `[--preview] [--json]` |
+
+**用户需求 → 命令序列(编排参考)**
+
+- 「今天(这几天)管道有问题吗」→ `check-online.mjs` → 有 FAIL 走 §7 → 按 category 处置 → 汇总。
+- 「昨天报告没有/转录失败」→ `api.mjs get /api/analysis/health` + `errors?pageSize=20` 看 category(§7 处置表)。
+- 「昨天的数据/录音列表不对」→ §8 列表诊断。
+- **「给我上月月报」(月报/年报 == 同流程换窗口)** → 确认门(窗口+产物形态)→ `report-aggregate --start 月初 --end 月末 --json` → 必要时补跑缺失日(§10)→ `fetch-report-text` 选 3-5 篇读正文 → 按 `templates/monthly-report.md` 八段模板产出 → 产物形态加工(Markdown/HTML/PPT)。
+- 「上线后核验一下」→ §12(`check-online` + `verify-online.py` + `nginx -t` + `pm2 logs`)。
+- 「OSS 里有哪些东西」→ `oss.mjs`(顶层)或 `oss.mjs recordings/` 等按前缀。
+
+## §4 架构与每日流程
+
+```
+每天 archive_time(默认 02:00 Asia/Shanghai, 可配 SbSystemConfig)
+ ├─ 归档: 上游 /audio/list → 下载本地 → OSS(ETag 校验) → SbAudioRecording 落库(含音源字段镜像)
+ ├─ 分析: process.py 三级过滤(静默/模糊/短音频) → 讯飞多说话人 → SbTranscriptSegment
+ ├─ 报告: DeepSeek → HTML → reports/ + OSS → SbReport(day/week)
+ ├─ 合并: 按设备×天拼接 MP3(OSS merges/,仅存档收听,不参与转录)
+ ├─ 24h 清理: 已 OSS 同步文件 → canPurge → 删除本地(DB 记录保留)
+ └─ 保留期兜底清理: 录音 15 天 / 合并 7 天 / 报告 HTML 7 天(删前补同步一次;报告 OSS 补传失败时安全闸保留本地)
+每周一(archive_time+30min): 周报(近 7 天聚合, 转录增量跳过不重复计费)
+失败任何环节 → 自动入队重试(3 次, 5/15/30 分钟指数退避) → 耗尽才进入错误展示;重试零残留
+```
+
+**边界事实(设计依据)**:
+- 归档窗口 ≤7 天(上游自动删除录音)→ 错过即丢;>`6` 天前的缺失**不可补**。
+- `POST /api/analysis/run` 窗口上限 `MAX_WINDOW_DAYS=7`、start 晚于今天报错、全局互斥 1 个任务(冲突 429)→ 区间聚合/补跑严格遵守:一次最多 7 天,或逐日。
+- 录音列表默认本地库(截至昨日);`endDate >= 今天` 走上游实时分流。
+- 转录消耗讯飞额度(每文件一次;清洗已滤约 90% 无效音频;周报/聚合复用已转录段落不重复计费)。
+
+## §5 接口命令速查(经 api.mjs)
+
+| 命令 | 用途 |
+|---|---|
+| `get /api/archive/status?date=&deviceNo=` | 当日归档状态(expected/downloaded/ossSynced/failed) |
+| `get /api/archive/missing` | 归档失败待重试列表(含 retryCount/lastError) |
+| `post /api/archive/run '{"dates":[...]}' \| '{"dateRange":{"start","end"}}' \| '{"devices":[...]}'` | 手动触发归档(默认全部×昨天) |
+| `post /api/archive/retry '{"ids":[...]}'` | 重试失败归档(也支持 {date}/{deviceNo}) |
+| `post /api/archive/retry-transcribe '{"date":"YYYY-MM-DD"}'` | 仅重跑转录(不重复清洗;互斥 429) |
+| `post /api/analysis/run '{"period":"yesterday"\|"last7days"\|{"start","end"},"type":"day"\|"week"}'` | 手动分析(窗口≤7 天,互斥) |
+| `get /api/analysis/jobs?status=&page=&pageSize=` | 任务列表(status/progress/summary/reportId/transcriptCount/nextRetryAt/failStage) |
+| `get /api/analysis/reconcile?date=` | 三方对账(total/localHit/ossHit/transcribeDone/missingCleansed/missingTranscript) |
+| `post /api/analysis/repair-oss '{"date":"YYYY-MM-DD"}'` | 补传缺失 OSS 对象(互斥 429;支持 {ids}) |
+| `get /api/analysis/health` | 健康快照(retryingCount/warnings/categoryCounts/ossMissingCount) |
+| `get "/api/analysis/errors?pageSize=20"` | 错误汇总(四来源,含 advice;重试中默认隐藏,`includeRetrying=true` 查) |
+| `post /api/smartbadge/recordings/list '{"page":1,"pageSize":20}'` | 录音列表(本地库;支持 deviceNo/startDate/endDate/keyword;endDate>=今天走实时) |
+| `post /api/smartbadge/logs/heartbeat '{"page":1,"pageSize":20}'` | 心跳列表(startTime/endTime 代替日期) |
+| `get /api/devices?page=&pageSize=&keyword=` | 设备分页(DB 级) |
+| `get /api/config` / `put /api/config '{"key":"value"}'` | 系统配置(archive_time 等;**PUT 前必过确认门**) |
+| `get /api/reports?type=day\|week&start=&end=&pageSize=` | 报告列表(分页;无正文) |
+| `get /api/reports/:id` | 报告详情(同样无正文;正文经 ossUrl/downloadUrl) |
+
+> 写请求必须带 `X-Requested-With` 头——api.mjs 已自动带上;手动 curl 时须加。
+
+## §6 每日巡检 SOP(营业前一次)
+
+1. `node scripts/check-online.mjs` → **12 项全 PASS 即健康**;0 FAIL 跳第 5 步汇报。
+2. 有 FAIL:进 §7 定位——先 `api.mjs get /api/analysis/health` 看 `retryingCount`(>0=引擎自动重试中,别急)。
+3. `api.mjs get /api/analysis/reconcile?date=<昨天>` 核对缺项;`oss.mjs` 核对四类目录数量(`recordings/ cleansed/ transcripts/ reports/`)。
+4. `<昨天>` 有 `missingCleansed`/`missingTranscript` → `repair-oss '{"date":"<昨天>"}'` 补传,复跑验证归零。
+5. 三行简报:正常项 / 异常项(及原因分类)/ 采取的动作。
+6. 周一额外:`jobs?pageSize=5` 见 week 任务 + `get /api/reports?type=week` 有周报。
+
+## §7 故障自诊断 SOP
+
+```bash
+node scripts/api.mjs get /api/analysis/health               # retryingCount=自动重试中数;categoryCounts=分类计数
+node scripts/api.mjs get "/api/analysis/errors?pageSize=20" # 重试中项默认隐藏(耗尽才出现)
+node scripts/api.mjs get "/api/analysis/errors?includeRetrying=true&pageSize=20"
+```
+> **重试语义**:失败先自动重试 3 次(5/15/30min,~50 分钟),耗尽才可见。`retryingCount>0` → 等引擎;0 且有 warnings → 真错误。
+> **无害告警**:health `warnings` 中「最近任务无待转录文件(属正常)」是无害告警(当日无录音或全部被三级过滤),不影响健康判定;check-online 已按此语义断言。
+
+按返回 `category` 行动(每条自带 `advice`):
+
+| category | 含义与动作 |
+|---|---|
+| `quota`(额度/并发) | 讯飞/DeepSeek 额度受限或并发超限(10407/10901/402/429/rate limit):**非接口故障**,等恢复或联系开通;可稍后 `retry-transcribe`/`analysis/run`(增量跳过,成本低) |
+| `network`/`timeout` | 网络抖动/超时(ETIMEDOUT/ECONNREFUSED/socket):**重试即可**;当日重试 `archive/retry`/`retry-transcribe` |
+| `auth`(鉴权) | 密钥/令牌错误(401/403/accessKeyId/signature):**报修**——检查服务器 `server/.env` 的 OSS_*/IFLYTEK_*/DEEPSEEK_API_KEY 是否过期,修复后重试 |
+| `server`(5xx) | 上游 5xx 或内部错误:**报修**——查 `pm2 logs smartbadge-api` 或宝塔日志 |
+| `other` | 未知格式:人工看 `message`;可复现则升级分类器关键词 |
+
+**口径示例**:「今天的报告为什么没有?」→ `errors?source=report` → quota:「不是接口问题,额度受限,已自动重试,等待恢复」;auth:「鉴权失败,需检查密钥(报修项)」。**先跑命令看 category,不要凭猜回复。**
+
+## §8 列表问题诊断 SOP
+
+1. 本地列表(默认窗)`recordings/list` → total>0 且无 failed/capped?缺失日 = 归档失败日:查 `archive/status?date=` 与 `archive/missing`。
+2. 「包含今日」开关走实时——`failed`/`capped` 非空 = 上游个别设备失败/超 2000 条截断;等 60s 缓存或缩小筛选。
+3. `startTime` 全为 `-`:上游 `/audio/list` 最后一条元数据未结算(数据源属性);旧行缺字段可跑回填脚本 `server/scripts/maintenance/backfill-recording-meta.js`。
+4. 上游误返全量:服务端已有近 7 天缺省窗,**勿**移除。
+
+## §9 补跑与重试 SOP
+
+- 昨天断电/失败 → `post /api/archive/run '{"dates":["<昨天>"]}'`(仅收 今天-6 ~ 今天)。
+- 部分文件下载失败 → `post /api/archive/retry '{}'`(按日期/设备过滤更精准)。
+- 转录失败 → `post /api/archive/retry-transcribe '{"date":"<日期>"}'`。
+- 报告失败(任务 error 含「报告生成失败」)→ `post /api/analysis/run '{"period":"yesterday","type":"day"}'`(已转录增量跳过,只补报告)。
+- 互斥 429 → 已有任务运行中:先 `get /api/analysis/jobs` 看进度;分析有超时兜底(20 分钟),等待即可。
+- **跨 >6 天窗口的缺失:上游已删,不可补**,在汇报中披露。
+
+## §10 月报/年报 SOP(按需聚合,不建永久管道)
+
+系统仅自动产生 day/week 报告;月报/年报 = **运行时拉取窗口数据自动聚合分析**。执行步骤:
+
+1. **确认门(重档无关,但必须问)**:
+   - 窗口:如「2026 年 8 月」= `--start 2026-08-01 --end 2026-08-31`;年报同理 `01-01 ~ 12-31`;或自定义区间。
+   - 内容范围:仅数据(录音/转录/报告清单)还是含智能分析(读报告正文提炼)?默认含。
+   - **产物形态**由用户选择:① Markdown(默认,回复正文+可存文件)② HTML(模板 `templates/report-html.html` + markdown 最小转换)③ PPT(使用全局 weekly-report-pptx 技能承接)。
+2. **拉数据**:`node scripts/report-aggregate.mjs --start <起> --end <止> --json`(可 `--type week` 只看周报;`--deviceNo` 单设备;`--no-reconcile` 跳过逐日对账)。得到:报告清单、逐日明细、**缺失三类清单(无报告日/转录=0 日/对账异常日)**、补跑建议命令。
+3. **补缺失(过确认门)**:对 `daysAgo<=6` 的无报告日,执行聚合输出里的建议命令(逐日 `analysis/run`,`{"period":{"start":D,"end":D},"type":"day"}`);复跑聚合验证归零。
+4. **读正文(需要智能分析时)**:`node scripts/fetch-report-text.mjs --id <reportId> --preview` 选 3-5 篇代表性报告(readme 说选择标准:设备覆盖广、转录量高、异常日),必要时 `--json` 定位 id(或 `api.mjs get "/api/reports?type=day&start=<起>&end=<止>&pageSize=10"`)。
+5. **产出**:按 `templates/monthly-report.md` 八段(总体概览/设备活跃/录音量与转录效率/趋势/异常与缺失/报告要点摘录/结论与建议)成稿;形态按第 1 步选择。
+6. **披露**:超 6 天的缺失日标注「上游已清理,不可补」;`summary=「无待转录文件」` 的任务属正常(三级过滤)。
+
+**注意**:`report-aggregate` 是只读(轻档权限);「补缺失」是写操作,必须重档确认。
+
+## §11 报告解读
+
+- 列表/详情无正文:正文经 `ossUrl`(OSS 公开直链)或 `downloadUrl`(302 OSS / 本地回源)获取 → 推荐 `fetch-report-text.mjs`。
+- 阅读视角(KPI 指标卡、客户画像表、关键事件时间线、销售策略、VOC 原话、风险提示);报告只含转录文本与指标,不内嵌录音 URL。
+- 报告 HTML 是 DeepSeek 提炼物(含 VOC 引用 6-9 条),非全文转录;需要全文 → §13。
+
+## §12 上线/部署核验 SOP
+
+```bash
+node scripts/check-online.mjs        # 12 项断言: 登录/health/归档/列表/实时分流/心跳/设备/互斥
+python scripts/verify-online.py      # 浏览器: 5 页走查 + 0 pageerror 断言
+nginx -t                             # 宝塔保存自动校验;配置见 deploy/nginx-recording.sh-lami.com.conf
+pm2 logs smartbadge-api --lines 50   # 服务器错误日志
+```
+- 前端打包:项目内 `pnpm build` → zip 上传解压到线上 dist;后端 `.env` 用 `.env.production` 模板(PORT=3007)+ CDN 自动拉取。
+- **双进程提醒**:共享 Parse 只允许一个后端常驻跑 cron——本地(3002)与线上(3007)不要同时在线。
+
+## §13 转录原文获取(边界)
+
+- 转录原文**无 HTTP 接口**,数据在 Parse `SbTranscriptSegment`(CLP 仅 masterKey,技能侧不可直读)。
+- OSS 有转录文本对象:`transcripts/{deviceNo}/{Y}/{M}/{D}/*.txt` → `node scripts/oss.mjs transcripts/` 列举,经 `publicDomain` 拼直链可下载。
+- 需逐条查原文时,在企业内用服务器 masterKey 侧(如维护脚本),不在本技能承诺。
+
+## §14 注意事项
+
+1. **归档窗口 ≤7 天**:错过即丢,巡检每天执行。
+2. 写操作(归档/分析/重试/补传/配置)一律先确认门,并提示互斥 429 可能性。
+3. 凭据只存 `server/.env`(讯飞/DeepSeek/OSS/masterKey),本仓库零密钥;git 提交需用户明确同意。
+4. **禁止直接在 `server/recordings/` 下手动跑 `process.py`**:对输入文件是**移动/删除**语义;排查过滤请用工作区副本或 `repair-oss`。
+5. Windows 本机 `PYTHON_BIN` 需指向真实 python.exe(生产默认 `python3`)——Python 相关仅服务器需要。
+6. `.token`/`out/`/`__pycache__` 已 gitignore,不提交。
+
+## §15 结束协议(每任务收尾)
+
+输出三行:① 做了什么(命令序列)② 结果(数据或 PASS/FAIL + 异常项)③ 遗留与建议下一步。有「未验证项/已知限制」要显式披露。
+
+## 相关技能
+
+- 报告人工解读模板细节见项目内 `smartbadge-report`(执行报告解读时可调用)。
+- PPT 产物:全局 `weekly-report-pptx` 技能。

+ 68 - 0
scripts/api.mjs

@@ -0,0 +1,68 @@
+#!/usr/bin/env node
+// 智能工牌录音管道 API 工具(零依赖, Node >=18)
+// 用法:
+//   node api.mjs login <密码>                       # 登录并缓存 token 到 scripts/.token
+//   node api.mjs get /api/archive/missing
+//   node api.mjs post /api/archive/run '{"dates":["2026-09-03"]}'
+//   node api.mjs env                                # 查看连接诊断(BASE / 是否已登录)
+//   node api.mjs get /api/reports --raw             # 输出完整响应(含 code/message)
+//   node api.mjs get /api/reports --base http://localhost:3002   # 指定接口地址
+// 环境变量: SMARTBADGE_API_BASE(默认线上 https://recording.sh-lami.com)、
+//          SMARTBADGE_USERNAME(默认 lami123456)、SMARTBADGE_PASSWORD(或参数传入)
+import { baseUrl, fail, login, httpJson, loadToken, TOKEN_FILE } from './lib.mjs';
+
+const args = process.argv.slice(2);
+const positional = [];
+let baseOverride;
+let rawFlag = false;
+for (let i = 0; i < args.length; i++) {
+  const a = args[i];
+  if (a === '--raw') rawFlag = true;
+  else if (a === '--base') baseOverride = args[++i];
+  else positional.push(a);
+}
+const [cmd, apiPath, bodyStr] = positional;
+
+async function main() {
+  if (cmd === 'login') {
+    const token = await login({
+      base: baseUrl(baseOverride),
+      username: process.env.SMARTBADGE_USERNAME,
+      password: process.env.SMARTBADGE_PASSWORD || apiPath,
+    });
+    console.log(`登录成功, token 已缓存(${token.slice(0, 8)}...)`);
+    return;
+  }
+
+  if (cmd === 'env') {
+    const token = loadToken();
+    console.log(`BASE    = ${baseUrl(baseOverride)}`);
+    console.log(`USERNAME= ${process.env.SMARTBADGE_USERNAME || 'lami123456'}`);
+    console.log(`TOKEN   = ${token ? `已登录 (${token.slice(0, 8)}... @ ${TOKEN_FILE})` : '未登录(先: node api.mjs login <密码>)'}`);
+    return;
+  }
+
+  if (!cmd || !apiPath) fail('用法: node api.mjs login|env|get|post|put|delete <路径> [JSON体] [--raw] [--base URL]');
+
+  const body = bodyStr !== undefined && bodyStr.trim() ? JSON.parse(bodyStr) : undefined;
+  const r = await httpJson(cmd, apiPath, body, { base: baseOverride, raw: rawFlag });
+  if (r.status >= 400 && r.json?.code !== 0 && !rawFlag) {
+    fail(`请求失败(${r.status}): ${JSON.stringify(r.json)}`);
+  }
+  if (rawFlag) {
+    console.log(JSON.stringify(r.json, null, 2));
+  } else {
+    const data = r.json?.data;
+    console.log(data === undefined || data === null
+      ? `(${r.status}) ${r.json?.message || 'no data'}`
+      : JSON.stringify(data, null, 2));
+  }
+  if (!r.ok && process.env.SMARTBADGE_DEBUG) fail(`HTTP ${r.status}`, 1);
+}
+
+main().catch((err) => {
+  if (err instanceof SyntaxError && /JSON/i.test(err.message)) {
+    fail(`JSON 体解析失败: ${err.message}\n  示例: node api.mjs post /api/archive/run '{"dates":["2026-09-03"]}'`);
+  }
+  fail(err.message);
+});

+ 72 - 0
scripts/check-online.mjs

@@ -0,0 +1,72 @@
+#!/usr/bin/env node
+// 线上环境一键核查(Node >=18, 零依赖): 登录 → 核心接口链 → 12 项 PASS/FAIL
+// 用法:
+//   node check-online.mjs                        # 核查线上 https://recording.sh-lami.com
+//   SMARTBADGE_API_BASE=http://localhost:3002 node check-online.mjs   # 核查本地
+// 密码: SMARTBADGE_PASSWORD 环境变量或第一个参数
+import { baseUrl, login, httpJson, loadToken, fail } from './lib.mjs';
+
+const PASSWORD = process.env.SMARTBADGE_PASSWORD || process.argv[2];
+const base = baseUrl();
+if (!PASSWORD) fail('用法: node check-online.mjs [密码](或先 SMARTBADGE_PASSWORD=<密码>)');
+
+let pass = 0;
+let failCount = 0;
+const check = (name, cond, extra = '') => {
+  const ok = !!cond;
+  console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}${extra ? ` — ${extra}` : ''}`);
+  ok ? pass++ : failCount++;
+};
+
+console.log(`== 线上健康核查 (${base}) ==`);
+
+// 1. 登录
+let token;
+try {
+  token = await login({ base, password: PASSWORD });
+} catch (err) {
+  console.log(`\n== 结果: 0 PASS / FAIL(登录失败) ==`);
+  process.exit(1);
+}
+const auth = { base, token };
+
+// 2. health / 错误面
+const h = await httpJson('GET', '/api/analysis/health', undefined, auth);
+const warnings = h.json?.data?.warnings ?? [];
+const isBenign = (w) => /无待转录文件|属正常/.test(String(w));
+const realWarnings = warnings.filter((w) => !isBenign(w));
+check('health ok(仅无害告警)', h.ok && (h.json?.data?.ok === true || realWarnings.length === 0), `warnings=${warnings.length}${realWarnings.length ? `(异常: ${realWarnings.join('; ')})` : ''}`);
+check('无害告警外无警告', realWarnings.length === 0, realWarnings.join('; ') || `无害=${warnings.length}条`);
+check('自动重试中=0', Number(h.json?.data?.retryingCount ?? -1) === 0, `retryingCount=${h.json?.data?.retryingCount}`);
+check('OSS 缺失=0', Number(h.json?.data?.ossMissingCount ?? -1) === 0, `ossMissingCount=${h.json?.data?.ossMissingCount}`);
+const cat = h.json?.data?.categoryCounts ?? {};
+const catTotal = Object.values(cat).reduce((a, b) => a + b, 0);
+check('错误分类计数=0', catTotal === 0, JSON.stringify(cat));
+
+// 3. 归档面
+const missing = await httpJson('GET', '/api/archive/missing', undefined, auth);
+const missingList = missing.json?.data?.items ?? missing.json?.data;
+check('归档失败待重试=空', missing.ok && Array.isArray(missingList) && missingList.length === 0, `missing=${Array.isArray(missingList) ? missingList.length : 'err'}`);
+
+// 4. 列表面(ADR-005 本地主路径 + 实时分流)
+const rec = await httpJson('POST', '/api/smartbadge/recordings/list', { page: 1, pageSize: 5 }, auth);
+check('录音本地列表', rec.ok && (rec.json?.data?.total || 0) > 0, `total=${rec.json?.data?.total}`);
+check('录音列表无失败项', rec.ok && Array.isArray(rec.json?.data?.failed) && rec.json.data.failed.length === 0);
+const today = new Date().toISOString().slice(0, 10);
+const rt = await httpJson('POST', '/api/smartbadge/recordings/list', { endDate: today, page: 1, pageSize: 5 }, auth);
+check('实时窗分流', rt.ok && Array.isArray(rt.json?.data?.items), `total=${rt.json?.data?.total}`);
+
+const hb = await httpJson('POST', '/api/smartbadge/logs/heartbeat', { page: 1, pageSize: 5 }, auth);
+check('心跳接口', hb.ok && typeof hb.json?.data?.total === 'number', `total=${hb.json?.data?.total}(上游无数据属正常)`);
+
+const devs = await httpJson('GET', '/api/devices?page=1&pageSize=15', undefined, auth);
+check('设备列表', devs.ok && Array.isArray(devs.json?.data?.items || devs.json?.data), `count=${(devs.json?.data?.items || devs.json?.data || []).length}`);
+
+// 5. 任务/报告
+const jobs = await httpJson('GET', '/api/analysis/jobs?pageSize=5', undefined, auth);
+const list = jobs.json?.data?.items ?? jobs.json?.data ?? [];
+const running = list.filter((j) => ['pending', 'downloading', 'filtering', 'transcribing', 'reporting'].includes(j.status));
+check('无运行中任务(互斥空闲)', running.length === 0, `running=${running.length}`);
+
+console.log(`\n== 结果: ${pass} PASS / ${failCount} FAIL ==`);
+process.exit(failCount > 0 ? 1 : 0);

+ 85 - 0
scripts/fetch-report-text.mjs

@@ -0,0 +1,85 @@
+#!/usr/bin/env node
+// 报告正文拉取: --id 按 reportId 定位(经 /api/reports/:id)或 --url 直链,下载 HTML → 剥标签存 out/ 并打印
+// 用法:
+//   node fetch-report-text.mjs --id <reportId>            # 按报告 id(当前窗口内最佳)
+//   node fetch-report-text.mjs --url <ossUrl|downloadUrl> # 直链(自动跟随 302)
+//   node fetch-report-text.mjs --id xxxx --preview        # 只输出标题+前 28 行摘要
+//   node fetch-report-text.mjs --id xxxx --json           # 输出 {id,title,chars,file}
+//   node fetch-report-text.mjs --id xxxx --out 报告.txt   # 指定输出文件
+// 密码: SMARTBADGE_PASSWORD(无则复用 scripts/.token)
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import { baseUrl, fail, loadToken, login, httpJson, htmlToText, fetchAllReports } from './lib.mjs';
+
+const args = process.argv.slice(2);
+const argVal = (name) => {
+  const i = args.indexOf(name);
+  return i >= 0 ? args[i + 1] : undefined;
+};
+const id = argVal('--id');
+const url = argVal('--url');
+const outPath = argVal('--out');
+const preview = args.includes('--preview');
+const jsonOut = args.includes('--json');
+const base = baseUrl();
+
+if (!id && !url) fail('用法: node fetch-report-text.mjs --id <reportId> | --url <http(s)>  [--preview] [--json] [--out 路径]', 3);
+if (url && !/^https?:\/\//i.test(url)) fail(`--url 仅接受 http(s) 协议(拒绝 file:// 等本地协议),收到: ${url}`, 4);
+
+async function getRedirectTarget() {
+  const token = loadToken();
+  let r = await httpJson('GET', `/api/reports/${id}`, undefined, { base, token, raw: true });
+  if (!r.ok || !r.json?.data) {
+    if (r.status === 401 && process.env.SMARTBADGE_PASSWORD) {
+      await login({ base, password: process.env.SMARTBADGE_PASSWORD });
+      r = await httpJson('GET', `/api/reports/${id}`, undefined, { base, token: loadToken(), raw: true });
+    }
+    if (!r.ok || !r.json?.data) {
+      const recent = await fetchAllReports({ base, token, pageSize: 3 }).then((rs) => rs.map((x) => `${x.id}(${x.type}, ${x.periodStart})`).join('、'));
+      fail(`报告 id 未找到(${id}). 近期报告参考: ${recent || '无'}`, 5);
+    }
+  }
+  const d = r.json.data;
+  const target = d.ossUrl || d.downloadUrl;
+  if (!target) fail(`报告 ${id} 无 ossUrl/downloadUrl,请在服务器侧检查 OSS 配置`, 6);
+  return { id: String(d.id), target, title: d.title, type: d.type, period: `${d.periodStart} ~ ${d.periodEnd}` };
+}
+
+async function fetchHtml(target) {
+  const controller = new AbortController();
+  const timer = setTimeout(() => controller.abort(), 30000);
+  try {
+    const res = await fetch(target, { signal: controller.signal });
+    if (!res.ok) fail(`下载失败 HTTP ${res.status}: ${target}`, 6);
+    return await res.text();
+  } catch (err) {
+    fail(`下载失败(超时 30s 或网络异常): ${err.message}\n  直链: ${target}`, 7);
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+const meta = id
+  ? await getRedirectTarget()
+  : { id: `url-${Date.now()}`, target: url, title: path.basename(new URL(url).pathname) || 'report', type: '', period: url };
+
+const html = await fetchHtml(meta.target);
+const text = htmlToText(html);
+const safeName = String(meta.id).replace(/[^a-zA-Z0-9._-]/g, '_');
+const file = path.resolve(outPath || path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'out', `report-${safeName}.txt`));
+fs.mkdirSync(path.dirname(file), { recursive: true });
+fs.writeFileSync(file, text);
+
+if (jsonOut) {
+  console.log(JSON.stringify({ id: meta.id, title: meta.title, type: meta.type, period: meta.period, chars: text.length, file }));
+  process.exit(0);
+}
+if (preview) {
+  const lines = text.split('\n').filter(Boolean);
+  const first = lines.slice(0, 28).join('\n');
+  console.log(`标题: ${meta.title || '(无)'}\n${first}\n...[共 ${text.length} 字符, 全文见 ${file}]`);
+} else {
+  console.log(text);
+  console.error(`\n[全文已另存: ${file}]`);
+}

+ 148 - 0
scripts/lib.mjs

@@ -0,0 +1,148 @@
+// 共享库:env 回退链 / token 读写 / login / httpJson / fetchAllReports / htmlToText(零依赖, Node >=18)
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+export const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
+export const REPO_ROOT = path.dirname(SCRIPTS_DIR);
+export const TOKEN_FILE = path.join(SCRIPTS_DIR, '.token');
+
+export function fail(msg, code = 1) {
+  console.error(msg);
+  process.exit(code);
+}
+
+// ---------- env 回退链 ----------
+// --env > SMARTBADGE_ENV_FILE > 自目录向上扫 server/.env > 兄弟目录自动发现(名称含 device|smartbadge 的仓库)
+export function findEnvFile({ envPath, envFileEnv = process.env.SMARTBADGE_ENV_FILE } = {}) {
+  const candidates = [];
+  const push = (p) => candidates.push(p);
+  if (envPath) push(path.resolve(envPath));
+  if (envFileEnv) push(path.resolve(envFileEnv));
+  for (let dir = REPO_ROOT; ; dir = path.dirname(dir)) {
+    push(path.join(dir, 'server', '.env'));
+    if (path.dirname(dir) === dir) break;
+  }
+  const siblings = path.dirname(REPO_ROOT);
+  let dirNames = [];
+  try { dirNames = fs.readdirSync(siblings); } catch { /* 忽略 */ }
+  for (const name of dirNames.sort()) {
+    if (/device|smartbadge/i.test(name)) {
+      push(path.join(siblings, name, 'server', '.env'));
+    }
+  }
+  const hit = candidates.find((p) => {
+    try { return fs.statSync(p).isFile(); } catch { return false; }
+  });
+  return { hit, candidates };
+}
+
+export function loadEnv(file) {
+  const parsed = {};
+  try {
+    const text = fs.readFileSync(file, 'utf8');
+    for (const rawLine of text.split(/\r?\n/)) {
+      const line = rawLine.trim();
+      if (!line || line.startsWith('#') || !line.includes('=')) continue;
+      const [key, ...rest] = line.split('=');
+      const value = rest.join('=').trim().replace(/^['"]|['"]$/g, '');
+      if (!process.env[key]) process.env[key] = value;
+      parsed[key] = process.env[key];
+    }
+  } catch (err) {
+    if (err.code !== 'ENOENT') throw err;
+  }
+  return parsed;
+}
+
+export function baseUrl(flagBase) {
+  return flagBase || process.env.SMARTBADGE_API_BASE || 'https://recording.sh-lami.com';
+}
+
+// ---------- token ----------
+export function loadToken() {
+  try { return fs.readFileSync(TOKEN_FILE, 'utf8').trim(); } catch { return ''; }
+}
+export function saveToken(token) {
+  fs.writeFileSync(TOKEN_FILE, token);
+}
+
+// ---------- 登录 ----------
+export async function login({ base, username, password }) {
+  if (!password) fail('用法: node api.mjs login <密码>(或环境变量 SMARTBADGE_PASSWORD)', 2);
+  const res = await fetch(`${base}/api/auth/login`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
+    body: JSON.stringify({ username: username || process.env.SMARTBADGE_USERNAME || 'lami123456', password }),
+  });
+  const json = await res.json().catch(() => ({ raw: '非 JSON 响应' }));
+  if (!json.success || !json.data?.sessionToken) {
+    fail(`登录失败(${res.status}): ${JSON.stringify(json)}`, 2);
+  }
+  saveToken(json.data.sessionToken);
+  return json.data.sessionToken;
+}
+
+// ---------- httpJson ----------
+export async function httpJson(method, apiPath, body, { base, token, raw = false, json = true } = {}) {
+  const tokenValue = token || loadToken();
+  const headers = { 'X-Requested-With': 'XMLHttpRequest' };
+  if (body !== undefined) headers['Content-Type'] = 'application/json';
+  if (tokenValue) headers['X-Parse-Session-Token'] = tokenValue;
+  let res;
+  try {
+    res = await fetch(baseUrl(base) + apiPath, {
+      method: method.toUpperCase(),
+      headers,
+      body: body !== undefined ? JSON.stringify(body) : undefined,
+    });
+  } catch (err) {
+    fail(`无法连接服务器(${err.message})\n  - 检查 SMARTBADGE_API_BASE / --base(默认线上 https://recording.sh-lami.com;本地 http://localhost:3002)\n  - 以 / 开头的参数在 Git Bash 需先 export MSYS_NO_PATHCONV=1\n  - 服务器是否已启动`, 3);
+  }
+  const text = await res.text();
+  if (json) {
+    try { return { status: res.status, ok: res.ok, json: JSON.parse(text) }; } catch { return { status: res.status, ok: res.ok, json: { raw: text } }; }
+  }
+  return { status: res.status, ok: res.ok, json: null, text };
+}
+
+// ---------- 报告分页拉全 ----------
+export async function fetchAllReports({ base, token, type, start, end, deviceNo, pageSize = 100 } = {}) {
+  const all = [];
+  let page = 1;
+  for (;;) {
+    const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
+    if (type) qs.set('type', type);
+    if (start) qs.set('start', start);
+    if (end) qs.set('end', end);
+    if (deviceNo) qs.set('deviceNo', deviceNo);
+    const r = await httpJson('GET', `/api/reports?${qs}`, undefined, { base, token, raw: true });
+    const data = r.json?.data;
+    const items = data?.items ?? [];
+    all.push(...items);
+    const total = data?.total ?? 0;
+    if (items.length === 0 || all.length >= total || items.length < 100) break;
+    page++;
+  }
+  return all;
+}
+
+// ---------- htmlToText(正则剥标签,非完整 DOM;表格按行序文本) ----------
+const ENTITY = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
+export function htmlToText(html) {
+  return String(html)
+    .replace(/<script[\s\S]*?<\/script>/gi, ' ')
+    .replace(/<style[\s\S]*?<\/style>/gi, ' ')
+    .replace(/<(head|nav|footer)[\s\S]*?<\/\1>/gi, ' ')
+    .replace(/<br\s*\/?>/gi, '\n')
+    .replace(/<\/(p|li|tr|td|th|div|h[1-6])>/gi, '\n')
+    .replace(/<li[^>]*>/gi, '- ')
+    .replace(/<[^>]+>/g, ' ')
+    .replace(/&([a-z]+);/gi, (m, name) => ENTITY[name.toLowerCase()] ?? m)
+    .replace(/&#(\d+);/g, (m, n) => { try { return String.fromCharCode(n); } catch { return m; } })
+    .replace(/[ \t]+\n/g, '\n')
+    .replace(/^[ \t]+/gm, '')
+    .replace(/\n{3,}/g, '\n\n')
+    .replace(/^\s+|\s+$/g, '')
+    .trim();
+}

+ 104 - 0
scripts/oss.mjs

@@ -0,0 +1,104 @@
+#!/usr/bin/env node
+// 技能直连 OSS 工具:列举桶内对象与目录结构(ADR-004 凭据边界,零硬编码)
+// 用法:
+//   node oss.mjs             # 桶顶层:按一级目录分组统计
+//   node oss.mjs recordings/ # 列出该前缀全部对象(按目录分组)
+//   node oss.mjs recordings/ --json          # JSON 输出 {domain,prefix,objects}
+//   node oss.mjs --find-env                  # 打印实际采用的 .env 路径
+//   node oss.mjs reports/ --env D:/1/device-management-backend/server/.env  # 显式指定 .env
+// 凭据回退链: --env > SMARTBADGE_ENV_FILE > 自动发现(见 lib.mjs) > 进程环境已有 OSS_*
+// 注意:需要项目 server/services/oss-client.js 所在仓库(自动发现通常命中 D:\1\device-management-backend)
+import fs from 'fs';
+import path from 'path';
+import { pathToFileURL } from 'url';
+import { findEnvFile, loadEnv, fail } from './lib.mjs';
+
+const args = process.argv.slice(2);
+const isFlag = (a) => a.startsWith('--');
+const envIdx = args.indexOf('--env');
+const envPathFlag = envIdx >= 0 ? args[envIdx + 1] : undefined;
+// 非 flag 且非 flag 值 的参数才视为 prefix
+const prefix = args.filter((a, i) => !isFlag(a) && !(envIdx >= 0 && (i === envIdx + 1)))[0] ?? '';
+const json = args.includes('--json');
+const findEnv = args.includes('--find-env');
+
+function groupKey(name, pfx) {
+  const rest = name.slice(pfx.length);
+  const seg = rest.split('/')[0];
+  return rest.includes('/') ? `${seg}/` : seg;
+}
+
+async function main() {
+  const env = findEnvFile({ envPath: envPathFlag });
+  if (findEnv) {
+    if (env.hit) {
+      console.log(`生效 .env: ${env.hit}`);
+      return;
+    }
+    console.error('未找到 .env,候选路径:');
+    for (const c of env.candidates) console.error(`  ${c}`);
+    process.exit(2);
+  }
+  if (!env.hit) {
+    console.error('未找到 OSS 凭据文件(探测序列如下),三选一:');
+    for (const c of env.candidates) console.error(`  ${c}`);
+    console.error('  → 1. node oss.mjs --env <server/.env 路径>');
+    console.error('  → 2. 设置 SMARTBADGE_ENV_FILE 指向 server/.env');
+    console.error('  → 3. 直接导出 OSS_* 环境变量(与 server/.env 同键名)');
+    process.exit(2);
+  }
+  loadEnv(env.hit);
+
+  const serverDir = path.dirname(env.hit);
+  const missing = ['OSS_ACCESS_KEY_ID', 'OSS_ACCESS_KEY_SECRET', 'OSS_BUCKET'].filter((k) => !process.env[k]);
+  if (missing.length) fail(`OSS 凭据不全,缺: ${missing.join(', ')}。请检查 --env 指向的 .env 或环境变量`, 4);
+
+  const ossClientPath = path.join(serverDir, 'services', 'oss-client.js');
+  if (!fs.existsSync(ossClientPath)) {
+    fail(`未找到 oss-client.js: ${ossClientPath}。自动发现命中路径可能不含项目 server/;请用 --env 显式指向 <项目>/server/.env`, 3);
+  }
+
+  const { listObjects, publicDomain } = await import(pathToFileURL(ossClientPath));
+  const objects = await listObjects(prefix);
+  const domain = publicDomain();
+
+  if (json) {
+    console.log(JSON.stringify({
+      domain, prefix,
+      objects: objects.map((o) => ({ name: o.name, size: o.size, lastModified: o.lastModified })),
+    }));
+    return;
+  }
+
+  if (!prefix) {
+    const groups = new Map();
+    for (const o of objects) {
+      const seg = groupKey(o.name, '');
+      if (!seg) continue;
+      if (!groups.has(seg)) groups.set(seg, { count: 0, bytes: 0 });
+      groups.get(seg).count++;
+      groups.get(seg).bytes += o.size;
+    }
+    console.log(`OSS 桶顶层目录(共 ${objects.length} 个对象): ${domain}`);
+    for (const [seg, g] of [...groups.entries()].sort()) {
+      console.log(`  ${seg.padEnd(16)} ${g.count} 个文件, ${(g.bytes / 1024 / 1024).toFixed(2)} MB`);
+    }
+    console.log('使用: node oss.mjs <prefix> 查看子目录(如 recordings/ cleansed/ transcripts/ reports/)');
+    return;
+  }
+  if (objects.length === 0) {
+    console.log(`[${prefix}] 无对象`);
+    return;
+  }
+  console.log(`[${prefix}] 共 ${objects.length} 个对象:`);
+  for (const o of objects) {
+    console.log(`  ${o.name}  (${(o.size / 1024 / 1024).toFixed(2)} MB, ${(o.lastModified || '').slice(0, 10)})`);
+  }
+  const bytes = objects.reduce((s, o) => s + o.size, 0);
+  console.log(`合计 ${(bytes / 1024 / 1024).toFixed(2)} MB`);
+}
+
+main().catch((err) => {
+  console.error(err.message);
+  process.exit(1);
+});

+ 171 - 0
scripts/report-aggregate.mjs

@@ -0,0 +1,171 @@
+#!/usr/bin/env node
+// 录音管道区间聚合(月报/年报/任意窗口): 拉取窗口内报告 + 逐日 reconcile 对账 → Markdown/JSON
+// 用法:
+//   node report-aggregate.mjs                     # 默认: 本月 1 日 ~ 今天
+//   node report-aggregate.mjs --start 2026-08-01 --end 2026-08-31   # 指定窗口(月报)
+//   node report-aggregate.mjs --start 2026-01-01 --end 2026-12-31   # 年报
+//   node report-aggregate.mjs --type week         # 仅周报(默认 day+week 全量)
+//   node report-aggregate.mjs --json              # 机器可读输出
+//   node report-aggregate.mjs --no-reconcile      # 跳过逐日对账(仅报告列表)
+//   node report-aggregate.mjs --out 月报数据.md   # 同时写入文件
+// 密码: SMARTBADGE_PASSWORD 环境变量(无则复用 scripts/.token,失效时先 node api.mjs login)
+import fs from 'fs';
+import path from 'path';
+import { baseUrl, fail, login, loadToken, httpJson, fetchAllReports } from './lib.mjs';
+
+const args = process.argv.slice(2);
+const argVal = (name) => {
+  const i = args.indexOf(name);
+  return i >= 0 ? args[i + 1] : undefined;
+};
+
+function ymd(d) {
+  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+}
+function parseDate(s, label) {
+  if (!/^\d{4}-\d{2}-\d{2}$/.test(s ?? '')) fail(`${label} 需为 YYYY-MM-DD,收到: ${JSON.stringify(s)}`, 4);
+  const d = new Date(`${s}T00:00:00`);
+  if (Number.isNaN(d.getTime())) fail(`${label} 非合法日期: ${s}`, 4);
+  return d;
+}
+
+const now = new Date();
+const todayYmd = ymd(now);
+const startArg = argVal('--start');
+const endArg = argVal('--end');
+const type = argVal('--type') ?? 'all';
+if (!['day', 'week', 'all'].includes(type)) fail(`--type 仅支持 day|week|all,收到: ${type}`, 4);
+const deviceNo = argVal('--deviceNo');
+const jsonOut = args.includes('--json');
+const noReconcile = args.includes('--no-reconcile');
+const concurrency = Math.min(Number(argVal('--concurrency') ?? 4) || 4, 10);
+const outPath = argVal('--out');
+
+const start = parseDate(startArg ?? `${todayYmd.slice(0, 8)}01`, '--start');
+const end = parseDate(endArg ?? todayYmd, '--end');
+if (start > end) fail(`--start(${startArg}) 晚于 --end(${endArg})`, 3);
+
+const base = baseUrl();
+let token = loadToken();
+if (process.env.SMARTBADGE_PASSWORD) {
+  try { token = await login({ base, password: process.env.SMARTBADGE_PASSWORD }); } catch { /* 用现有 token 兜底 */ }
+}
+if (!token) fail(`未登录。先: node api.mjs login <密码>` +
+  `\n  或在环境变量设 SMARTBADGE_PASSWORD,或指定 --base URL(当前 ${base})`, 2);
+const auth = { base, token };
+
+// 1. 报告列表(分页拉全)
+const reports = await fetchAllReports({
+  base, token,
+  type: type === 'all' ? undefined : type,
+  start: ymd(start), end: ymd(end), deviceNo,
+});
+
+// 2. 逐日 reconcile(并发,有损:失败跳过但披露)
+const dayList = [];
+for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) dayList.push(ymd(d));
+const reconcile = new Map();
+if (!noReconcile) {
+  for (let i = 0; i < dayList.length; i += concurrency) {
+    const batch = dayList.slice(i, i + concurrency);
+    await Promise.all(batch.map(async (d) => {
+      try {
+        const r = await httpJson('GET', `/api/analysis/reconcile?date=${d}`, undefined, auth);
+        reconcile.set(d, r.ok ? r.json?.data ?? { reconcileError: 'no-data' } : { reconcileError: `HTTP ${r.status}` });
+      } catch (err) {
+        reconcile.set(d, { reconcileError: err.message });
+      }
+    }));
+  }
+}
+
+// 3. 聚合与输出
+const byType = (t) => reports.filter((r) => r.type === t);
+const dayReports = byType('day');
+const weekReports = byType('week');
+const deviceCountMax = Math.max(0, ...reports.map((r) => Number(r.deviceCount) || 0));
+const days = dayList.map((d) => {
+  const dayR = dayReports.filter((r) => r.periodStart === d);
+  const weekR = weekReports.filter((r) => r.periodStart === d);
+  const rc = reconcile.get(d);
+  return {
+    date: d,
+    reports: dayR,
+    weekReports: weekR,
+    transcribeDone: rc?.transcribeDone ?? null,
+    localHit: rc?.localHit ?? null,
+    ossHit: rc?.ossHit ?? null,
+    missing: { cleansed: rc?.missingCleansed ?? [], transcript: rc?.missingTranscript ?? [] },
+    reconcileError: rc?.reconcileError ?? null,
+  };
+});
+const daysAgo = (d) => Math.round((new Date(todayYmd) - new Date(d)) / 86400000);
+const noReportDays = days.filter((x) => x.reports.length === 0).map((x) => x.date);
+const noTranscribeDays = days.filter((x) => x.transcribeDone === 0).map((x) => x.date);
+const missingDays = days.filter((x) => x.reconcileError || x.missing.cleansed.length || x.missing.transcript.length);
+const actions = noReportDays.map((d) => ({
+  date: d,
+  command: `node scripts/api.mjs post /api/analysis/run '{"period":{"start":"${d}","end":"${d}"},"type":"day"}'`,
+  executable: daysAgo(d) <= 6,
+}));
+
+const data = {
+  window: { start: ymd(start), end: ymd(end), days: dayList.length, generatedAt: new Date().toISOString() },
+  type,
+  deviceNo: deviceNo ?? '全部',
+  totalReports: reports.length,
+  reportsByType: { day: dayReports.length, week: weekReports.length },
+  deviceCountMax,
+  totalTranscribeDone: days.reduce((s, x) => s + (x.transcribeDone || 0), 0),
+  days,
+  missing: {
+    noReport: noReportDays,
+    noTranscribe: noTranscribeDays,
+    reconcileErrorOrMissing: missingDays.map((x) => ({ date: x.date, error: x.reconcileError, cleansed: x.missing.cleansed, transcript: x.missing.transcript })),
+  },
+  actions,
+};
+
+if (jsonOut) {
+  const str = JSON.stringify(data, null, 2);
+  console.log(str);
+  if (outPath) fs.writeFileSync(outPath, str);
+  process.exit(0);
+}
+
+const lines = [];
+lines.push(`# 录音管道区间聚合(${ymd(start)} ~ ${ymd(end)})`);
+lines.push(`> 生成: ${data.window.generatedAt} | 口径: type=${type}, 设备=${data.deviceNo}, 窗口 ${dayList.length} 天`);
+lines.push('');
+lines.push('## 总体统计');
+lines.push('| 指标 | 值 |');
+lines.push('|---|---|');
+lines.push(`| 窗口天数 | ${dayList.length} |`);
+lines.push(`| 报告数(日/周) | ${data.reportsByType.day} / ${data.reportsByType.week} |`);
+lines.push(`| 覆盖设备数(报告最大口径) | ${deviceCountMax} |`);
+lines.push(`| 转录完成合计(逐日 reconcile 加总) | ${data.totalTranscribeDone} |`);
+lines.push('');
+lines.push('## 逐日明细');
+lines.push('| 日期 | 报告标题 | 周报 | 设备数 | 转录完成 | 缺失(清洗/转录) |');
+lines.push('|---|---|---|---|---|---|');
+for (const d of days) {
+  const titles = d.reports.map((r) => r.title ?? r.id).join('; ') || '⚠ 无报告';
+  const weekTitle = d.weekReports.map((r) => r.title ?? r.id).join('; ') || '—';
+  const miss = (d.missing.cleansed.length || d.missing.transcript.length)
+    ? `${d.missing.cleansed.length}/${d.missing.transcript.length}`
+    : '—';
+  const rc = d.reconcileError ? `(对账失败 ${d.reconcileError})` : '';
+  lines.push(`| ${d.date} | ${titles} | ${weekTitle} | ${d.reports.map((r) => r.deviceCount ?? '-').join(';') || '—'} | ${d.transcribeDone ?? 'n/a'}${rc} | ${miss} |`);
+}
+lines.push('');
+lines.push('## 缺失清单');
+lines.push(`1. **无日报日**: ${noReportDays.length ? noReportDays.join(', ') : '无'}`);
+lines.push(`2. **转录完成=0 日**: ${noTranscribeDays.length ? noTranscribeDays.join(', ') : '无'}`);
+lines.push(`3. **对账失败或缺失非空日**: ${missingDays.length ? missingDays.map((x) => `${x.date}${x.reconcileError ? `[对账失败]` : ''}`).join(', ') : '无'}`);
+lines.push('');
+lines.push('## 建议动作');
+lines.push(actions.length ? actions.map((a) => `${a.executable ? '✅ 可执行' : '⛔ 超 7 天,上游已清理,不可补'}: ${a.command}`).join('\n') : '无(窗口完整)');
+
+const md = lines.join('\n');
+console.log(md);
+if (outPath) fs.writeFileSync(outPath, md);

+ 37 - 0
scripts/verify-online.py

@@ -0,0 +1,37 @@
+#!/usr/bin/env python3
+# 浏览器走查: 登录 → 5 页(dashboard/recordings/logs/reports/pipeline) → 0 pageerror/console.error 断言
+# 环境变量: PLAYWRIGHT_BASE_URL(默认 https://recording.sh-lami.com)、SMARTBADGE_USERNAME/PASSWORD(默认 lami123456)
+import asyncio
+import os
+from playwright.async_api import async_playwright
+
+BASE = os.environ.get('PLAYWRIGHT_BASE_URL', 'https://recording.sh-lami.com/')
+USER = os.environ.get('SMARTBADGE_USERNAME', 'lami123456')
+PASS = os.environ.get('SMARTBADGE_PASSWORD', 'lami123456')
+errors = []
+
+async def main():
+    async with async_playwright() as p:
+        browser = await p.chromium.launch()
+        page = await browser.new_page()
+        page.on('pageerror', lambda e: errors.append(f'PAGEERROR: {e}'))
+        page.on('console', lambda m: errors.append(f'CONSOLE[{m.type}]: {m.text}') if m.type in ('error', 'warning') else None)
+
+        await page.goto(BASE + 'login', wait_until='networkidle')
+        await page.fill('input >> nth=0', USER)
+        await page.fill('input >> nth=1', PASS)
+        await page.click('button:has-text("登录"), button:has-text("登 录")')
+        await page.wait_for_timeout(3000)
+        await page.goto(BASE + 'dashboard', wait_until='networkidle')
+        await page.wait_for_timeout(4000)
+        print('dashboard url:', page.url, 'title:', await page.title())
+        for path in ['recordings', 'logs', 'reports', 'pipeline']:
+            await page.goto(BASE + path, wait_until='networkidle')
+            await page.wait_for_timeout(4000)
+            print(path, 'rows:', await page.locator('table tbody tr, .ui-table tbody tr, [role="row"]').count())
+        await browser.close()
+
+asyncio.run(main())
+print('--- 错误捕获 ---')
+print('\n'.join(errors) if errors else '0 errors')
+raise SystemExit(1 if errors else 0)

+ 45 - 0
templates/monthly-report.md

@@ -0,0 +1,45 @@
+# <窗口> 录音管道分析报告(月报/年报)
+
+> 数据来源: `report-aggregate.mjs` 输出 | 生成: <日期> | 口径: type=<day/week/all>, 设备=<全部或 xx>
+
+## 一、总体概览
+
+- 窗口: <start> ~ <end>(<天数> 天)
+- 报告数: 日 <n> / 周 <n>
+- 数据完整度: 无报告日 <n> / 转录完成=0 日 <n> / 对账失败或缺失日 <n>
+
+## 二、设备活跃
+
+- 报告覆盖设备数(最宽口径): <n>
+- 活跃规律: 工作日/周末对比, 设备覆盖的峰谷日期与可能原因
+
+## 三、录音量与转录效率
+
+- 转录完成合计(逐日 reconcile 加总): <n>
+- 趋势: 环比上一窗口 <↑/↓ n%>
+- 异常: 缺失清洗对象 <n>/缺失转录对象 <n>
+
+## 四、趋势分析
+
+- 按周/旬汇总 deviceCount 与转录量, 描述走向(上升/平稳/波动高峰)
+
+## 五、异常与缺失
+
+- 无报告日: <清单> — 原因(系统停机/上游无录音/任务失败)与状态(已补/超 7 天不可补)
+- 对账失败日: <清单>
+- 建议动作: <具体命令或"无需动作">
+
+## 六、报告要点摘录
+
+> 经 fetch-report-text.mjs 拉取 3-5 篇代表性报告正文提炼;每篇注明标题与日期。
+
+- 报告 A(<日期>): KPI 关键数字、画像要点、时间线事件、VOC 原话 1-2 条、风险点
+- 报告 B(<日期>): ...
+
+## 七、结论与建议
+
+1. <结论 1>
+2. <结论 2>
+3. <结论 3>
+4. <建议 1(可执行)>
+5. <建议 2(可执行)>

+ 30 - 0
templates/report-html.html

@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{标题}</title>
+<!-- 由 AI 按此模板填充: 将 markdown 内容(标题/表格/列表/段落)最小转换为 HTML 后放入 <main> -->
+<style>
+  body { font-family: "Microsoft YaHei", "PingFang SC", sans-serif; background: #f5f6fa; color: #24292f; margin: 0; padding: 24px; }
+  .container { max-width: 1000px; margin: 0 auto; background: #fff; border-radius: 8px; padding: 32px; box-shadow: 0 2px 12px rgba(0,0,0,.06); }
+  h1 { font-size: 24px; margin: 0 0 8px; } h2 { font-size: 18px; margin: 28px 0 8px; border-left: 4px solid #3564ff; padding-left: 8px; }
+  h3 { font-size: 15px; } table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 13px; }
+  th, td { border: 1px solid #d0d7de; padding: 6px 10px; text-align: left; } th { background: #f0f2f5; }
+  code { background: #f0f2f5; padding: 2px 4px; border-radius: 4px; font-size: 12px; }
+  .meta { color: #6a737d; font-size: 12px; } .kpi { display: flex; gap: 16px; flex-wrap: wrap; margin: 16px 0; }
+  .kpi div { flex: 1; min-width: 120px; border: 1px solid #d0d7de; border-radius: 6px; padding: 12px; text-align: center; }
+  .kpi b { display: block; font-size: 20px; color: #3564ff; } .risk { background: #fff8e6; border: 1px solid #f0c36d; border-radius: 6px; padding: 10px 14px; }
+  .ok { background: #eaf7ef; border: 1px solid #6cc180; border-radius: 6px; padding: 10px 14px; }
+</style>
+</head>
+<body>
+<div class="container">
+  <h1>{标题}</h1>
+  <p class="meta">生成时间: {日期} | 来源: smartbadge-ops 区间聚合</p>
+  <main>
+    <!-- AI 填充: KPI 卡片(.kpi)、表格、段落、风险/完成提示框 -->
+  </main>
+</div>
+</body>
+</html>