+"""
+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()