]> acesimba.cloud Git - family-chat.git/commitdiff
家人看护推送系统:完整项目初始提交 main
authorcodebuddy <codebuddy@local>
Wed, 2 Sep 2026 23:25:14 +0000 (07:25 +0800)
committercodebuddy <codebuddy@local>
Wed, 2 Sep 2026 23:25:14 +0000 (07:25 +0800)
- 基础:FastAPI + APScheduler + 企业微信自建应用,SQLite 存储
- 模板管理:多步消息编辑器(文字/图片),发送时机(延时/等回复),
  未回复超时兜底,图片上传与 media_id 缓存
- 规则管理:每日/每周/每月三种周期,成员多选,起止日期自动失效
- 会话推进引擎:回复即时推进、超时兜底、并发竞态防护
- 每日记录:模板回复矩阵(行=步骤,列=执行),step_replies 全量记录
- 安全:手动推 60s 冷却 + 防抖确认;.env 与 data/ 不入库
- UI:清新风格(白底灰标题、淡绿/淡蓝/灰系),手机/iPad 适配

40 files changed:
.env.example [new file with mode: 0644]
.gitignore [new file with mode: 0644]
HANDOFF.md [new file with mode: 0644]
README.md [new file with mode: 0644]
app/__init__.py [new file with mode: 0644]
app/__main__.py [new file with mode: 0644]
app/ai.py [new file with mode: 0644]
app/api.py [new file with mode: 0644]
app/asr.py [new file with mode: 0644]
app/config.py [new file with mode: 0644]
app/db.py [new file with mode: 0644]
app/feedback.py [new file with mode: 0644]
app/grading.py [new file with mode: 0644]
app/images.py [new file with mode: 0644]
app/materials.py [new file with mode: 0644]
app/notify.py [new file with mode: 0644]
app/scheduler.py [new file with mode: 0644]
app/seed.py [new file with mode: 0644]
app/server.py [new file with mode: 0644]
app/templates_render.py [new file with mode: 0644]
app/web.py [new file with mode: 0644]
app/wecom.py [new file with mode: 0644]
app/weekly_report.py [new file with mode: 0644]
config/config.example.yaml [new file with mode: 0644]
config/config.yaml [new file with mode: 0644]
deploy/DEPLOY.md [new file with mode: 0644]
deploy/family-chat.service [new file with mode: 0644]
deploy/nginx-wechatbot.conf [new file with mode: 0644]
doc/RISK_AND_PLAN.md [new file with mode: 0644]
doc/SPEC.md [new file with mode: 0644]
requirements.txt [new file with mode: 0644]
spike/README.md [new file with mode: 0644]
spike/app.py [new file with mode: 0644]
spike/asr_probe.py [new file with mode: 0644]
spike/common.py [new file with mode: 0644]
spike/sample.wav [new file with mode: 0644]
spike/send.py [new file with mode: 0644]
test_edit_feature.py [new file with mode: 0644]
test_session_engine.py [new file with mode: 0644]
wecom.py [new file with mode: 0644]

diff --git a/.env.example b/.env.example
new file mode 100644 (file)
index 0000000..c3dc396
--- /dev/null
@@ -0,0 +1,15 @@
+# ===== 企业微信(R1 Spike 必填) =====
+WECOM_CORP_ID=ww你的企业ID
+WECOM_SECRET=应用Secret
+WECOM_AGENT_ID=1000002
+WECOM_TOKEN=回调Token
+WECOM_AES_KEY=回调EncodingAESKey(43字符)
+WECOM_RECEIVE_ID=ww你的企业ID    # 企业微信此处 = CorpID
+WECOM_TEST_USER=mom             # spike 发送/接收测试目标(通讯录账号)
+
+# ===== 腾讯云 ASR(R3 实测必填) =====
+TENCENT_SECRET_ID=
+TENCENT_SECRET_KEY=
+
+# ===== spike 服务 =====
+SPIKE_PORT=1063
diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..f37f639
--- /dev/null
@@ -0,0 +1,20 @@
+# Python
+__pycache__/
+*.pyc
+*.pyo
+
+# 虚拟环境
+.venv/
+venv/
+
+# 密钥 / 本地配置(含企业微信与腾讯云凭据,禁止入库)
+.env
+
+# 运行时产物
+*.log
+
+# 运行数据(家人隐私:数据库/备份/语音/图片,禁止入库)
+data/
+
+# 测试产物
+/tmp/
diff --git a/HANDOFF.md b/HANDOFF.md
new file mode 100644 (file)
index 0000000..ef8778d
--- /dev/null
@@ -0,0 +1,31 @@
+# HANDOFF.md — 进度交接
+
+> 按 SPEC §17 更新。最后更新:2026-09-01
+
+## 当前状态:v1 初版已实现并跑通完整闭环 ✅
+
+### 已实现(对照 SPEC)
+- [x] P0 推送引擎:10 张表(SQLAlchemy/WAL)+ 种子;模板渲染;素材轮换;规则引擎(APScheduler 按星期 cron、幂等、静默顺延、misfire 15min);`config/config.yaml` 三段配置
+- [x] P1 反馈闭环:等待态(4h 超时)+ 归档;ASR 双引擎兜底(16k_zh_dialect / 16k_zh_en)+ ffmpeg 8k→16k 重采样 + 热词表;关键词先跑再 AI 打标(OpenAI 兼容);通知中心(WeCom 实现 + 路由 + 幂等账本);降噪(同级 30min 冷却、L2 单日上限 2)
+- [x] P2 收尾:周报(周日自动 + 手动);素材体检(周日 HEAD + 存活不足告警);短信通道适配器(默认关闭,需报备);后台鉴权(密码登录 `220526` + HttpOnly 会话 cookie,URL 不带 token)
+- [x] 企业微信回调:加解密回环已验证;推送/回调解密/反馈归档/分级/告警 全链路实测通过
+- [x] 手机优先 Web 后台:今日状态 / 看反馈 / 暂停恢复 / 手动推 / 编辑,已通过 nginx `/chatbot/` 验证
+- [x] 部署:systemd 常驻 + nginx 反代(1064,未动现有服务与端口)
+
+### 验证记录
+- 推送三段消息到老人微信实测成功(push_id 记录)
+- 模拟加密回调 → 解密 → 反馈入库(膝盖疼→L2 身体不适)→ 等待态清除,实测通过
+- WeCom GET 验证握手(echostr 回显明文)经 nginx 实测通过
+- 分级矩阵:摔了一跤→L3、累→L1、膝盖疼→L2,单测通过
+
+### 关键文件
+- `app/`:`api.py`(回调+API+后台路由) `scheduler.py`(规则引擎+推送) `feedback.py`(等待态) `asr.py` `ai.py` `grading.py` `notify.py` `materials.py` `templates_render.py` `weekly_report.py` `wecom.py`(加解密+客户端) `db.py`(10表) `seed.py` `web.py`(手机UI) `config.py` `server.py`
+- `config/config.yaml`:成员/模板/规则/素材/热词/通知(改这里最常见)
+- `deploy/`:`family-chat.service` `nginx-wechatbot.conf` `DEPLOY.md`
+- `.env`:密钥(不提交)
+
+### 注意事项(下次接手必读)
+- 路径大小写:真实项目目录是 `/home/chatbot`(小写)。`/home/Chatbot` 已设为指向它的软链接,写入任一路径都落到同一处。
+- 端口 1064;1063 是「学习山峰」不可碰。
+- 红线:不碰微信个人号 Hook、不骗老人(被问机器人如实说)、半夜不补发推送。
+- 待办:短信报备后开 `SMS_ENABLED`;生产接企微 OAuth2 替换密码登录;方言阈值用真人样音坐实。
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..78ce7d1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,151 @@
+# family-chat 家人看护推送系统(v1)
+
+给家里老人做的**微信看护推送系统**:按星期推送主题内容(锻炼 / 防诈骗 / 陪聊),
+老人用**文字或语音**回复,系统回收反馈、判断情绪与身体状况,异常分级通知子女,每周出周报。
+
+- 通道:企业微信自建应用 + 微信插件(老人在自己微信里收发,不装新 App)
+- 后端:FastAPI + SQLite(WAL) + APScheduler
+- 语音:腾讯云一句话识别(双引擎兜底 + 热词)
+- AI:OpenAI 兼容接口(模型走环境变量)
+
+---
+
+## 一、怎么用(手机后台)
+
+后台地址(手机浏览器打开):
+
+```
+https://acesimba.cloud/chatbot/
+```
+
+打开后会进入**登录页**,输入密码即可(默认 `220526`)。密码在 `.env` 用 `ADMIN_PASSWORD` 设置,**上线前务必改掉**。
+登录后服务端下发 `HttpOnly` 会话 cookie,后续页面与接口凭 cookie 鉴权,**URL 里不再带 token**。
+
+四个页面:
+- **今日状态**:今天推了没、老人回了没、有无未处理告警
+- **看反馈**:按人按日期看原文与风险标记
+- **暂停/恢复**:某人生病/外出,一键停推送
+- **手动推**:调试或临时补发一条
+
+底部 **编辑** 页可看规则、素材,并触发「素材体检」「生成周报」。
+
+> 本地调试也可直接访问 `http://服务器IP:1064/chatbot/`,同样走密码登录(需防火墙放行 1064)。
+
+---
+
+## 二、怎么改(最常改的都在 `config/config.yaml`)
+
+`config.yaml` 不含任何密钥(密钥只在 `.env`),可以提交 git。改完**重启服务**生效:
+
+```bash
+systemctl restart family-chat
+```
+
+### 1. 成员:谁是老人、谁是子女(收告警)
+改 `config.yaml` 的 `family.members`:
+```yaml
+family:
+  members:
+    - wecom_userid: WangYu      # 必须与企业微信通讯录里的「账号」完全一致
+      name: 王瑜
+      role: member              # member=收推送的老人;admin=收告警的子女
+      quiet_hours: "21:00-08:00"
+    - wecom_userid: yaya
+      name: 牙牙
+      role: admin
+```
+改了成员后,需重置数据库重新播种(会清空已有数据):
+```bash
+systemctl stop family-chat
+rm -f data/family.db data/family.db-wal data/family.db-shm
+systemctl start family-chat
+```
+
+### 2. 推送时间 / 星期
+改 `config.yaml` 的 `seed.rules`:
+```yaml
+seed:
+  rules:
+    - {days: "1,3,5", at: "09:00", template: exercise}  # 周一三五 09:00
+    - {days: "2,4,6", at: "09:00", template: anti_fraud}
+    - {days: "7",     at: "10:00", template: casual}      # 周日
+```
+`days`:`"1,3,5"` 周一三五;`"*"` 每天;`7` 周日。`at` 为 `HH:MM`。
+> 改 rules 后服务会自动热重载,无需重启(APScheduler 重新注册)。
+
+### 3. 推送内容(模板)
+改 `config.yaml` 的 `seed.templates`。一个模板按 `seq` 顺序播放,步骤类型:
+- `text`:直接发一段话
+- `material`:按 `tag` 自动选一个视频链接(不写死链接,方便轮换)
+- `question`:发一句提问,老人下一条回复会**归档为这条问题的反馈**(一个模板最多一个 question)
+```yaml
+- id: exercise
+  name: 锻炼身体
+  theme: 锻炼
+  steps:
+    - {seq: 1, type: text, content: "妈,今天天气还行,活动活动筋骨没?"}
+    - {seq: 2, type: material, tag: 锻炼}
+    - {seq: 3, type: question, content: "跟着做了没?哪儿觉得累就跟我说。", question_key: did_exercise, delay_sec: 20}
+```
+`delay_sec` 是每条之间的间隔(模拟真人节奏,别连发)。
+
+### 4. 素材(视频链接)
+改 `config.yaml` 的 `seed.materials`,或直接在后台**编辑**页看,未来也可在后台增删(API 已支持):
+```yaml
+materials:
+  - {tag: 锻炼, title: "八段锦完整跟练", url: "https://...", platform: B站, source: official}
+```
+`tag` 要和模板 `material` 步骤的 `tag` 对应;同 tag 多个时自动轮换(抽查过的优先、最久没用的优先)。
+
+### 5. 语音识别热词
+改 `config.yaml` 的 `asr.hotwords`(家人名字、病名等,权重 1-11,最多 128 个)。
+改完**下次识别自动生效**,无需重启:
+```yaml
+asr:
+  hotwords:
+    - "欢欢|11"
+    - "膝盖|9"
+    - "降压药|9"
+```
+
+### 6. 接 AI 打标(情绪/身体判断)
+在 `.env` 增加:
+```
+AI_API_KEY=sk-xxx
+AI_BASE_URL=https://你的兼容端点/v1
+AI_MODEL=gpt-4o-mini
+```
+不设也能跑:关键词先判(摔倒/胸痛→L3 等),AI 失败自动降级到关键词,不影响主流程。
+
+### 7. 短信告警(L3 第二通道)
+默认**关闭**(短信需报备签名+模板,1-3 天)。要开:在 `.env` 设 `SMS_ENABLED=1` 并填 `SMS_SECRET_ID/KEY/APP_ID/SIGN/TEMPLATE_ID`,重启。
+
+---
+
+## 三、怎么看风险分级
+
+| 级别 | 触发 | 通知 |
+|---|---|---|
+| L0 | 正常 | 无 |
+| L1 | 轻度负面、模糊不适、答非所问 | 仅进周报 |
+| L2 | 明确身体不适、连续负面 | 企微推子女 |
+| L3 | 摔倒/胸痛/喘不上气/轻生等关键词,或 AI 判高危 | 企微 + 短信 |
+
+防告警疲劳:同人同级 30 分钟内只发一次;L2 单日最多 2 条(超出进周报)。
+**建议上线前两周只记录不通知**,用真实数据调阈值(代码已支持,调 `config.yaml` 的 `notify` 段)。
+
+---
+
+## 四、常见操作
+
+```bash
+systemctl status family-chat     # 看状态
+systemctl restart family-chat    # 改配置后重启
+journalctl -u family-chat -f     # 看实时日志
+```
+
+## 五、已知限制 / 待办(按 SPEC)
+- 短信通道未报备,默认关闭(需签名模板)。
+- 后台鉴权为**密码登录 + 会话 cookie**(默认密码 `220526`,`.env` 的 `ADMIN_PASSWORD` 可改;`ADMIN_SECRET` 可固定会话密钥)。生产如需更强鉴权可接企业微信 OAuth2(SPEC §11.2)。
+- 方言样本(关中话/低质量音频)的置信度阈值需真人样音坐实后微调(代码已做可选兼容)。
+- 连续未回复升级(连续 2/3 天)目前以「当天未回→L1」实现,跨天连续逻辑待后续增强。
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644 (file)
index 0000000..c206d0e
--- /dev/null
@@ -0,0 +1,2 @@
+"""family-chat 应用包"""
+__version__ = "v1.0"
diff --git a/app/__main__.py b/app/__main__.py
new file mode 100644 (file)
index 0000000..f5f6e40
--- /dev/null
@@ -0,0 +1,4 @@
+from .server import main
+
+if __name__ == "__main__":
+    main()
diff --git a/app/ai.py b/app/ai.py
new file mode 100644 (file)
index 0000000..2fbd873
--- /dev/null
+++ b/app/ai.py
@@ -0,0 +1,118 @@
+"""
+app/ai.py — 小模型风险打标(OpenAI 兼容接口,urllib 直连,无 SDK 依赖)
+  要求模型返回严格 JSON:{"mood":"正面|中性|负面|低落","physical":"正常|不适|异常","risk":0-3,"reason":"<=20字"}
+  失败返回 None,由 grading 降级到关键词兜底。
+"""
+import json
+import urllib.request
+import urllib.error
+import logging
+
+logger = logging.getLogger("ai")
+
+_VALID_MOOD = {"正面", "中性", "负面", "低落"}
+_VALID_PHYS = {"正常", "不适", "异常"}
+
+
+def tag(text: str) -> dict | None:
+    """
+    对一段老人回复打标。成功返回 dict,失败/非法返回 None。
+    """
+    from .config import get_config
+    cfg = get_config()
+    if not cfg.ai_cfg.get("enabled") or not cfg.ai_api_key:
+        logger.info("[ai] 未启用或无密钥,跳过 AI 打标")
+        return None
+    sys_prompt = cfg.ai_cfg.get("system_prompt") or (
+        "你是帮子女照看独居父母的助手。下面是一段父母(老人)的回复文字。"
+        "请判断情绪与身体状况,并给出风险等级。只输出一个 JSON,不要任何解释。"
+        '格式:{"mood":"正面|中性|负面|低落","physical":"正常|不适|异常","risk":0-3,"reason":"不超过20字"}'
+        "risk:0正常 1关注 2注意 3紧急(紧急含摔倒/胸痛/喘不上气/轻生倾向等)。"
+    )
+    body = {
+        "model": cfg.ai_model,
+        "messages": [
+            {"role": "system", "content": sys_prompt},
+            {"role": "user", "content": text},
+        ],
+        "temperature": 0,
+        "response_format": {"type": "json_object"},
+    }
+    url = cfg.ai_base_url.rstrip("/") + "/chat/completions"
+    data = json.dumps(body).encode("utf-8")
+    req = urllib.request.Request(url, data=data, headers={
+        "Content-Type": "application/json",
+        "Authorization": "Bearer %s" % cfg.ai_api_key,
+    })
+    try:
+        with urllib.request.urlopen(req, timeout=cfg.ai_cfg.get("timeout_sec", 15)) as resp:
+            result = json.loads(resp.read().decode("utf-8"))
+        content = result["choices"][0]["message"]["content"]
+        return _parse(content)
+    except urllib.error.HTTPError as e:
+        logger.warning("[ai] HTTP 错误 %s: %s", e.code, e.read().decode("utf-8")[:200])
+        return None
+    except Exception as e:
+        logger.warning("[ai] 调用失败: %s", e)
+        return None
+
+
+def reply_as_daughter(text: str) -> str | None:
+    """普通陪聊:以女儿口吻回一句(不入库为反馈)。失败返回 None。"""
+    from .config import get_config
+    cfg = get_config()
+    if not cfg.ai_api_key:
+        return None
+    sys_prompt = ("你是老人的女儿,在微信里陪独居父母聊天。用简短、口语化、关爱的语气回一句,"
+                  "不要解释、不要列要点,像真人发微信。不超过 30 字。")
+    body = {
+        "model": cfg.ai_model,
+        "messages": [
+            {"role": "system", "content": sys_prompt},
+            {"role": "user", "content": text},
+        ],
+        "temperature": 0.7,
+        "max_tokens": 80,
+    }
+    url = cfg.ai_base_url.rstrip("/") + "/chat/completions"
+    data = json.dumps(body).encode("utf-8")
+    req = urllib.request.Request(url, data=data, headers={
+        "Content-Type": "application/json",
+        "Authorization": "Bearer %s" % cfg.ai_api_key,
+    })
+    try:
+        with urllib.request.urlopen(req, timeout=cfg.ai_cfg.get("timeout_sec", 15)) as resp:
+            result = json.loads(resp.read().decode("utf-8"))
+        return result["choices"][0]["message"]["content"].strip()
+    except Exception as e:
+        logger.warning("[ai] 陪聊回复失败: %s", e)
+        return None
+
+
+def _parse(content: str) -> dict | None:
+    try:
+        obj = json.loads(content)
+    except Exception:
+        # 容错:截取第一个 { } 之间的内容
+        s = content.find("{")
+        e = content.rfind("}")
+        if s >= 0 and e > s:
+            try:
+                obj = json.loads(content[s:e + 1])
+            except Exception:
+                return None
+        else:
+            return None
+    try:
+        mood = obj.get("mood")
+        physical = obj.get("physical")
+        risk = int(obj.get("risk", 0))
+        reason = obj.get("reason", "")[:20]
+        if mood not in _VALID_MOOD:
+            mood = "中性"
+        if physical not in _VALID_PHYS:
+            physical = "正常"
+        risk = max(0, min(3, risk))
+        return {"mood": mood, "physical": physical, "risk": risk, "reason": reason}
+    except Exception:
+        return None
diff --git a/app/api.py b/app/api.py
new file mode 100644 (file)
index 0000000..7314868
--- /dev/null
@@ -0,0 +1,672 @@
+"""
+app/api.py — FastAPI 应用:企业微信回调 + 后台 API + 健康检查
+回调:GET 验签回显 echostr;POST 解密老人消息 → 文本/语音 → 反馈采集/陪聊
+后台:密码登录(/login)→ 设置 HttpOnly 会话 cookie → 页面与 API 凭 cookie 鉴权
+"""
+import json
+import logging
+import os
+import threading
+import time
+from datetime import datetime, timedelta
+
+from fastapi import FastAPI, Request, Query, HTTPException, Depends, Body
+from fastapi.responses import (PlainTextResponse, HTMLResponse, JSONResponse,
+                               RedirectResponse, FileResponse)
+
+from . import db as _db
+from . import config as _cfg
+from . import wecom, feedback as _fb, scheduler as _sch, weekly_report as _wr
+from . import materials as _mat, notify as _notify
+from .db import (Family, Member, Template, TemplateStep, Rule, Material, Push,
+                 Feedback, Alert, Notification, WeeklyReport, get_sessionmaker, now_ts)
+
+logger = logging.getLogger("api")
+
+app = FastAPI(title="family-chat", version="v1.0")
+
+ADMIN_COOKIE = "fc_sid"
+
+
+# ---------------------------------------------------------------------------
+# 鉴权:密码登录 + 会话 cookie
+# ---------------------------------------------------------------------------
+def _authed(request: Request) -> bool:
+    return request.cookies.get(ADMIN_COOKIE) == _cfg.get_config().admin_secret
+
+
+def _require(request: Request):
+    """API 鉴权依赖:未登录返回 401。"""
+    if not _authed(request):
+        raise HTTPException(status_code=401, detail="未登录")
+
+
+LOGIN_HTML = """<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>登录</title>
+<style>
+body{font-family:-apple-system,system-ui,'PingFang SC',sans-serif;background:#f6f8f9;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
+.box{background:#fff;padding:26px;border-radius:14px;box-shadow:0 1px 3px rgba(60,80,100,.08);border:1px solid #eef2f5;width:290px;text-align:center}
+h2{margin:0 0 14px;color:#5b6672;font-size:16px;font-weight:600}
+input{width:100%;padding:10px;margin:8px 0;border:1px solid #dfe5ea;border-radius:8px;font-size:13px;box-sizing:border-box}
+input:focus{outline:none;border-color:#8ec5ff}
+button{width:100%;padding:11px;margin-top:10px;background:#e6f6ee;color:#1f8a5f;border:1px solid #c4e8d6;border-radius:10px;font-size:13px}
+button:active{background:#d7f0e3}
+.err{color:#c0392b;font-size:11px;min-height:16px}
+</style></head>
+<body><div class="box"><h2>家人看护后台</h2>
+<form id="f"><input id="pw" type="password" placeholder="请输入密码" autocomplete="off">
+<div class="err" id="err"></div>
+<button type="submit">登录</button></form>
+<script>
+document.getElementById('f').onsubmit=async(e)=>{
+  e.preventDefault();
+  const r=await fetch('/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:document.getElementById('pw').value})});
+  if(r.redirected||r.status===302){location.href='/chatbot/';}
+  else{document.getElementById('err').textContent='密码错误';}
+};
+</script></div></body></html>"""
+
+
+@app.get("/login")
+def login_page(request: Request):
+    if _authed(request):
+        return RedirectResponse("/chatbot/")
+    return HTMLResponse(LOGIN_HTML)
+
+
+@app.post("/login")
+async def login_post(request: Request):
+    try:
+        data = await request.json()
+        pw = (data or {}).get("password", "")
+    except Exception:
+        pw = ""
+    if pw == _cfg.get_config().admin_password:
+        resp = RedirectResponse("/chatbot/", status_code=302)
+        resp.set_cookie(ADMIN_COOKIE, _cfg.get_config().admin_secret,
+                        httponly=True, path="/", max_age=60 * 60 * 24 * 30, samesite="lax")
+        return resp
+    return HTMLResponse(LOGIN_HTML)
+
+
+# ---------------------------------------------------------------------------
+# 企业微信回调
+# ---------------------------------------------------------------------------
+@app.get("/wecom/callback")
+def wecom_verify(msg_signature: str, timestamp: str, nonce: str, echostr: str):
+    crypt = wecom.make_crypt()
+    try:
+        if not crypt.verify_signature(msg_signature, timestamp, nonce, echostr):
+            return PlainTextResponse("error", status_code=400)
+        plain = crypt.decrypt(echostr)
+    except Exception as e:
+        logger.warning("[callback] 验签失败: %s", e)
+        return PlainTextResponse("error", status_code=400)
+    return PlainTextResponse(plain)
+
+
+def _handle_message(plain_xml: str):
+    try:
+        d = wecom.parse_xml(plain_xml)
+        msg_type = d.get("MsgType", "")
+        from_user = d.get("FromUserName", "")
+        if msg_type == "event":
+            return
+        sm = get_sessionmaker()
+        sess = sm()
+        member = sess.query(Member).filter(Member.wecom_userid == from_user).first()
+        sess.close()
+        if not member:
+            logger.warning("[callback] 未知成员: %s", from_user)
+            return
+        if msg_type == "text":
+            _fb.collect(member, d.get("Content", ""), "text")
+            try:
+                _sch.on_member_reply(member.id, d.get("Content", ""), "text")
+            except Exception:
+                logger.exception("[callback] 会话推进失败")
+        elif msg_type == "voice":
+            media_id = d.get("MediaId", "")
+            cfg = _cfg.get_config()
+            amr_path = os.path.join(cfg.media_dir, "voice_%d.amr" % now_ts())
+            client = wecom.make_client()
+            try:
+                client.download_media(media_id, amr_path)
+                vres = _fb.process_voice(member, amr_path, cfg.media_dir)
+                try:
+                    _sch.on_member_reply(member.id,
+                                         vres.get("text") or "(语音未识别)", "voice")
+                except Exception:
+                    logger.exception("[callback] 会话推进失败")
+            except Exception as e:
+                logger.exception("[callback] 语音处理失败: %s", e)
+        else:
+            logger.info("[callback] 忽略消息类型: %s", msg_type)
+    except Exception:
+        logger.exception("[callback] 处理异常")
+
+
+@app.post("/wecom/callback")
+async def wecom_callback(request: Request,
+                          msg_signature: str = Query(None),
+                          timestamp: str = Query(None),
+                          nonce: str = Query(None)):
+    body = (await request.body()).decode("utf-8")
+    crypt = wecom.make_crypt()
+    try:
+        enc = wecom.parse_xml(body).get("Encrypt", "")
+        if not enc:
+            return PlainTextResponse("success")
+        if not crypt.verify_signature(msg_signature, timestamp, nonce, enc):
+            return PlainTextResponse("success", status_code=400)
+        plain = crypt.decrypt(enc)
+    except Exception as e:
+        logger.warning("[callback] 解密失败: %s", e)
+        return PlainTextResponse("success")
+    threading.Thread(target=_handle_message, args=(plain,), daemon=True).start()
+    return PlainTextResponse("success")
+
+
+@app.get("/health")
+def health():
+    return {"status": "ok", "time": now_ts()}
+
+
+# ---------------------------------------------------------------------------
+# 仪表盘
+# ---------------------------------------------------------------------------
+@app.get("/api/dashboard", dependencies=[Depends(_require)])
+def dashboard():
+    today = datetime.now().strftime("%Y-%m-%d")
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        pushes = sess.query(Push).filter(Push.push_date == today).all()
+        fbs = sess.query(Feedback).filter(Feedback.created_at >= now_ts() - 86400).all()
+        pending = sess.query(Alert).filter(Alert.resolved == 0, Alert.level >= 2).count()
+        members = [{"id": m.id, "name": m.name, "role": m.role, "enabled": m.enabled,
+                    "wecom_userid": m.wecom_userid} for m in sess.query(Member).all()]
+        push_list = [{"member": (sess.get(Member, p.member_id).name if sess.get(Member, p.member_id) else p.member_id),
+                      "template_id": p.template_id, "status": p.status,
+                      "sent_at": p.sent_at} for p in pushes]
+        fb_list = [{"id": f.id, "member": (sess.get(Member, f.member_id).name if sess.get(Member, f.member_id) else f.member_id),
+                    "answer": f.answer, "mood": f.mood, "physical": f.physical,
+                    "risk": f.risk_level, "created_at": f.created_at,
+                    "input_type": f.input_type} for f in fbs]
+        return {"today": today, "pushes": push_list, "feedbacks": fb_list,
+                "pending_alerts": pending, "members": members}
+    finally:
+        sess.close()
+
+
+# ---------------------------------------------------------------------------
+# 成员:暂停/恢复
+# ---------------------------------------------------------------------------
+@app.post("/api/members/{member_id}/toggle", dependencies=[Depends(_require)])
+def toggle_member(member_id: int):
+    with _db.session_scope() as sess:
+        m = sess.get(Member, member_id)
+        if not m:
+            raise HTTPException(404, "成员不存在")
+        m.enabled = 0 if m.enabled else 1
+        return {"id": m.id, "enabled": m.enabled}
+
+
+# ---------------------------------------------------------------------------
+# 模板 CRUD
+# ---------------------------------------------------------------------------
+@app.get("/api/templates", dependencies=[Depends(_require)])
+def list_templates():
+    sm = get_sessionmaker(); sess = sm()
+    try:
+        out = []
+        for t in sess.query(Template).all():
+            steps = [{"seq": s.seq, "type": s.type, "content": s.content, "tag": s.tag,
+                      "question_key": s.question_key, "delay_sec": s.delay_sec,
+                      "trigger_type": s.trigger_type or "delay",
+                      "reply_timeout_sec": s.reply_timeout_sec}
+                     for s in sess.query(TemplateStep).filter(
+                        TemplateStep.template_id == t.id).order_by(TemplateStep.seq)]
+            out.append({"id": t.id, "name": t.name, "theme": t.theme,
+                        "enabled": t.enabled, "steps": steps})
+        return out
+    finally:
+        sess.close()
+
+
+@app.post("/api/templates", dependencies=[Depends(_require)])
+def create_template(payload: dict = Body(...)):
+    with _db.session_scope() as sess:
+        fam = sess.query(Family).first()
+        t = Template(family_id=fam.id if fam else 1, name=payload["name"],
+                     theme=payload.get("theme"), enabled=1,
+                     created_at=now_ts(), updated_at=now_ts())
+        sess.add(t); sess.flush()
+        _save_steps(sess, t.id, payload.get("steps", []))
+        return {"id": t.id}
+
+
+@app.put("/api/templates/{tid}", dependencies=[Depends(_require)])
+def update_template(tid: int, payload: dict = Body(...)):
+    with _db.session_scope() as sess:
+        t = sess.get(Template, tid)
+        if not t:
+            raise HTTPException(404, "模板不存在")
+        t.name = payload.get("name", t.name)
+        t.theme = payload.get("theme", t.theme)
+        t.updated_at = now_ts()
+        sess.query(TemplateStep).filter(TemplateStep.template_id == tid).delete()
+        _save_steps(sess, tid, payload.get("steps", []))
+        return {"id": tid}
+
+
+@app.delete("/api/templates/{tid}", dependencies=[Depends(_require)])
+def delete_template(tid: int):
+    with _db.session_scope() as sess:
+        t = sess.get(Template, tid)
+        if not t:
+            raise HTTPException(404, "模板不存在")
+        sess.delete(t)
+        return {"ok": True}
+
+
+def _save_steps(sess, template_id, steps):
+    for i, s in enumerate(steps):
+        sess.add(TemplateStep(
+            template_id=template_id, seq=s.get("seq", i + 1), type=s["type"],
+            content=s.get("content"), tag=s.get("tag"),
+            question_key=s.get("question_key"), delay_sec=s.get("delay_sec", 0),
+            trigger_type=s.get("trigger_type", "delay") or "delay",
+            reply_timeout_sec=s.get("reply_timeout_sec")))
+
+
+@app.post("/api/templates/{tid}/preview", dependencies=[Depends(_require)])
+def preview_template(tid: int, user: str = Query(None)):
+    cfg = _cfg.get_config()
+    target = user or cfg.wecom_test_user
+    fam = _first_family()
+    res = _sch.send_template_to_userid(target, tid, fam, deferrable=False)
+    return {"result": res}
+
+
+# ---------------------------------------------------------------------------
+# 图片上传 / 访问(模板编辑器用)
+# ---------------------------------------------------------------------------
+@app.post("/api/upload/image", dependencies=[Depends(_require)])
+async def upload_image(request: Request, name: str = Query("image.jpg")):
+    from . import images as _images
+    data = await request.body()
+    try:
+        fn = _images.save_image(name, data)
+    except ValueError as e:
+        raise HTTPException(400, str(e))
+    return {"filename": fn}
+
+
+@app.get("/api/media/images/{filename}", dependencies=[Depends(_require)])
+def get_image(filename: str):
+    from . import images as _images
+    try:
+        path = _images.image_path(filename)
+    except ValueError:
+        raise HTTPException(404, "图片不存在")
+    if not os.path.exists(path):
+        raise HTTPException(404, "图片不存在")
+    return FileResponse(path)
+
+
+# ---------------------------------------------------------------------------
+# 规则 CRUD
+# ---------------------------------------------------------------------------
+_RULE_FIELDS = ("name", "member_ids", "period_type", "days", "month_day",
+                "at_time", "start_date", "end_date", "template_id",
+                "priority", "enabled", "member_id")
+
+
+@app.get("/api/rules", dependencies=[Depends(_require)])
+def list_rules():
+    sm = get_sessionmaker(); sess = sm()
+    try:
+        out = []
+        for r in sess.query(Rule).all():
+            mids = _parse_ids(r.member_ids)
+            names = []
+            for i in mids:
+                m = sess.get(Member, i)
+                if m:
+                    names.append(m.name)
+            tpl = sess.get(Template, r.template_id)
+            out.append({"id": r.id, "name": r.name or ("规则%d" % r.id),
+                        "member_ids": r.member_ids, "member_names": names,
+                        "period_type": r.period_type or "weekly",
+                        "days": r.days, "month_day": r.month_day,
+                        "at_time": r.at_time, "start_date": r.start_date,
+                        "end_date": r.end_date, "template_id": r.template_id,
+                        "template_name": tpl.name if tpl else "#%s" % r.template_id,
+                        "priority": r.priority, "enabled": r.enabled})
+        return out
+    finally:
+        sess.close()
+
+
+def _parse_ids(s):
+    if not s:
+        return []
+    return [int(p) for p in str(s).split(",") if p.strip().isdigit()]
+
+
+@app.post("/api/rules", dependencies=[Depends(_require)])
+def create_rule(payload: dict = Body(...)):
+    with _db.session_scope() as sess:
+        fam = sess.query(Family).first()
+        r = Rule(family_id=fam.id if fam else 1,
+                 name=payload.get("name") or "未命名规则",
+                 member_id=payload.get("member_id"),
+                 member_ids=payload.get("member_ids"),
+                 period_type=payload.get("period_type") or "weekly",
+                 days=payload.get("days") or "*",
+                 month_day=payload.get("month_day"),
+                 at_time=payload["at_time"],
+                 start_date=payload.get("start_date") or None,
+                 end_date=payload.get("end_date") or None,
+                 template_id=payload["template_id"],
+                 priority=payload.get("priority", 0),
+                 enabled=1)
+        sess.add(r); sess.flush(); rid = r.id
+    _sch.reload_rules()
+    return {"id": rid}
+
+
+@app.put("/api/rules/{rid}", dependencies=[Depends(_require)])
+def update_rule(rid: int, payload: dict = Body(...)):
+    with _db.session_scope() as sess:
+        r = sess.get(Rule, rid)
+        if not r:
+            raise HTTPException(404, "规则不存在")
+        for k in _RULE_FIELDS:
+            if k in payload:
+                setattr(r, k, payload[k])
+        if not (r.name or "").strip():
+            r.name = "规则%d" % rid
+    _sch.reload_rules()
+    return {"id": rid}
+
+
+@app.delete("/api/rules/{rid}", dependencies=[Depends(_require)])
+def delete_rule(rid: int):
+    with _db.session_scope() as sess:
+        r = sess.get(Rule, rid)
+        if not r:
+            raise HTTPException(404, "规则不存在")
+        sess.delete(r)
+    _sch.reload_rules()
+    return {"ok": True}
+
+
+# ---------------------------------------------------------------------------
+# 素材 CRUD
+# ---------------------------------------------------------------------------
+@app.get("/api/materials", dependencies=[Depends(_require)])
+def list_materials(tag: str = Query(None)):
+    sm = get_sessionmaker(); sess = sm()
+    try:
+        q = sess.query(Material)
+        if tag:
+            q = q.filter(Material.tag == tag)
+        out = [{"id": m.id, "tag": m.tag, "title": m.title, "url": m.url,
+                "platform": m.platform, "source": m.source, "checked": m.checked,
+                "alive": m.alive, "used_count": m.used_count,
+                "last_used_at": m.last_used_at} for m in q.all()]
+        return out
+    finally:
+        sess.close()
+
+
+@app.post("/api/materials", dependencies=[Depends(_require)])
+def create_material(payload: dict = Body(...)):
+    with _db.session_scope() as sess:
+        fam = sess.query(Family).first()
+        m = Material(family_id=fam.id if fam else 1, tag=payload["tag"],
+                     title=payload["title"], url=payload["url"],
+                     platform=payload.get("platform"), source=payload.get("source"),
+                     checked=0, alive=1, fail_count=0, used_count=0, created_at=now_ts())
+        sess.add(m); sess.flush(); return {"id": m.id}
+
+
+@app.put("/api/materials/{mid}", dependencies=[Depends(_require)])
+def update_material(mid: int, payload: dict = Body(...)):
+    with _db.session_scope() as sess:
+        m = sess.get(Material, mid)
+        if not m:
+            raise HTTPException(404, "素材不存在")
+        for k in ("tag", "title", "url", "platform", "source", "checked", "alive"):
+            if k in payload:
+                setattr(m, k, payload[k])
+        return {"id": mid}
+
+
+@app.delete("/api/materials/{mid}", dependencies=[Depends(_require)])
+def delete_material(mid: int):
+    with _db.session_scope() as sess:
+        m = sess.get(Material, mid)
+        if not m:
+            raise HTTPException(404, "素材不存在")
+        sess.delete(m)
+        return {"ok": True}
+
+
+@app.post("/api/materials/import", dependencies=[Depends(_require)])
+def import_materials(payload: dict = Body(...)):
+    items = payload.get("items", [])
+    with _db.session_scope() as sess:
+        fam = sess.query(Family).first()
+        fid = fam.id if fam else 1
+        cnt = 0
+        for it in items:
+            sess.add(Material(family_id=fid, tag=it["tag"], title=it["title"],
+                              url=it["url"], platform=it.get("platform"),
+                              source=it.get("source"), checked=0, alive=1,
+                              fail_count=0, used_count=0, created_at=now_ts()))
+            cnt += 1
+    return {"imported": cnt}
+
+
+@app.post("/api/materials/check", dependencies=[Depends(_require)])
+def check_materials():
+    alerts = _mat.health_check()
+    return {"checked": True, "alerts": alerts}
+
+
+# ---------------------------------------------------------------------------
+# 反馈 / 告警
+# ---------------------------------------------------------------------------
+@app.get("/api/feedbacks", dependencies=[Depends(_require)])
+def list_feedbacks(member: int = Query(None), risk: int = Query(None),
+                   from_: str = Query(None), to: str = Query(None)):
+    sm = get_sessionmaker(); sess = sm()
+    try:
+        q = sess.query(Feedback)
+        if member:
+            q = q.filter(Feedback.member_id == member)
+        if risk is not None:
+            q = q.filter(Feedback.risk_level == risk)
+        if from_:
+            q = q.filter(Feedback.created_at >= int(datetime.strptime(from_, "%Y-%m-%d").timestamp()))
+        if to:
+            q = q.filter(Feedback.created_at < int((datetime.strptime(to, "%Y-%m-%d") + timedelta(days=1)).timestamp()))
+        fbs = q.order_by(Feedback.created_at.desc()).limit(200).all()
+        out = []
+        for f in fbs:
+            m = sess.query(Member).get(f.member_id)
+            out.append({"id": f.id, "member": m.name if m else f.member_id,
+                        "question": f.question, "answer": f.answer,
+                        "input_type": f.input_type, "mood": f.mood,
+                        "physical": f.physical, "risk": f.risk_level,
+                        "reason": f.ai_reason, "created_at": f.created_at})
+        return out
+    finally:
+        sess.close()
+
+
+@app.get("/api/alerts", dependencies=[Depends(_require)])
+def list_alerts(resolved: int = Query(0)):
+    sm = get_sessionmaker(); sess = sm()
+    try:
+        q = sess.query(Alert).filter(Alert.resolved == resolved)
+        out = [{"id": a.id, "member_id": a.member_id, "level": a.level,
+                "reason": a.reason, "created_at": a.created_at,
+                "resolved": a.resolved} for a in q.order_by(Alert.created_at.desc()).limit(200)]
+        return out
+    finally:
+        sess.close()
+
+
+@app.post("/api/alerts/{aid}/resolve", dependencies=[Depends(_require)])
+def resolve_alert(aid: int, payload: dict = Body(None)):
+    by = (payload or {}).get("by", "admin")
+    with _db.session_scope() as sess:
+        a = sess.get(Alert, aid)
+        if not a:
+            raise HTTPException(404, "告警不存在")
+        a.resolved = 1
+        a.resolved_by = by
+        a.resolved_at = now_ts()
+        return {"id": aid, "resolved": 1}
+
+
+# ---------------------------------------------------------------------------
+# 模板回复矩阵(每日记录-模板回复总览)
+# ---------------------------------------------------------------------------
+@app.get("/api/matrix", dependencies=[Depends(_require)])
+def reply_matrix(template_id: int, member_id: int):
+    """行=模板步骤,列=每次执行,格=该步的回复(无记录显示未回复)。
+    旧 Feedback 数据按 push_id+问题内容回填;提问步附风险/情绪标签。"""
+    from .db import StepReply
+    sm = get_sessionmaker(); sess = sm()
+    try:
+        steps = (sess.query(TemplateStep)
+                 .filter(TemplateStep.template_id == template_id)
+                 .order_by(TemplateStep.seq.asc()).all())
+        if not steps:
+            return {"steps": [], "executions": []}
+        pushes = (sess.query(Push)
+                  .filter(Push.member_id == member_id,
+                          Push.template_id == template_id,
+                          Push.status == "sent")
+                  .order_by(Push.sent_at.desc()).all())  # 最新执行放最前一列
+        pids = [p.id for p in pushes] or [0]
+        srs = (sess.query(StepReply)
+               .filter(StepReply.member_id == member_id,
+                       StepReply.template_id == template_id).all())
+        fbs = (sess.query(Feedback)
+               .filter(Feedback.member_id == member_id,
+                       Feedback.push_id.in_(pids)).all())
+        content2seq = {s.content: s.seq for s in steps}
+        # 单元格: {(push_id, seq): [cell,...]}
+        cells = {}
+        for r in srs:
+            cells.setdefault((r.push_id, r.seq), []).append({
+                "answer": r.answer, "input_type": r.input_type,
+                "risk": None, "mood": None, "created_at": r.created_at})
+        # 旧 Feedback 回填 / 给 step_reply 附风险标签
+        for f in fbs:
+            seq = content2seq.get(f.question)
+            if seq is None:
+                continue
+            key = (f.push_id, seq)
+            if key not in cells:
+                cells[key] = [{"answer": f.answer, "input_type": f.input_type,
+                               "risk": f.risk_level, "mood": f.mood,
+                               "created_at": f.created_at}]
+            else:
+                for c in cells[key]:
+                    if c["risk"] is None and c["answer"] == f.answer:
+                        c["risk"], c["mood"] = f.risk_level, f.mood
+        executions = []
+        for p in pushes:
+            dt = datetime.fromtimestamp(p.sent_at) if p.sent_at else None
+            executions.append({
+                "push_id": p.id,
+                "date": dt.strftime("%m-%d %H:%M") if dt else p.push_date,
+                "cells": {str(s.seq): cells.get((p.id, s.seq), [])
+                          for s in steps}})
+        return {"steps": [{"seq": s.seq, "type": s.type, "content": s.content or ""}
+                          for s in steps],
+                "executions": executions}
+    finally:
+        sess.close()
+
+
+# ---------------------------------------------------------------------------
+# 手动推送 / 周报 / 短信回执
+# ---------------------------------------------------------------------------
+PUSH_COOLDOWN_SEC = 60   # 手动推冷却:同成员同模板 60s 内只能发一次
+
+
+@app.post("/api/push/{member_id}", dependencies=[Depends(_require)])
+def manual_push(member_id: int, template_id: int = Query(None), rule_id: int = Query(None)):
+    # 解析模板(可能由 rule_id 带出)
+    if template_id is None and rule_id:
+        sm = get_sessionmaker(); sess = sm()
+        try:
+            r = sess.get(Rule, rule_id)
+            template_id = r.template_id if r else None
+        finally:
+            sess.close()
+    # 冷却检查(防止误连点骚扰老人)
+    if template_id is not None:
+        sm = get_sessionmaker(); sess = sm()
+        try:
+            last = sess.query(Push).filter(
+                Push.member_id == member_id, Push.template_id == template_id,
+                Push.sent_at > now_ts() - PUSH_COOLDOWN_SEC).first()
+        finally:
+            sess.close()
+        if last:
+            raise HTTPException(429, "该模板 %d 秒内已发过一次,请稍后再试" % PUSH_COOLDOWN_SEC)
+    res = _sch.manual_push(member_id, template_id, rule_id)
+    return res
+
+
+@app.post("/api/report/weekly", dependencies=[Depends(_require)])
+def gen_weekly():
+    content = _wr.generate_and_send()
+    return {"content": content}
+
+
+@app.post("/notify/sms/receipt")
+async def sms_receipt(request: Request):
+    body = (await request.body()).decode("utf-8", "ignore")
+    logger.info("[sms-receipt] %s", body[:500])
+    return JSONResponse({"status": "ok"})
+
+
+# ---------------------------------------------------------------------------
+# 后台页面(手机优先,cookie 鉴权)
+# ---------------------------------------------------------------------------
+from . import web
+
+def _page_guard(func):
+    """页面鉴权:未登录直接由路由函数返回跳转,确保一定重定向。
+    (依赖里 return Response 在部分 FastAPI 版本不短路,故在 handler 内处理。)"""
+    def _wrapped(request: Request):
+        if not _authed(request):
+            return RedirectResponse("/login")
+        return func(request)
+    return _wrapped
+
+for _route in web.routes:
+    app.add_api_route(_route["path"], _page_guard(_route["func"]), methods=_route["methods"])
+
+
+# ---------------------------------------------------------------------------
+# 辅助
+# ---------------------------------------------------------------------------
+def _first_family():
+    sm = get_sessionmaker(); sess = sm()
+    try:
+        f = sess.query(Family).first()
+        return f.id if f else 1
+    finally:
+        sess.close()
diff --git a/app/asr.py b/app/asr.py
new file mode 100644 (file)
index 0000000..d7e8204
--- /dev/null
@@ -0,0 +1,162 @@
+"""
+app/asr.py — 腾讯云一句话识别(双引擎兜底 + ffmpeg 重采样 + 热词)
+  - 主引擎 16k_zh_dialect(含关中话/西安话),低置信度/空结果时换 16k_zh_en 兜底
+  - 微信语音 amr 8k → ffmpeg 重采样 16k 单声道
+  - 热词:首次识别前把 config 里的热词表注册成腾讯云词表,拿到 VocabularyId 作 HotwordId
+  - 沿用 spike 验证过的 client.call 原始字典调用,绕过 SDK SentenceRecognition 序列化 bug
+"""
+import os
+import json
+import base64
+import subprocess
+import logging
+
+logger = logging.getLogger("asr")
+
+
+def _client():
+    from tencentcloud.common import credential
+    from tencentcloud.common.profile.client_profile import ClientProfile
+    from tencentcloud.common.profile.http_profile import HttpProfile
+    from tencentcloud.asr.v20190614 import asr_client
+    from .config import get_config
+    cfg = get_config()
+    cred = credential.Credential(cfg.tencent_secret_id, cfg.tencent_secret_key)
+    hp = HttpProfile()
+    hp.reqTimeout = 15
+    cp = ClientProfile()
+    cp.httpProfile = hp
+    return asr_client.AsrClient(cred, "ap-guangzhou", cp)
+
+
+def resample_to_16k(src_path: str, dst_path: str) -> str:
+    """ffmpeg 重采样为 16k 单声道 wav(一句话识别只收 16k)。"""
+    cmd = ["ffmpeg", "-y", "-i", src_path, "-ar", "16000", "-ac", "1", "-f", "wav", dst_path]
+    subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+    return dst_path
+
+
+def _call_once(client, engine: str, data_b64: str, data_len: int, hotword_id: str):
+    """调用一次 SentenceRecognition,返回 (text, confidence)。失败抛异常。"""
+    params = {
+        "EngSerViceType": engine,
+        "SourceType": 1,                 # 1 = 上传音频数据
+        "VoiceFormat": "wav",
+        "SubServiceType": 2,             # 2 = 一句话识别
+        "ProjectId": 0,
+        "UsrAudioKey": "familychat",
+        "Data": data_b64,
+        "DataLen": data_len,
+    }
+    if hotword_id:
+        params["HotwordId"] = hotword_id
+    resp = client.call("SentenceRecognition", params)
+    raw = json.loads(resp.decode("utf-8"))
+    if "Response" not in raw:
+        raise RuntimeError("ASR 返回结构异常: %s" % str(raw)[:200])
+    r = raw["Response"]
+    if "Error" in r:
+        raise RuntimeError("ASR 错误 %s: %s" % (r["Error"].get("Code"), r["Error"].get("Message")))
+    text = r.get("Result", "") or ""
+    conf = r.get("Confidence")
+    # Confidence 在某些引擎/版本返回整数 0-100,也可能缺失;统一为 0-1 或 None
+    conf = (conf / 100.0) if isinstance(conf, (int, float)) else None
+    return text.strip(), conf
+
+
+# ---- 热词表管理(腾讯云托管词表 → VocabularyId → HotwordId) ----
+_HOTWORD_CACHE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+                              "data", "hotword_id.txt")
+
+
+def ensure_hotword_id(force=False) -> str:
+    """把 config.asr.hotwords 注册为腾讯云词表,返回 VocabularyId(失败返回 '')。"""
+    from .config import get_config
+    cfg = get_config()
+    hotwords = cfg.asr_hotwords()
+    if not hotwords:
+        return ""
+    if (not force) and os.path.exists(_HOTWORD_CACHE):
+        try:
+            return open(_HOTWORD_CACHE, encoding="utf-8").read().strip()
+        except Exception:
+            pass
+    try:
+        client = _client()
+        words = []
+        for item in hotwords:
+            if "|" in item:
+                w, weight = item.rsplit("|", 1)
+                try:
+                    words.append({"Word": w.strip(), "Weight": int(weight)})
+                except ValueError:
+                    words.append({"Word": w.strip(), "Weight": 5})
+            else:
+                words.append({"Word": item.strip(), "Weight": 5})
+        if not words:
+            return ""
+        params = {
+            "Name": "family_chat_hotwords",
+            "WordWeights": words,
+        }
+        resp = client.call("CreateAsrVocab", params)
+        raw = json.loads(resp.decode("utf-8"))
+        vid = raw.get("Response", {}).get("VocabularyId", "")
+        if vid:
+            os.makedirs(os.path.dirname(_HOTWORD_CACHE), exist_ok=True)
+            with open(_HOTWORD_CACHE, "w", encoding="utf-8") as f:
+                f.write(vid)
+            logger.info("[asr] 热词表已注册 VocabularyId=%s", vid)
+        return vid
+    except Exception as e:
+        logger.warning("[asr] 热词表注册失败(将跳过热词): %s", e)
+        return ""
+
+
+def transcribe_audio_file(wav_path: str) -> dict:
+    """
+    对本地 wav(应为 16k 单声道)做双引擎识别。
+    返回 {"text":..., "confidence":..., "engine":...}
+    """
+    from .config import get_config
+    cfg = get_config()
+    hotword_id = ensure_hotword_id()
+    with open(wav_path, "rb") as f:
+        raw = f.read()
+    data_b64 = base64.b64encode(raw).decode("ascii")
+    client = _client()
+    primary = cfg.asr.get("engine_primary", "16k_zh_dialect")
+    secondary = cfg.asr.get("engine_secondary", "16k_zh_en")
+    retry_below = float(cfg.asr.get("confidence_retry_below", 0.7))
+    accept_above = float(cfg.asr.get("confidence_accept_above", 0.5))
+
+    text, conf = _call_once(client, primary, data_b64, len(raw), hotword_id)
+    engine_used = primary
+    if (conf is not None and (conf < retry_below)) or (not text):
+        # 兜底:备用引擎
+        try:
+            t2, c2 = _call_once(client, secondary, data_b64, len(raw), hotword_id)
+            if t2 and (conf is None or c2 is None or c2 >= conf):
+                text, conf, engine_used = t2, c2, secondary
+        except Exception as e:
+            logger.warning("[asr] 备用引擎失败: %s", e)
+
+    if not text:
+        return {"text": "", "confidence": conf, "engine": engine_used, "empty": True}
+    # 仍低置信度:标记,由上层决定是否要求重说
+    low = (conf is not None and conf < accept_above)
+    return {"text": text, "confidence": conf, "engine": engine_used, "low_confidence": low}
+
+
+def transcribe_wecom_voice(amr_path: str, media_dir: str) -> dict:
+    """微信语音 amr(8k) → 重采样 16k wav → 识别。"""
+    wav_path = os.path.join(media_dir, "asr_%d.wav" % int(__import__("time").time() * 1000))
+    try:
+        resample_to_16k(amr_path, wav_path)
+        return transcribe_audio_file(wav_path)
+    finally:
+        try:
+            if os.path.exists(wav_path):
+                os.remove(wav_path)
+        except Exception:
+            pass
diff --git a/app/config.py b/app/config.py
new file mode 100644 (file)
index 0000000..4e7d3f8
--- /dev/null
@@ -0,0 +1,123 @@
+"""
+app/config.py — 配置加载
+  - 密钥从 .env(load_dotenv,不入库)
+  - 模板/规则/素材/通知等从 config.yaml
+  - 单例 Config,全局复用
+"""
+import os
+import threading
+import yaml
+
+try:
+    from . import __version__
+except Exception:
+    __version__ = "v1.0"
+
+_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+
+def load_dotenv(path=None):
+    """从 .env 读入环境变量(仅当未设置时)。"""
+    if path is None:
+        path = os.path.join(_BASE_DIR, ".env")
+    if not os.path.exists(path):
+        return
+    with open(path, encoding="utf-8") as f:
+        for line in f:
+            line = line.strip()
+            if not line or line.startswith("#") or "=" not in line:
+                continue
+            k, v = line.split("=", 1)
+            os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
+
+
+class Config:
+    _instance = None
+    _lock = threading.Lock()
+
+    def __init__(self):
+        load_dotenv()
+        self.base_dir = _BASE_DIR
+        # ---- 企业微信(来自 .env) ----
+        self.wecom_corp_id = os.environ.get("WECOM_CORP_ID", "")
+        self.wecom_secret = os.environ.get("WECOM_SECRET", "")
+        self.wecom_agent_id = int(os.environ.get("WECOM_AGENT_ID", "0"))
+        self.wecom_token = os.environ.get("WECOM_TOKEN", "")
+        self.wecom_aes_key = os.environ.get("WECOM_AES_KEY", "")
+        self.wecom_receive_id = os.environ.get("WECOM_RECEIVE_ID", "")
+        self.wecom_test_user = os.environ.get("WECOM_TEST_USER", "mom")
+
+        # ---- 腾讯云 ASR(来自 .env) ----
+        self.tencent_secret_id = os.environ.get("TENCENT_SECRET_ID", "")
+        self.tencent_secret_key = os.environ.get("TENCENT_SECRET_KEY", "")
+
+        # ---- AI 打标(来自 .env,OpenAI 兼容) ----
+        self.ai_api_key = os.environ.get("AI_API_KEY", "")
+        self.ai_base_url = os.environ.get("AI_BASE_URL", "https://api.openai.com/v1")
+        self.ai_model = os.environ.get("AI_MODEL", "gpt-4o-mini")
+
+        # ---- 后台鉴权(密码登录 + 会话 cookie) ----
+        # 登录密码:默认 220526,可用 .env 的 ADMIN_PASSWORD 覆盖
+        self.admin_password = os.environ.get("ADMIN_PASSWORD", "220526")
+        # 会话密钥(cookie 值):可用 .env 的 ADMIN_SECRET 固定,否则每次启动随机
+        self.admin_secret = os.environ.get("ADMIN_SECRET") or os.urandom(16).hex()
+
+        # ---- 短信(默认关闭,需报备) ----
+        self.sms_enabled = os.environ.get("SMS_ENABLED", "0") in ("1", "true", "True")
+        self.sms_secret_id = os.environ.get("SMS_SECRET_ID", "")
+        self.sms_secret_key = os.environ.get("SMS_SECRET_KEY", "")
+        self.sms_app_id = os.environ.get("SMS_APP_ID", "")
+        self.sms_sign = os.environ.get("SMS_SIGN", "")
+        self.sms_template_id = os.environ.get("SMS_TEMPLATE_ID", "")
+
+        # ---- 数据库 ----
+        self.db_path = os.path.join(_BASE_DIR, "data", "family.db")
+        self.media_dir = os.path.join(_BASE_DIR, "data", "media")
+
+        # ---- 以下来自 config.yaml ----
+        self.server = {"host": "0.0.0.0", "port": 1064,
+                       "public_base_url": "https://acesimba.cloud"}
+        self.asr = {"engine_primary": "16k_zh_dialect", "engine_secondary": "16k_zh_en",
+                    "confidence_retry_below": 0.7, "confidence_accept_above": 0.5,
+                    "hotwords": []}
+        self.ai_cfg = {"enabled": True, "timeout_sec": 15, "system_prompt": ""}
+        self.notify = {"routing": {1: [], 2: ["wecom"], 3: ["wecom", "sms"]},
+                       "degrade": {}, "budget": {"sms_monthly_cny": 20},
+                       "cooldown_minutes": 30, "l2_daily_cap": 2, "wait_state_hours": 4}
+        self.family = {"name": "家里", "members": []}
+        self.seed = {"templates": [], "rules": [], "materials": []}
+
+        self._load_yaml()
+
+    def _load_yaml(self):
+        path = os.path.join(_BASE_DIR, "config", "config.yaml")
+        if not os.path.exists(path):
+            # 允许只跑 spike 或仅用 example
+            example = os.path.join(_BASE_DIR, "config", "config.example.yaml")
+            if os.path.exists(example):
+                path = example
+        if not os.path.exists(path):
+            return
+        with open(path, encoding="utf-8") as f:
+            data = yaml.safe_load(f) or {}
+        self.server.update(data.get("server", {}) or {})
+        self.asr.update(data.get("asr", {}) or {})
+        self.ai_cfg.update(data.get("ai", {}) or {})
+        self.notify.update(data.get("notify", {}) or {})
+        self.family = data.get("family", self.family) or self.family
+        self.seed = data.get("seed", self.seed) or self.seed
+
+    # 便捷读取
+    def asr_hotwords(self):
+        return self.asr.get("hotwords", []) or []
+
+    def wait_state_seconds(self):
+        return int(self.notify.get("wait_state_hours", 4)) * 3600
+
+
+def get_config() -> Config:
+    if Config._instance is None:
+        with Config._lock:
+            if Config._instance is None:
+                Config._instance = Config()
+    return Config._instance
diff --git a/app/db.py b/app/db.py
new file mode 100644 (file)
index 0000000..67d43d5
--- /dev/null
+++ b/app/db.py
@@ -0,0 +1,323 @@
+"""
+app/db.py — SQLAlchemy 模型 + 引擎(SQLite WAL)
+按 SPEC §4 的 10 张表定义。create_all 为建表来源(等价 schema.sql)。
+"""
+import os
+import time
+from contextlib import contextmanager
+
+from sqlalchemy import (
+    Column, Integer, String, Text, Float, ForeignKey, create_engine, event
+)
+from sqlalchemy.orm import declarative_base, sessionmaker, relationship
+
+from .config import get_config
+
+Base = declarative_base()
+
+
+class Family(Base):
+    __tablename__ = "families"
+    id = Column(Integer, primary_key=True)
+    name = Column(String, nullable=False)
+    timezone = Column(String, nullable=False, default="Asia/Shanghai")
+    created_at = Column(Integer, nullable=False)
+
+
+class Member(Base):
+    __tablename__ = "members"
+    id = Column(Integer, primary_key=True)
+    family_id = Column(Integer, ForeignKey("families.id"), nullable=False)
+    wecom_userid = Column(String, nullable=False, unique=True)
+    name = Column(String, nullable=False)
+    role = Column(String, nullable=False)          # admin | member
+    phone = Column(String)
+    persona = Column(String, nullable=False, default="elder")
+    quiet_hours = Column(String, default="21:00-08:00")
+    enabled = Column(Integer, nullable=False, default=1)
+    created_at = Column(Integer, nullable=False)
+
+
+class Template(Base):
+    __tablename__ = "templates"
+    id = Column(Integer, primary_key=True)
+    family_id = Column(Integer, nullable=False)
+    name = Column(String, nullable=False)
+    theme = Column(String)
+    enabled = Column(Integer, nullable=False, default=1)
+    created_at = Column(Integer, nullable=False)
+    updated_at = Column(Integer, nullable=False)
+
+
+class TemplateStep(Base):
+    __tablename__ = "template_steps"
+    id = Column(Integer, primary_key=True)
+    template_id = Column(Integer, ForeignKey("templates.id", ondelete="CASCADE"), nullable=False)
+    seq = Column(Integer, nullable=False)
+    type = Column(String, nullable=False)          # text | material | question | image
+    content = Column(Text)                         # text=正文 image=图片文件名
+    tag = Column(String)
+    question_key = Column(String)
+    delay_sec = Column(Integer, nullable=False, default=0)
+    trigger_type = Column(String, nullable=False, default="delay")   # delay=上一条发出后 | after_reply=上一条回复后
+    reply_timeout_sec = Column(Integer)            # 该条发出后多久没回复就发下一条(NULL=不兜底)
+    __table_args__ = ({"sqlite_autoincrement": False},)
+
+
+class Rule(Base):
+    __tablename__ = "rules"
+    id = Column(Integer, primary_key=True)
+    family_id = Column(Integer, nullable=False)
+    member_id = Column(Integer)                     # 旧字段(NULL=全家),已由 member_ids 取代
+    member_ids = Column(String)                     # 发送对象,逗号分隔,如 "1,2";空=全家
+    name = Column(String, default="")               # 规则名称
+    period_type = Column(String, nullable=False, default="weekly")  # daily | weekly | monthly
+    days = Column(String, nullable=False)           # weekly: "1,3,5" / "*"
+    month_day = Column(Integer)                     # monthly: 每月几号
+    at_time = Column(String, nullable=False)        # "09:00"
+    start_date = Column(String)                     # YYYY-MM-DD,空=立即开始
+    end_date = Column(String)                       # YYYY-MM-DD,空=不结束
+    template_id = Column(Integer, ForeignKey("templates.id"), nullable=False)
+    priority = Column(Integer, nullable=False, default=0)
+    enabled = Column(Integer, nullable=False, default=1)
+
+
+class Material(Base):
+    __tablename__ = "materials"
+    id = Column(Integer, primary_key=True)
+    family_id = Column(Integer, nullable=False)
+    tag = Column(String, nullable=False)
+    title = Column(String, nullable=False)
+    url = Column(String, nullable=False)
+    platform = Column(String)
+    source = Column(String)
+    checked = Column(Integer, nullable=False, default=0)
+    alive = Column(Integer, nullable=False, default=1)
+    fail_count = Column(Integer, nullable=False, default=0)
+    used_count = Column(Integer, nullable=False, default=0)
+    last_used_at = Column(Integer)
+    created_at = Column(Integer, nullable=False)
+
+
+class Push(Base):
+    __tablename__ = "pushes"
+    id = Column(Integer, primary_key=True)
+    member_id = Column(Integer, ForeignKey("members.id"), nullable=False)
+    rule_id = Column(Integer, ForeignKey("rules.id"))
+    template_id = Column(Integer, nullable=False)
+    push_date = Column(String, nullable=False)      # YYYY-MM-DD
+    material_id = Column(Integer)
+    status = Column(String, nullable=False)         # sent | failed | skipped
+    sent_at = Column(Integer)
+
+
+class Feedback(Base):
+    __tablename__ = "feedbacks"
+    id = Column(Integer, primary_key=True)
+    member_id = Column(Integer, ForeignKey("members.id"), nullable=False)
+    push_id = Column(Integer, ForeignKey("pushes.id"))
+    question_key = Column(String)
+    question = Column(Text)
+    answer = Column(Text, nullable=False)
+    input_type = Column(String, nullable=False)     # text | voice
+    asr_confidence = Column(Float)
+    mood = Column(String)
+    physical = Column(String)
+    risk_level = Column(Integer, nullable=False, default=0)
+    ai_reason = Column(Text)
+    created_at = Column(Integer, nullable=False)
+
+
+class Alert(Base):
+    __tablename__ = "alerts"
+    id = Column(Integer, primary_key=True)
+    member_id = Column(Integer, nullable=False)
+    feedback_id = Column(Integer, ForeignKey("feedbacks.id"))
+    level = Column(Integer, nullable=False)
+    reason = Column(String, nullable=False)
+    channels = Column(String)
+    resolved = Column(Integer, nullable=False, default=0)
+    resolved_by = Column(String)
+    created_at = Column(Integer, nullable=False)
+    resolved_at = Column(Integer)
+
+
+class Notification(Base):
+    __tablename__ = "notifications"
+    id = Column(Integer, primary_key=True)
+    alert_id = Column(Integer, ForeignKey("alerts.id"))
+    channel = Column(String, nullable=False)        # wecom | sms | email | webhook
+    target = Column(String, nullable=False)
+    status = Column(String, nullable=False)          # sent | failed | skipped | degraded
+    cost_cny = Column(Float, nullable=False, default=0)
+    vendor_msg = Column(Text)
+    retry_count = Column(Integer, nullable=False, default=0)
+    created_at = Column(Integer, nullable=False)
+
+
+class WeeklyReport(Base):
+    __tablename__ = "weekly_reports"
+    id = Column(Integer, primary_key=True)
+    family_id = Column(Integer, nullable=False)
+    week_start = Column(String, nullable=False)
+    content = Column(Text, nullable=False)
+    sent_at = Column(Integer)
+    __table_args__ = ({"sqlite_autoincrement": False},)
+
+
+class WaitState(Base):
+    """等待反馈态:某成员处于「刚被提问,等回复」的状态。超时自动失效。"""
+    __tablename__ = "wait_states"
+    id = Column(Integer, primary_key=True)
+    member_id = Column(Integer, nullable=False)
+    push_id = Column(Integer)
+    question_key = Column(String)
+    question = Column(Text)
+    expires_at = Column(Integer, nullable=False)
+    created_at = Column(Integer, nullable=False)
+
+
+class TemplateSession(Base):
+    """模板推送会话:记录某成员某次模板推送走到第几步。
+    支持两种推进:delay 定时推进 / after_reply 等回复推进(超时兜底)。"""
+    __tablename__ = "template_sessions"
+    id = Column(Integer, primary_key=True)
+    member_id = Column(Integer, ForeignKey("members.id"), nullable=False)
+    template_id = Column(Integer, nullable=False)
+    rule_id = Column(Integer)
+    push_id = Column(Integer)
+    cur_step = Column(Integer, nullable=False, default=0)   # 已发送的最后一步下标(0-based)
+    waiting_for = Column(String, default="")                # "reply"=在等回复后推进
+    status = Column(String, nullable=False, default="active")  # active | done | aborted
+    created_at = Column(Integer, nullable=False)
+    updated_at = Column(Integer, nullable=False)
+
+
+class StepReply(Base):
+    """模板会话中每一步的回复记录(回复总览矩阵数据源,供后续总结提炼)。
+    seq 与 template_steps.seq 对齐(1-based)。"""
+    __tablename__ = "step_replies"
+    id = Column(Integer, primary_key=True)
+    member_id = Column(Integer, nullable=False)
+    template_id = Column(Integer, nullable=False)
+    push_id = Column(Integer)                     # 哪次执行(推送)
+    session_id = Column(Integer)                  # 哪个会话
+    seq = Column(Integer, nullable=False)         # 回答的是第几步(1-based)
+    question = Column(Text)                       # 该步内容快照(模板修改不影响历史)
+    answer = Column(Text)                         # 回复原文(语音为转写文本)
+    input_type = Column(String, nullable=False, default="text")  # text | voice
+    created_at = Column(Integer, nullable=False)
+
+
+# ---------------------------------------------------------------------------
+# 增量迁移(SQLite ADD COLUMN)
+# ---------------------------------------------------------------------------
+_MIGRATIONS = {
+    "rules": [
+        ("name", "VARCHAR DEFAULT ''"),
+        ("member_ids", "VARCHAR"),
+        ("period_type", "VARCHAR DEFAULT 'weekly'"),
+        ("month_day", "INTEGER"),
+        ("start_date", "VARCHAR"),
+        ("end_date", "VARCHAR"),
+    ],
+    "template_steps": [
+        ("trigger_type", "VARCHAR DEFAULT 'delay'"),
+        ("reply_timeout_sec", "INTEGER"),
+    ],
+}
+
+
+def _migrate(engine):
+    """为旧库补充新增列,并把旧数据迁移到新语义。幂等。"""
+    from sqlalchemy import text
+    with engine.connect() as conn:
+        for table, cols in _MIGRATIONS.items():
+            rows = conn.execute(text("PRAGMA table_info(%s)" % table)).fetchall()
+            existing = {r[1] for r in rows}
+            if not existing:
+                continue  # 表还不存在(create_all 会按新结构建)
+            for col, ddl in cols:
+                if col not in existing:
+                    conn.execute(text("ALTER TABLE %s ADD COLUMN %s %s" % (table, col, ddl)))
+        # 旧规则语义迁移
+        conn.execute(text(
+            "UPDATE rules SET period_type='daily' "
+            "WHERE days='*' AND (period_type IS NULL OR period_type='')"))
+        conn.execute(text(
+            "UPDATE rules SET period_type='weekly' "
+            "WHERE days<>'*' AND (period_type IS NULL OR period_type='')"))
+        conn.execute(text(
+            "UPDATE rules SET member_ids=CAST(member_id AS TEXT) "
+            "WHERE member_id IS NOT NULL AND (member_ids IS NULL OR member_ids='')"))
+        conn.execute(text(
+            "UPDATE rules SET name='规则'||CAST(id AS TEXT) "
+            "WHERE name IS NULL OR name=''"))
+        conn.commit()
+
+
+# ---------------------------------------------------------------------------
+# 引擎与会话
+# ---------------------------------------------------------------------------
+_engine = None
+_Session = None
+
+
+def _make_engine():
+    cfg = get_config()
+    os.makedirs(os.path.dirname(cfg.db_path), exist_ok=True)
+    os.makedirs(cfg.media_dir, exist_ok=True)
+    url = "sqlite:///%s" % cfg.db_path
+    eng = create_engine(url, connect_args={"check_same_thread": False},
+                        pool_pre_ping=True, future=True)
+    # 打开 WAL,提升并发读写
+    @event.listens_for(eng, "connect")
+    def _set_pragma(dbapi_conn, conn_record):
+        cur = dbapi_conn.cursor()
+        cur.execute("PRAGMA journal_mode=WAL;")
+        cur.execute("PRAGMA foreign_keys=ON;")
+        cur.close()
+
+    return eng
+
+
+def init_db():
+    """建表(若不存在)+ 增量迁移 + 确保目录。返回 engine。"""
+    global _engine, _Session
+    if _engine is None:
+        _engine = _make_engine()
+        _Session = sessionmaker(bind=_engine, future=True)
+    Base.metadata.create_all(_engine)
+    _migrate(_engine)
+    return _engine
+
+
+def get_engine():
+    if _engine is None:
+        init_db()
+    return _engine
+
+
+def get_sessionmaker():
+    if _Session is None:
+        init_db()
+    return _Session
+
+
+@contextmanager
+def session_scope():
+    """事务性 session:正常提交,异常回滚。"""
+    sm = get_sessionmaker()
+    session = sm()
+    try:
+        yield session
+        session.commit()
+    except Exception:
+        session.rollback()
+        raise
+    finally:
+        session.close()
+
+
+def now_ts():
+    return int(time.time())
diff --git a/app/feedback.py b/app/feedback.py
new file mode 100644 (file)
index 0000000..b23acd1
--- /dev/null
@@ -0,0 +1,110 @@
+"""
+app/feedback.py — 反馈采集与等待态(SPEC §8.1)
+  流程:
+    收到消息 → 是否处于「等待反馈」态?
+      是 → 语音走 ASR(若需要)→ 记 feedback → 风险打标 → 清除等待态 → 派发告警
+      否 → 普通陪聊(AI 接话,不入库为反馈);被问机器人如实说(C3)
+"""
+import logging
+import time
+
+from .db import (Feedback, WaitState, Member, get_sessionmaker, session_scope, now_ts)
+from . import grading, ai, asr, notify, config as _cfg
+
+logger = logging.getLogger("feedback")
+
+ROBOT_HONEST_REPLY = (
+    "这是欢欢给你弄的一个陪你说话的小东西,话是它替我说的,但我每天都看你说的话。"
+)
+
+
+def set_wait_state(member_id: int, push_id, question_key, question, hours: int = 4):
+    with session_scope() as sess:
+        # 同一人只保留一个等待态
+        sess.query(WaitState).filter(WaitState.member_id == member_id).delete()
+        sess.add(WaitState(
+            member_id=member_id, push_id=push_id, question_key=question_key,
+            question=question, expires_at=now_ts() + hours * 3600, created_at=now_ts()))
+
+
+def get_active_wait_state(member_id: int):
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        ws = (sess.query(WaitState)
+              .filter(WaitState.member_id == member_id,
+                      WaitState.expires_at > now_ts())
+              .order_by(WaitState.created_at.desc())
+              .first())
+        if not ws:
+            return None
+        return {"member_id": ws.member_id, "push_id": ws.push_id,
+                "question_key": ws.question_key, "question": ws.question,
+                "id": ws.id}
+    finally:
+        sess.close()
+
+
+def clear_wait_state(member_id: int):
+    with session_scope() as sess:
+        sess.query(WaitState).filter(WaitState.member_id == member_id).delete()
+
+
+def clear_expired_wait_states():
+    with session_scope() as sess:
+        sess.query(WaitState).filter(WaitState.expires_at <= now_ts()).delete()
+
+
+def collect(member: Member, raw_text: str, input_type: str, asr_confidence=None) -> dict:
+    """
+    处理一条老人消息。返回 {
+      kind: "feedback" | "chat",
+      feedback_id?, grading?, alert?, reply? (给老人的回执文本,可选)
+    }
+    """
+    ws = get_active_wait_state(member.id)
+    if ws:
+        # 入库为反馈
+        ai_result = None
+        if input_type == "voice" or True:
+            # 文本也跑一次 AI 打标,丰富情绪/身体判断
+            try:
+                ai_result = ai.tag(raw_text)
+            except Exception as e:
+                logger.warning("[feedback] AI 打标异常: %s", e)
+        g = grading.analyze(raw_text, ai_result)
+        with session_scope() as sess:
+            fb = Feedback(
+                member_id=member.id, push_id=ws.get("push_id"),
+                question_key=ws.get("question_key"), question=ws.get("question"),
+                answer=raw_text, input_type=input_type,
+                asr_confidence=asr_confidence, mood=g["mood"], physical=g["physical"],
+                risk_level=g["risk"], ai_reason=g.get("reason"), created_at=now_ts())
+            sess.add(fb)
+            sess.flush()
+            fb_id = fb.id
+        clear_wait_state(member.id)
+        alert = notify.handle_grading_result(member.id, g, feedback_id=fb_id)
+        logger.info("[feedback] 已归档 member=%s risk=%d reason=%s",
+                    member.id, g["risk"], g["reason"])
+        return {"kind": "feedback", "feedback_id": fb_id, "grading": g,
+                "alert": (alert.id if alert else None)}
+
+    # 非等待态:普通陪聊
+    if grading.is_robot_question(raw_text):
+        return {"kind": "chat", "reply": ROBOT_HONEST_REPLY}
+    reply = ai.reply_as_daughter(raw_text)
+    return {"kind": "chat", "reply": reply or "妈,我在呢,你说~"}
+
+
+def process_voice(member: Member, amr_path: str, media_dir: str) -> dict:
+    """下载好的微信语音(amr) → 重采样 → ASR → collect。返回结果带转写文本。"""
+    res = asr.transcribe_wecom_voice(amr_path, media_dir)
+    text = res.get("text", "")
+    conf = res.get("confidence")
+    if not text:
+        # 没听清,请重说(保存原始音频已在 download 时落盘)
+        return {"kind": "chat", "reply": "没太听清,你再说一遍?", "text": None}
+    out = collect(member, text, "voice", asr_confidence=conf)
+    out["text"] = text
+    return out
diff --git a/app/grading.py b/app/grading.py
new file mode 100644 (file)
index 0000000..a51560a
--- /dev/null
@@ -0,0 +1,98 @@
+"""
+app/grading.py — 风险分级(SPEC §8.3 / §8.4)
+  顺序:关键词先跑,命中直接 L3(不等 AI)。
+  再融合 AI 打标结果,取两者的较高风险。
+  返回结构化结果,供 notify 做降噪与路由。
+"""
+import logging
+import re
+
+logger = logging.getLogger("grading")
+
+# L3 紧急关键词(命中直接 L3)
+L3_KEYWORDS = [
+    "摔倒", "摔了", "摔一跤", "跌倒", "摔跟头",
+    "胸痛", "胸口疼", "胸口痛", "喘不上气", "喘不过气", "憋气", "呼吸困难",
+    "不想活", "活不下去", "轻生", "自杀", "不想过了",
+    "救命", "晕倒", "晕厥", "昏迷", "中风", "脑梗", "心梗",
+]
+# 身体不适关键词(→ physical 不适,可能升 L2)
+PHYSICAL_WORDS = [
+    "头晕", "头疼", "头痛", "膝盖", "腰疼", "腰酸痛", "腿疼", "关节",
+    "乏力", "没劲", "恶心", "吃不下", "发烧", "发热", "咳嗽", "心慌", "气短",
+    "不舒服", "难受", "疼", "痛", "胀", "麻",
+]
+# 情绪负面关键词(→ mood 负面,可能升 L1)
+NEGATIVE_WORDS = [
+    "累", "烦", "闷", "孤单", "寂寞", "想你", "哭了", "难受", "不开心",
+    "难过", "委屈", "害怕", "慌", "郁闷", "无聊", "没意思",
+]
+
+# C3:被问是不是机器人,必须如实说
+ROBOT_QUESTION_PATTERNS = ["机器人", "是不是机器", "你是机器", "自动的", "真人还是"]
+
+
+def is_robot_question(text: str) -> bool:
+    return any(p in text for p in ROBOT_QUESTION_PATTERNS)
+
+
+def analyze(text: str, ai_result: dict | None) -> dict:
+    """
+    返回 {
+      risk: 0-3, mood, physical, reason,
+      keyword_hit: bool, hit_word: str,
+    }
+    """
+    risk = 0
+    mood = "中性"
+    physical = "正常"
+    reason = ""
+    keyword_hit = False
+    hit_word = ""
+
+    # 1) 关键词先跑
+    for w in L3_KEYWORDS:
+        if w in text:
+            risk = 3
+            keyword_hit = True
+            hit_word = w
+            physical = "异常"
+            mood = "低落"
+            reason = "关键词命中: %s" % w
+            break
+    if not keyword_hit:
+        for w in PHYSICAL_WORDS:
+            if w in text:
+                physical = "不适"
+                risk = max(risk, 2)
+                reason = reason or ("身体不适: %s" % w)
+                break
+        for w in NEGATIVE_WORDS:
+            if w in text:
+                mood = "负面"
+                risk = max(risk, 1)
+                reason = reason or ("情绪低落: %s" % w)
+                break
+
+    # 2) 融合 AI 结果(AI 出错也不影响,因为关键词已兜底)
+    if ai_result:
+        ai_risk = ai_result.get("risk", 0) or 0
+        risk = max(risk, ai_risk)
+        # 仅在关键词未给具体值时,采用 AI 的 mood/physical
+        if not keyword_hit:
+            if ai_result.get("mood"):
+                mood = ai_result["mood"]
+            if ai_result.get("physical"):
+                physical = ai_result["physical"]
+        if ai_risk >= 1 and not keyword_hit:
+            reason = ai_result.get("reason") or reason
+        # 关键词 L3 时仍记录 AI 理由(更细)
+        if keyword_hit and ai_result.get("reason"):
+            reason = "%s;AI:%s" % (reason, ai_result["reason"])
+
+    risk = max(0, min(3, int(risk)))
+    return {
+        "risk": risk, "mood": mood, "physical": physical,
+        "reason": (reason or "正常")[:60],
+        "keyword_hit": keyword_hit, "hit_word": hit_word,
+    }
diff --git a/app/images.py b/app/images.py
new file mode 100644 (file)
index 0000000..78a3c4b
--- /dev/null
@@ -0,0 +1,70 @@
+"""
+app/images.py — 图片素材管理
+  - 模板编辑器上传的图片保存在 data/media/images/
+  - 发送时上传企业微信拿 media_id(临时素材 3 天有效),进程内缓存 2 天,过期自动重传
+"""
+import logging
+import os
+import threading
+import time
+import uuid
+
+from .config import get_config
+
+logger = logging.getLogger("images")
+
+ALLOWED_EXT = (".jpg", ".jpeg", ".png", ".gif")
+MAX_SIZE = 10 * 1024 * 1024          # 10MB
+_MEDIA_VALID_SEC = 2 * 86400         # 临时素材官方 3 天有效,缓存 2 天
+
+_cache = {}                          # filename -> (media_id, expire_ts)
+_lock = threading.Lock()
+
+
+def images_dir() -> str:
+    d = os.path.join(get_config().media_dir, "images")
+    os.makedirs(d, exist_ok=True)
+    return d
+
+
+def image_path(filename: str) -> str:
+    """校验文件名安全(防路径穿越)并返回绝对路径。"""
+    if not filename or "/" in filename or "\\" in filename or ".." in filename:
+        raise ValueError("非法文件名")
+    return os.path.join(images_dir(), filename)
+
+
+def save_image(orig_name: str, data: bytes) -> str:
+    """保存上传图片,返回生成的文件名。"""
+    if not data:
+        raise ValueError("空文件")
+    if len(data) > MAX_SIZE:
+        raise ValueError("图片超过 10MB")
+    ext = os.path.splitext(orig_name or "")[1].lower()
+    if ext not in ALLOWED_EXT:
+        ext = ".jpg"
+    fn = "img_%d_%s%s" % (int(time.time() * 1000), uuid.uuid4().hex[:8], ext)
+    path = os.path.join(images_dir(), fn)
+    with open(path, "wb") as f:
+        f.write(data)
+    logger.info("[images] 已保存 %s (%d KB)", fn, len(data) // 1024)
+    return fn
+
+
+def send_image_to(client, userid: str, filename: str) -> dict:
+    """发送图片消息(自动处理 media_id 缓存/重传)。"""
+    path = image_path(filename)
+    if not os.path.exists(path):
+        return {"errcode": -1, "errmsg": "图片文件不存在: %s" % filename}
+    with _lock:
+        hit = _cache.get(filename)
+        if hit and hit[1] > time.time():
+            media_id = hit[0]
+        else:
+            media_id = None
+    if media_id is None:
+        media_id = client.upload_media(path, "image")
+        with _lock:
+            _cache[filename] = (media_id, time.time() + _MEDIA_VALID_SEC)
+        logger.info("[images] %s 已上传企业微信 media_id=%s", filename, media_id)
+    return client.send_image(userid, media_id)
diff --git a/app/materials.py b/app/materials.py
new file mode 100644 (file)
index 0000000..d73f6c1
--- /dev/null
@@ -0,0 +1,114 @@
+"""
+app/materials.py — 素材子系统
+  - pick_material:按 SPEC §7.1 轮换(抽查优先 → 未用过优先 → 最久未用 → 用得最少)
+  - mark_used:更新 used_count / last_used_at
+  - health_check:HEAD 体检,连续 2 次失败置 alive=0;存活 < 3 告警
+"""
+import time
+import logging
+import urllib.request
+
+from .db import Material, session_scope, now_ts
+from . import notify
+
+logger = logging.getLogger("materials")
+
+
+def pick_material(family_id: int, tag: str):
+    """返回 Material 或 None(该 tag 无存活素材)。"""
+    from .db import get_sessionmaker
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        mat = (
+            sess.query(Material)
+            .filter(Material.family_id == family_id, Material.tag == tag,
+                    Material.alive == 1)
+            .order_by(
+                (Material.checked == 1).desc(),
+                (Material.last_used_at.is_(None)).desc(),
+                Material.last_used_at.asc(),
+                Material.used_count.asc(),
+            )
+            .first()
+        )
+        return mat
+    finally:
+        sess.close()
+
+
+def mark_used(material_id: int):
+    from .db import get_sessionmaker
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        mat = sess.get(Material, material_id)
+        if mat:
+            mat.used_count += 1
+            mat.last_used_at = now_ts()
+            sess.commit()
+    finally:
+        sess.close()
+
+
+def alive_count(family_id: int, tag: str) -> int:
+    from .db import get_sessionmaker
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        return sess.query(Material).filter(
+            Material.family_id == family_id, Material.tag == tag,
+            Material.alive == 1).count()
+    finally:
+        sess.close()
+
+
+def health_check():
+    """每周体检:HEAD 请求,连续 2 次失败 → alive=0;存活<3 告警。返回告警消息列表。"""
+    from .db import get_sessionmaker, Family
+    sm = get_sessionmaker()
+    sess = sm()
+    alerts = []
+    try:
+        mats = sess.query(Material).filter(Material.alive == 1).all()
+        for m in mats:
+            ok = _head_ok(m.url)
+            if ok:
+                m.fail_count = 0
+            else:
+                m.fail_count += 1
+                if m.fail_count >= 2:
+                    m.alive = 0
+                    alerts.append("素材失效已剔除: %s (%s)" % (m.title, m.url))
+        sess.commit()
+        # 各 tag 存活数
+        fam = sess.query(Family).first()
+        fid = fam.id if fam else 1
+        tags = [t for (t,) in sess.query(Material.tag).filter(
+            Material.family_id == fid).distinct()]
+        for tag in tags:
+            if alive_count(fid, tag) < 3:
+                msg = "素材不足预警: tag=%s 存活仅 %d 条,请补充" % (tag, alive_count(fid, tag))
+                alerts.append(msg)
+    finally:
+        sess.close()
+    if alerts:
+        notify.alert_admins("\n".join(alerts), level=2, reason="素材体检")
+    return alerts
+
+
+def _head_ok(url: str, timeout: int = 3) -> bool:
+    try:
+        req = urllib.request.Request(url, method="HEAD", headers={
+            "User-Agent": "Mozilla/5.0 (compatible; family-chat/1.0)"})
+        with urllib.request.urlopen(req, timeout=timeout) as resp:
+            return resp.status < 400
+    except Exception:
+        # HEAD 可能被拒,退回 GET 探测
+        try:
+            req = urllib.request.Request(url, method="GET", headers={
+                "User-Agent": "Mozilla/5.0 (compatible; family-chat/1.0)"})
+            with urllib.request.urlopen(req, timeout=timeout) as resp:
+                return resp.status < 400
+        except Exception:
+            return False
diff --git a/app/notify.py b/app/notify.py
new file mode 100644 (file)
index 0000000..0910ae0
--- /dev/null
@@ -0,0 +1,228 @@
+"""
+app/notify.py — 通知中心(SPEC §10)
+  - Notifier 协议:wecom / sms(sms 默认关闭,需报备)
+  - 路由:L1 不通知(进周报);L2 企微;L3 企微+短信
+  - 降噪:同成员同级冷却 30min;L2 单日上限 2 条(超出跳过发送,仍留痕进周报)
+  - 幂等:notifications 唯一(alert_id, channel, target)
+"""
+import time
+import logging
+from dataclasses import dataclass
+
+from .db import Alert, Notification, Member, get_sessionmaker, session_scope, now_ts
+from . import config as _cfg
+
+logger = logging.getLogger("notify")
+
+
+@dataclass
+class Receipt:
+    status: str          # sent | failed | skipped | degraded
+    vendor_msg: str = ""
+    cost_cny: float = 0.0
+
+
+class WeComNotifier:
+    name = "wecom"
+
+    def send(self, alert: Alert, target_userid: str) -> Receipt:
+        from .wecom import make_client
+        client = make_client()
+        text = "【家人看护提醒 L%d】\n%s" % (alert.level, alert.reason)
+        try:
+            r = client.send_text(target_userid, text)
+            if r.get("errcode", 0) == 0:
+                return Receipt("sent", str(r))
+            return Receipt("failed", str(r))
+        except Exception as e:
+            return Receipt("failed", str(e))
+
+
+class SmsNotifier:
+    name = "sms"
+
+    def send(self, alert: Alert, target_phone: str) -> Receipt:
+        from .config import get_config
+        cfg = get_config()
+        if not cfg.sms_enabled:
+            return Receipt("skipped", "短信未启用")
+        # 适配腾讯云短信:{1}=家人名 {2}=时间/位置 {3}=情况(≤12字)
+        try:
+            from tencentcloud.common import credential
+            from tencentcloud.common.profile.client_profile import ClientProfile
+            from tencentcloud.common.profile.http_profile import HttpProfile
+            from tencentcloud.sms.v20210111 import sms_client
+        except Exception as e:
+            return Receipt("failed", "短信 SDK 不可用: %s" % e)
+        try:
+            cred = credential.Credential(cfg.sms_secret_id, cfg.sms_secret_key)
+            hp = HttpProfile()
+            hp.reqTimeout = 10
+            cp = ClientProfile()
+            cp.httpProfile = hp
+            client = sms_client.SmsClient(cred, "ap-guangzhou", cp)
+            params = {
+                "PhoneNumberSet": ["+86%s" % target_phone],
+                "SmsSdkAppId": cfg.sms_app_id,
+                "SignName": cfg.sms_sign,
+                "TemplateId": cfg.sms_template_id,
+                "TemplateParamSet": [alert.reason[:12] or "情况异常", time.strftime("%H:%M")],
+            }
+            resp = client.call("SendSms", params)
+            raw = resp if isinstance(resp, dict) else __import__("json").loads(resp.decode("utf-8"))
+            # 简化判断
+            if raw.get("Response", {}).get("SendStatusSet"):
+                return Receipt("sent", str(raw)[:200], cost_cny=0.04)
+            return Receipt("failed", str(raw)[:200])
+        except Exception as e:
+            return Receipt("failed", str(e))
+
+
+_NOTIFIERS = {"wecom": WeComNotifier(), "sms": SmsNotifier()}
+
+
+# ---------------------------------------------------------------------------
+# 告警创建与派发
+# ---------------------------------------------------------------------------
+def create_alert(member_id: int, level: int, reason: str, feedback_id=None) -> Alert:
+    with session_scope() as sess:
+        a = Alert(member_id=member_id, feedback_id=feedback_id, level=level,
+                  reason=reason, created_at=now_ts())
+        sess.add(a)
+        sess.flush()
+        aid = a.id
+        # 重新查以便返回(session 关闭前)
+        return Alert(member_id=member_id, feedback_id=feedback_id, level=level,
+                     reason=reason, created_at=a.created_at, id=aid)
+    # 上面 session_scope 已关闭,返回的对象脱离 session,但字段已填充,够用
+
+
+def admin_targets(channel: str):
+    """返回某通道的目标列表:wecom→admin userid 串;sms→admin 手机号。"""
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        admins = sess.query(Member).filter(Member.role == "admin").all()
+        if channel == "wecom":
+            ids = [m.wecom_userid for m in admins if m.wecom_userid]
+            return ["|".join(ids)] if ids else []
+        elif channel == "sms":
+            return [m.phone for m in admins if m.phone]
+        return []
+    finally:
+        sess.close()
+
+
+def _cooldown_ok(member_id: int, level: int, cooldown_min: int) -> bool:
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        since = now_ts() - cooldown_min * 60
+        cnt = sess.query(Alert).filter(
+            Alert.member_id == member_id, Alert.level == level,
+            Alert.created_at >= since).count()
+        return cnt == 0
+    finally:
+        sess.close()
+
+
+def _l2_under_cap(member_id: int, cap: int) -> bool:
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        today_start = now_ts() - (now_ts() % 86400)  # 粗略按当天
+        cnt = sess.query(Alert).filter(
+            Alert.member_id == member_id, Alert.level == 2,
+            Alert.created_at >= today_start).count()
+        return cnt < cap
+    finally:
+        sess.close()
+
+
+def dispatch(alert: Alert) -> list:
+    """按路由派发,应用降噪与幂等。返回发出的通知列表。"""
+    from .config import get_config
+    cfg = get_config()
+    routing = cfg.notify.get("routing", {})
+    channels = routing.get(alert.level, []) or []
+    if not channels:
+        return []  # L1 不通知
+
+    # 降噪
+    cd = int(cfg.notify.get("cooldown_minutes", 30))
+    if not _cooldown_ok(alert.member_id, alert.level, cd):
+        logger.info("[notify] 冷却期内跳过 L%d 告警 (member=%s)", alert.level, alert.member_id)
+        return []
+    if alert.level == 2:
+        cap = int(cfg.notify.get("l2_daily_cap", 2))
+        if not _l2_under_cap(alert.member_id, cap):
+            logger.info("[notify] L2 单日上限,跳过发送(进周报) member=%s", alert.member_id)
+            return []
+
+    sent = []
+    for ch in channels:
+        notifier = _NOTIFIERS.get(ch)
+        if not notifier:
+            continue
+        targets = admin_targets(ch)
+        if not targets:
+            continue
+        for tgt in targets:
+            # 幂等:已发过则跳过
+            if _notified(alert.id, ch, tgt):
+                continue
+            receipt = notifier.send(alert, tgt)
+            _record(alert.id, ch, tgt, receipt)
+            sent.append((ch, tgt, receipt))
+            # L3 企微失败 → 降级短信
+            if receipt.status == "failed" and alert.level == 3 and ch == "wecom":
+                for dch in cfg.notify.get("degrade", {}).get("wecom_failed_on_level_3", []):
+                    dn = _NOTIFIERS.get(dch)
+                    if dn:
+                        for dt in admin_targets(dch):
+                            if not _notified(alert.id, dch, dt):
+                                r2 = dn.send(alert, dt)
+                                _record(alert.id, dch, dt, r2)
+                                sent.append((dch, dt, r2))
+    return sent
+
+
+def _notified(alert_id, channel, target) -> bool:
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        return sess.query(Notification).filter(
+            Notification.alert_id == alert_id, Notification.channel == channel,
+            Notification.target == target).first() is not None
+    finally:
+        sess.close()
+
+
+def _record(alert_id, channel, target, receipt: Receipt):
+    with session_scope() as sess:
+        sess.add(Notification(
+            alert_id=alert_id, channel=channel, target=target,
+            status=receipt.status, cost_cny=receipt.cost_cny,
+            vendor_msg=receipt.vendor_msg[:500], created_at=now_ts()))
+
+
+def alert_admins(text: str, level: int = 2, reason: str = "系统提醒"):
+    """系统级提醒(素材体检等):直接建告警并派发,member_id 用 0 哨兵。"""
+    a = create_alert(0, level, "%s\n%s" % (reason, text))
+    try:
+        dispatch(a)
+    except Exception as e:
+        logger.warning("[notify] alert_admins 派发失败: %s", e)
+
+
+def handle_grading_result(member_id: int, grading: dict, feedback_id=None):
+    """feedback 收集后调用:按风险等级建告警并派发。"""
+    level = grading.get("risk", 0)
+    if level <= 0:
+        return None
+    a = create_alert(member_id, level, grading.get("reason", "风险反馈"), feedback_id)
+    try:
+        dispatch(a)
+    except Exception as e:
+        logger.warning("[notify] 派发失败: %s", e)
+    return a
diff --git a/app/scheduler.py b/app/scheduler.py
new file mode 100644 (file)
index 0000000..4e77cc7
--- /dev/null
@@ -0,0 +1,661 @@
+"""
+app/scheduler.py — 规则引擎 + 推送闭环
+  - 每个 rule 一个 APScheduler cron job(每日/每周/每月三种周期),配置变更热重载
+  - 规则支持起止日期:不在区间内不发送;过期规则不再注册
+  - 幂等:pushes 唯一(member_id, rule_id, push_date)
+  - 静默时段顺延到结束后 30 分钟
+  - 模板会话推进:每步支持「上一条发出后延时」或「等老人回复后」两种时机,
+    每步可设「未回复超时」兜底(超时即发下一条);回复到达立即推进
+  - misfire_grace_time=900(15min 内补发,超时不补)
+  - 每日维护:清过期等待态、未回复升级、清僵尸会话、周日周报+素材体检
+"""
+import time
+import logging
+from datetime import datetime, timedelta
+
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+from apscheduler.triggers.date import DateTrigger
+
+from .db import (Family, Member, Rule, Template, TemplateStep, TemplateSession,
+                 StepReply, Push, get_sessionmaker, session_scope, now_ts)
+from . import templates_render, materials, feedback, notify, weekly_report, config as _cfg
+from . import images as _images
+
+logger = logging.getLogger("scheduler")
+
+_sched = None
+_DOW_MAP = {"1": "mon", "2": "tue", "3": "wed", "4": "thu", "5": "fri", "6": "sat", "7": "sun"}
+SESSION_STALE_HOURS = 48      # 会话最长存活,超过视为僵尸清理
+STEP_MISFIRE_GRACE = 3600     # 步骤推进任务 1h 内可补发
+
+
+def get_scheduler() -> BackgroundScheduler:
+    global _sched
+    if _sched is None:
+        _sched = BackgroundScheduler(timezone="Asia/Shanghai")
+    return _sched
+
+
+def _first_family_id():
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        f = sess.query(Family).first()
+        return f.id if f else 1
+    finally:
+        sess.close()
+
+
+# ---------------------------------------------------------------------------
+# 规则 → CronTrigger(每日 / 每周 / 每月)
+# ---------------------------------------------------------------------------
+def _cron_for_rule(rule: Rule) -> CronTrigger:
+    hh, mm = (rule.at_time.strip() + ":00")[:8].split(":")[:2]
+    hour, minute = int(hh), int(mm)
+    ptype = (rule.period_type or "weekly").strip()
+    if ptype == "daily":
+        return CronTrigger(hour=hour, minute=minute)
+    if ptype == "monthly":
+        day = int(rule.month_day or 1)
+        return CronTrigger(day=day, hour=hour, minute=minute)  # 当月无该日则自然跳过
+    # weekly
+    days = (rule.days or "").strip()
+    if days in ("*", ""):
+        return CronTrigger(hour=hour, minute=minute)
+    parts = [p.strip() for p in days.split(",") if p.strip()]
+    dow = ",".join(_DOW_MAP.get(p, p) for p in parts)
+    return CronTrigger(day_of_week=dow, hour=hour, minute=minute)
+
+
+def _rule_in_date_range(rule: Rule, today: str) -> bool:
+    if rule.start_date and today < rule.start_date:
+        return False
+    if rule.end_date and today > rule.end_date:
+        return False
+    return True
+
+
+def _parse_member_ids(member_ids) -> list:
+    if not member_ids:
+        return []
+    out = []
+    for p in str(member_ids).split(","):
+        p = p.strip()
+        if p.isdigit():
+            out.append(int(p))
+    return out
+
+
+# ---------------------------------------------------------------------------
+# 静默时段
+# ---------------------------------------------------------------------------
+def _in_quiet_hours(qh: str) -> bool:
+    if not qh:
+        return False
+    try:
+        start_s, end_s = [x.strip() for x in qh.split("-")]
+        sh, sm_ = int(start_s.split(":")[0]), int(start_s.split(":")[1])
+        eh, em = int(end_s.split(":")[0]), int(end_s.split(":")[1])
+    except Exception:
+        return False
+    now = datetime.now()
+    cur = now.hour * 60 + now.minute
+    s = sh * 60 + sm_
+    e = eh * 60 + em
+    if s <= e:
+        return s <= cur < e
+    else:  # 跨午夜,如 21:00-08:00
+        return cur >= s or cur < e
+
+
+def _quiet_end_dt(qh: str) -> datetime:
+    """静默时段结束时刻 +30min。"""
+    try:
+        end_s = qh.split("-")[1].strip()
+        eh, em = int(end_s.split(":")[0]), int(end_s.split(":")[1])
+    except Exception:
+        eh, em = 8, 30
+    now = datetime.now()
+    end = now.replace(hour=eh, minute=em, second=0, microsecond=0)
+    if end <= now:
+        end += timedelta(days=1)
+    return end + timedelta(minutes=30)
+
+
+# ---------------------------------------------------------------------------
+# 模板步骤 → 待发送单元
+# ---------------------------------------------------------------------------
+def _load_units(template_id: int) -> list:
+    """按 seq 读取模板步骤为发送单元(素材在实际发送时才选取)。"""
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        steps = (sess.query(TemplateStep)
+                 .filter(TemplateStep.template_id == template_id)
+                 .order_by(TemplateStep.seq.asc())
+                 .all())
+        return [{
+            "type": s.type,
+            "content": s.content or "",
+            "tag": s.tag,
+            "question_key": s.question_key,
+            "delay_sec": s.delay_sec or 0,
+            "trigger_type": s.trigger_type or "delay",
+            "reply_timeout_sec": s.reply_timeout_sec,
+        } for s in steps]
+    finally:
+        sess.close()
+
+
+def _send_unit(client, member, unit: dict, family_id: int):
+    """发送单个单元。返回 (ok, material_id, warning)。"""
+    if unit["type"] == "material":
+        mat = materials.pick_material(family_id, unit.get("tag"))
+        if not mat:
+            return False, None, "素材缺失(tag=%s),跳过该步" % unit.get("tag")
+        r = client.send_news(member.wecom_userid, mat.title, mat.url)
+        return r.get("errcode", 0) == 0, (mat.id if r.get("errcode", 0) == 0 else None), None
+    if unit["type"] == "image":
+        r = _images.send_image_to(client, member.wecom_userid, unit["content"])
+        if r.get("errcode", 0) != 0:
+            logger.warning("[scheduler] 图片发送失败 %s: %s", unit["content"], r)
+        return r.get("errcode", 0) == 0, None, None
+    # text / question 都按文本发
+    r = client.send_text(member.wecom_userid, unit["content"])
+    if r.get("errcode", 0) != 0:
+        logger.warning("[scheduler] 文本发送失败: %s", r)
+    return r.get("errcode", 0) == 0, None, None
+
+
+# ---------------------------------------------------------------------------
+# 模板会话推进
+# ---------------------------------------------------------------------------
+def _remove_session_jobs(sid: int):
+    sched = get_scheduler()
+    for job in sched.get_jobs():
+        if job.id.startswith("step-%d-" % sid) or job.id.startswith("tmout-%d-" % sid):
+            try:
+                sched.remove_job(job.id)
+            except Exception:
+                pass
+
+
+def _abort_member_session(member_id: int):
+    """中止该成员**所有**活动会话(新推送开始前调用;并发推送可能遗留多个)。"""
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        stale = (sess.query(TemplateSession)
+                 .filter(TemplateSession.member_id == member_id,
+                         TemplateSession.status == "active")
+                 .all())
+        for s in stale:
+            s.status = "aborted"
+            s.updated_at = now_ts()
+            sess.commit()
+            _remove_session_jobs(s.id)
+        if stale:
+            logger.info("[scheduler] %d 个旧会话因新推送被中止 (member=%d)",
+                        len(stale), member_id)
+    finally:
+        sess.close()
+
+
+def _schedule_advance(sid: int, cur_step: int, unit_sent: dict, next_unit: dict):
+    """发送完 cur_step 后,按下一单元的触发时机安排推进任务。"""
+    sched = get_scheduler()
+    now = datetime.now()
+    # 未回复超时兜底:无论下一单元是延时还是等回复,超时到即推进
+    timeout = unit_sent.get("reply_timeout_sec")
+    if timeout:
+        sched.add_job(_advance_session, trigger=DateTrigger(run_date=now + timedelta(seconds=timeout)),
+                      args=[sid, cur_step, "timeout"],
+                      id="tmout-%d-%d" % (sid, cur_step), replace_existing=True,
+                      misfire_grace_time=STEP_MISFIRE_GRACE)
+    if next_unit["trigger_type"] == "after_reply":
+        # 等回复:不排定时任务,由 on_member_reply 推进(可选超时兜底)
+        with session_scope() as sess:
+            s = sess.get(TemplateSession, sid)
+            if s and s.status == "active":
+                s.waiting_for = "reply"
+                s.updated_at = now_ts()
+        return
+    # 上一条发出后延时推进
+    delay = next_unit.get("delay_sec", 0)
+    sched.add_job(_advance_session, trigger=DateTrigger(run_date=now + timedelta(seconds=delay)),
+                  args=[sid, cur_step, "delay"],
+                  id="step-%d-%d" % (sid, cur_step), replace_existing=True,
+                  misfire_grace_time=STEP_MISFIRE_GRACE)
+
+
+def _advance_session(sid: int, expect_step: int, via: str):
+    """推进会话:发送下一条消息。expect_step 不匹配(已被推进过/中止)则忽略。"""
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        s = sess.get(TemplateSession, sid)
+        if not s or s.status != "active" or s.cur_step != expect_step:
+            return
+        # 防御:同一成员只允许最新的 active 会话推进(并发推送可能遗留多个 active)
+        newest = (sess.query(TemplateSession.id)
+                  .filter(TemplateSession.member_id == s.member_id,
+                          TemplateSession.status == "active")
+                  .order_by(TemplateSession.id.desc()).first())
+        if newest and newest[0] != sid:
+            s.status = "aborted"
+            s.updated_at = now_ts()
+            sess.commit()
+            _remove_session_jobs(sid)
+            logger.info("[scheduler] 会话 %d 非最新活动会话,中止", sid)
+            return
+        member = sess.get(Member, s.member_id)
+        if not member or not member.enabled:
+            s.status = "aborted"
+            s.updated_at = now_ts()
+            sess.commit()
+            _remove_session_jobs(sid)
+            return
+        sid_, mid, tpl_id, rule_id, push_id = s.id, s.member_id, s.template_id, s.rule_id, s.push_id
+        sess.close()
+
+        units = _load_units(tpl_id)
+        nxt = expect_step + 1
+        if nxt >= len(units):
+            _finish_session(sid_)
+            return
+        fam_id = _first_family_id()
+        from .wecom import make_client
+        client = make_client()
+        unit = units[nxt]
+        ok, material_id, warning = _send_unit(client, member, unit, fam_id)
+        if warning:
+            notify.alert_admins(warning, level=2, reason="推送素材缺失")
+        if material_id:
+            materials.mark_used(material_id)
+        logger.info("[scheduler] 会话%d推进 step=%d via=%s ok=%s type=%s",
+                    sid_, nxt, via, ok, unit["type"])
+
+        sm2 = get_sessionmaker()
+        sess = sm2()
+        s = sess.get(TemplateSession, sid_)
+        if not s or s.status != "active":
+            sess.close()
+            return
+        s.cur_step = nxt
+        s.waiting_for = ""
+        s.updated_at = now_ts()
+        sess.commit()
+        sess.close()
+
+        # 问题步骤:设置等待反馈态
+        if unit["type"] == "question" and ok:
+            fb_hours = int(_cfg.get_config().notify.get("wait_state_hours", 4))
+            feedback.set_wait_state(mid, push_id, unit.get("question_key"),
+                                    unit.get("content"), hours=fb_hours)
+        # 还有下一条 → 安排推进;没有了 → 结束
+        if nxt + 1 < len(units):
+            _schedule_advance(sid_, nxt, unit, units[nxt + 1])
+        else:
+            _finish_session(sid_)
+    except Exception as e:
+        logger.exception("[scheduler] 会话推进异常 sid=%s: %s", sid, e)
+    finally:
+        try:
+            sess.close()
+        except Exception:
+            pass
+
+
+def _finish_session(sid: int):
+    with session_scope() as sess:
+        s = sess.get(TemplateSession, sid)
+        if s and s.status == "active":
+            s.status = "done"
+            s.waiting_for = ""
+            s.updated_at = now_ts()
+    _remove_session_jobs(sid)
+
+
+def on_member_reply(member_id: int, text: str = "", input_type: str = "text"):
+    """收到老人回复后调用:
+    - 会话进行中:把回复记录到 step_replies(对应到已发送的最新一步)
+    - 最新会话在等回复:立即推进下一条;顺带清理遗留旧会话"""
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        actives = (sess.query(TemplateSession)
+                   .filter(TemplateSession.member_id == member_id,
+                           TemplateSession.status == "active")
+                   .order_by(TemplateSession.id.desc()).all())
+        if not actives:
+            return
+        newest = actives[0]
+        stale_ids = []
+        # 防御:中止并发遗留的旧会话
+        for s in actives[1:]:
+            s.status = "aborted"
+            s.updated_at = now_ts()
+            stale_ids.append(s.id)
+        sess.commit()
+        sid, cur, waiting = newest.id, newest.cur_step, newest.waiting_for
+        tpl_id, push_id = newest.template_id, newest.push_id
+        sess.close()
+        for old_id in stale_ids:
+            _remove_session_jobs(old_id)
+        # 记录该步的回复(无论下一步是等回复还是延时,都归到最新已发送的一步)
+        if text:
+            try:
+                units = _load_units(tpl_id)
+                q = units[cur]["content"] if 0 <= cur < len(units) else ""
+                with session_scope() as sess2:
+                    sess2.add(StepReply(
+                        member_id=member_id, template_id=tpl_id,
+                        push_id=push_id, session_id=sid,
+                        seq=cur + 1, question=q, answer=text,
+                        input_type=input_type, created_at=now_ts()))
+                logger.info("[scheduler] 已记录回复 member=%d session=%d step=%d",
+                            member_id, sid, cur + 1)
+            except Exception as e:
+                logger.exception("[scheduler] 记录回复失败 member=%s: %s", member_id, e)
+        if waiting != "reply":
+            return
+        _remove_session_jobs(sid)
+        _advance_session(sid, cur, "reply")
+    except Exception as e:
+        logger.exception("[scheduler] 回复推进异常 member=%s: %s", member_id, e)
+    finally:
+        try:
+            sess.close()
+        except Exception:
+            pass
+
+
+# ---------------------------------------------------------------------------
+# 推送入口
+# ---------------------------------------------------------------------------
+def send_template_to_member(member: Member, template_id: int, rule_id=None,
+                            family_id=None, deferrable=True) -> dict:
+    """对单个成员执行一次模板推送(首条立即发送,后续按步骤时机推进)。"""
+    if family_id is None:
+        family_id = _first_family_id()
+    if not member.enabled:
+        return {"skipped": "member disabled"}
+
+    # 静默时段顺延
+    if deferrable and _in_quiet_hours(member.quiet_hours or ""):
+        run_at = _quiet_end_dt(member.quiet_hours)
+        sched = get_scheduler()
+        sched.add_job(_deferred_send, trigger=DateTrigger(run_date=run_at),
+                      args=[member.id, template_id, rule_id, family_id],
+                      id="defer-%d-%d-%d" % (member.id, template_id, now_ts()),
+                      replace_existing=False)
+        logger.info("[scheduler] %s 在静默时段,顺延到 %s", member.name, run_at)
+        return {"deferred": str(run_at)}
+
+    # 幂等
+    push_date = datetime.now().strftime("%Y-%m-%d")
+    if rule_id is not None:
+        if _push_exists(member.id, rule_id, push_date):
+            return {"skipped": "already pushed today"}
+
+    units = _load_units(template_id)
+    if not units:
+        return {"error": "模板没有可发送的步骤: %s" % template_id}
+
+    from .wecom import make_client
+    client = make_client()
+
+    # 预览/测试(非库内成员):全部步骤顺序即时发送(旧行为)
+    if not getattr(member, "id", 0):
+        sent = 0
+        warnings = []
+        for i, unit in enumerate(units):
+            if i > 0 and unit["trigger_type"] == "delay":
+                time.sleep(min(unit.get("delay_sec", 0), 60))
+            ok, material_id, warning = _send_unit(client, member, unit, family_id)
+            if warning:
+                warnings.append(warning)
+                notify.alert_admins(warning, level=2, reason="推送素材缺失")
+            if ok:
+                sent += 1
+            if material_id:
+                materials.mark_used(material_id)
+        if warnings:
+            return {"ok": True, "steps": sent, "warnings": warnings}
+        return {"ok": True, "steps": sent}
+
+    # 正式推送:中止旧会话 → 发第一条 → 建会话 → 安排推进
+    _abort_member_session(member.id)
+    ok, material_id, warning = _send_unit(client, member, units[0], family_id)
+    if warning:
+        notify.alert_admins(warning, level=2, reason="推送素材缺失")
+    if material_id:
+        materials.mark_used(material_id)
+
+    with session_scope() as sess:
+        p = Push(member_id=member.id, rule_id=rule_id, template_id=template_id,
+                 push_date=push_date, material_id=material_id,
+                 status="sent" if ok else "failed", sent_at=now_ts())
+        sess.add(p)
+        sess.flush()
+        pid = p.id
+        s = TemplateSession(member_id=member.id, template_id=template_id,
+                            rule_id=rule_id, push_id=pid, cur_step=0,
+                            waiting_for="", status="active",
+                            created_at=now_ts(), updated_at=now_ts())
+        sess.add(s)
+        sess.flush()
+        sid = s.id
+
+    # 首条是问题:设置等待反馈态
+    if units[0]["type"] == "question" and ok:
+        fb_hours = int(_cfg.get_config().notify.get("wait_state_hours", 4))
+        feedback.set_wait_state(member.id, pid, units[0].get("question_key"),
+                                units[0].get("content"), hours=fb_hours)
+
+    if len(units) > 1:
+        _schedule_advance(sid, 0, units[0], units[1])
+    else:
+        _finish_session(sid)
+
+    logger.info("[scheduler] 已推送 member=%s template=%d session=%d",
+                member.name, template_id, sid)
+    return {"ok": True, "steps": 1, "push_id": pid, "session_id": sid}
+
+
+def send_template_to_userid(userid: str, template_id: int, family_id=None, deferrable=True) -> dict:
+    """给指定 userid(不一定是库内成员,如预览/测试)直接推送。"""
+    class _M:
+        id = 0
+        wecom_userid = userid
+        name = userid
+        enabled = 1
+        quiet_hours = ""
+    return send_template_to_member(_M(), template_id, rule_id=None,
+                                   family_id=family_id, deferrable=deferrable)
+
+
+def _deferred_send(member_id, template_id, rule_id, family_id):
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        m = sess.get(Member, member_id)
+    finally:
+        sess.close()
+    if m:
+        send_template_to_member(m, template_id, rule_id, family_id, deferrable=False)
+
+
+def _push_exists(member_id, rule_id, push_date) -> bool:
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        return sess.query(Push).filter(
+            Push.member_id == member_id, Push.rule_id == rule_id,
+            Push.push_date == push_date).first() is not None
+    finally:
+        sess.close()
+
+
+# ---------------------------------------------------------------------------
+# 规则执行
+# ---------------------------------------------------------------------------
+def run_rule(rule_id: int):
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        rule = sess.get(Rule, rule_id)
+        if not rule or rule.enabled != 1:
+            return
+        today = datetime.now().strftime("%Y-%m-%d")
+        if not _rule_in_date_range(rule, today):
+            logger.info("[scheduler] 规则 %d(%s) 不在起止日期内,跳过", rule_id, rule.name)
+            return
+        fam_id = _first_family_id()
+        mids = _parse_member_ids(rule.member_ids)
+        if mids:
+            members = [sess.get(Member, i) for i in mids]
+        else:
+            members = sess.query(Member).filter(
+                Member.family_id == fam_id, Member.role == "member",
+                Member.enabled == 1).all()
+    finally:
+        sess.close()
+    for m in members:
+        if m:
+            send_template_to_member(m, rule.template_id, rule_id, fam_id)
+
+
+def manual_push(member_id: int, template_id: int = None, rule_id=None) -> dict:
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        m = sess.get(Member, member_id)
+        if not m:
+            return {"error": "成员不存在"}
+        if template_id is None:
+            if rule_id:
+                rule = sess.get(Rule, rule_id)
+                template_id = rule.template_id if rule else None
+        if template_id is None:
+            return {"error": "缺少模板"}
+        fam_id = _first_family_id()
+    finally:
+        sess.close()
+    return send_template_to_member(m, template_id, rule_id, fam_id, deferrable=False)
+
+
+# ---------------------------------------------------------------------------
+# 维护
+# ---------------------------------------------------------------------------
+def _daily_maintenance():
+    """每日 23:00 维护:清过期等待态、僵尸会话、未回复升级、周日周报+体检。"""
+    logger.info("[scheduler] 每日维护开始")
+    feedback.clear_expired_wait_states()
+    _expire_stale_sessions()
+    _escalate_unanswered()
+    # 周日:周报 + 素材体检
+    if datetime.now().weekday() == 6:  # Monday=0, Sunday=6
+        try:
+            weekly_report.generate_and_send(week_start=_this_monday())
+        except Exception as e:
+            logger.warning("[scheduler] 周报生成失败: %s", e)
+        try:
+            materials.health_check()
+        except Exception as e:
+            logger.warning("[scheduler] 素材体检失败: %s", e)
+
+
+def _expire_stale_sessions():
+    """清理超过 48h 仍 active 的僵尸会话。"""
+    cutoff = now_ts() - SESSION_STALE_HOURS * 3600
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        stale = sess.query(TemplateSession).filter(
+            TemplateSession.status == "active",
+            TemplateSession.created_at < cutoff).all()
+        for s in stale:
+            s.status = "aborted"
+            s.updated_at = now_ts()
+            _remove_session_jobs(s.id)
+        sess.commit()
+        if stale:
+            logger.info("[scheduler] 清理僵尸会话 %d 个", len(stale))
+    finally:
+        sess.close()
+
+
+def _escalate_unanswered():
+    """应回未回:有 question 的推送当天无反馈 → L1;连续多日无反馈由周报体现。"""
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        from .db import Feedback, WaitState
+        today = datetime.now().strftime("%Y-%m-%d")
+        pushes = sess.query(Push).filter(Push.push_date == today,
+                                         Push.status == "sent").all()
+        for p in pushes:
+            # 该推送是否含 question(通过 template 的 steps 判断太重,简化:有 wait_state 曾设置过?)
+            # 简化:检查该成员当天是否有任何反馈
+            has = sess.query(Feedback).filter(
+                Feedback.member_id == p.member_id,
+                Feedback.created_at >= now_ts() - 86400).first()
+            if not has:
+                # 仅告警一次/天(降噪由 notify 冷却处理)
+                notify.create_alert(p.member_id, 1, "今日未收到该成员回复", feedback_id=None)
+    finally:
+        sess.close()
+
+
+def _this_monday():
+    d = datetime.now()
+    monday = d - timedelta(days=d.weekday())
+    return monday.strftime("%Y-%m-%d")
+
+
+# ---------------------------------------------------------------------------
+# 规则注册 / 启动
+# ---------------------------------------------------------------------------
+def reload_rules():
+    sched = get_scheduler()
+    # 移除旧 rule job
+    for job in sched.get_jobs():
+        if job.id.startswith("rule-"):
+            sched.remove_job(job.id)
+    sm = get_sessionmaker()
+    sess = sm()
+    today = datetime.now().strftime("%Y-%m-%d")
+    n = 0
+    try:
+        rules = sess.query(Rule).filter(Rule.enabled == 1).all()
+        for r in rules:
+            try:
+                if r.end_date and today > r.end_date:
+                    continue  # 已过期,不再注册
+                sched.add_job(run_rule, trigger=_cron_for_rule(r), args=[r.id],
+                              id="rule-%d" % r.id, replace_existing=True,
+                              misfire_grace_time=900)
+                n += 1
+            except Exception as e:
+                logger.warning("[scheduler] 规则 %d 注册失败: %s", r.id, e)
+    finally:
+        sess.close()
+    logger.info("[scheduler] 已加载 %d 条规则", n)
+
+
+def start_scheduler():
+    sched = get_scheduler()
+    if sched.running:
+        return
+    # 服务重启后,旧的进行中会话任务已丢失,全部中止(下次推送会新建)
+    with session_scope() as sess:
+        sess.query(TemplateSession).filter(TemplateSession.status == "active").update(
+            {TemplateSession.status: "aborted", TemplateSession.updated_at: now_ts()})
+    reload_rules()
+    # 每日维护
+    sched.add_job(_daily_maintenance, trigger=CronTrigger(hour=23, minute=0),
+                  id="daily-maintenance", replace_existing=True)
+    sched.start()
+    logger.info("[scheduler] 已启动")
diff --git a/app/seed.py b/app/seed.py
new file mode 100644 (file)
index 0000000..6f8e413
--- /dev/null
@@ -0,0 +1,69 @@
+"""
+app/seed.py — 首次启动播种(库为空时写入 config 里的 family/members/templates/rules/materials)
+  之后在 Web 后台编辑,不回写 config.yaml。
+"""
+import logging
+
+from .db import (Family, Member, Template, TemplateStep, Rule, Material,
+                 get_sessionmaker, now_ts)
+from .config import get_config
+
+logger = logging.getLogger("seed")
+
+
+def seed_if_empty() -> bool:
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        if sess.query(Family).first():
+            return False
+        cfg = get_config()
+        fam = Family(name=cfg.family.get("name", "家里"),
+                     timezone="Asia/Shanghai", created_at=now_ts())
+        sess.add(fam)
+        sess.flush()
+        fid = fam.id
+
+        for m in cfg.family.get("members", []):
+            sess.add(Member(
+                family_id=fid, wecom_userid=m["wecom_userid"], name=m.get("name", m["wecom_userid"]),
+                role=m.get("role", "member"), phone=m.get("phone", ""),
+                persona=("elder" if m.get("role") == "member" else "admin"),
+                quiet_hours=m.get("quiet_hours", "21:00-08:00"),
+                enabled=1, created_at=now_ts()))
+
+        id_map = {}
+        for t in cfg.seed.get("templates", []):
+            tpl = Template(family_id=fid, name=t["name"], theme=t.get("theme"),
+                           enabled=1, created_at=now_ts(), updated_at=now_ts())
+            sess.add(tpl)
+            sess.flush()
+            id_map[t["id"]] = tpl.id
+            for s in t.get("steps", []):
+                sess.add(TemplateStep(
+                    template_id=tpl.id, seq=s["seq"], type=s["type"],
+                    content=s.get("content"), tag=s.get("tag"),
+                    question_key=s.get("question_key"),
+                    delay_sec=s.get("delay_sec", 0)))
+
+        for r in cfg.seed.get("rules", []):
+            tid = id_map.get(r["template"])
+            if tid is None:
+                logger.warning("[seed] 规则引用了未知模板 %s,跳过", r["template"])
+                continue
+            sess.add(Rule(family_id=fid, member_id=None, days=r["days"],
+                          at_time=r["at"], template_id=tid, priority=0, enabled=1))
+
+        for m in cfg.seed.get("materials", []):
+            sess.add(Material(
+                family_id=fid, tag=m["tag"], title=m["title"], url=m["url"],
+                platform=m.get("platform"), source=m.get("source"),
+                checked=0, alive=1, fail_count=0, used_count=0, created_at=now_ts()))
+
+        sess.commit()
+        logger.info("[seed] 已播种 family=%s 模板=%d 规则=%d 素材=%d",
+                    fid, len(cfg.seed.get("templates", [])), len(cfg.seed.get("rules", [])),
+                    len(cfg.seed.get("materials", [])))
+        return True
+    finally:
+        sess.close()
diff --git a/app/server.py b/app/server.py
new file mode 100644 (file)
index 0000000..c169dcf
--- /dev/null
@@ -0,0 +1,29 @@
+"""
+app/server.py — 服务入口
+  启动:建库 → 播种 → 启动调度器 → 起 FastAPI(uvicorn)
+  运行:python3 -m app.server
+"""
+import logging
+
+from .db import init_db
+from . import seed, scheduler
+from .config import get_config
+from .api import app
+
+logging.basicConfig(level=logging.INFO,
+                    format="%(asctime)s %(levelname)s %(name)s: %(message)s")
+
+
+def main():
+    cfg = get_config()
+    init_db()
+    seed.seed_if_empty()
+    scheduler.start_scheduler()
+    import uvicorn
+    uvicorn.run(app, host=cfg.server.get("host", "0.0.0.0"),
+                port=int(cfg.server.get("port", 1064)),
+                log_level="info")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/app/templates_render.py b/app/templates_render.py
new file mode 100644 (file)
index 0000000..97fabc4
--- /dev/null
@@ -0,0 +1,60 @@
+"""
+app/templates_render.py — 按 seq 渲染模板为「发送计划」
+  每个 step 变成一条待发送单元:text / question / material / image
+  material 步骤在渲染时按 tag 选素材(缺素材则跳过该步并告警)
+"""
+import logging
+
+from .db import Template, TemplateStep, get_sessionmaker
+from . import materials
+
+logger = logging.getLogger("templates")
+
+
+def build_push_plan(template_id: int, family_id: int):
+    """
+    返回 (plan, material_id, warnings)
+      plan: list of dict:
+        {type, content?, question_key?, delay_sec, material?{title,url}}
+    """
+    sm = get_sessionmaker()
+    sess = sm()
+    warnings = []
+    material_id = None
+    try:
+        tpl = sess.get(Template, template_id)
+        if not tpl or tpl.enabled != 1:
+            return None, None, ["模板不存在或已禁用: %s" % template_id]
+        steps = (
+            sess.query(TemplateStep)
+            .filter(TemplateStep.template_id == template_id)
+            .order_by(TemplateStep.seq.asc())
+            .all()
+        )
+        plan = []
+        for s in steps:
+            unit = {
+                "type": s.type,
+                "delay_sec": s.delay_sec or 0,
+                "seq": s.seq,
+                "trigger_type": s.trigger_type or "delay",
+                "reply_timeout_sec": s.reply_timeout_sec,
+            }
+            if s.type == "text":
+                unit["content"] = s.content or ""
+            elif s.type == "question":
+                unit["content"] = s.content or ""
+                unit["question_key"] = s.question_key
+            elif s.type == "material":
+                mat = materials.pick_material(family_id, s.tag)
+                if not mat:
+                    warnings.append("素材缺失(tag=%s),跳过该步" % s.tag)
+                    continue
+                unit["material"] = {"title": mat.title, "url": mat.url}
+                material_id = mat.id
+            elif s.type == "image":
+                unit["content"] = s.content or ""
+            plan.append(unit)
+        return plan, material_id, warnings
+    finally:
+        sess.close()
diff --git a/app/web.py b/app/web.py
new file mode 100644 (file)
index 0000000..b12c88d
--- /dev/null
@@ -0,0 +1,561 @@
+"""
+app/web.py — 手机优先后台页面
+  六个功能页,纯 HTML+JS,调用 /api(凭登录 cookie 鉴权,无需 token)。
+  页面用相对 BASE(由 JS 按当前路径推算),无论从 http://ip:1064/ 还是
+  https://域名/chatbot/ 访问都正常。未登录会被重定向到 /login。
+  路由在 api.py 末尾统一注册(带页面鉴权依赖)。
+"""
+from fastapi import Request
+from fastapi.responses import HTMLResponse
+
+
+def _page(title, body):
+    return f"""<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>{title}</title>
+<style>
+*{{box-sizing:border-box}} body{{margin:0;font-family:-apple-system,system-ui,'PingFang SC','Helvetica Neue',sans-serif;background:#f6f8f9;color:#3d454f;font-size:14px}}
+header{{background:#fff;color:#5b6672;padding:12px 16px;font-size:16px;font-weight:600;border-bottom:1px solid #e8edf1;position:sticky;top:0;z-index:10}}
+nav{{display:flex;overflow-x:auto;background:#fff;border-bottom:1px solid #e8edf1;-webkit-overflow-scrolling:touch}}
+nav a{{flex:1;min-width:56px;text-align:center;padding:10px 4px;color:#97a1ad;text-decoration:none;font-size:12px;white-space:nowrap}}
+nav a.on{{color:#2f9d6d;border-bottom:2px solid #7cc9a5;font-weight:600}}
+.card{{background:#fff;margin:10px;border-radius:12px;padding:12px;box-shadow:0 1px 2px rgba(60,80,100,.05);border:1px solid #eef2f5}}
+.btn{{display:block;width:100%;padding:11px;margin:8px 0;background:#e6f6ee;color:#1f8a5f;border:1px solid #c4e8d6;border-radius:10px;font-size:13px}}
+.btn:active{{background:#d7f0e3}}
+.btn.s{{background:#f0f2f5;color:#5b6672;border-color:#dfe4e9}}
+.btn.g{{background:#e6f0fb;color:#2b6cb0;border-color:#c4dcf5}}
+.btn.x{{background:#fdecec;color:#c0392b;border-color:#f2c9c9}}
+.sbtn{{display:inline-block;width:auto;padding:5px 10px;margin:2px;background:#f0f2f5;color:#5b6672;border:1px solid #dfe4e9;border-radius:8px;font-size:12px}}
+.row{{padding:7px 0;border-bottom:1px solid #f2f5f7;font-size:13px}}
+.row:last-child{{border-bottom:0}}
+.tag{{display:inline-block;padding:1px 7px;border-radius:8px;font-size:11px;margin-left:5px}}
+.l0{{background:#e8f5e9;color:#2e7d32}} .l1{{background:#fdf3d8;color:#a67c00}}
+.l2{{background:#fde8cf;color:#c05621}} .l3{{background:#fdecea;color:#c0392b}}
+input,select,textarea{{width:100%;padding:8px;margin:5px 0;border:1px solid #dfe5ea;border-radius:8px;font-size:13px;background:#fff;color:#3d454f}}
+input:focus,select:focus,textarea:focus{{outline:none;border-color:#8ec5ff}}
+.muted{{color:#9aa4af;font-size:11px;margin:3px 0}}
+.stepbox{{background:#fafbfc;border:1px solid #eef2f5;border-radius:10px;padding:10px;margin:8px 0}}
+@media(min-width:768px){{header,nav,#app{{max-width:760px;margin-left:auto;margin-right:auto}}}}
+</style></head>
+<body>
+<header>{title}</header>
+<nav id="nav">
+<a data-p="" href="#">今日</a>
+<a data-p="feedback" href="#">反馈</a>
+<a data-p="daily" href="#">每日记录</a>
+<a data-p="control" href="#">暂停</a>
+<a data-p="push" href="#">手动推</a>
+<a data-p="manage" href="#">模板</a>
+<a data-p="rules" href="#">规则</a>
+</nav>
+<div id="app"></div>
+<script>
+const PAGES=['daily/replies','feedback','control','push','manage','rules','daily'];
+var BASE='';
+(function(){{ var p=location.pathname;
+  for(var i=0;i<PAGES.length;i++){{ var k=PAGES[i];
+    if(p.length>k.length+1 && p.endsWith('/'+k)){{ BASE=p.slice(0,p.length-k.length-1); return; }} }}
+  BASE=p.replace(/\\/[^/]*$/,'');  // 兜底:根路径等
+}})();
+// 统一 fetch:401 跳登录
+async function api(path, opts){{ try{{ const r=await fetch(path, opts);
+  if(r.status===401){{ location.href=BASE+'/login'; return null; }} return r; }} catch(e){{ return null; }} }}
+function go(p){{ location.href = BASE + '/' + p; }}
+document.querySelectorAll('#nav a').forEach(a=>{{ a.onclick=(e)=>{{e.preventDefault(); go(a.dataset.p);}}; }});
+(function(){{ var cur=location.pathname.split('/').pop().split('?')[0];
+  document.querySelectorAll('#nav a').forEach(a=>{{ if(a.dataset.p===cur) a.classList.add('on'); }}); }})();
+function esc(s){{ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }}
+</script>
+{body}
+</body></html>"""
+
+
+def _today(req: Request):
+    body = """
+<script>
+function load(){
+  api(BASE+'/api/dashboard').then(r=>r&&r.json()).then(d=>{ if(!d) return;
+  let h='<div class=card><b>今日 '+d.today+'</b><br>待处理告警: '+d.pending_alerts+'</div>';
+  h+='<div class=card><b>今日推送</b>';
+  d.pushes.forEach(p=>h+='<div class=row>'+esc(p.member)+' · 模板#'+p.template_id+' · '+p.status+'</div>');
+  h+='</div>';
+  h+='<div class=card><b>今日反馈('+d.feedbacks.length+')</b>';
+  d.feedbacks.forEach(f=>h+='<div class=row>'+esc(f.member)+' <span class=tag l'+f.risk+'>L'+f.risk+'</span> '+esc(f.answer)+'</div>');
+  h+='</div>';
+  document.getElementById('app').innerHTML=h;
+  });
+}
+load();
+</script>"""
+    return HTMLResponse(_page("今日状态", body))
+
+
+def _feedback(req: Request):
+    body = """
+<script>
+function load(){
+  api(BASE+'/api/feedbacks').then(r=>r&&r.json()).then(d=>{ if(!d) return;
+  let h='<div class=card><b>反馈('+d.length+')</b>';
+  d.forEach(f=>{h+='<div class=row><b>'+esc(f.member)+'</b> <span class=tag l'+f.risk+'>L'+f.risk+'</span> '+esc(f.mood)+'/'+esc(f.physical)+
+    '<br><span class=muted>'+esc(f.question||'')+'</span><br>'+esc(f.answer)+'</div>';});
+  h+='</div>';document.getElementById('app').innerHTML=h;
+  });
+}
+load();
+</script>"""
+    return HTMLResponse(_page("看反馈", body))
+
+
+def _control(req: Request):
+    body = """
+<script>
+function load(){
+  api(BASE+'/api/dashboard').then(r=>r&&r.json()).then(d=>{ if(!d) return;
+  let h='<div class=card><b>成员</b>';
+  d.members.forEach(m=>{
+    const on=m.enabled? '运行中':'已暂停';
+    h+='<div class=row>'+esc(m.name)+' ('+m.role+') · '+on+
+       ' <button class=sbtn onclick="toggle('+m.id+')">'+(m.enabled?'暂停推送':'恢复推送')+'</button></div>';
+  });
+  h+='</div>';document.getElementById('app').innerHTML=h;
+  });
+}
+function toggle(id){ api(BASE+'/api/members/'+id+'/toggle',{method:'POST'}).then(load); }
+load();
+</script>"""
+    return HTMLResponse(_page("暂停/恢复", body))
+
+
+def _push(req: Request):
+    body = """
+<script>
+let pushing=false;
+function load(){
+  Promise.all([api(BASE+'/api/dashboard').then(r=>r.json()),
+               api(BASE+'/api/templates').then(r=>r.json())]).then(([d,ts])=>{
+  if(!d||!ts) return;
+  let sel='<select id=tid>'; ts.forEach(t=>sel+='<option value='+t.id+'>'+esc(t.name)+'</option>'); sel+='</select>';
+  let msel='<select id=mid>'; d.members.filter(m=>m.role=='member').forEach(m=>msel+='<option value='+m.id+'>'+esc(m.name)+'</option>'); msel+='</select>';
+  let h='<div class=card><div class=muted>发送给</div>'+msel+
+    '<div class=muted>选择模板</div>'+sel+
+    '<button class=btn id=sendbtn onclick="doPush()">发送一条</button>'+
+    '<div class=muted>注意:每次点击都会立即发到微信,同一模板 60 秒内只能发一次。</div>'+
+    '<div id=msg class=muted></div></div>';
+  document.getElementById('app').innerHTML=h;
+  });
+}
+function doPush(){
+  if(pushing) return;
+  const mid=document.getElementById('mid'), tid=document.getElementById('tid');
+  if(!mid||!tid) return;
+  const mname=mid.options[mid.selectedIndex].text, tname=tid.options[tid.selectedIndex].text;
+  if(!confirm('确认立即向「'+mname+'」发送模板「'+tname+'」?')) return;
+  pushing=true;
+  const btn=document.getElementById('sendbtn');
+  btn.disabled=true; btn.textContent='发送中…'; btn.style.background='#e9edf0';
+  api(BASE+'/api/push/'+mid.value+'?template_id='+tid.value,{method:'POST'})
+    .then(r=>{
+      if(!r){ document.getElementById('msg').textContent='请求失败'; return null; }
+      if(r.status===429){ return r.json().then(d=>{ throw new Error(d.detail||'发送太频繁'); }); }
+      return r.json();
+    })
+    .then(d=>{ if(d) document.getElementById('msg').textContent='结果: '+JSON.stringify(d); })
+    .catch(e=>{ document.getElementById('msg').textContent=e.message||'发送失败'; })
+    .finally(()=>{
+      pushing=false;
+      btn.disabled=false; btn.textContent='发送一条'; btn.style.background='';
+    });
+}
+load();
+</script>"""
+    return HTMLResponse(_page("手动推", body))
+
+
+# ---------------------------------------------------------------------------
+# 模板管理:列表 + 新建/编辑(多步消息编辑器:文字/图片、发送时机、未回复超时)
+# ---------------------------------------------------------------------------
+def _manage(req: Request):
+    body = """
+<script>
+let templates=[], editing=null, steps=[];
+
+function load(){
+  api(BASE+'/api/templates').then(r=>r&&r.json()).then(d=>{ if(!d) return;
+    templates=d; renderList(); });
+}
+
+function renderList(){
+  let h='<div class=card><b>模板</b>';
+  templates.forEach((t,i)=>{
+    h+='<div class=row><b>'+esc(t.name)+'</b> <span class=muted>'+t.steps.length+'条消息</span><br>'+
+       '<button class=sbtn onclick="editTpl('+i+')">编辑</button> '+
+       '<button class=sbtn onclick="previewTpl('+t.id+')">测试发送</button> '+
+       '<button class="sbtn" style="background:#fdecec;color:#c0392b;border-color:#f2c9c9" onclick="delTpl('+t.id+')">删除</button></div>';
+  });
+  h+='</div>';
+  h+='<div class=card><button class=btn onclick="newTpl()">+ 新建模板</button></div>';
+  h+='<div class=card id=matcard><b>素材库</b><div id=mats class=muted>加载中…</div>'+
+     '<button class=sbtn onclick="checkMat()">素材体检</button> '+
+     '<button class="sbtn" style="background:#e6f0fb;color:#2b6cb0;border-color:#c4dcf5" onclick="weekly()">生成周报</button>'+
+     '<div id=msg class=muted></div></div>';
+  document.getElementById('app').innerHTML=h;
+  loadMats();
+}
+
+function loadMats(){
+  api(BASE+'/api/materials').then(r=>r&&r.json()).then(ms=>{
+    if(!ms) return;
+    let h='';
+    ms.slice(0,20).forEach(m=>h+='<div class=row>['+esc(m.tag)+'] '+esc(m.title)+' '+(m.alive?'':'<span class=tag l3>失效</span>')+'</div>');
+    if(!h) h='<div class=muted>暂无素材</div>';
+    document.getElementById('mats').innerHTML=h;
+  });
+}
+function checkMat(){ api(BASE+'/api/materials/check',{method:'POST'}).then(r=>r&&r.json()).then(d=>{ if(d){ document.getElementById('msg').textContent='体检完成'; loadMats(); } }); }
+function weekly(){ api(BASE+'/api/report/weekly',{method:'POST'}).then(r=>r&&r.json()).then(d=>{ if(d) document.getElementById('msg').textContent=(d.content||'').slice(0,200); }); }
+function previewTpl(id){
+  if(!confirm('向测试账号发送该模板?')) return;
+  api(BASE+'/api/templates/'+id+'/preview',{method:'POST'}).then(r=>r&&r.json()).then(d=>{ if(d) alert(JSON.stringify(d.result||d)); });
+}
+function delTpl(id){
+  if(!confirm('删除该模板?关联规则将失效。')) return;
+  api(BASE+'/api/templates/'+id,{method:'DELETE'}).then(load);
+}
+
+function newTpl(){
+  editing={id:null,name:''};
+  steps=[{type:'text',content:'',trigger:'delay',delay_min:1,timeout_min:''}];
+  renderEdit();
+}
+function editTpl(i){
+  const t=templates[i];
+  steps=t.steps.map(s=>({type:s.type,content:s.content||'',
+    trigger:s.trigger_type||'delay',
+    delay_min:s.delay_sec?Math.round(s.delay_sec/60):0,
+    timeout_min:s.reply_timeout_sec?Math.round(s.reply_timeout_sec/60):''}));
+  editing={id:t.id,name:t.name};
+  renderEdit();
+}
+function cancelTpl(){ editing=null; load(); }
+function addStep(){
+  const last=steps[steps.length-1];
+  steps.push({type:'text',content:'',trigger:last&&last.trigger=='after_reply'?'after_reply':'delay',delay_min:1,timeout_min:''});
+  renderEdit();
+}
+function delStep(i){ if(steps.length<=1){alert('至少保留一条消息');return;} steps.splice(i,1); renderEdit(); }
+function chType(i,v){ steps[i].type=v; renderEdit(); }
+
+function upImg(i,inp){
+  const f=inp.files[0]; if(!f) return;
+  api(BASE+'/api/upload/image?name='+encodeURIComponent(f.name),{method:'POST',body:f})
+    .then(r=>r&&r.json()).then(d=>{
+      if(!d) return;
+      if(d.filename){ steps[i].content=d.filename; renderEdit(); }
+      else alert('上传失败: '+JSON.stringify(d));
+    });
+}
+
+function renderEdit(){
+  let h='<div class=card><b>'+(editing.id?'编辑模板':'新建模板')+'</b>'+
+    '<div class=muted>模板名称</div><input id=tname placeholder="如:晨间问候" value="'+esc(editing.name)+'">'+
+    '<div id=steps></div>'+
+    '<button class="btn s" onclick="addStep()">+ 添加一条消息</button>'+
+    '<button class=btn onclick="saveTpl()">保存</button>'+
+    '<button class="btn s" onclick="cancelTpl()">取消</button></div>';
+  document.getElementById('app').innerHTML=h;
+  renderSteps();
+}
+
+function renderSteps(){
+  let h='';
+  steps.forEach((s,i)=>{
+    h+='<div class=stepbox>';
+    h+='<b>第'+(i+1)+'条</b> '+(i==0?'<span class=muted>(立即发送)</span>':'')+
+       ' <button class=sbtn style="float:right" onclick="delStep('+i+')">删除</button>';
+    h+='<div class=muted>类型</div><select onchange="chType('+i+',this.value)">'+
+       '<option value=text '+(s.type=='text'?'selected':'')+'>文字</option>'+
+       '<option value=image '+(s.type=='image'?'selected':'')+'>图片</option>'+
+       (s.type=='material'?'<option value=material selected style="color:#c62828">素材(旧类型,不支持)</option>':'')+
+       (s.type=='question'?'<option value=question selected style="color:#c62828">提问(旧类型,不支持)</option>':'')+
+       '</select>';
+    if(s.type!='image'){
+      h+='<textarea rows=3 placeholder="消息内容" oninput="steps['+i+'].content=this.value">'+esc(s.content)+'</textarea>';
+    } else {
+      h+='<input type=file accept="image/*" onchange="upImg('+i+',this)">';
+      if(s.content) h+='<img src="'+BASE+'/api/media/images/'+esc(s.content)+'" style="max-width:100%;max-height:180px;border-radius:8px;margin-top:6px"><div class=muted>'+esc(s.content)+'</div>';
+      else h+='<div class=muted>未选择图片</div>';
+    }
+    if(i>0){
+      h+='<div class=muted>发送时机</div><select onchange="steps['+i+'].trigger=this.value;renderEdit()">'+
+         '<option value=delay '+(s.trigger!='after_reply'?'selected':'')+'>上一条发出一段时间后</option>'+
+         '<option value=after_reply '+(s.trigger=='after_reply'?'selected':'')+'>上一条回复后</option></select>';
+      if(s.trigger!='after_reply')
+        h+='<input type=number min=0 step=any placeholder="延时(分钟)" value="'+(s.delay_min||'')+'" oninput="steps['+i+'].delay_min=this.value">';
+    }
+    h+='<div class=muted>本条发出后,多久没回复就发下一条(分钟,留空=不启用)</div>'+
+       '<input type=number min=1 placeholder="如 30" value="'+(s.timeout_min||'')+'" oninput="steps['+i+'].timeout_min=this.value">';
+    h+='</div>';
+  });
+  document.getElementById('steps').innerHTML=h;
+}
+
+function saveTpl(){
+  const name=document.getElementById('tname').value.trim();
+  if(!name){alert('请填模板名称');return;}
+  for(let i=0;i<steps.length;i++){
+    const s=steps[i];
+    if(s.type!='image' && !String(s.content).trim()){alert('第'+(i+1)+'条内容为空');return;}
+    if(s.type=='image' && !s.content){alert('第'+(i+1)+'条未上传图片');return;}
+  }
+  // 老类型自动转文字并提示(避免再次被默认选中陷阱坑)
+  let legacy=[];
+  const normalized = steps.map((s,i)=>{
+    let t=s.type;
+    if(t==='material'||t==='question'){ legacy.push((i+1)+'('+t+')'); t='text'; }
+    return t;
+  });
+  if(legacy.length) alert('注:'+legacy.join('、')+'是老类型,已自动转为文字保存(内容保留)');
+  const payload={name:name, steps: steps.map((s,i)=>({
+    seq:i+1, type:normalized[i], content:s.content,
+    delay_sec: (i>0 && s.trigger!='after_reply') ? Math.round((parseFloat(s.delay_min)||0)*60) : 0,
+    trigger_type: (i>0 && s.trigger=='after_reply') ? 'after_reply' : 'delay',
+    reply_timeout_sec: (s.timeout_min!==''&&s.timeout_min!=null) ? Math.max(60,Math.round(parseFloat(s.timeout_min)*60)) : null
+  }))};
+  const opts={method:editing.id?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)};
+  const url=editing.id? BASE+'/api/templates/'+editing.id : BASE+'/api/templates';
+  api(url,opts).then(r=>r&&r.json()).then(d=>{ if(d&&(d.id!==undefined)){ editing=null; load(); } else alert('保存失败'); });
+}
+load();
+</script>"""
+    return HTMLResponse(_page("模板管理", body))
+
+
+# ---------------------------------------------------------------------------
+# 规则管理:列表 + 新建/编辑(名称/模板单选/成员多选/周期/起止日期)
+# ---------------------------------------------------------------------------
+def _rules(req: Request):
+    body = """
+<script>
+let rules=[], templates=[], members=[], R=null;
+const WD=['一','二','三','四','五','六','日'];
+
+function load(){
+  Promise.all([api(BASE+'/api/rules').then(r=>r.json()),
+               api(BASE+'/api/templates').then(r=>r.json()),
+               api(BASE+'/api/dashboard').then(r=>r.json())]).then(([rs,ts,d])=>{
+    if(!rs||!ts||!d) return;
+    rules=rs; templates=ts; members=d.members; renderList();
+  });
+}
+
+function desc(r){
+  let p='';
+  if(r.period_type=='daily') p='每天 '+r.at_time;
+  else if(r.period_type=='monthly') p='每月'+r.month_day+'号 '+r.at_time;
+  else if(!r.days||r.days=='*') p='每天 '+r.at_time;
+  else p='每周'+r.days.split(',').map(x=>WD[parseInt(x)-1]||x).join('、')+' '+r.at_time;
+  if(r.start_date||r.end_date) p+='('+(r.start_date||'即日起')+' ~ '+(r.end_date||'长期')+')';
+  return p;
+}
+
+function renderList(){
+  let h='<div class=card><b>规则</b>';
+  rules.forEach((r,i)=>{
+    h+='<div class=row><b>'+esc(r.name)+'</b>'+(r.enabled?'':' <span class=tag l1>已停用</span>')+
+       '<br><span class=muted>'+esc(desc(r))+' · 模板「'+esc(r.template_name)+'」 · 发给:'+(r.member_names&&r.member_names.length?esc(r.member_names.join('、')):'全家')+'</span><br>'+
+       '<button class=sbtn onclick="editRule('+i+')">编辑</button> '+
+       '<button class=sbtn onclick="toggleRule('+r.id+','+(r.enabled?0:1)+')">'+(r.enabled?'停用':'启用')+'</button> '+
+       '<button class="sbtn" style="background:#fdecec;color:#c0392b;border-color:#f2c9c9" onclick="delRule('+r.id+')">删除</button></div>';
+  });
+  if(!rules.length) h+='<div class=muted>还没有规则,点下面新建</div>';
+  h+='</div>';
+  h+='<div class=card><button class=btn onclick="newRule()">+ 新建规则</button></div>';
+  document.getElementById('app').innerHTML=h;
+}
+
+function newRule(){
+  if(!templates.length){alert('请先到「模板」页创建一个模板');return;}
+  R={id:null,name:'',template_id:templates[0].id,mids:[],period:'weekly',
+     days:['1'],time:'09:00',month_day:1,start:'',end:''};
+  renderEdit();
+}
+function editRule(i){
+  const r=rules[i];
+  R={id:r.id,name:r.name,template_id:r.template_id,
+     mids:(r.member_ids||'').split(',').filter(x=>x).map(Number),
+     period:r.period_type||'weekly',
+     days:(!r.days||r.days=='*')?[]:r.days.split(','),
+     time:r.at_time||'09:00',month_day:r.month_day||1,
+     start:r.start_date||'',end:r.end_date||''};
+  renderEdit();
+}
+function cancelRule(){ R=null; renderList(); }
+function togMid(id){ const i=R.mids.indexOf(id); if(i>=0)R.mids.splice(i,1); else R.mids.push(id); }
+function togDay(d){ const i=R.days.indexOf(d); if(i>=0)R.days.splice(i,1); else R.days.push(d); }
+
+function renderEdit(){
+  let h='<div class=card><b>'+(R.id?'编辑规则':'新建规则')+'</b>';
+  h+='<div class=muted>规则名称(随便起)</div><input id=rname value="'+esc(R.name)+'" placeholder="如:晨间问候">';
+  h+='<div class=muted>选择模板</div><select onchange="R.template_id=+this.value">';
+  templates.forEach(t=>h+='<option value='+t.id+' '+(R.template_id==t.id?'selected':'')+'>'+esc(t.name)+'</option>');
+  h+='</select>';
+  h+='<div class=muted>发送给谁(可多选,都不选=全家老人)</div>';
+  members.forEach(m=>{
+    h+='<label style="display:inline-block;padding:6px 12px"><input type=checkbox style="width:auto" '+
+       (R.mids.includes(m.id)?'checked':'')+' onchange="togMid('+m.id+')"> '+esc(m.name)+'</label>';
+  });
+  h+='<div class=muted>发送周期</div><div>';
+  [['daily','每日'],['weekly','每周'],['monthly','每月']].forEach(p=>{
+    h+='<label style="display:inline-block;padding:6px 12px"><input type=radio name=ptype style="width:auto" '+
+       (R.period==p[0]?'checked':'')+' onchange="R.period=\\''+p[0]+'\\';renderEdit()"> '+p[1]+'</label>';
+  });
+  h+='</div>';
+  if(R.period=='daily'){
+    h+='<div class=muted>每天几点发送</div><input type=time value="'+esc(R.time)+'" onchange="R.time=this.value">';
+  } else if(R.period=='weekly'){
+    h+='<div class=muted>选周几(可多选)</div><div>';
+    WD.forEach((w,idx)=>{
+      const d=String(idx+1);
+      h+='<label style="display:inline-block;padding:6px 10px"><input type=checkbox style="width:auto" '+
+         (R.days.includes(d)?'checked':'')+' onchange="togDay(\\''+d+'\\')"> 周'+w+'</label>';
+    });
+    h+='</div><input type=time value="'+esc(R.time)+'" onchange="R.time=this.value">';
+  } else {
+    h+='<div class=muted>每月几号几点发送(当月没有该日期则跳过)</div>'+
+       '<input type=number min=1 max=31 value="'+R.month_day+'" onchange="R.month_day=+this.value">'+
+       '<input type=time value="'+esc(R.time)+'" onchange="R.time=this.value">';
+  }
+  h+='<div class=muted>起止日期(选填:到结束日期后不再发送)</div>'+
+     '<input type=date value="'+esc(R.start)+'" onchange="R.start=this.value" placeholder="开始日期">'+
+     '<input type=date value="'+esc(R.end)+'" onchange="R.end=this.value" placeholder="结束日期">';
+  h+='<button class=btn onclick="saveRule()">保存</button>'+
+     '<button class="btn s" onclick="cancelRule()">取消</button></div>';
+  document.getElementById('app').innerHTML=h;
+}
+
+function saveRule(){
+  const name=document.getElementById('rname').value.trim();
+  if(!name){alert('请填规则名称');return;}
+  if(!R.time){alert('请选发送时间');return;}
+  let days='*';
+  if(R.period=='weekly'){
+    if(!R.days.length){alert('每周至少选一个周几');return;}
+    days=R.days.join(',');
+  }
+  if(R.start&&R.end&&R.start>R.end){alert('开始日期不能晚于结束日期');return;}
+  const payload={name:name,template_id:R.template_id,member_ids:R.mids.join(','),
+    period_type:R.period,days:days,month_day:R.month_day,at_time:R.time,
+    start_date:R.start||null,end_date:R.end||null};
+  const opts={method:R.id?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)};
+  const url=R.id? BASE+'/api/rules/'+R.id : BASE+'/api/rules';
+  api(url,opts).then(r=>r&&r.json()).then(d=>{
+    if(d&&(d.id!==undefined)){ R=null; load(); } else alert('保存失败');
+  });
+}
+function toggleRule(id,en){
+  api(BASE+'/api/rules/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},
+    body:JSON.stringify({enabled:en})}).then(load);
+}
+function delRule(id){
+  if(!confirm('删除该规则?')) return;
+  api(BASE+'/api/rules/'+id,{method:'DELETE'}).then(load);
+}
+load();
+</script>"""
+    return HTMLResponse(_page("规则管理", body))
+
+
+# ---------------------------------------------------------------------------
+# 每日记录:汇总入口页(后续更多内容挂这里)+ 模板回复总览子页面
+# ---------------------------------------------------------------------------
+def _daily(req: Request):
+    body = """
+<script>
+function load(){
+  let h='<div class=card><b>每日记录</b><div class=muted">家人每天的互动记录汇总</div></div>'+
+    '<div class=card><button class=btn onclick="location.href=BASE+\\'/daily/replies\\'">模板回复总览</button>'+
+    '<div class=muted>行=模板每条消息,列=每次执行,格=回复内容(未回复灰显)</div></div>';
+  document.getElementById('app').innerHTML=h;
+}
+load();
+</script>"""
+    return HTMLResponse(_page("每日记录", body))
+
+
+def _daily_replies(req: Request):
+    body = """
+<script>
+let templates=[], members=[], T=null, M=null;
+function load(){
+  Promise.all([api(BASE+'/api/templates').then(r=>r.json()),
+               api(BASE+'/api/dashboard').then(r=>r.json())]).then(([ts,d])=>{
+    if(!ts||!d) return;
+    templates=ts; members=d.members;
+    T=ts.length?ts[0].id:null;
+    const el=d.members.filter(m=>m.role=='member');
+    M=el.length?el[0].id:(d.members.length?d.members[0].id:null);
+    render();
+  });
+}
+function render(){
+  let sel='<div class=muted>模板</div><select onchange="T=+this.value;draw()">';
+  templates.forEach(t=>sel+='<option value='+t.id+' '+(T==t.id?'selected':'')+'>'+esc(t.name)+'</option>');
+  sel+='</select>';
+  let msel='<div class=muted>成员</div><select onchange="M=+this.value;draw()">';
+  members.forEach(m=>msel+='<option value='+m.id+' '+(M==m.id?'selected':'')+'>'+esc(m.name)+'</option>');
+  msel+='</select>';
+  let h='<div class=card>'+sel+msel+'</div><div id=tbl></div>';
+  document.getElementById('app').innerHTML=h;
+  draw();
+}
+function draw(){
+  if(T==null||M==null) return;
+  api(BASE+'/api/matrix?template_id='+T+'&member_id='+M).then(r=>r&&r.json()).then(d=>{
+    if(!d) return;
+    const el=document.getElementById('tbl');
+    if(!d.steps.length){ el.innerHTML='<div class=card><div class=muted>该模板没有步骤</div></div>'; return; }
+    let h='';
+    if(d.executions.length){
+      h+='<div style="overflow-x:auto"><table style="border-collapse:collapse;font-size:12px;width:max-content;min-width:100%">';
+      h+='<tr><th style="border:1px solid #e8edf1;padding:5px;background:#f4f7f9;position:sticky;left:0;z-index:2;max-width:150px">消息/问题</th>';
+      d.executions.forEach(e=>h+='<th style="border:1px solid #e8edf1;padding:5px;background:#f4f7f9;white-space:nowrap">'+esc(e.date)+'</th>');
+      h+='</tr>';
+      d.steps.forEach(s=>{
+        h+='<tr><th style="border:1px solid #e8edf1;padding:5px;text-align:left;position:sticky;left:0;z-index:1;background:#fff;max-width:150px;white-space:normal;font-weight:400;vertical-align:top">'+s.seq+'. '+esc(s.content)+'</th>';
+        d.executions.forEach(e=>{
+          const cs=(e.cells||{})[s.seq]||[];
+          if(cs.length){
+            h+='<td style="border:1px solid #e8edf1;padding:5px;vertical-align:top">';
+            cs.forEach(c=>{
+              h+=esc(c.answer)+(c.risk!=null?' <span class="tag l'+c.risk+'">L'+c.risk+'</span>':'')+
+                 (c.input_type=='voice'?' <span class=muted>[语音]</span>':'')+'<br>';
+            });
+            h+='</td>';
+          } else {
+            h+='<td style="border:1px solid #e8edf1;padding:5px;color:#b6bec7;text-align:center;vertical-align:top">未回复</td>';
+          }
+        });
+        h+='</tr>';
+      });
+      h+='</table></div>';
+    } else {
+      h+='<div class=card><div class=muted>该模板对该成员还没有执行记录</div></div>';
+    }
+    el.innerHTML=h;
+  });
+}
+load();
+</script>"""
+    return HTMLResponse(_page("模板回复总览", body))
+
+
+routes = [
+    {"path": "/", "func": _today, "methods": ["GET"]},
+    {"path": "/feedback", "func": _feedback, "methods": ["GET"]},
+    {"path": "/daily", "func": _daily, "methods": ["GET"]},
+    {"path": "/daily/replies", "func": _daily_replies, "methods": ["GET"]},
+    {"path": "/control", "func": _control, "methods": ["GET"]},
+    {"path": "/push", "func": _push, "methods": ["GET"]},
+    {"path": "/manage", "func": _manage, "methods": ["GET"]},
+    {"path": "/rules", "func": _rules, "methods": ["GET"]},
+]
diff --git a/app/wecom.py b/app/wecom.py
new file mode 100644 (file)
index 0000000..2d7bc3f
--- /dev/null
@@ -0,0 +1,227 @@
+"""
+app/wecom.py — 企业微信加解密 + 主动调用(从 spike/wecom.py 移入,已实测)
+  - WXBizMsgCrypt:回调消息加解密(AES-256-CBC + PKCS7 block=32)
+  - WeComClient:access_token 缓存、主动发消息、下载媒体、被动回复加密
+依赖标准库 + cryptography。
+"""
+import base64
+import hashlib
+import json
+import os
+import struct
+import threading
+import time
+import urllib.parse
+import urllib.request
+import logging
+
+from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+
+logger = logging.getLogger("wecom")
+
+_BLOCK = 32  # 企业微信 PKCS7 块大小
+
+
+def _pkcs7_pad(data: bytes) -> bytes:
+    pad = _BLOCK - (len(data) % _BLOCK)
+    if pad == 0:
+        pad = _BLOCK
+    return data + bytes([pad]) * pad
+
+
+def _pkcs7_unpad(data: bytes) -> bytes:
+    pad = data[-1]
+    if pad < 1 or pad > _BLOCK:
+        raise ValueError("bad pkcs7 padding")
+    return data[:-pad]
+
+
+class WXBizMsgCrypt:
+    def __init__(self, token: str, encoding_aes_key: str, receive_id: str):
+        if len(encoding_aes_key) != 43:
+            raise ValueError("EncodingAESKey 必须为 43 字符")
+        self.token = token
+        self.receive_id = receive_id
+        self.aes_key = base64.b64decode(encoding_aes_key + "=")  # 32 bytes
+        self.iv = self.aes_key[:16]
+
+    def _signature(self, *parts: str) -> str:
+        sha = hashlib.sha1()
+        sha.update("".join(sorted(parts)).encode("utf-8"))
+        return sha.hexdigest()
+
+    def verify_signature(self, msg_signature: str, timestamp: str, nonce: str, encrypt: str) -> bool:
+        return self._signature(self.token, timestamp, nonce, encrypt) == msg_signature
+
+    def decrypt(self, encrypt: str) -> str:
+        ct = base64.b64decode(encrypt)
+        cipher = Cipher(algorithms.AES(self.aes_key), modes.CBC(self.iv))
+        dec = cipher.decryptor()
+        plain = dec.update(ct) + dec.finalize()
+        plain = _pkcs7_unpad(plain)
+        content = plain[16:]
+        msg_len = struct.unpack(">I", content[:4])[0]
+        msg = content[4:4 + msg_len]
+        rid = content[4 + msg_len:]
+        if rid.decode("utf-8") != self.receive_id:
+            raise ValueError("receive_id 不匹配")
+        return msg.decode("utf-8")
+
+    def decrypt_message(self, msg_signature: str, timestamp: str, nonce: str, encrypt: str) -> str:
+        if not self.verify_signature(msg_signature, timestamp, nonce, encrypt):
+            raise ValueError("签名校验失败")
+        return self.decrypt(encrypt)
+
+    def encrypt(self, text: str, nonce: str):
+        rand = os.urandom(16)
+        text_b = text.encode("utf-8")
+        msg = rand + struct.pack(">I", len(text_b)) + text_b + self.receive_id.encode("utf-8")
+        msg = _pkcs7_pad(msg)
+        cipher = Cipher(algorithms.AES(self.aes_key), modes.CBC(self.iv))
+        enc = cipher.encryptor()
+        out = enc.update(msg) + enc.finalize()
+        encrypt = base64.b64encode(out).decode("ascii")
+        ts = str(int(time.time()))
+        sig = self._signature(self.token, ts, nonce, encrypt)
+        return encrypt, sig, ts
+
+    def encrypt_reply(self, reply_xml: str, nonce: str) -> str:
+        encrypt, sig, ts = self.encrypt(reply_xml, nonce)
+        return (
+            "<xml><Encrypt><![CDATA[%s]]></Encrypt>"
+            "<MsgSignature><![CDATA[%s]]></MsgSignature>"
+            "<TimeStamp><![CDATA[%s]]></TimeStamp>"
+            "<Nonce><![CDATA[%s]]></Nonce></xml>"
+            % (encrypt, sig, ts, nonce)
+        )
+
+
+class WeComClient:
+    """主动调用:access_token 缓存 + 发文本/图文 + 下载媒体。线程安全。"""
+
+    def __init__(self, corp_id: str, secret: str, agent_id: int):
+        self.corp_id = corp_id
+        self.secret = secret
+        self.agent_id = agent_id
+        self._token = None
+        self._expire_at = 0
+        self._lock = threading.Lock()
+
+    def get_access_token(self, force: bool = False) -> str:
+        with self._lock:
+            if self._token and not force and time.time() < self._expire_at - 60:
+                return self._token
+            url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?" + urllib.parse.urlencode(
+                {"corpid": self.corp_id, "corpsecret": self.secret})
+            data = self._get_json(url)
+            if data.get("errcode", 0) != 0:
+                raise RuntimeError("gettoken 失败: %s" % data)
+            self._token = data["access_token"]
+            self._expire_at = time.time() + data.get("expires_in", 7200)
+            return self._token
+
+    def send_text(self, userid: str, text: str) -> dict:
+        token = self.get_access_token()
+        url = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + token
+        body = {
+            "touser": userid,
+            "msgtype": "text",
+            "agentid": self.agent_id,
+            "text": {"content": text},
+            "safe": 0,
+        }
+        return self._post_json(url, body)
+
+    def send_news(self, userid: str, title: str, url: str, desc: str = "", picurl: str = "") -> dict:
+        token = self.get_access_token()
+        api = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + token
+        body = {
+            "touser": userid,
+            "msgtype": "news",
+            "agentid": self.agent_id,
+            "news": {"articles": [{"title": title, "description": desc,
+                                  "url": url, "picurl": picurl}]},
+        }
+        return self._post_json(api, body)
+
+    def download_media(self, media_id: str, save_path: str) -> str:
+        token = self.get_access_token()
+        url = "https://qyapi.weixin.qq.com/cgi-bin/media/get?" + urllib.parse.urlencode(
+            {"access_token": token, "media_id": media_id})
+        req = urllib.request.Request(url)
+        with urllib.request.urlopen(req, timeout=15) as resp:
+            data = resp.read()
+        with open(save_path, "wb") as f:
+            f.write(data)
+        return save_path
+
+    def upload_media(self, file_path: str, media_type: str = "image") -> str:
+        """上传临时素材(multipart),返回 media_id(3 天有效)。"""
+        token = self.get_access_token()
+        url = ("https://qyapi.weixin.qq.com/cgi-bin/media/upload?"
+               "access_token=%s&type=%s" % (token, media_type))
+        filename = os.path.basename(file_path)
+        with open(file_path, "rb") as f:
+            file_data = f.read()
+        boundary = "----codebuddy%s" % int(time.time() * 1000)
+        body = (
+            ("--%s\r\nContent-Disposition: form-data; name=\"media\"; "
+             "filename=\"%s\"\r\nContent-Type: application/octet-stream\r\n\r\n"
+             % (boundary, filename)).encode("utf-8")
+            + file_data
+            + ("\r\n--%s--\r\n" % boundary).encode("utf-8")
+        )
+        req = urllib.request.Request(
+            url, data=body,
+            headers={"Content-Type": "multipart/form-data; boundary=%s" % boundary})
+        with urllib.request.urlopen(req, timeout=60) as resp:
+            data = json.loads(resp.read().decode("utf-8"))
+        if "media_id" not in data:
+            raise RuntimeError("素材上传失败: %s" % data)
+        return data["media_id"]
+
+    def send_image(self, userid: str, media_id: str) -> dict:
+        token = self.get_access_token()
+        url = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + token
+        body = {
+            "touser": userid,
+            "msgtype": "image",
+            "agentid": self.agent_id,
+            "image": {"media_id": media_id},
+        }
+        return self._post_json(url, body)
+
+    @staticmethod
+    def _get_json(url: str) -> dict:
+        with urllib.request.urlopen(url, timeout=15) as resp:
+            return json.loads(resp.read().decode("utf-8"))
+
+    @staticmethod
+    def _post_json(url: str, body: dict) -> dict:
+        data = json.dumps(body, ensure_ascii=False).encode("utf-8")
+        req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
+        with urllib.request.urlopen(req, timeout=15) as resp:
+            return json.loads(resp.read().decode("utf-8"))
+
+
+def make_client(cfg=None):
+    if cfg is None:
+        from .config import get_config
+        cfg = get_config()
+    return WeComClient(cfg.wecom_corp_id, cfg.wecom_secret, cfg.wecom_agent_id)
+
+
+def make_crypt(cfg=None):
+    if cfg is None:
+        from .config import get_config
+        cfg = get_config()
+    return WXBizMsgCrypt(cfg.wecom_token, cfg.wecom_aes_key, cfg.wecom_receive_id)
+
+
+def parse_xml(xml: str) -> dict:
+    """极简 XML 解析(企业微信回调字段有限,够用)。返回标签->文本。"""
+    import re
+    out = {}
+    for m in re.finditer(r"<(\w+)>(?:<!\[CDATA\[(.*?)\]\]>|([^<]*))</\1>", xml, re.S):
+        out[m.group(1)] = (m.group(2) if m.group(2) is not None else m.group(3) or "").strip()
+    return out
diff --git a/app/weekly_report.py b/app/weekly_report.py
new file mode 100644 (file)
index 0000000..14cc4c7
--- /dev/null
@@ -0,0 +1,94 @@
+"""
+app/weekly_report.py — 周报(SPEC §14 P2-1)
+  每周日生成:反馈汇总 / 情绪趋势 / 未回复次数 / 本月短信花费。
+  生成后推送给 admin(企微),并入库 weekly_reports。
+"""
+import logging
+from datetime import datetime, timedelta
+
+from .db import (WeeklyReport, Feedback, Member, Notification, Alert,
+                 get_sessionmaker, session_scope, now_ts)
+from . import notify
+
+logger = logging.getLogger("weekly_report")
+
+
+def build_content(week_start: str) -> str:
+    start = datetime.strptime(week_start, "%Y-%m-%d")
+    end = start + timedelta(days=7)
+    s_ts, e_ts = int(start.timestamp()), int(end.timestamp())
+
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        fbs = sess.query(Feedback).filter(
+            Feedback.created_at >= s_ts, Feedback.created_at < e_ts).all()
+        members = {m.id: m.name for m in sess.query(Member).all()}
+        total = len(fbs)
+        by_mood = {}
+        by_risk = {0: 0, 1: 0, 2: 0, 3: 0}
+        for f in fbs:
+            by_mood[f.mood] = by_mood.get(f.mood, 0) + 1
+            by_risk[f.risk_level] = by_risk.get(f.risk_level, 0) + 1
+        # 未回复:本周有推送但无反馈的成员天数(简化:按推送数 - 反馈数估算)
+        # 短信花费:本月
+        month_start = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
+        sms_cost = sess.query(Notification).filter(
+            Notification.channel == "sms",
+            Notification.created_at >= int(month_start.timestamp())).with_entities(
+            Notification.cost_cny).all()
+        sms_cost = sum((c[0] or 0) for c in sms_cost)
+        alerts = sess.query(Alert).filter(
+            Alert.created_at >= s_ts, Alert.created_at < e_ts,
+            Alert.level >= 2).count()
+    finally:
+        sess.close()
+
+    lines = []
+    lines.append("📋 家人看护周报(%s ~ %s)" % (week_start, (end - timedelta(days=1)).strftime("%Y-%m-%d")))
+    lines.append("— 反馈 %d 条" % total)
+    if by_mood:
+        lines.append("— 情绪: " + ",".join("%s %d" % (k, v) for k, v in by_mood.items()))
+    lines.append("— 风险: L0 %d / L1 %d / L2 %d / L3 %d" % (
+        by_risk[0], by_risk[1], by_risk[2], by_risk[3]))
+    lines.append("— L2+ 告警 %d 次" % alerts)
+    lines.append("— 本月短信花费 ¥%.2f" % sms_cost)
+    if by_risk[3] or by_risk[2]:
+        lines.append("⚠️ 本周有高风险反馈,请关注。")
+    lines.append("(详情见后台「看反馈」)")
+    return "\n".join(lines)
+
+
+def generate_and_send(week_start: str = None) -> str:
+    if week_start is None:
+        d = datetime.now()
+        monday = d - timedelta(days=d.weekday())
+        week_start = monday.strftime("%Y-%m-%d")
+    content = build_content(week_start)
+    # 入库(唯一 family_id, week_start)
+    fam_id = 1
+    sm = get_sessionmaker()
+    sess = sm()
+    try:
+        existing = sess.query(WeeklyReport).filter(
+            WeeklyReport.family_id == fam_id, WeeklyReport.week_start == week_start).first()
+        if existing:
+            existing.content = content
+            sess.commit()
+            wid = existing.id
+        else:
+            wr = WeeklyReport(family_id=fam_id, week_start=week_start, content=content)
+            sess.add(wr)
+            sess.flush()
+            wid = wr.id
+            sess.commit()
+    finally:
+        sess.close()
+    # 推送给 admin
+    try:
+        a = notify.create_alert(0, 1, "周报\n" + content)
+        notify.dispatch(a)
+    except Exception as e:
+        logger.warning("[weekly] 周报推送失败: %s", e)
+    logger.info("[weekly] 周报已生成 id=%s", wid)
+    return content
diff --git a/config/config.example.yaml b/config/config.example.yaml
new file mode 100644 (file)
index 0000000..4d1a0b2
--- /dev/null
@@ -0,0 +1,129 @@
+# family-chat 配置文件(复制为 config.yaml 后修改)
+# 安全:config.yaml 含模板/规则/素材,不含密钥;密钥只在 .env 中。
+# 本文件应提交 git(不含凭据),.env 不提交。
+
+# ---------------------------------------------------------------------------
+# 家庭与成员(一期通常一家,可多预留)
+#   members 中 role=admin 的是子女(可编辑 + 收告警);
+#                  role=member 的是父母(只收推送)。
+#   wecom_userid 必须与企业微信通讯录里的「账号」完全一致(区分大小写)。
+#   注意:企业微信通讯录目前成员为 WangYu / yaya / tiantian,按需填。
+# ---------------------------------------------------------------------------
+family:
+  name: 家里
+  members:
+    - wecom_userid: WangYu      # ← 收推送的老人账号(按通讯录实际改)
+      name: 王瑜
+      role: member
+      phone: ""                 # 短信告警用,可空
+      quiet_hours: "21:00-08:00"
+    - wecom_userid: yaya        # ← 收告警的子女账号(按通讯录实际改)
+      name: 牙牙
+      role: admin
+      phone: ""
+      quiet_hours: ""
+
+# ---------------------------------------------------------------------------
+# 推送服务端口(SPEC:避开 1060/1061/1062/2341/4999,学习山峰占 1063,故用 1064)
+# 回调对外地址:https://<域名>/wecom/callback
+# ---------------------------------------------------------------------------
+server:
+  host: 0.0.0.0
+  port: 1064
+  # 对外基础地址,用于生成素材/回执等绝对链接(一般同 nginx 反代域名)
+  public_base_url: "https://acesimba.cloud"
+
+# ---------------------------------------------------------------------------
+# ASR 双引擎 + 热词
+#   主引擎 16k_zh_dialect(含西安话/关中话);备用 16k_zh_en(低质量音频兜底)
+#   热词权重 1-11,最多 128 个;改完即时生效(每次识别重读),无需重启。
+# ---------------------------------------------------------------------------
+asr:
+  engine_primary: "16k_zh_dialect"
+  engine_secondary: "16k_zh_en"
+  # 置信度阈值(腾讯云字段若返回则生效;无则返回时仅在结果为空触发重试)
+  confidence_retry_below: 0.7
+  confidence_accept_above: 0.5
+  hotwords:
+    - "欢欢|11"
+    - "西安|8"
+    - "膝盖|9"
+    - "降压药|9"
+    - "头晕|9"
+    - "王瑜|9"
+
+# ---------------------------------------------------------------------------
+# AI 打标(OpenAI 兼容接口,模型走 .env 的 AI_API_KEY / AI_BASE_URL / AI_MODEL)
+#   要求返回严格 JSON:{"mood":"正面|中性|负面|低落","physical":"正常|不适|异常","risk":0-3,"reason":"<=20字"}
+#   失败降级到关键词兜底,不影响主流程。
+# ---------------------------------------------------------------------------
+ai:
+  enabled: true
+  timeout_sec: 15
+  # 系统提示,约束女儿口吻与打标格式
+  system_prompt: |
+    你是帮子女照看独居父母的助手。下面是一段父母(老人)的回复文字。
+    请判断情绪与身体状况,并给出风险等级。只输出一个 JSON,不要任何解释。
+    格式:{"mood":"正面|中性|负面|低落","physical":"正常|不适|异常","risk":0-3,"reason":"不超过20字"}
+    risk:0正常 1关注 2注意 3紧急(紧急含摔倒/胸痛/喘不上气/轻生倾向等)。
+
+# ---------------------------------------------------------------------------
+# 通知路由(SPEC §10.3)
+#   L1 不通知(进周报);L2 企微;L3 企微+短信。
+#   短信默认关闭(需报备签名模板),打开见 .env 的 SMS_ENABLED。
+# ---------------------------------------------------------------------------
+notify:
+  routing:
+    1: []
+    2: ["wecom"]
+    3: ["wecom", "sms"]
+  degrade:
+    wecom_failed_on_level_3: ["sms"]
+    sms_failed: ["email"]
+  budget:
+    sms_monthly_cny: 20
+  # 降噪(SPEC §8.5)
+  cooldown_minutes: 30          # 同一成员同一级别冷却
+  l2_daily_cap: 2               # L2 单日上限,超出合并进周报
+  wait_state_hours: 4           # 等待反馈态超时
+
+# ---------------------------------------------------------------------------
+# 种子数据:首次启动(库为空)时写入。之后在 Web 后台编辑,不回写此文件。
+# ---------------------------------------------------------------------------
+seed:
+  # 模板:steps 按顺序播放,最多一个 question
+  templates:
+    - id: exercise
+      name: 锻炼身体
+      theme: 锻炼
+      steps:
+        - {seq: 1, type: text, content: "妈,今天天气还行,活动活动筋骨没?"}
+        - {seq: 2, type: material, tag: 锻炼}
+        - {seq: 3, type: question, content: "跟着做了没?哪儿觉得累就跟我说,别硬撑。", question_key: did_exercise, delay_sec: 20}
+    - id: anti_fraud
+      name: 防诈骗提醒
+      theme: 防诈骗
+      steps:
+        - {seq: 1, type: text, content: "跟你说个最近挺多的事,别嫌我啰嗦。"}
+        - {seq: 2, type: material, tag: 防诈骗}
+        - {seq: 3, type: question, content: "最近有没有接到奇怪的电话或者短信?", question_key: met_scam}
+    - id: casual
+      name: 随便聊聊
+      theme: 日常
+      steps:
+        - {seq: 1, type: question, content: "今天干啥了?", question_key: daily}
+
+  # 规则:days "1,3,5" 周一三五;"*" 每天;at "HH:MM"
+  rules:
+    - {days: "1,3,5", at: "09:00", template: exercise}
+    - {days: "2,4,6", at: "09:00", template: anti_fraud}
+    - {days: "7",     at: "10:00", template: casual}
+
+  # 素材:tag 与模板 material 的 tag 对应;url 为视频链接
+  materials:
+    - {tag: 锻炼,   title: "八段锦完整跟练",   url: "https://www.bilibili.com/video/BV1cT4y1o7q1", platform: B站,   source: official}
+    - {tag: 锻炼,   title: "椅子操适合老人",   url: "https://www.bilibili.com/video/BV1aT411u7G7", platform: B站,   source: official}
+    - {tag: 锻炼,   title: "晨起拉伸5分钟",    url: "https://www.bilibili.com/video/BV1Lp4y1s7vV", platform: B站,   source: media}
+    - {tag: 防诈骗, title: "冒充客服诈骗揭秘", url: "https://www.bilibili.com/video/BV1oP4y1k7hK", platform: B站,   source: official}
+    - {tag: 防诈骗, title: "养老投资骗局",     url: "https://www.bilibili.com/video/BV1cT4y1o7q1", platform: B站,   source: official}
+    - {tag: 防诈骗, title: "保健品套路",       url: "https://www.bilibili.com/video/BV1aT411u7G7", platform: B站,   source: media}
diff --git a/config/config.yaml b/config/config.yaml
new file mode 100644 (file)
index 0000000..4d1a0b2
--- /dev/null
@@ -0,0 +1,129 @@
+# family-chat 配置文件(复制为 config.yaml 后修改)
+# 安全:config.yaml 含模板/规则/素材,不含密钥;密钥只在 .env 中。
+# 本文件应提交 git(不含凭据),.env 不提交。
+
+# ---------------------------------------------------------------------------
+# 家庭与成员(一期通常一家,可多预留)
+#   members 中 role=admin 的是子女(可编辑 + 收告警);
+#                  role=member 的是父母(只收推送)。
+#   wecom_userid 必须与企业微信通讯录里的「账号」完全一致(区分大小写)。
+#   注意:企业微信通讯录目前成员为 WangYu / yaya / tiantian,按需填。
+# ---------------------------------------------------------------------------
+family:
+  name: 家里
+  members:
+    - wecom_userid: WangYu      # ← 收推送的老人账号(按通讯录实际改)
+      name: 王瑜
+      role: member
+      phone: ""                 # 短信告警用,可空
+      quiet_hours: "21:00-08:00"
+    - wecom_userid: yaya        # ← 收告警的子女账号(按通讯录实际改)
+      name: 牙牙
+      role: admin
+      phone: ""
+      quiet_hours: ""
+
+# ---------------------------------------------------------------------------
+# 推送服务端口(SPEC:避开 1060/1061/1062/2341/4999,学习山峰占 1063,故用 1064)
+# 回调对外地址:https://<域名>/wecom/callback
+# ---------------------------------------------------------------------------
+server:
+  host: 0.0.0.0
+  port: 1064
+  # 对外基础地址,用于生成素材/回执等绝对链接(一般同 nginx 反代域名)
+  public_base_url: "https://acesimba.cloud"
+
+# ---------------------------------------------------------------------------
+# ASR 双引擎 + 热词
+#   主引擎 16k_zh_dialect(含西安话/关中话);备用 16k_zh_en(低质量音频兜底)
+#   热词权重 1-11,最多 128 个;改完即时生效(每次识别重读),无需重启。
+# ---------------------------------------------------------------------------
+asr:
+  engine_primary: "16k_zh_dialect"
+  engine_secondary: "16k_zh_en"
+  # 置信度阈值(腾讯云字段若返回则生效;无则返回时仅在结果为空触发重试)
+  confidence_retry_below: 0.7
+  confidence_accept_above: 0.5
+  hotwords:
+    - "欢欢|11"
+    - "西安|8"
+    - "膝盖|9"
+    - "降压药|9"
+    - "头晕|9"
+    - "王瑜|9"
+
+# ---------------------------------------------------------------------------
+# AI 打标(OpenAI 兼容接口,模型走 .env 的 AI_API_KEY / AI_BASE_URL / AI_MODEL)
+#   要求返回严格 JSON:{"mood":"正面|中性|负面|低落","physical":"正常|不适|异常","risk":0-3,"reason":"<=20字"}
+#   失败降级到关键词兜底,不影响主流程。
+# ---------------------------------------------------------------------------
+ai:
+  enabled: true
+  timeout_sec: 15
+  # 系统提示,约束女儿口吻与打标格式
+  system_prompt: |
+    你是帮子女照看独居父母的助手。下面是一段父母(老人)的回复文字。
+    请判断情绪与身体状况,并给出风险等级。只输出一个 JSON,不要任何解释。
+    格式:{"mood":"正面|中性|负面|低落","physical":"正常|不适|异常","risk":0-3,"reason":"不超过20字"}
+    risk:0正常 1关注 2注意 3紧急(紧急含摔倒/胸痛/喘不上气/轻生倾向等)。
+
+# ---------------------------------------------------------------------------
+# 通知路由(SPEC §10.3)
+#   L1 不通知(进周报);L2 企微;L3 企微+短信。
+#   短信默认关闭(需报备签名模板),打开见 .env 的 SMS_ENABLED。
+# ---------------------------------------------------------------------------
+notify:
+  routing:
+    1: []
+    2: ["wecom"]
+    3: ["wecom", "sms"]
+  degrade:
+    wecom_failed_on_level_3: ["sms"]
+    sms_failed: ["email"]
+  budget:
+    sms_monthly_cny: 20
+  # 降噪(SPEC §8.5)
+  cooldown_minutes: 30          # 同一成员同一级别冷却
+  l2_daily_cap: 2               # L2 单日上限,超出合并进周报
+  wait_state_hours: 4           # 等待反馈态超时
+
+# ---------------------------------------------------------------------------
+# 种子数据:首次启动(库为空)时写入。之后在 Web 后台编辑,不回写此文件。
+# ---------------------------------------------------------------------------
+seed:
+  # 模板:steps 按顺序播放,最多一个 question
+  templates:
+    - id: exercise
+      name: 锻炼身体
+      theme: 锻炼
+      steps:
+        - {seq: 1, type: text, content: "妈,今天天气还行,活动活动筋骨没?"}
+        - {seq: 2, type: material, tag: 锻炼}
+        - {seq: 3, type: question, content: "跟着做了没?哪儿觉得累就跟我说,别硬撑。", question_key: did_exercise, delay_sec: 20}
+    - id: anti_fraud
+      name: 防诈骗提醒
+      theme: 防诈骗
+      steps:
+        - {seq: 1, type: text, content: "跟你说个最近挺多的事,别嫌我啰嗦。"}
+        - {seq: 2, type: material, tag: 防诈骗}
+        - {seq: 3, type: question, content: "最近有没有接到奇怪的电话或者短信?", question_key: met_scam}
+    - id: casual
+      name: 随便聊聊
+      theme: 日常
+      steps:
+        - {seq: 1, type: question, content: "今天干啥了?", question_key: daily}
+
+  # 规则:days "1,3,5" 周一三五;"*" 每天;at "HH:MM"
+  rules:
+    - {days: "1,3,5", at: "09:00", template: exercise}
+    - {days: "2,4,6", at: "09:00", template: anti_fraud}
+    - {days: "7",     at: "10:00", template: casual}
+
+  # 素材:tag 与模板 material 的 tag 对应;url 为视频链接
+  materials:
+    - {tag: 锻炼,   title: "八段锦完整跟练",   url: "https://www.bilibili.com/video/BV1cT4y1o7q1", platform: B站,   source: official}
+    - {tag: 锻炼,   title: "椅子操适合老人",   url: "https://www.bilibili.com/video/BV1aT411u7G7", platform: B站,   source: official}
+    - {tag: 锻炼,   title: "晨起拉伸5分钟",    url: "https://www.bilibili.com/video/BV1Lp4y1s7vV", platform: B站,   source: media}
+    - {tag: 防诈骗, title: "冒充客服诈骗揭秘", url: "https://www.bilibili.com/video/BV1oP4y1k7hK", platform: B站,   source: official}
+    - {tag: 防诈骗, title: "养老投资骗局",     url: "https://www.bilibili.com/video/BV1cT4y1o7q1", platform: B站,   source: official}
+    - {tag: 防诈骗, title: "保健品套路",       url: "https://www.bilibili.com/video/BV1aT411u7G7", platform: B站,   source: media}
diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md
new file mode 100644 (file)
index 0000000..50da2b2
--- /dev/null
@@ -0,0 +1,51 @@
+# 部署说明(family-chat v1)
+
+环境:腾讯云 Lighthouse Ubuntu,Python 3.12,nginx 已在运行,ffmpeg 已装。
+服务器 IP:124.222.157.124。内部端口 **1064**(避开 1060/1061/1062/2341/4999;1063 被「学习山峰」占用,不动)。
+
+## 1. 依赖安装(系统 Python,与 spike 一致)
+```bash
+pip install --break-system-packages -r requirements.txt
+# 关键依赖:fastapi uvicorn sqlalchemy apscheduler pyyaml jinja2 cryptography tencentcloud-sdk-python
+```
+
+## 2. 配置
+- `.env`(已存在,含密钥,**不提交 git**):企业微信 + 腾讯云 ASR + AI 密钥。
+  需补充:`ADMIN_TOKEN=改一个强口令`(后台登录用)。可选:`AI_API_KEY/AI_BASE_URL/AI_MODEL`、`SMS_*`。
+- `config/config.yaml`(已存在,可提交):成员、模板、规则、素材、热词、通知路由。
+  首次启动若只有 `config.example.yaml`,复制一份为 `config.yaml` 再改。
+
+## 3. 数据库
+SQLite 在 `data/family.db`(WAL)。首次启动自动建表并播种 `config.yaml` 的种子数据。
+重置:`systemctl stop family-chat && rm -f data/family.db* && systemctl start family-chat`。
+
+## 4. 常驻服务(systemd)
+```bash
+cp deploy/family-chat.service /etc/systemd/system/
+systemctl daemon-reload
+systemctl enable --now family-chat
+systemctl status family-chat
+```
+改代码/配置后:`systemctl restart family-chat`。
+
+## 5. nginx 反代(新增 conf.d,不改现有站点)
+```bash
+cp deploy/nginx-wechatbot.conf /etc/nginx/conf.d/wechatbot.conf
+nginx -t && systemctl reload nginx
+```
+- 回调:`https://acesimba.cloud/wecom/callback` → 1064
+- 后台:`https://acesimba.cloud/chatbot/` → 1064(剥离 /chatbot 前缀)
+
+## 6. 企业微信后台(人工,代码不管)
+1. 应用名/logo 设成家属称呼与本人照片(C1 约束:让老人觉得是女儿发的)。
+2. 「接收消息 → API 接收」URL 填 `https://acesimba.cloud/wecom/callback`,Token/AESKey 取自 `.env`。
+   **等本服务起来后再点保存**(否则验证不过)。
+3. 「微信插件」勾选允许在微信里收发;让老人微信扫码关注。
+4. 通讯录成员账号须与 `config.yaml` 的 `wecom_userid` 完全一致。
+
+## 7. 验证闭环
+- 健康检查:`curl https://acesimba.cloud/wecom/callback` 应被拒绝(无参数);
+  带正确参数时回显 echostr(保存验证)。
+- 手动推一条:后台「手动推」选成员+模板;老人微信应收到三段消息。
+- 老人用文字/语音回复 → 后台「看反馈」出现记录与风险标记;命中 L3 时子女企微收到提醒。
+- 周报:后台「编辑」页点「生成周报」,或每周日自动生成并推子女。
diff --git a/deploy/family-chat.service b/deploy/family-chat.service
new file mode 100644 (file)
index 0000000..258e39d
--- /dev/null
@@ -0,0 +1,14 @@
+[Unit]
+Description=family-chat 家人看护推送系统
+After=network.target
+
+[Service]
+Type=simple
+WorkingDirectory=/home/chatbot
+ExecStart=/usr/bin/python3 -m app.server
+Restart=always
+RestartSec=5
+Environment=PYTHONUNBUFFERED=1
+
+[Install]
+WantedBy=multi-user.target
diff --git a/deploy/nginx-wechatbot.conf b/deploy/nginx-wechatbot.conf
new file mode 100644 (file)
index 0000000..fe14e5f
--- /dev/null
@@ -0,0 +1,60 @@
+# ============================================================
+# family-chat 回调反代(路径方式,复用 acesimba.cloud 现有证书)
+#
+# 回调地址: https://acesimba.cloud/wecom/callback
+#
+# 说明:
+#  - 子域名 wechatbot.acesimba.cloud 配不通,改用路径方式挂在已配置好的
+#    acesimba.cloud 下面,复用其现有 Let's Encrypt 证书(无需 certbot)。
+#  - 1063 端口被「学习山峰」(/home/study-mountion/backend) 占用,
+#    依 SPEC「不碰学习山峰」原则,本服务改用 1064。
+#  - 本文件是【新增】的 conf.d 片段,不修改任何现有 nginx 配置。
+#
+# 应用方式(确认后执行):
+#   cp deploy/nginx-wechatbot.conf /etc/nginx/conf.d/wechatbot.conf
+#   nginx -t && systemctl reload nginx
+# ============================================================
+
+server {
+    listen 80;
+    server_name acesimba.cloud;
+    return 301 https://$host$request_uri;
+}
+
+server {
+    listen 443 ssl;
+    server_name acesimba.cloud;
+
+    # 复用 acesimba.cloud 现有证书
+    ssl_certificate     /etc/letsencrypt/live/acesimba.cloud/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/acesimba.cloud/privkey.pem;
+
+    # 企业微信回调 → 本机 1064(app 监听 /wecom/callback)
+    location /wecom/ {
+        proxy_pass http://127.0.0.1:1064;
+        proxy_set_header Host              $host;
+        proxy_set_header X-Real-IP         $remote_addr;
+        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+
+    # 后台登录页(密码登录,app 监听 /login)→ 本机 1064
+    # 未登录访问 /chatbot/ 会被后端 302 跳到 /login,故此处需放开。
+    location = /login {
+        proxy_pass http://127.0.0.1:1064;
+        proxy_set_header Host              $host;
+        proxy_set_header X-Real-IP         $remote_addr;
+        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+
+    # 手机后台(今日/反馈/暂停/手动推/编辑)→ 本机 1064,剥离 /chatbot 前缀
+    # 访问:https://acesimba.cloud/chatbot/   (页面内 JS 用 BASE 自适应)
+    location /chatbot/ {
+        proxy_pass http://127.0.0.1:1064/;
+        proxy_set_header Host              $host;
+        proxy_set_header X-Real-IP         $remote_addr;
+        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+}
diff --git a/doc/RISK_AND_PLAN.md b/doc/RISK_AND_PLAN.md
new file mode 100644 (file)
index 0000000..df05497
--- /dev/null
@@ -0,0 +1,173 @@
+# family-chat 风险清单与实施计划
+
+> 配套文档:`SPEC.md`(v1.0)。本文件只做两件事:
+> 1. 把上线前必须看清的风险列成可跟踪的清单;
+> 2. 给出分阶段、可验收的实施路线。
+>
+> 状态:规划阶段,未开始编码。
+> 日期:2026-08-31
+
+---
+
+## 一、风险清单(Risk Register)
+
+严重程度:🔴 阻断级(不过此关项目不成立)|🟠 高(会显著影响效果/成本)|🟡 中(可缓解,需留意)|🟢 低(已知可接受)
+
+| ID | 风险 | 等级 | 影响 | 验证 / 触发条件 | 缓解措施 |
+|---|---|---|---|---|---|
+| R1 | **企业微信「微信插件」在 2026 仍能让普通微信用户收发自建应用消息** | 🔴 | 整条 C1 链路地基。若插件失效或受限,老人无法在微信里收发,项目核心假设崩塌 | Spike 阶段端到端验证:建组织→建应用→开插件→服务端发消息→微信回复看回调 | 先跑 Spike(见 §二 Phase 0)。若失败,回退方案:家属改用企业微信 App 收发(需装 App,违背"不装新 App"但功能保住);或评估其他合规通道 |
+| R2 | **C1 身份伪装不彻底** | 🟠 | 老人收到的不是"女儿个人号私聊",而是"企业微信服务消息"形态,详情页/通讯录仍露企业信息 | 产品预期管理:和家属明确告知这是形态限制,非 bug | 头像用真人照片、名字用家里称呼、口吻用 persona;不掩饰残留差异(SPEC 9 节已诚实列出) |
+| R3 | **ASR 置信度字段缺失/改名导致重试逻辑失效** | 🟠 | 8.2 的双引擎兜底依赖 `Confidence` 字段;若接口不返回,低质量语音无法触发兜底,转写直接废 | 实测 `SentenceRecognition` 响应结构,确认 `Confidence` 是否存在 | 代码写成可选字段兼容:有字段按阈值判,无字段仅在"结果为空"时触发重试(SPEC 8.2 已要求) |
+| R4 | **关中话 / 小声远距语音识别准确率不足** | 🟠 | 老人多半声音小、离手机远,识别错会直接误判身体状况 | P1 验收:普通话 + 关中话各测;再录"人声小、离半米"一条验证 `16k_zh_en` 兜底 | 双引擎兜底 + 热词表(膝盖/降压药/头晕等)+ 原始 amr 存 30 天供回听调优 |
+| R5 | **素材链接体检误判** | 🟡 | 小红书/抖音对无头请求或全 200 或全拒,可能误杀优质素材 | 体检实现后用 `source=official` 链接优先;观察误杀率 | 看"连续"失败次数而非单次;优先 official 源;误杀仅降权,连续 2 次才剔除(SPEC 7.2 已定) |
+| R6 | **告警疲劳 / 误报致家属忽略** | 🟠 | 误报几次后家属开始忽略告警,整套系统失效 | 上线前 2 周只记录不通知,用真实数据校准阈值 | 降噪规则(30min 冷却 / L2 单日≤2 / 已 resolved 不重发 / 连续 2-3 天才升级)+ 静默期(SPEC 8.5) |
+| R7 | **触碰服务器现有服务 / 端口冲突** | 🟡 | 影响 words / 学习山峰 / gitweb / nginx 现有站点,属高危操作 | 部署时核对端口、nginx 配置 | 只新增 server 块反代,**不改现有 nginx 配置**;内部端口固定 **1063**(1060/1061/1062/2341/4999 禁用);不碰 words 等现有服务(SPEC 3、16、17) |
+| R8 | **access_token 失效 / 缓存异常** | 🟡 | 所有主动发消息、素材下载依赖 token | 复用 `app/wecom.py`(已测试),联调时观察 token 刷新 | 依赖现有已测实现;加监控告警 token 获取失败 |
+| R9 | **等待态边界混淆** | 🟡 | 老人主动发起的闲聊被误当成某问题的反馈,或 4h 超时后漏判 | 代码区分"等待反馈态"与"普通陪聊态" | 等待态默认 4h 超时自动清除;超时前半夜一句话不被当成白天问题反馈(SPEC 8.1) |
+| R10 | **短信报备周期阻塞 L3 第二通道** | 🟡 | 签名+模板审核 1-3 天,且变量位有限(腾讯云 `{1}{2}`),上线初期 L3 可能只有企微 | 尽早启动报备,不卡主流程 | `SMS_ENABLED` 开关 + 月度预算 ¥20 双保险;报备完成前 L3 仅走企微(SPEC 10.4) |
+| R11 | **静默时段与推送冲突** | 🟢 | 默认 quiet 21:00-08:00,推送 09:00/周日 10:00 均不在静默内,正常 | 配置时核对时间点 | 落在静默内的推送顺延到时段结束后 30min(SPEC 6) |
+| R12 | **微信插件消息送达率 / 后台限制** | 🟡 | 老人微信可能不弹通知、或不及时,导致"应回未回" | 实测老人手机收推送的及时性与通知展示 | 周报统计未回复;连续未回升级 L1→L2→L3;建议每周至少 1 条真人语音兜底(SPEC 9) |
+| R13 | **隐私与数据安全** | 🟡 | 老人语音(含健康信息)存 30 天、反馈入库,存在泄露风险 | 服务器仅家人可访问;`.env` 不入库;数据库文件权限控制 | 原始音频加密/限访问;DB 不对外开放;遵守"不骗老人"底线,数据用途透明 |
+| R14 | **成本失控** | 🟡 | ASR / SMS / AI token 意外放大 | 监控 `notifications.cost_cny` 与 ASR 调用量 | 双引擎仅低置信度才二次调用;SMS 月度预算上限;AI 用小模型 + 严格 JSON 省 token(SPEC 8.4、10.3) |
+| R15 | **OAuth 鉴权依赖域名 / 开发后门遗留** | 🟡 | 后台用企业微信 OAuth 需可信域名(纯 IP 不行);`ADMIN_TOKEN` 开发绕过若留生产是漏洞 | 部署前强制关闭 `ADMIN_TOKEN`;OAuth 必须配域名 | 配置开关区分 dev/prod;生产禁用令牌绕过(SPEC 11.2) |
+| R16 | **跨天升级 job 规格未显式定义** | 🟡 | "连续 2/3 天负面""连续 3 天无反馈"需跨日扫描,SPEC 未单列该 job | 实施时在调度里补一条每日 00:30 扫描 job | 由 feedbacks/alerts 历史聚合判定,写入 alerts 或置 risk_level,再走通知路由 |
+| R17 | **V1 复用边界误用** | 🟢 | 误改不兼容的 `store.py / bot.py / scheduler.py` | 代码评审时核对 | 仅复用 `wecom.py / asr.py / ai.py / smoke.py`;其余重写(SPEC 15) |
+| R18 | **misfire 超 15min 当天不补发** | 🟢 | 服务器 09:00 起 15min 外宕机,当天推送丢失 | 监控服务存活;告警推送失败 | 已知且接受(半夜补发比不发更糟);记日志,周报体现缺失(SPEC 6) |
+| R19 | **热词表每次识别读取的性能** | 🟢 | 每次 ASR 读配置,量极小可忽略 | — | 可选内存缓存 + 文件 mtime 失效;一期直接读文件即可 |
+| R20 | **同时刻多规则冲突** | 🟢 | 同日同时刻多规则可能重复推送 | 单元测试同刻多规则 | 取 `priority` 最高,其余跳过并记日志(SPEC 6) |
+
+### 风险处置优先级
+
+```
+必须先解:R1(Spike)→ R3(接口实测)
+上线前必须解:R6(静默校准)、R10(短信报备启动)、R15(后门关闭)
+持续监控:R4、R5、R12、R13、R14
+已知接受:R11、R18、R19、R20
+```
+
+---
+
+## 二、实施计划(分阶段、可验收)
+
+> 总原则(SPEC 17):只新增不修改不碰现有服务;高风险操作先列确认再执行;端口冲突主动上报;改完 git commit 写清原因;诚实告知成本与风险。
+
+### Phase 0 · 可行性 Spike(最高优先,约 0.5 天)
+
+**目标**:在写任何推送引擎前,验证 R1 是否成立。
+
+- [ ] 注册企业微信组织(不认证,免费),组织名起得像家
+- [ ] 建自建应用:应用名=家里称呼、logo=家属照片、可见范围=全家,记 AgentId/Secret
+- [ ] 应用详情填企业可信 IP = 服务器公网 IP
+- [ ] 微信插件:勾选"允许成员在微信插件中接收和回复";家人微信扫码关注
+- [ ] 通讯录添加成员 `mom`,与 `members.wecom_userid` 一致
+- [ ] 用 `app/wecom.py`(复用)向 `mom` 主动发一条文本
+- [ ] **用老人微信回复一条,确认回调能收到消息**
+- [ ] 结论:R1 通过 → 继续;R1 失败 → 启动回退方案(见 R1 缓解)
+
+**验收**:服务端发出、老人微信收到、老人回复被服务端回调捕获,三件事全成立。
+
+**出口闸门**:R1 不过,不进入 P0。
+
+---
+
+### P0 · 推送引擎(先跑通"推得出去")
+
+| 任务 | 内容 | 对应 SPEC | 依赖 |
+|---|---|---|---|
+| P0-1 | `app/schema.sql` 建 10 张表 + 初始化迁移 | §4 | — |
+| P0-2 | `app/store.py` 重写,CRUD 覆盖新表 | §4 | P0-1 |
+| P0-3 | `app/template.py` 按 seq 渲染 steps(text/material/question/image) | §5 | P0-2 |
+| P0-4 | `app/materials.py` 实现 7.1 轮换查询(抽查优先、未用优先) | §7.1 | P0-2 |
+| P0-5 | `app/scheduler.py` 重写:cron 每 rule 一 job、幂等、静默顺延、misfire、素材缺失跳过 | §6 | P0-2/3/4 |
+| P0-6 | `config/config.example.yaml` 加 templates/rules/materials 三段 + 端口 1063 | §5、§3 | — |
+| P0-7 | 联调:手动触发一次推送,微信看到三段消息 | §14 P0-7 | P0-1~6 |
+
+**验收**:`POST /api/push/mom` 能在微信收到"文本 + 视频链接 + 提问"三条,且重复触发不产生第二条(`pushes` 唯一约束生效)。
+
+---
+
+### P1 · 反馈闭环、告警、手机后台
+
+| 任务 | 内容 | 对应 SPEC | 依赖 |
+|---|---|---|---|
+| P1-1 | 反馈采集:等待态(4h 超时)+ 归档进 `feedbacks`;区分普通陪聊 | §8.1 | P0 |
+| P1-2 | `app/asr.py` 改双引擎兜底(8.2)+ ffmpeg 8k→16k 重采样 + 热词表(**先实测 R3**) | §8.2 | P0、R3 |
+| P1-3 | 风险打标:关键词先跑(命中直接 L3)→ 小模型 JSON 打标 L1/L2 | §8.3/8.4 | P1-1 |
+| P1-4 | 通知中心:`Notifier` 协议 + 企微实现 + 路由 + 幂等 + 账本 | §10 | P1-3 |
+| P1-5 | 降噪:30min 冷却、L2 单日≤2、已 resolved 不重发、连续 2-3 天升级 job(补 R16) | §8.5 | P1-4 |
+| P1-6 | 手机优先 Web 后台:今日状态 / 看反馈 / 暂停恢复 / 手动推一条 | §11 | P1-4 |
+
+**验收**:
+1. 老人发语音"我摔了一跤" → 转文字 → 命中关键词 L3 → 家属企微收到告警 → 后台可见该反馈;
+2. **普通话 + 关中话各测一遍**确认 `16k_zh_dialect` 转写正确;再录"人声小、离半米"一条确认 `16k_zh_en` 兜底救回;
+3. 热词表生效:含"膝盖"的话不被识别成别字。
+
+---
+
+### P2 · 收尾
+
+| 任务 | 内容 | 对应 SPEC | 依赖 |
+|---|---|---|---|
+| P2-1 | 周报:每周日生成,含反馈汇总/情绪趋势/未回复次数/本月短信花费 | §14 P2-1 | P1 |
+| P2-2 | 素材体检:每周 HEAD 检测 + 存活数不足告警 | §7.2 | P0-4 |
+| P2-3 | OAuth 鉴权(需域名) | §11.2 | 域名就位 |
+| P2-4 | 短信通道接入(含签名/模板报备,启动 R10) | §10.4 | 报备完成 |
+
+---
+
+## 三、上线策略(关键,防告警疲劳)
+
+```
+P2 完成后 ──► 灰度试运行 2 周:
+   推送照常发、反馈照常采集与打标、告警【只入库不通知】
+   ↓
+   用真实数据观察:误报率、方言识别准确率、未回复率
+   ↓
+   校准阈值(冷却/上限/升级天数/关键词)
+   ↓
+   打开通知 → 正式运行
+```
+
+> 依据 SPEC 8.5:误报几次后家属会忽略告警,整套系统白做。此 2 周静默期是硬要求,不可跳过。
+
+---
+
+## 四、待家属确认事项(开工前阻塞项)
+
+来自 SPEC §12,落到本计划作为前置条件:
+
+| # | 待确认 | 阻塞阶段 | 负责 |
+|---|---|---|---|
+| 1 | 方言已确认(接近普通话 + 关中话) | 已定 | — |
+| 2 | admin 企业微信 userid + 手机号 | P1(告警接收人) | 家属 |
+| 3 | 老人通讯录账号(mom/dad) | P0 Spike | 家属 |
+| 4 | 域名与子域(回调 + 后台 OAuth 必需) | P0 / P2-3 | 家属/环境 |
+| 5 | 短信服务商与签名主体 | P2-4 | 家属 |
+| 6 | 推送时间点(暂 09:00 / 周日 10:00) | P0 配置 | 家属 |
+| 7 | 素材平台优先级(小红书/抖音/视频号/B站) | P0 素材 | 家属 |
+
+---
+
+## 五、运维与部署注意
+
+- 服务器:124.222.157.124(腾讯云 Lighthouse,Ubuntu);内部端口 **1063**;nginx 已运行,**只加 server 块**;域名已备案,建议子域 `family.<域名>`。
+- 部署:systemd 常驻(与现有服务一致);不改现有 nginx 站点;不碰 words/学习山峰/gitweb/现有配置/腾讯云 stargate 监控。
+- 已具备:nginx、ffmpeg、python3 3.12.3(venv)、git、certbot、pm2、node。
+- 文档同步:每完成一阶段更新 `HANDOFF.md` 进度(SPEC 17.6)。
+
+---
+
+## 六、风险→任务对照速查
+
+| 风险 | 由哪个阶段兜底 |
+|---|---|
+| R1 | Phase 0 Spike |
+| R3 | P1-2(先实测) |
+| R4 | P1-2 + P1 验收 2/3 |
+| R5 | P2-2 |
+| R6 | 上线策略 2 周静默 |
+| R7 | 部署注意(§五) |
+| R10 | P2-4(尽早启动报备) |
+| R15 | P2-3 / 部署检查 |
+| R16 | P1-5 补 job |
+| R17 | 代码评审 |
diff --git a/doc/SPEC.md b/doc/SPEC.md
new file mode 100644 (file)
index 0000000..7195821
--- /dev/null
@@ -0,0 +1,685 @@
+# family-chat 开发规格说明书
+
+> **用途**:这份文件是给执行开发的 AI 看的施工图纸,自包含,不依赖任何历史对话。
+> 读完即可开工。标注【已定】的条目不要再讨论方案,直接执行。
+> 设计论证(选型理由、调研证据)见 `docs/DESIGN.md`,本文档只讲"做什么、怎么做、验收标准"。
+
+- 版本:v1.0
+- 日期:2026-08-31
+- 目标环境:云端 AI 开发环境(git / 调试 / 带备案域名均已具备)
+
+---
+
+## 1. 项目是什么
+
+给家里老人做的**微信看护推送系统**。
+
+每天按星期推送不同主题的内容(周一三五锻炼、周二四六防诈骗),内容含文本 + 视频链接 + 一个提问;
+老人用**文字或语音**回复;系统回收反馈、判断情绪与身体状况,
+异常时按 L1/L2/L3 分级通知家属(企业微信 / 短信),每周出一份周报。
+
+### 1.1 老人视角的完整链路
+
+```
+早上 09:00,微信消息列表顶部弹出一条未读
+  头像:女儿的照片
+  名字:女儿在家里的称呼
+  内容:"妈,今天天气还行,活动活动筋骨没?"
+  → 跟着一条视频链接(按主题轮换,不重复)
+  → 再跟一句:"跟着做了没?哪儿觉得累就跟我说,别硬撑。"
+
+老人点进去,按住说话:"做啦,就是膝盖有点疼"
+   → 语音转文字 → 存为反馈 → 判断:身体不适,L2
+   → 女儿的企业微信收到一条提醒
+```
+
+### 1.2 三条不可妥协的约束
+
+| # | 约束 | 原因 |
+|---|---|---|
+| C1 | **消息必须看起来是女儿本人发的** | 老人收到的不是"系统通知",是"女儿的关心"。头像是她本人照片、名字是家里称呼、口吻是女儿。见第 9 节 |
+| C2 | **合规,不碰微信个人号自动化** | Hook / 协议逆向会导致永久封号且不可申诉,连带微信支付。已排除 |
+| C3 | **被直接问"是不是机器人"必须如实说** | 骗老人一旦识破,伤害比不用这套系统更大 |
+
+---
+
+## 2. 技术选型【已定】
+
+| 项 | 选型 | 一句话理由 |
+|---|---|---|
+| 通道 | 企业微信**自建应用 + 微信插件** | 官方 API,零封号风险,家人在自己微信里收发、不装新 App,个人免费注册组织(无需营业执照、无需认证) |
+| 后端 | FastAPI + SQLite(WAL) | 与现有项目栈一致;预留 PostgreSQL 迁移 |
+| 调度 | APScheduler(应用内) | 规则变更可热重载 |
+| 语音 | 腾讯云 ASR 一句话识别 | 免费额度够用;**方言需换引擎,见第 12 节待确认** |
+| AI | OpenAI 兼容接口,模型走环境变量 | 换模型只改 `.env` |
+| 部署 | systemd 常驻 | 与现有服务一致 |
+
+### 2.1 已排除的方案(不要再提)
+
+- **itchat / 微信个人号 Hook**:2026 年已大面积失效,且封号永久
+- **公众号订阅号**:只能被动回复,5 秒超时,无法每天主动找人聊
+- **公众号服务号**:每月 4 次群发 + 300 元/年认证
+- **企业微信「客户联系」(外部联系人)**:看似是普通微信好友对话,但
+  ① 主动推送只能走群发,每客户每天 1 条,**且必须成员在 App 手动点确认**
+  ② **没有任何接口可以自动回复外部联系人**(官方技术支持原话:"暂无相关接口支持您所描述的功能")
+  ③ 唯一能回的 `externalcontact/message/send` 仅限教育行业 K12
+- **电话外呼**:一期不做,先看微信回复率数据
+
+---
+
+## 3. 运行环境
+
+| 项 | 值 | 备注 |
+|---|---|---|
+| 部署服务器 | 124.222.157.124(腾讯云 Lighthouse,Ubuntu) | 若云端开发环境的部署目标不同,以实际为准 |
+| **内部端口** | **1063** | ⚠️ 1060/1061/1062 已被 words-v1/words-v2/words 占用,**不可使用** |
+| 对外端口 | 80 / 443 | nginx 已在运行,占用中。**新增 server 块反代即可,不要改现有配置** |
+| 域名 | 由云端环境提供(已备案) | 回调与 Web 后台都挂它下面。建议子域 `family.<你的域名>` |
+| HTTPS | certbot 可签,或复用现有证书体系 | 企业微信回调支持 http,但 Web 后台 OAuth 建议 https |
+
+**服务器上已具备**:nginx、ffmpeg(语音转码要用)、python3 3.12.3(venv 可用)、git、certbot、pm2、node。环境无需额外配置。
+
+**服务器上不要碰**:words / words-v1 / words-v2 / 学习山峰 / gitweb / nginx 现有站点配置 / 腾讯云 stargate 监控。
+
+---
+
+## 4. 数据模型
+
+10 张表,SQLite 语法。关键约束已标注用途。
+
+```sql
+-- 家庭(一期只有一行,为多家庭预留)
+CREATE TABLE families (
+  id         INTEGER PRIMARY KEY,
+  name       TEXT NOT NULL,
+  timezone   TEXT NOT NULL DEFAULT 'Asia/Shanghai',
+  created_at INTEGER NOT NULL
+);
+
+-- 成员:role 是整个权限模型的核心
+CREATE TABLE members (
+  id           INTEGER PRIMARY KEY,
+  family_id    INTEGER NOT NULL REFERENCES families(id),
+  wecom_userid TEXT NOT NULL UNIQUE,   -- 企业微信通讯录"账号",如 mom / dad
+  name         TEXT NOT NULL,
+  role         TEXT NOT NULL,          -- admin=子女(可编辑+收告警) / member=父母(只收推送)
+  phone        TEXT,                   -- 短信告警用
+  persona      TEXT NOT NULL DEFAULT 'elder',
+  quiet_hours  TEXT DEFAULT '21:00-08:00',
+  enabled      INTEGER NOT NULL DEFAULT 1,
+  created_at   INTEGER NOT NULL
+);
+
+CREATE TABLE templates (
+  id         INTEGER PRIMARY KEY,
+  family_id  INTEGER NOT NULL,
+  name       TEXT NOT NULL,            -- "锻炼身体"
+  theme      TEXT,                     -- 主题标签,用于素材检索与周报归类
+  enabled    INTEGER NOT NULL DEFAULT 1,
+  created_at INTEGER NOT NULL,
+  updated_at INTEGER NOT NULL
+);
+
+-- 一次推送按顺序播放的"配方"
+CREATE TABLE template_steps (
+  id           INTEGER PRIMARY KEY,
+  template_id  INTEGER NOT NULL REFERENCES templates(id) ON DELETE CASCADE,
+  seq          INTEGER NOT NULL,
+  type         TEXT NOT NULL,          -- text | material | question | image
+  content      TEXT,                   -- text/question 的话术
+  tag          TEXT,                   -- material 的素材标签
+  question_key TEXT,                   -- question 的字段名,反馈按它归档
+  delay_sec    INTEGER NOT NULL DEFAULT 0,  -- 与上一条的间隔,模拟真人节奏
+  UNIQUE(template_id, seq)
+);
+
+CREATE TABLE rules (
+  id          INTEGER PRIMARY KEY,
+  family_id   INTEGER NOT NULL,
+  member_id   INTEGER,                 -- NULL=全家适用
+  days        TEXT NOT NULL,           -- "1,3,5" 周一三五;"*" 每天
+  at_time     TEXT NOT NULL,           -- "09:00"
+  template_id INTEGER NOT NULL REFERENCES templates(id),
+  priority    INTEGER NOT NULL DEFAULT 0,
+  enabled     INTEGER NOT NULL DEFAULT 1
+);
+
+CREATE TABLE materials (
+  id           INTEGER PRIMARY KEY,
+  family_id    INTEGER NOT NULL,
+  tag          TEXT NOT NULL,          -- 锻炼 / 防诈骗 / 防跌倒 / 饮食
+  title        TEXT NOT NULL,
+  url          TEXT NOT NULL,
+  platform     TEXT,                   -- 小红书 / 抖音 / 视频号 / B站
+  source       TEXT,                   -- official=政务媒体号 / media / personal
+  checked      INTEGER NOT NULL DEFAULT 0,  -- 人工是否抽查过
+  alive        INTEGER NOT NULL DEFAULT 1,  -- 链接体检结果
+  fail_count   INTEGER NOT NULL DEFAULT 0,
+  used_count   INTEGER NOT NULL DEFAULT 0,
+  last_used_at INTEGER,
+  created_at   INTEGER NOT NULL
+);
+
+-- 幂等关键:同一人同一规则同一天只会有一条
+CREATE TABLE pushes (
+  id          INTEGER PRIMARY KEY,
+  member_id   INTEGER NOT NULL REFERENCES members(id),
+  rule_id     INTEGER REFERENCES rules(id),
+  template_id INTEGER NOT NULL,
+  push_date   TEXT NOT NULL,           -- YYYY-MM-DD
+  material_id INTEGER,
+  status      TEXT NOT NULL,           -- sent | failed | skipped
+  sent_at     INTEGER,
+  UNIQUE(member_id, rule_id, push_date)
+);
+
+-- 核心资产
+CREATE TABLE feedbacks (
+  id            INTEGER PRIMARY KEY,
+  member_id     INTEGER NOT NULL REFERENCES members(id),
+  push_id       INTEGER REFERENCES pushes(id),
+  question_key  TEXT,
+  question      TEXT,
+  answer        TEXT NOT NULL,         -- 语音已转文字
+  input_type    TEXT NOT NULL,         -- text | voice
+  asr_confidence REAL,
+  mood          TEXT,                  -- 正面 / 中性 / 负面 / 低落
+  physical      TEXT,                  -- 正常 / 不适 / 异常
+  risk_level    INTEGER NOT NULL DEFAULT 0,  -- 0正常 1关注 2注意 3紧急
+  ai_reason     TEXT,
+  created_at    INTEGER NOT NULL
+);
+
+CREATE TABLE alerts (
+  id          INTEGER PRIMARY KEY,
+  member_id   INTEGER NOT NULL,
+  feedback_id INTEGER REFERENCES feedbacks(id),
+  level       INTEGER NOT NULL,
+  reason      TEXT NOT NULL,
+  channels    TEXT,                    -- 实际发出的通道,逗号分隔
+  resolved    INTEGER NOT NULL DEFAULT 0,
+  resolved_by TEXT,
+  created_at  INTEGER NOT NULL,
+  resolved_at INTEGER
+);
+
+-- 通知账本:幂等 + 成本统计
+CREATE TABLE notifications (
+  id          INTEGER PRIMARY KEY,
+  alert_id    INTEGER REFERENCES alerts(id),
+  channel     TEXT NOT NULL,           -- wecom | sms | email | webhook
+  target      TEXT NOT NULL,
+  status      TEXT NOT NULL,           -- sent | failed | skipped | degraded
+  cost_cny    REAL NOT NULL DEFAULT 0,
+  vendor_msg  TEXT,
+  retry_count INTEGER NOT NULL DEFAULT 0,
+  created_at  INTEGER NOT NULL,
+  UNIQUE(alert_id, channel, target)    -- 幂等:同一告警同一通道同一人只发一次
+);
+
+CREATE TABLE weekly_reports (
+  id         INTEGER PRIMARY KEY,
+  family_id  INTEGER NOT NULL,
+  week_start TEXT NOT NULL,
+  content    TEXT NOT NULL,
+  sent_at    INTEGER,
+  UNIQUE(family_id, week_start)
+);
+```
+
+---
+
+## 5. 模板与规则 DSL
+
+一期用 YAML 配置,二期落库 + Web 后台编辑。
+
+```yaml
+templates:
+  - id: exercise
+    name: 锻炼身体
+    theme: 锻炼
+    steps:
+      - seq: 1
+        type: text
+        content: "妈,今天天气还行,活动活动筋骨没?"
+      - seq: 2
+        type: material      # 具体链接在推送时才决定,模板只声明标签
+        tag: 锻炼
+      - seq: 3
+        type: question      # 发出后,下一条回复计为该问题的反馈
+        content: "跟着做了没?哪儿觉得累就跟我说,别硬撑。"
+        question_key: did_exercise
+        delay_sec: 20       # 模拟真人打字间隔,三条连发很假
+
+  - id: anti_fraud
+    name: 防诈骗提醒
+    theme: 防诈骗
+    steps:
+      - seq: 1
+        type: text
+        content: "跟你说个最近挺多的事,别嫌我啰嗦。"
+      - seq: 2
+        type: material
+        tag: 防诈骗
+      - seq: 3
+        type: question
+        content: "最近有没有接到奇怪的电话或者短信?"
+        question_key: met_scam
+
+  - id: casual
+    name: 随便聊聊
+    theme: 日常
+    steps:
+      - seq: 1
+        type: question
+        content: "今天干啥了?"
+        question_key: daily
+
+rules:
+  - days: "1,3,5"     # 周一三五
+    at: "09:00"
+    template: exercise
+  - days: "2,4,6"     # 周二四六
+    at: "09:00"
+    template: anti_fraud
+  - days: "7"         # 周日不安排任务,纯陪聊
+    at: "10:00"
+    template: casual
+```
+
+**约束**:
+- 一个模板最多一个 `question`,避免连珠炮追问
+- `material` 的 `tag` 必须能在 materials 表找到存活素材,否则跳过该步并告警
+
+---
+
+## 6. 规则引擎
+
+| 问题 | 规则 |
+|---|---|
+| 触发 | APScheduler cron,每个 rule 一个 job,配置变更热重载 |
+| 同日多规则 | 各自按 `at_time` 独立触发,允许一天多次 |
+| 同时刻冲突 | 取 `priority` 最高的,其余跳过并记日志 |
+| **幂等** | `pushes` 唯一约束 `(member_id, rule_id, push_date)` |
+| **宕机补发** | `misfire_grace_time=900`(15 分钟内补发);**超时不补发**,只记录——半夜补发比不发更糟 |
+| 静默时段 | 落在 `quiet_hours` 内的推送顺延到时段结束后 30 分钟 |
+| 素材缺失 | 该 tag 无存活素材 → 跳过 material 步骤,继续后续步骤,并发告警提示补货 |
+
+---
+
+## 7. 素材子系统
+
+### 7.1 轮换算法
+
+```sql
+SELECT * FROM materials
+WHERE family_id=? AND tag=? AND alive=1
+ORDER BY (checked=1) DESC,          -- 抽查过的优先
+         (last_used_at IS NULL) DESC,  -- 没用过的优先
+         last_used_at ASC,
+         used_count ASC
+LIMIT 1;
+```
+
+### 7.2 链接体检
+
+- 每周日凌晨跑,对每个 `alive=1` 素材发 HEAD 请求(3 秒超时,随机 UA)
+- 连续 2 次失败 → `alive=0`,剔除出候选
+- 某 tag 存活素材 < 3 条 → 告警让家属补货
+
+⚠️ 已知:小红书 / 抖音对无头请求可能一律返回 200 或一律拒绝,体检会有误判。
+缓解方式是看**连续**失败次数而非单次,并优先用 `source=official` 的链接。
+
+---
+
+## 8. 反馈采集与风险分级
+
+### 8.1 采集
+
+```
+收到消息 → 是否处于"等待反馈"状态?
+   是 → 语音走 ASR → 记为 feedback → 风险打标 → 清除等待态
+   否 → 普通陪聊(AI 接话,不入库为反馈)
+```
+
+等待态默认 4 小时超时,超时自动清除——避免半夜一句话被当成白天问题的反馈。
+
+### 8.2 语音链路【方言已确认:接近普通话 + 陕西关中话】
+
+```
+微信语音 → 企业微信 media/get 下载(amr,8k 采样)
+        → ffmpeg 重采样为 16k 单声道(一句话识别只收 16k 引擎)
+        → 腾讯云 ASR 一句话识别(SentenceRecognition)
+        → 文本 + 置信度入库
+```
+
+#### 引擎选型【已定,已查证官方文档】
+
+腾讯云一句话识别支持两类可用引擎,**均覆盖关中话(官方列表中称"西安话"/"陕西")**:
+
+| 引擎 | 覆盖 | 适用 |
+|---|---|---|
+| `16k_zh_dialect` | 普通话 + 23 种方言,含**西安话** | **主引擎**。方言覆盖最广,老人"基本接近普通话"时表现最好 |
+| `16k_zh_en` | 中英粤 + 7 种方言(含**陕西**),大模型版 | **备用引擎**。官方说明:针对**噪声大、回音大、人声小、人声远**等低质量音频识别准确率极大提升 |
+
+老人多半声音偏小、离手机有距离、环境可能有杂音,所以备用引擎选 `16k_zh_en` 而不是另一个普通话引擎。
+
+#### 双引擎兜底策略
+
+```
+识别(16k_zh_dialect)
+   ↓
+置信度 < 0.7 或结果为空?
+   是 → 用 16k_zh_en 再识别一次,取置信度更高的那个
+   否 → 直接用
+   ↓
+仍然为空或置信度 < 0.5 → 回"没听清,你再说一遍?",保存原始音频备查
+```
+
+只在低置信度时才多调一次,老人一条语音几秒,成本可忽略。
+
+⚠️ **实现注意**:`SentenceRecognition` 响应中的 `Confidence` 字段需实测确认是否存在。
+代码要写成**可选字段兼容**——有就按阈值判断,没有就只在"结果为空"时触发重试。
+
+#### 热词表【强烈建议做】
+
+腾讯云支持热词:单个"热词|权重",权重 1-11(11 为超级热词,慎用),最多 128 个。
+家庭场景下这东西提升非常明显,尤其是**身体健康相关的词**("膝盖"被识别成"膝盖腾"就废了)。
+
+```yaml
+asr:
+  hotwords:
+    - "欢欢|11"        # 家人名字,超级热词
+    - "西安|8"
+    - "膝盖|9"
+    - "降压药|9"
+    - "头晕|9"
+    - "<小区名>|8"
+    - "<常用医院名>|8"
+```
+
+做成配置文件,家属随时可以加,改完不用重启(每次识别时读取)。
+
+#### 其他约束
+
+- 一句话识别限制:时长 ≤ 60s、文件 ≤ 3MB。老人语音一般几秒到几十秒,够用
+- 支持的音频格式含 amr,但**采样率必须是 16k**,所以重采样那步省不掉
+- 建议把原始 amr 存 30 天,识别不准时可以人工回听,也方便后续调优
+
+### 8.3 分级矩阵
+
+| 级别 | 触发条件(满足任一) | 通知 |
+|---|---|---|
+| L0 正常 | — | 无 |
+| L1 关注 | 情绪轻度负面、模糊不适("有点累")、答非所问 | 入库,进周报 |
+| L2 注意 | 明确身体不适、连续 2 天负面、当天应回未回 | 企业微信推 admin(合并成一条) |
+| L3 紧急 | 关键词命中(摔倒/胸痛/喘不上气/不想活了)、AI 判定高危、连续 3 天无反馈 | 企微 + 短信,立即 |
+
+**顺序**:关键词先跑,命中直接 L3,不等 AI(省时间,也防模型把"摔倒了"判成 L0)。
+
+### 8.4 AI 打标
+
+用小模型(省 token),要求输出严格 JSON:
+
+```json
+{"mood":"正面|中性|负面|低落","physical":"正常|不适|异常","risk":0-3,"reason":"≤20字"}
+```
+
+返回非法 JSON 或调用失败 → 降级到关键词兜底,不会因为打标失败就不发。
+
+### 8.5 降噪【重要,防止告警疲劳】
+
+| 规则 | 阈值 |
+|---|---|
+| 同一成员同一级别冷却 | 30 分钟 |
+| L2 单日上限 | 2 条,超出合并进周报 |
+| L3 | 不设限 |
+| 已 resolved | 不再重复 |
+| 连续未回复 | 需连续 2/3 天才升级,单日漏回只记 L1 |
+
+**强烈建议**:上线前两周只记录不通知,用真实数据把阈值调准,再打开推送。
+误报几次之后家属就会开始忽略告警,整套系统就白做了。
+
+---
+
+## 9. 身份呈现【C1 约束的落地】
+
+老人在微信消息列表只看到三样:**头像、名字、消息内容**,三样全部可控。
+
+| 元素 | 做法 |
+|---|---|
+| 头像 | 企业微信应用 logo 用家属本人照片 |
+| 名字 | **企业名和应用名都设成同一个家里称呼**(微信插件会话标题显示哪个取决于版本,两个都改最保险) |
+| 内容 | 女儿口吻,由 persona 约束 |
+
+**残留差异**(诚实列出,不掩饰):会话详情页仍会显示企业信息;
+"通讯录 → 我的企业"里也能找到这个入口。老人几乎不会点进去。
+
+**诚信底线**:默认不主动提 AI;被直接问"你是不是机器人"必须如实说。建议话术:
+> "这是欢欢给你弄的一个陪你说话的小东西,话是它替我说的,但我每天都看你说的话。"
+
+**真人介入机制**(别让系统变成甩手掌柜):
+1. 家属可随时在企业微信 App 里手动插话,消息同样到老人微信
+2. **每周至少一条家属本人录的真人语音**——一条真人语音抵得过三十条 AI 文本
+
+---
+
+## 10. 通知中心
+
+### 10.1 通道抽象
+
+```python
+class Notifier(Protocol):
+    name: str                 # wecom | sms | email | webhook
+    capabilities: Capability  # 富文本? 成本/条? 频率限制? 需报备? 有回执?
+    async def send(self, alert: Alert, target: str) -> Receipt
+    async def health_check(self) -> bool
+```
+
+`Receipt(status, vendor_msg, cost_cny)` 统一写进 `notifications` 表。
+
+### 10.2 通道对比
+
+| 通道 | 成本 | 富文本 | 报备 | 送达 | 回执 | 频率限制 |
+|---|---|---|---|---|---|---|
+| 企业微信应用消息 | 0 | ✓ | 无 | 好 | 仅发送结果 | 成员数 × 200/天 |
+| 短信 | ~¥0.03-0.05/条 | ✗ | **签名+模板审核 1-3 天** | 最强 | 有 | 同号 30s/条、1h/5条、1天/10条 |
+| 邮件 | 0 | ✓ | 无 | 一般 | 无 | 取决于服务商 |
+| Webhook | 0 | ✓ | 无 | 好 | 有 | 取决于服务商 |
+
+### 10.3 路由与降级
+
+```yaml
+notify:
+  routing:
+    1: []                    # L1 不通知,进周报
+    2: [wecom]
+    3: [wecom, sms]
+  degrade:
+    wecom_failed_on_level_3: [sms]     # 只有 L3 企微失败才降级到短信(省钱)
+    sms_failed: [email]
+  budget:
+    sms_monthly_cny: 20               # 超预算自动停用短信,只走企微并发告警
+```
+
+- **幂等**:`notifications` 唯一约束 `(alert_id, channel, target)`
+- **重试**:指数退避 3 次(1s / 4s / 15s),仍失败走降级链
+- **回执**:短信回执是异步回调,需 `POST /notify/sms/receipt` 端点更新状态
+
+### 10.4 短信特殊说明
+
+四个通道里唯一需要外部审核的,单独规划:
+
+1. **签名 + 模板都要报备**,模板变量位有限(腾讯云是 `{1}{2}` 形式)
+2. 建议模板:`【签名】您的家人{1}在{2}回复中出现{3},请尽快联系确认。`
+   ——**三个变量,AI 的 reason 必须压缩到 12 字内**
+3. **vendor adapter 必须独立**:腾讯云 / 阿里云 / 华为云三家 SDK 与签名算法完全不同,
+   用适配器模式隔离,`SmsNotifier` 只依赖抽象的 `SmsVendor`
+4. **双保险**:`SMS_ENABLED` 开关 + 月度预算上限,防止模型误判导致短信轰炸
+5. 长期未收到回执的告警应升级(重发或换通道)
+
+---
+
+## 11. Web 后台(手机优先)
+
+### 11.1 为什么提前
+
+家属不会一直坐在电脑前,**下班后要能在手机上管理**。所以后台从 P3 提前,且**按手机浏览器设计**:
+大按钮、大字体、竖屏、只放四个功能。
+
+| 页面 | 功能 |
+|---|---|
+| 今日状态 | 今天推了没、老人回了没、有无未处理告警 |
+| 看反馈 | 按人按日期看原文与风险标记,语音显示转写结果 |
+| 暂停 / 恢复 | 某人生病住院或外出,一键停推送 |
+| 手动推一条 | 调试或临时补发 |
+
+### 11.2 鉴权
+
+- 生产:企业微信 OAuth2 网页授权(`snsapi_base` 静默授权拿 userid)→ 查 `members.role` 判权限
+  ⚠️ 需要在自建应用配置"网页授权及 JS-SDK"的可信域名,**必须用域名,纯 IP 不行**
+- 开发期:可用 `ADMIN_TOKEN` 口令绕开,不阻塞主流程
+
+### 11.3 权限矩阵
+
+| 功能 | admin(子女) | member(父母) |
+|---|---|---|
+| 查看自己收到的推送 | ✓ | ✓ |
+| 编辑模板 / 规则 / 素材 | ✓ | ✗ |
+| 查看全部反馈与告警 | ✓ | ✗ |
+| 标记告警已处理 | ✓ | ✗ |
+| 查看周报 | ✓ | ✗ |
+
+### 11.4 API 清单
+
+```
+POST /wecom/callback                 企业微信回调(消息 + 事件)
+GET  /health                         健康检查
+
+GET/POST/PUT/DELETE  /api/templates  模板 CRUD(steps 全量替换)
+POST /api/templates/{id}/preview     发给自己看效果
+GET/POST/PUT/DELETE  /api/rules      规则 CRUD
+GET/POST/PUT/DELETE  /api/materials  素材 CRUD
+POST /api/materials/import           导入候选 YAML
+POST /api/materials/check            手动触发链接体检
+
+GET  /api/feedbacks?member=&from=&to=&risk=   反馈查询
+GET  /api/alerts?resolved=0                    告警查询
+POST /api/alerts/{id}/resolve                  标记处理
+
+POST /api/push/{member_id}           手动触发一次推送
+POST /api/report/weekly              手动生成周报
+POST /notify/sms/receipt             短信回执回调
+```
+
+---
+
+## 12. 待确认事项(开工前需要家属提供)
+
+| # | 问题 | 影响 |
+|---|---|---|
+| 1 | ~~老人说方言吗?~~ **已确认:基本接近普通话,也有陕西关中话** | 已按此定型,见 8.2 节。主引擎 `16k_zh_dialect`,备用 `16k_zh_en` |
+| 2 | 家属(admin)的企业微信 userid 与手机号 | 告警接收人 |
+| 3 | 老人的企业微信通讯录账号(如 mom / dad) | `members.wecom_userid` |
+| 4 | 域名与子域 | 回调 + 后台 |
+| 5 | 短信服务商与签名主体 | L3 告警第二通道 |
+| 6 | 推送时间点(暂定 09:00 / 周日 10:00) | 按老人作息定 |
+| 7 | 素材平台优先级(小红书 / 抖音 / 视频号 / B站) | 决定素材从哪找 |
+
+---
+
+## 13. 企业微信配置步骤(人工做,代码不管)
+
+1. <https://work.weixin.qq.com> 注册,主体选「企业」或「其他组织」,组织名起得像家(如"家里")
+   ——**不认证也能用,免费**
+2. 「我的企业 → 企业信息」最下方复制**企业 ID**
+3. 「应用管理 → 自建 → 创建应用」
+   - 应用名 = 家里称呼(见第 9 节)
+   - **logo = 家属本人照片**(见第 9 节)
+   - 可见范围勾全家
+   - 记下 AgentId 和 Secret
+4. 应用详情最下方「开发者接口」→ 填**企业可信 IP** = 服务器公网 IP
+5. 「接收消息 → 设置 API 接收」
+   - URL:`https://<域名>/wecom/callback`
+   - Token、EncodingAESKey 点"随机获取",三个值填进 `.env`
+   - ⚠️ **等服务起来再点保存**,否则验证通不过
+6. 「我的企业 → 微信插件」
+   - 勾选 **允许成员在微信插件中接收和回复聊天消息**(漏了这步老人收不到)
+   - 让家人微信扫码关注
+7. 「通讯录 → 添加成员」:账号填 `mom` / `dad`,**必须与 `members.wecom_userid` 一致**
+
+---
+
+## 14. 开发任务清单
+
+### P0 · 推送引擎(先跑通"推得出去")
+
+- [ ] **P0-1** 建表:`app/schema.sql`,照第 4 节 DDL 建 10 张表 + 初始化迁移
+- [ ] **P0-2** 存储层:`app/store.py` 重写,CRUD 覆盖新表
+- [ ] **P0-3** 模板渲染:`app/template.py`,按 seq 渲染 steps
+- [ ] **P0-4** 素材轮换:`app/materials.py`,实现第 7.1 的查询
+- [ ] **P0-5** 规则引擎:`app/scheduler.py` 重写,第 6 节全部规则
+- [ ] **P0-6** 新版配置:`config/config.example.yaml` 加 templates / rules / materials 三段
+- [ ] **P0-7** 联调:手动触发一次推送,在微信里看到三段消息
+
+**验收**:`POST /api/push/mom` 能在微信收到"文本 + 视频链接 + 提问"三条,且重复触发不会产生第二条。
+
+### P1 · 反馈、告警、手机后台
+
+- [ ] **P1-1** 反馈采集:等待态(4h 超时)+ 归档进 `feedbacks`
+- [ ] **P1-2** ASR 接入:`app/asr.py` 已有腾讯云实现,需改成**双引擎兜底**(8.2 节)+ ffmpeg 重采样 8k→16k + 热词表
+- [ ] **P1-3** 风险打标:关键词先跑,再小模型判 L1/L2
+- [ ] **P1-4** 通知中心:`Notifier` 协议 + 企微实现 + 路由 + 幂等 + 账本
+- [ ] **P1-5** 降噪:冷却 30min、L2 单日上限 2 条
+- [ ] **P1-6** 手机优先 Web 后台:四个页面(第 11 节)
+
+**验收**:
+1. 老人发一条语音"我摔了一跤" → 转文字 → 判 L3 → 家属企微收到告警 → 后台能看到这条反馈
+2. **普通话和关中话各测一遍**:用方言说一条内容,确认 `16k_zh_dialect` 能正确转写;
+   再录一条"人声小、离手机半米远"的,确认 `16k_zh_en` 兜底能救回来
+3. 热词表生效验证:说一句含"膝盖"的话,确认不会识别成别的字
+
+### P2 · 收尾
+
+- [ ] **P2-1** 周报:每周日生成,含反馈汇总 / 情绪趋势 / 未回复次数 / 本月短信花费
+- [ ] **P2-2** 素材体检:每周 HEAD 检测 + 存活数不足告警
+- [ ] **P2-3** OAuth 鉴权(需域名)
+- [ ] **P2-4** 短信通道接入(含报备)
+
+---
+
+## 15. 参考实现
+
+仓库里已有一版 **V1 陪聊原型**(`app/` 下 8 个模块,24 项冒烟测试通过),
+**数据模型与 V2 不兼容,不要在上面改**,但以下部分可直接复用:
+
+| 文件 | 可复用内容 |
+|---|---|
+| `app/wecom.py` | 企业微信加解密(WXBizMsgCrypt)、access_token 缓存、主动发消息、素材下载——**已测试,直接用** |
+| `app/asr.py` | 腾讯云 ASR 签名与调用 |
+| `app/ai.py` | OpenAI 兼容客户端 |
+| `tests/smoke.py` | 测试写法参考(加解密、回调端到端、鉴权) |
+
+**V1 里不要复用**:`store.py`(表结构不同)、`bot.py`(陪聊模型)、`scheduler.py`(时间点触发,非星期规则)。
+
+---
+
+## 16. 禁止事项
+
+1. **不要**为了实现"普通微信好友"去接个人号 Hook / 协议自动化——封号永久且不可申诉
+2. **不要**在 V1 的表结构上改(不兼容)
+3. **不要**骗老人:被问是不是机器人,如实说
+4. **不要**半夜补发推送(misfire 超时就跳过,只记日志)
+5. **不要**动服务器上现有服务(words / 学习山峰 / gitweb / nginx 现有配置)
+6. **不要**使用 1060 / 1061 / 1062 / 2341 / 4999 端口
+
+---
+
+## 17. 协作规矩
+
+1. 高风险操作(动现有服务、删改数据、覆盖线上配置)**先列出来确认再执行**
+2. 只新增不修改不碰现有服务
+3. 端口冲突主动上报
+4. 改完 git commit,信息写清楚为什么改
+5. 诚实告知成本与风险,别粉饰
+6. 完成一个阶段更新 `HANDOFF.md` 的进度
diff --git a/requirements.txt b/requirements.txt
new file mode 100644 (file)
index 0000000..ce1e366
--- /dev/null
@@ -0,0 +1,10 @@
+# family-chat 运行依赖(与 spike 阶段一致:系统 Python + --break-system-packages 安装)
+cryptography>=41
+fastapi
+uvicorn
+sqlalchemy>=2.0
+apscheduler>=3.10
+pyyaml
+jinja2
+tencentcloud-sdk-python
+# 注:AI 打标用标准库 urllib 直接调用 OpenAI 兼容接口,无需 openai SDK
diff --git a/spike/README.md b/spike/README.md
new file mode 100644 (file)
index 0000000..8315dab
--- /dev/null
@@ -0,0 +1,100 @@
+# Spike 运行手册(解 R1 + R3)
+
+本目录是 **可行性探针**,目的是在写正式推送引擎之前,先把两个最高风险验证掉:
+- **R1**:企业微信「微信插件」能否让普通微信用户收发自建应用消息(端到端)。
+- **R3**:腾讯云一句话识别的返回里到底有没有 `Confidence` 字段(决定双引擎兜底逻辑是否成立)。
+
+代码只依赖标准库 + `cryptography`(已装),`tencentcloud` SDK 需 pip 安装。
+**凭据未到前,本手册标注「可现在做」的步骤都能跑;标「需凭据」的步骤等 .env 填好再跑。**
+
+---
+
+## 0. 准备(可现在做)
+
+```bash
+cd /home/chatbot
+python3 -m venv .venv && source .venv/bin/activate
+pip install -r requirements.txt
+cp .env.example .env          # 先留空,拿到凭据再填
+python3 wecom.py              # 自测:加密->解密 回环,无需凭据
+```
+
+预期:`[self-test] OK: 加密->解密 回环一致`
+
+---
+
+## 1. 解 R1(企业微信端到端)
+
+### 1.1 企业微信配置(家属在网页做,见 SPEC §13)
+1. 注册组织(不认证免费),组织名起得像家。
+2. 建自建应用:应用名=家里称呼、logo=家属照片、可见范围勾全家;记 AgentId / Secret。
+3. 应用详情填「企业可信 IP」= 服务器公网 IP。
+4. 微信插件:勾选「允许成员在微信插件中接收和回复」;家人微信扫码关注。
+5. 通讯录添加成员 `mom`,账号必须与 `WECOM_TEST_USER` 一致。
+
+### 1.2 填 .env(需凭据)
+把 CorpID / Secret / AgentId / Token / EncodingAESKey 填入 `.env`。
+
+### 1.3 步骤A:主动发消息(验证「推得出去」)
+```bash
+python3 spike/send.py
+```
+- 预期:`errcode=0`,且**老人微信里收到这条文本**。
+- 收不到 → 检查微信插件是否开启、可见范围、mom 账号拼写。
+
+### 1.4 步骤B:收回调(验证「回得进来」)
+1. 启动服务:`python3 spike/app.py`(监听 1063)。
+2. nginx 新增 server 块反代 `https://<域名>/wecom/callback` → `127.0.0.1:1063`。
+   ⚠️ 只加新 server 块,**不改现有 nginx 配置**。
+3. 企业微信后台「接收消息 → 设置 API 接收」填 `https://<域名>/wecom/callback`,
+   Token / EncodingAESKey 与 .env 一致;**等服务起来再点保存**。
+4. 用老人微信**回复一条消息**。
+5. 看 `spike/app.py` 日志是否打印 `[callback] 收到并解密: <内容>`。
+
+**R1 通过判定**:步骤A 老人收到 + 步骤B 服务端日志收到并解密成功。
+任一步失败 → R1 不成立,启动 SPEC R1 缓解里的回退方案(家属改用企业微信 App,或重新评估通道)。
+
+---
+
+## 2. 解 R3(ASR Confidence 字段实测)
+
+### 2.1 准备
+```bash
+pip install tencentcloud-sdk-python
+# 在 .env 填 TENCENT_SECRET_ID / TENCENT_SECRET_KEY
+# 准备一段 16k 单声道音频(ffmpeg 重采样)
+ffmpeg -i in.amr -ar 16000 -ac 1 out.wav
+```
+
+### 2.2 跑探针(需凭据)
+```bash
+python3 spike/asr_probe.py out.wav                 # 主引擎
+python3 spike/asr_probe.py out.wav 16k_zh_en       # 备用引擎
+```
+
+### 2.3 看结论
+脚本会打印原始返回,并给出:
+- `含 Confidence 字段: True/False`
+- `含 Result 字段: True/False`
+
+**R3 通过判定与代码落地规则**(SPEC 8.2):
+- 若 `Confidence` 存在 → `wecom.py`/asr 模块按阈值判断(<0.7 触发备用引擎重试)。
+- 若不存在 → 代码退化为「仅在结果为空时触发重试」。
+- 无论哪种,都必须在「结果为空」时重试,不能让转写直接废掉。
+
+---
+
+## 3. 出口闸门
+
+| 项 | 通过条件 | 失败动作 |
+|---|---|---|
+| R1 | A 收到 + B 解密成功 | 启动回退方案,不进 P0 |
+| R3 | 明确 Confidence 是否存在,并据此写死兜底分支 | 不可跳过,双引擎逻辑依赖此结论 |
+
+两者都过 → 回到 RISK_AND_PLAN.md 进入 **P0 推送引擎**。
+
+---
+
+## 4. 清理
+spike 跑完验证后,`spike/` 与 `wecom.py` 可保留作参考;若正式项目已引入 V1 的 `app/wecom.py`,
+则本 `wecom.py` 仅作 spike 记录,正式代码以 V1 那份为准(避免两套实现漂移)。
diff --git a/spike/app.py b/spike/app.py
new file mode 100644 (file)
index 0000000..958a0de
--- /dev/null
@@ -0,0 +1,65 @@
+"""R1 Spike 步骤B:启动回调接收服务,验证老人微信回复能被服务端收到并解密。
+
+启动: python3 spike/app.py   (默认端口 1063)
+企业微信后台「接收消息 → 设置 API 接收」URL 填: https://<域名>/wecom/callback
+
+注意:企业微信验证回调时要求服务端能公网访问,因此本服务需经 nginx 反代到 1063,
+      且回调 URL 必须 https(或企业微信支持 http,但建议 https)。
+"""
+import os
+import sys
+import xml.etree.ElementTree as ET
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from spike.common import get_config, make_crypt
+from fastapi import FastAPI, Request, Response
+
+cfg = get_config()
+crypt = make_crypt(cfg)
+app = FastAPI()
+
+
+def _xml_text(xml: str, tag: str):
+    try:
+        return ET.fromstring(xml).find(tag).text
+    except Exception:
+        return None
+
+
+@app.get("/wecom/callback")
+def verify(timestamp: str = "", nonce: str = "", msg_signature: str = "", echostr: str = ""):
+    # URL 验证:解密 echostr 并原样返回明文
+    try:
+        plain = crypt.decrypt_message(msg_signature, timestamp, nonce, echostr)
+    except Exception as e:
+        return Response("verify failed: %s" % e, status_code=400)
+    return Response(plain)
+
+
+@app.post("/wecom/callback")
+async def callback(timestamp: str = "", nonce: str = "", msg_signature: str = "",
+                   request: Request = None):
+    body = await request.body()
+    xml = body.decode("utf-8")
+    encrypt = _xml_text(xml, "Encrypt")
+    if not encrypt:
+        return Response("success")
+    try:
+        msg = crypt.decrypt_message(msg_signature, timestamp, nonce, encrypt)
+        print("[callback] 收到并解密:", msg)
+        # spike 阶段只记录,不自动回复(避免误用被动回复 5 秒约束;回老人用主动消息)
+    except Exception as e:
+        print("[callback] 解密失败:", e)
+    return Response("success")  # 企业微信要求返回 success
+
+
+@app.get("/health")
+def health():
+    return {"ok": True}
+
+
+if __name__ == "__main__":
+    import uvicorn
+    port = int(os.environ.get("SPIKE_PORT", "1063"))
+    print("spike callback listening on :%d  (nginx 反代到 /wecom/callback)" % port)
+    uvicorn.run(app, host="0.0.0.0", port=port)
diff --git a/spike/asr_probe.py b/spike/asr_probe.py
new file mode 100644 (file)
index 0000000..5809090
--- /dev/null
@@ -0,0 +1,92 @@
+"""
+R3 接口实测:调用腾讯云一句话识别,打印原始返回,确认是否含 Confidence 字段。
+
+依赖: pip install tencentcloud-sdk-python
+配置: 在 .env 增加 TENCENT_SECRET_ID / TENCENT_SECRET_KEY
+用法: python3 spike/asr_probe.py <音频路径或url> [engine]
+  engine 默认 16k_zh_dialect(主引擎);可传 16k_zh_en 测备用引擎
+
+音频准备(一句话识别只收 16k):
+  ffmpeg -i in.amr -ar 16000 -ac 1 out.wav
+  (时长 ≤ 60s、文件 ≤ 3MB;amr 也支持但采样率必须 16k)
+"""
+import os
+import sys
+import json
+import base64
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from spike.common import load_dotenv
+
+
+def main():
+    load_dotenv()
+    sid = os.environ.get("TENCENT_SECRET_ID")
+    skey = os.environ.get("TENCENT_SECRET_KEY")
+    if not sid or not skey:
+        raise SystemExit("缺少 TENCENT_SECRET_ID / TENCENT_SECRET_KEY")
+    if len(sys.argv) < 2:
+        raise SystemExit("用法: asr_probe.py <音频文件或url> [engine]")
+    path = sys.argv[1]
+    engine = sys.argv[2] if len(sys.argv) > 2 else "16k_zh_dialect"
+
+    try:
+        from tencentcloud.common import credential
+        from tencentcloud.common.profile.client_profile import ClientProfile
+        from tencentcloud.common.profile.http_profile import HttpProfile
+        from tencentcloud.asr.v20190614 import asr_client, models
+    except ImportError:
+        raise SystemExit("请先安装: pip install tencentcloud-sdk-python")
+
+    cred = credential.Credential(sid, skey)
+    hp = HttpProfile()
+    hp.reqTimeout = 10
+    cp = ClientProfile()
+    cp.httpProfile = hp
+    client = asr_client.AsrClient(cred, "ap-guangzhou", cp)
+
+    # 注意: 该 SDK 版本的 SentenceRecognitionRequest 序列化有 bug(如 ChannelNum→HannelNum),
+    # 故直接用 client.call 传原始字典,绕过有 bug 的请求模型。正式 asr.py 也将采用此方式。
+    if path.startswith("http://") or path.startswith("https://"):
+        voice_format = path.rsplit(".", 1)[-1].lower() if "." in path else "wav"
+        params = {
+            "EngSerViceType": engine,
+            "SourceType": 0,            # 0 = 音频 URL
+            "VoiceFormat": voice_format,
+            "SubServiceType": 2,        # 2 = 一句话识别
+            "ProjectId": 0,
+            "UsrAudioKey": "spike",
+            "Url": path,
+        }
+    else:
+        with open(path, "rb") as f:
+            raw_bytes = f.read()
+        voice_format = path.rsplit(".", 1)[-1].lower() if "." in path else "wav"
+        params = {
+            "EngSerViceType": engine,
+            "SourceType": 1,            # 1 = 上传音频数据
+            "VoiceFormat": voice_format,
+            "SubServiceType": 2,        # 2 = 一句话识别
+            "ProjectId": 0,
+            "UsrAudioKey": "spike",
+            "Data": base64.b64encode(raw_bytes).decode("ascii"),
+            "DataLen": len(raw_bytes),
+        }
+
+    try:
+        resp_content = client.call("SentenceRecognition", params)
+    except Exception as e:
+        print("=== 调用返回错误(仍可确认连通/鉴权) ===")
+        print(e)
+        return
+
+    raw = json.loads(resp_content.decode("utf-8"))
+    print("=== 原始返回 ===")
+    print(json.dumps(raw, ensure_ascii=False, indent=2))
+    print("=== R3 结论 ===")
+    print("含 Confidence 字段:", "Confidence" in raw)
+    print("含 Result 字段:", "Result" in raw)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/spike/common.py b/spike/common.py
new file mode 100644 (file)
index 0000000..46c53bb
--- /dev/null
@@ -0,0 +1,37 @@
+"""spike 共享:从 .env 或环境变量加载配置,构建 WeComClient / WXBizMsgCrypt。"""
+import os
+
+
+def load_dotenv(path=".env"):
+    if not os.path.exists(path):
+        return
+    with open(path, encoding="utf-8") as f:
+        for line in f:
+            line = line.strip()
+            if not line or line.startswith("#") or "=" not in line:
+                continue
+            k, v = line.split("=", 1)
+            os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
+
+
+def get_config():
+    load_dotenv()
+    required = ["WECOM_CORP_ID", "WECOM_SECRET", "WECOM_AGENT_ID",
+                "WECOM_TOKEN", "WECOM_AES_KEY", "WECOM_RECEIVE_ID"]
+    cfg = {k: os.environ.get(k, "") for k in required}
+    missing = [k for k, v in cfg.items() if not v]
+    if missing:
+        raise SystemExit("缺少环境变量: %s\n请复制 .env.example 为 .env 并填写" % missing)
+    cfg["WECOM_AGENT_ID"] = int(cfg["WECOM_AGENT_ID"])
+    cfg["WECOM_TEST_USER"] = os.environ.get("WECOM_TEST_USER", "mom")
+    return cfg
+
+
+def make_client(cfg):
+    from wecom import WeComClient
+    return WeComClient(cfg["WECOM_CORP_ID"], cfg["WECOM_SECRET"], cfg["WECOM_AGENT_ID"])
+
+
+def make_crypt(cfg):
+    from wecom import WXBizMsgCrypt
+    return WXBizMsgCrypt(cfg["WECOM_TOKEN"], cfg["WECOM_AES_KEY"], cfg["WECOM_RECEIVE_ID"])
diff --git a/spike/sample.wav b/spike/sample.wav
new file mode 100644 (file)
index 0000000..b312ef1
Binary files /dev/null and b/spike/sample.wav differ
diff --git a/spike/send.py b/spike/send.py
new file mode 100644 (file)
index 0000000..da550a7
--- /dev/null
@@ -0,0 +1,30 @@
+"""R1 Spike 步骤A:向 mom 主动发一条文本,验证企业微信通道可达。
+
+跑通条件:
+  - 已完成 SPEC §13 的企业微信配置(含微信插件开启、mom 已在通讯录且可见范围勾选)
+  - 已填好 .env
+用法:python3 spike/send.py
+"""
+import os
+import sys
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from spike.common import get_config, make_client
+
+
+def main():
+    cfg = get_config()
+    client = make_client(cfg)
+    target = cfg.get("WECOM_TEST_USER", "mom")
+    text = "[spike] 通道验证 %s" % time.strftime("%H:%M:%S")
+    res = client.send_text(target, text)
+    print("send_text ->", res)
+    if res.get("errcode") == 0:
+        print("OK: 已向 %s 发送,请在微信里确认是否收到(需已开启微信插件)" % target)
+    else:
+        print("FAIL: 发送失败,见 errmsg;常见原因:agentid 错 / 可见范围未勾 mom / 微信插件未开")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/test_edit_feature.py b/test_edit_feature.py
new file mode 100644 (file)
index 0000000..dfec4d0
--- /dev/null
@@ -0,0 +1,88 @@
+"""验证脚本:登录后台 API,测试模板/规则新字段、图片上传、会话推进逻辑。"""
+import json
+import sys
+
+import requests
+
+from app.config import get_config
+
+BASE = "http://127.0.0.1:1064"
+cfg = get_config()
+
+s = requests.Session()
+r = s.post(BASE + "/login", json={"password": cfg.admin_password}, allow_redirects=False)
+print("login:", r.status_code)
+assert r.status_code in (302, 200), "登录失败"
+
+# 1. 模板列表(新字段)
+r = s.get(BASE + "/api/templates")
+tpls = r.json()
+print("\n[templates] status=%d count=%d" % (r.status_code, len(tpls)))
+t0 = tpls[0]["steps"][0]
+assert "trigger_type" in t0 and "reply_timeout_sec" in t0, "模板步骤缺少新字段"
+print("  step new fields OK:", t0)
+
+# 2. 新建模板(文字+图片步骤,含发送时机/超时)
+payload = {
+    "name": "测试-编辑功能验证",
+    "steps": [
+        {"seq": 1, "type": "text", "content": "妈,早上好呀", "trigger_type": "delay", "delay_sec": 0, "reply_timeout_sec": None},
+        {"seq": 2, "type": "text", "content": "记得吃早饭哦", "trigger_type": "after_reply", "delay_sec": 0, "reply_timeout_sec": 1800},
+        {"seq": 3, "type": "text", "content": "那我不打扰你啦", "trigger_type": "delay", "delay_sec": 60, "reply_timeout_sec": None},
+    ],
+}
+r = s.post(BASE + "/api/templates", json=payload)
+print("\n[create template]", r.status_code, r.json())
+tid = r.json()["id"]
+
+# 3. 图片上传(1x1 PNG)
+import base64
+png = base64.b64decode(
+    "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
+)
+r = s.post(BASE + "/api/upload/image?name=test.png", data=png)
+print("[upload image]", r.status_code, r.json())
+fn = r.json()["filename"]
+
+# 图片访问
+r = s.get(BASE + "/api/media/images/" + fn)
+print("[fetch image]", r.status_code, r.headers.get("content-type"))
+
+# 4. 新建规则(每月 1 号 08:30,指定成员,带起止日期)
+r = s.get(BASE + "/api/dashboard")
+member_id = [m for m in r.json()["members"] if m["role"] == "member"][0]["id"]
+rule_payload = {
+    "name": "测试-月度规则",
+    "template_id": tid,
+    "member_ids": str(member_id),
+    "period_type": "monthly",
+    "days": "*",
+    "month_day": 1,
+    "at_time": "08:30",
+    "start_date": "2026-09-01",
+    "end_date": "2026-12-31",
+}
+r = s.post(BASE + "/api/rules", json=rule_payload)
+print("\n[create rule]", r.status_code, r.json())
+rid = r.json()["id"]
+
+# 5. 规则列表(验证回显)
+r = s.get(BASE + "/api/rules")
+rules = r.json()
+mine = [x for x in rules if x["id"] == rid][0]
+print("[rule echo]", json.dumps(mine, ensure_ascii=False))
+assert mine["period_type"] == "monthly" and mine["month_day"] == 1
+assert mine["member_names"], "成员名未解析"
+
+# 6. 页面可访问
+for path in ["/", "/manage", "/rules"]:
+    r = s.get(BASE + path)
+    print("[page %s]" % path, r.status_code, len(r.text), "bytes")
+
+# 7. 清理测试数据(规则→模板,注意 FK)
+r = s.delete(BASE + "/api/rules/%d" % rid)
+print("\n[cleanup rule]", r.status_code)
+r = s.delete(BASE + "/api/templates/%d" % tid)
+print("[cleanup template]", r.status_code)
+
+print("\nALL TESTS PASSED")
diff --git a/test_session_engine.py b/test_session_engine.py
new file mode 100644 (file)
index 0000000..d5e3883
--- /dev/null
@@ -0,0 +1,153 @@
+"""会话推进引擎验证(mock 微信客户端,不实际发消息)。
+场景:3 步模板 = [文字(立即), 文字(等回复,首条超时30min兜底), 文字(延时10s)]
+验证:首条立即发 → 等 reply 状态 + 超时任务 → 回复推进第2条 → 延时推进第3条 → 会话完成
+"""
+import time
+
+import app.scheduler as sched
+from app.db import (init_db, get_sessionmaker, session_scope, now_ts,
+                    Member, Family, Template, TemplateStep, TemplateSession, Push)
+from app import scheduler
+
+init_db()
+
+# 测试进程内启动调度器(服务进程本来就有,这里为独立验证)
+scheduler.get_scheduler().start()
+
+# ---- mock 微信客户端 ----
+sent_log = []
+
+
+class FakeClient:
+    def send_text(self, userid, text):
+        sent_log.append(("text", userid, text))
+        return {"errcode": 0}
+
+    def send_news(self, userid, title, url, desc="", picurl=""):
+        sent_log.append(("news", userid, title))
+        return {"errcode": 0}
+
+    def send_image(self, userid, media_id):
+        sent_log.append(("image", userid, media_id))
+        return {"errcode": 0}
+
+
+scheduler.make_client_orig = None
+import app.wecom as wecom_mod
+wecom_mod.make_client = lambda cfg=None: FakeClient()
+# scheduler 里是函数内 import,patch 模块函数即可
+sched_wecom = None
+# send_template_to_member / _advance_session 内部 `from .wecom import make_client`
+# 每次 import 都会拿到 wecom 模块属性 → 已被 patch
+
+# ---- 造测试数据 ----
+with session_scope() as sess:
+    fam = sess.query(Family).first()
+    fid = fam.id
+    m = Member(family_id=fid, wecom_userid="test_session_user", name="测试成员",
+               role="member", persona="elder", enabled=1, created_at=now_ts())
+    sess.add(m)
+    sess.flush()
+    mid = m.id
+    t = Template(family_id=fid, name="测试-会话推进", enabled=1,
+                 created_at=now_ts(), updated_at=now_ts())
+    sess.add(t)
+    sess.flush()
+    tid = t.id
+    sess.add_all([
+        TemplateStep(template_id=tid, seq=1, type="text", content="第一条",
+                     delay_sec=0, trigger_type="delay", reply_timeout_sec=1800),
+        TemplateStep(template_id=tid, seq=2, type="text", content="第二条",
+                     delay_sec=0, trigger_type="after_reply", reply_timeout_sec=None),
+        TemplateStep(template_id=tid, seq=3, type="text", content="第三条",
+                     delay_sec=2, trigger_type="delay", reply_timeout_sec=None),
+    ])
+
+try:
+    sm = get_sessionmaker()
+    sess = sm()
+    member = sess.get(Member, mid)
+    sess.close()
+
+    # 1. 首条推送
+    res = scheduler.send_template_to_member(member, tid, family_id=fid, deferrable=False)
+    print("push:", res)
+    assert res.get("ok"), res
+
+    sm = get_sessionmaker()
+    sess = sm()
+    s = sess.query(TemplateSession).filter(TemplateSession.member_id == mid,
+                                           TemplateSession.status == "active").first()
+    assert s, "会话未创建"
+    sid, cur, waiting = s.id, s.cur_step, s.waiting_for
+    sess.close()
+    print("session: id=%s cur_step=%d waiting_for=%r" % (sid, cur, waiting))
+    assert cur == 0 and waiting == "reply", "应停在等回复态"
+
+    jobs = {j.id: str(j.trigger) for j in scheduler.get_scheduler().get_jobs()
+            if j.id.startswith(("step-%d-" % sid, "tmout-%d-" % sid))}
+    print("jobs:", jobs)
+    assert any(j.startswith("tmout-%d-" % sid) for j in jobs), "超时兜底任务未注册"
+    assert sent_log[-1] == ("text", "test_session_user", "第一条")
+
+    # 2. 模拟回复 → 立即推进第 2 条
+    scheduler.on_member_reply(mid)
+    time.sleep(0.3)
+    sm = get_sessionmaker()
+    sess = sm()
+    s = sess.get(TemplateSession, sid)
+    print("after reply: cur_step=%d waiting=%r status=%r" % (s.cur_step, s.waiting_for, s.status))
+    assert s.cur_step == 1 and s.status == "active"
+    sess.close()
+    assert sent_log[-1] == ("text", "test_session_user", "第二条"), sent_log
+    # 第3条是 delay 2s → 应有 step 任务
+    jobs = [j.id for j in scheduler.get_scheduler().get_jobs()
+            if j.id.startswith("step-%d-" % sid)]
+    assert jobs, "延时推进任务未注册"
+    print("delay job:", jobs)
+
+    # 3. 等延时到期 → 第 3 条 → 会话完成
+    time.sleep(2.5)
+    sm = get_sessionmaker()
+    sess = sm()
+    s = sess.get(TemplateSession, sid)
+    print("after delay: cur_step=%d status=%r" % (s.cur_step, s.status))
+    assert s.cur_step == 2 and s.status == "done", "会话未完成"
+    sess.close()
+    assert sent_log[-1] == ("text", "test_session_user", "第三条"), sent_log
+
+    # 4. 超时兜底路径:重新推一次,不回复,手动触发超时任务
+    res = scheduler.send_template_to_member(member, tid, family_id=fid, deferrable=False)
+    sm = get_sessionmaker()
+    sess = sm()
+    s = sess.query(TemplateSession).filter(TemplateSession.member_id == mid,
+                                           TemplateSession.status == "active").first()
+    sid2 = s.id
+    sess.close()
+    n_before = len(sent_log)
+    scheduler._advance_session(sid2, 0, "timeout")  # 模拟超时触发
+    sm = get_sessionmaker()
+    sess = sm()
+    s = sess.get(TemplateSession, sid2)
+    print("after timeout: cur_step=%d waiting=%r" % (s.cur_step, s.waiting_for))
+    assert s.cur_step == 1, "超时未推进"
+    sess.close()
+    assert len(sent_log) == n_before + 1 and sent_log[-1][2] == "第二条"
+    # 清理:中止会话
+    scheduler._finish_session(sid2)
+
+    print("\nSESSION ENGINE ALL PASSED")
+    print("sent order:", [x[2] for x in sent_log])
+
+finally:
+    # ---- 清理测试数据 ----
+    from app.db import Feedback, WaitState
+    with session_scope() as sess:
+        sess.query(TemplateSession).filter(TemplateSession.member_id == mid).delete()
+        sess.query(Push).filter(Push.member_id == mid).delete()
+        sess.query(WaitState).filter(WaitState.member_id == mid).delete()
+        sess.query(TemplateStep).filter(TemplateStep.template_id == tid).delete()
+        sess.query(Template).filter(Template.id == tid).delete()
+        sess.query(Member).filter(Member.id == mid).delete()
+    print("test data cleaned")
+    scheduler.get_scheduler().shutdown(wait=False)
diff --git a/wecom.py b/wecom.py
new file mode 100644 (file)
index 0000000..53444cc
--- /dev/null
+++ b/wecom.py
@@ -0,0 +1,179 @@
+"""
+wecom.py — 企业微信最小工具(spike 用,从零实现)
+
+仅依赖标准库 + cryptography。提供:
+  - WXBizMsgCrypt:回调消息加解密(腾讯官方算法:AES-256-CBC + PKCS7(block=32))
+  - WeComClient:access_token 缓存、主动发文本、下载媒体
+  - 末尾 self-test:无需任何凭据,验证「加密→解密」回环正确
+
+注意:这是 spike 用的最小实现。若正式项目已有 V1 的 app/wecom.py(SPEC §15 说已测试),
+      应以那份为准;本文件仅用于 spike 阶段,不要长期维护两份实现。
+"""
+import base64
+import hashlib
+import json
+import os
+import struct
+import time
+import urllib.parse
+import urllib.request
+import logging
+
+from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+
+logger = logging.getLogger("wecom")
+
+_BLOCK = 32  # 企业微信 PKCS7 块大小
+
+
+def _pkcs7_pad(data: bytes) -> bytes:
+    pad = _BLOCK - (len(data) % _BLOCK)
+    if pad == 0:
+        pad = _BLOCK
+    return data + bytes([pad]) * pad
+
+
+def _pkcs7_unpad(data: bytes) -> bytes:
+    pad = data[-1]
+    if pad < 1 or pad > _BLOCK:
+        raise ValueError("bad pkcs7 padding")
+    return data[:-pad]
+
+
+class WXBizMsgCrypt:
+    def __init__(self, token: str, encoding_aes_key: str, receive_id: str):
+        if len(encoding_aes_key) != 43:
+            raise ValueError("EncodingAESKey 必须为 43 字符")
+        self.token = token
+        self.receive_id = receive_id
+        self.aes_key = base64.b64decode(encoding_aes_key + "=")  # 32 bytes
+        self.iv = self.aes_key[:16]
+
+    # ---- 签名 ----
+    def _signature(self, *parts: str) -> str:
+        sha = hashlib.sha1()
+        sha.update("".join(sorted(parts)).encode("utf-8"))
+        return sha.hexdigest()
+
+    def verify_signature(self, msg_signature: str, timestamp: str, nonce: str, encrypt: str) -> bool:
+        return self._signature(self.token, timestamp, nonce, encrypt) == msg_signature
+
+    # ---- 解密 ----
+    def decrypt(self, encrypt: str) -> str:
+        ct = base64.b64decode(encrypt)
+        cipher = Cipher(algorithms.AES(self.aes_key), modes.CBC(self.iv))
+        dec = cipher.decryptor()
+        plain = dec.update(ct) + dec.finalize()
+        plain = _pkcs7_unpad(plain)
+        # plain = random(16) + len(4) + msg + receive_id
+        content = plain[16:]
+        msg_len = struct.unpack(">I", content[:4])[0]
+        msg = content[4:4 + msg_len]
+        rid = content[4 + msg_len:]
+        if rid.decode("utf-8") != self.receive_id:
+            raise ValueError("receive_id 不匹配")
+        return msg.decode("utf-8")
+
+    def decrypt_message(self, msg_signature: str, timestamp: str, nonce: str, encrypt: str) -> str:
+        if not self.verify_signature(msg_signature, timestamp, nonce, encrypt):
+            raise ValueError("签名校验失败")
+        return self.decrypt(encrypt)
+
+    # ---- 加密(被动回复用,spike 一般只记录不回复) ----
+    def encrypt(self, text: str, nonce: str):
+        rand = os.urandom(16)
+        text_b = text.encode("utf-8")
+        msg = rand + struct.pack(">I", len(text_b)) + text_b + self.receive_id.encode("utf-8")
+        msg = _pkcs7_pad(msg)
+        cipher = Cipher(algorithms.AES(self.aes_key), modes.CBC(self.iv))
+        enc = cipher.encryptor()
+        out = enc.update(msg) + enc.finalize()
+        encrypt = base64.b64encode(out).decode("ascii")
+        ts = str(int(time.time()))
+        sig = self._signature(self.token, ts, nonce, encrypt)
+        return encrypt, sig, ts
+
+    def encrypt_reply(self, reply_xml: str, nonce: str) -> str:
+        encrypt, sig, ts = self.encrypt(reply_xml, nonce)
+        return (
+            "<xml><Encrypt><![CDATA[%s]]></Encrypt>"
+            "<MsgSignature><![CDATA[%s]]></MsgSignature>"
+            "<TimeStamp><![CDATA[%s]]></TimeStamp>"
+            "<Nonce><![CDATA[%s]]></Nonce></xml>"
+            % (encrypt, sig, ts, nonce)
+        )
+
+
+class WeComClient:
+    """主动调用:access_token 缓存 + 发文本 + 下载媒体。"""
+
+    def __init__(self, corp_id: str, secret: str, agent_id: int):
+        self.corp_id = corp_id
+        self.secret = secret
+        self.agent_id = agent_id
+        self._token = None
+        self._expire_at = 0
+
+    def get_access_token(self, force: bool = False) -> str:
+        if self._token and not force and time.time() < self._expire_at - 60:
+            return self._token
+        url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?" + urllib.parse.urlencode(
+            {"corpid": self.corp_id, "corpsecret": self.secret})
+        data = self._get_json(url)
+        if data.get("errcode", 0) != 0:
+            raise RuntimeError("gettoken 失败: %s" % data)
+        self._token = data["access_token"]
+        self._expire_at = time.time() + data.get("expires_in", 7200)
+        return self._token
+
+    def send_text(self, userid: str, text: str) -> dict:
+        token = self.get_access_token()
+        url = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + token
+        body = {
+            "touser": userid,
+            "msgtype": "text",
+            "agentid": self.agent_id,
+            "text": {"content": text},
+        }
+        return self._post_json(url, body)
+
+    def download_media(self, media_id: str, save_path: str) -> str:
+        token = self.get_access_token()
+        url = "https://qyapi.weixin.qq.com/cgi-bin/media/get?" + urllib.parse.urlencode(
+            {"access_token": token, "media_id": media_id})
+        req = urllib.request.Request(url)
+        with urllib.request.urlopen(req, timeout=10) as resp:
+            data = resp.read()
+        with open(save_path, "wb") as f:
+            f.write(data)
+        return save_path
+
+    @staticmethod
+    def _get_json(url: str) -> dict:
+        with urllib.request.urlopen(url, timeout=10) as resp:
+            return json.loads(resp.read().decode("utf-8"))
+
+    @staticmethod
+    def _post_json(url: str, body: dict) -> dict:
+        data = json.dumps(body, ensure_ascii=False).encode("utf-8")
+        req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
+        with urllib.request.urlopen(req, timeout=10) as resp:
+            return json.loads(resp.read().decode("utf-8"))
+
+
+def _self_test():
+    aes_key = base64.b64encode(os.urandom(32)).decode("ascii")[:43]  # 43 字符
+    rid = "wwspikecorpid"
+    crypt = WXBizMsgCrypt("spike_token", aes_key, rid)
+    sample = "<xml><Content><![CDATA[妈,今天活动没?]]></Content></xml>"
+    nonce = "12345"
+    encrypt, sig, ts = crypt.encrypt(sample, nonce)
+    assert crypt.verify_signature(sig, ts, nonce, encrypt), "签名失败"
+    out = crypt.decrypt_message(sig, ts, nonce, encrypt)
+    assert out == sample, "解密结果不符: %s" % out
+    print("[self-test] OK: 加密->解密 回环一致")
+
+
+if __name__ == "__main__":
+    logging.basicConfig(level=logging.INFO)
+    _self_test()