Files
server-core/app/training/main.py
T
Pine b9b5e2e2ff feat(task): 任务中心扫码接单(多端) —— 服务端任务状态机/端口/迁移
- Task 加 task_code/tags/display_priority/claimed_by/claimed_at/doing_at;新增 TaskClaim 流水表。
- TaskService 加 claimed/doing/completed 状态机(claim/start_doing/complete),grab 并入 claim。
- TaskRepository 加 list_published/get_by_code/update/claim/set_doing + TaskClaimRepository;挂 Database。
- 端口:
  · opc  /tasks/grab-by-code、/tasks/{id}/doing、/tasks/{id}/complete
  · operator POST /tasks(auto task_code) + PATCH /tasks/:id
  · park  /park/api/tasks(大屏展示,含 scan_payload 二维码载荷)
  · training /api/tasks/claim-by-code、/api/tasks/my(小程序 C 端账号→OPC 身份 find-or-create 领单)
- 迁移 0008(tasks 加列 + task_claims)已应用+stamp;seed 补 task_code/tags/grab demo。
- tests/test_task_claim.py(自包含内存库, 状态机+流水+list_published), 直接 async 校验通过。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 18:40:35 +08:00

994 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""云超服 OPC 培训站 · FastAPI 子应用(已迁入 server-core 总后端)
由 server-core/dispatcher.py 统一对外(opc.pinesound.cn):`/api/*` 路由到本子应用,
`/auth`、`/opc`、`/admin` 等由平台应用(app.main)服务。独立使用 data/opc.db 业务库。
"""
import asyncio
import json
import os
import secrets
import time
from typing import Optional
from fastapi import FastAPI, Header, Request, HTTPException, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from . import db, opc_engine, policy_engine, policy_data, survey_data
from .auth import hash_password, make_token, verify_token, send_sms_code, check_sms_code, PHONE_RE
from .opc_engine import SECTIONS, AXIS_NAMES, ADAPT_LABELS, PROFILES
from contextlib import asynccontextmanager
# 平台域(任务/OPC 身份)访问:复用唯一总库 + 四层 Repository
from ..infrastructure.db import AsyncSessionLocal
from ..infrastructure.repositories import Database
from ..services.task_service import TaskService
from ..api.routers.auth import _ensure_opc_identity
@asynccontextmanager
async def lifespan(app):
# 建表/种子由 alembic + scripts/db/seed.py 非运行态完成
yield
app = FastAPI(title="云超服 OPC 培训站后端", version="0.1", lifespan=lifespan)
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # server-core 根
# ---- 上传目录 + 静态挂载(供活动封面图等) ----
UPLOAD_DIR = os.path.join(ROOT, "serverdata", "uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")
# ---- 微信小程序配置(真实登录) ----
# 微信 AppID / AppSecret 由 server-core 环境变量或 server-core/.env 提供。
def _load_dotenv():
"""轻量 .env 加载(server-core/.env),不依赖 python-dotenv"""
env_path = os.path.join(ROOT, ".env")
if os.path.exists(env_path):
with open(env_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.partition("=")
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
_load_dotenv()
WX_APPID = os.environ.get("WX_APPID", "")
WX_SECRET = os.environ.get("WX_SECRET", "")
WX_HAS_CONFIG = bool(WX_APPID and WX_SECRET)
# 站点公开基础地址(用于把 /uploads 相对路径补全为完整 HTTPS 地址,供小程序/网页加载图片)
PUBLIC_BASE = os.environ.get("PUBLIC_BASE", "https://opc.pinesound.cn").rstrip("/")
def abs_url(u):
"""把相对路径(如 /uploads/x.png)补全为绝对地址;已是完整 URL 则原样返回"""
if not u:
return u
u = str(u).strip()
if u.startswith("http://") or u.startswith("https://"):
return u
return PUBLIC_BASE + u
import httpx # 微信 code2session 请求用
async def _wx_code2session(code: str):
"""用登录 code 换 openid/session_key(真实微信登录核心)"""
url = (
"https://api.weixin.qq.com/sns/jscode2session"
f"?appid={WX_APPID}&secret={WX_SECRET}&js_code={code}&grant_type=authorization_code"
)
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(url)
return r.json() # {openid, session_key} 或 {errcode, errmsg}
async def _wx_get_access_token():
url = f"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={WX_APPID}&secret={WX_SECRET}"
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(url)
return r.json().get("access_token")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
def now_iso():
return db.now_iso()
def require_auth(authorization: str):
token = (authorization or "").removeprefix("Bearer ").strip()
payload = verify_token(token)
if not payload:
raise HTTPException(status_code=401, detail="未授权或登录已过期")
return payload
# ================= 工具 =================
def read_answers(body: dict, key="answers"):
a = body.get(key)
return a if isinstance(a, dict) else {}
def _parse_topics(t):
"""accounts.topics 为 JSON 文本 → list,解析失败兜底 []"""
if not t:
return []
if isinstance(t, list):
return t
try:
v = json.loads(t)
return v if isinstance(v, list) else []
except Exception:
return []
def _count_enrolled(event_id):
"""已报名人数 = 该活动全部报名数(含审核中)"""
conn = db.get_conn()
n = conn.execute("SELECT COUNT(*) AS c FROM bookings WHERE event_id=?", (event_id,)).fetchone()["c"]
conn.close()
return n
def _user_payload(acct):
"""统一构建 /me 与 /update-profile 返回的用户资料(含报名资料)"""
return {
"username": acct["username"],
"name": acct.get("name") or acct["username"],
"avatar": abs_url(acct.get("avatar")),
"phone": acct.get("phone") or "",
"phoneBound": bool(acct.get("phone_bound")),
"status": acct.get("status_label") or "",
"topics": _parse_topics(acct.get("topics")),
"source": acct.get("source") or "",
}
def jsonify(obj):
# 处理 datetime 等 → dict
return obj
# ================= 域名验证文件(微信/腾讯域名校验,须在根路径返回) =================
@app.get("/SpXvScDiDT.txt")
async def domain_verify():
from fastapi.responses import Response
return Response(content="d813c7bbab566807a833fbc3b0e20a9c", media_type="text/plain")
# ================= 认证 =================
@app.get("/api/health")
async def health():
return {"ok": True, "service": "opc-fastapi"}
@app.post("/api/auth/register")
async def register(req: Request):
b = await req.json()
username = str(b.get("username", "")).strip()
password = str(b.get("password", ""))
name = str(b.get("name", "")).strip()
contact = str(b.get("contact", "")).strip()
if not username or len(username) < 2:
raise HTTPException(400, "账号至少 2 个字符")
if len(password) < 4:
raise HTTPException(400, "密码至少 4 个字符")
if await asyncio.to_thread(db.fetch_one, "accounts", username=username):
raise HTTPException(400, "账号已存在,请直接登录")
entry = {"id": db.gen_id("U"), "username": username, "password": hash_password(password),
"name": name or username, "contact": contact, "identities": json.dumps(["opc_member|certified"]),
"created_at": now_iso()}
await asyncio.to_thread(db.insert, "accounts", entry)
return {"ok": True, "token": make_token(username), "username": username, "name": name or username}
@app.post("/api/auth/login")
async def login(req: Request):
b = await req.json()
username = str(b.get("username", "")).strip()
password = str(b.get("password", ""))
if username == "pine":
if password == "123456":
return {"ok": True, "token": make_token("pine"), "username": "pine", "name": "Pine"}
raise HTTPException(401, "密码错误,请重新输入")
acct = await asyncio.to_thread(db.fetch_one, "accounts", username=username)
if not acct:
raise HTTPException(404, "账号不存在,请先注册")
if acct["password"] != hash_password(password):
raise HTTPException(401, "密码错误,请重新输入")
return {"ok": True, "token": make_token(username), "username": username, "name": acct.get("name") or username}
# ============================= 扫码接单(任务中心)=============================
@app.post("/api/tasks/claim-by-code")
async def tasks_claim_by_code(request: Request):
"""扫码接单:task_code → 领单(published → claimed,记录 claimed_by/流水)。"""
body = await request.json()
code = str(body.get("task_code") or body.get("code") or "").strip()
if not code:
raise HTTPException(status_code=400, detail="缺少任务码")
auth = request.headers.get("Authorization", "")
async with AsyncSessionLocal() as session:
pdb = Database(session=session)
payload = require_auth(auth)
username = payload.get("username") or payload.get("sub") or ""
acct = await asyncio.to_thread(db.fetch_one, "accounts", username=username)
if not acct:
raise HTTPException(status_code=401, detail="请先登录")
key = (acct.get("phone") or "").strip() or username
user = await pdb.users.get_by_username(key)
if user is None:
user = await pdb.users.create(
key, password=secrets.token_hex(16),
phone=(acct.get("phone") or "").strip() or "",
nickname=(acct.get("name") or "").strip() or "",
role="opc_member", source="mini_program", auth_type="phone",
)
identity = await _ensure_opc_identity(pdb, user["id"])
actor = dict(user)
actor["id"] = user["id"]
actor["username"] = user["username"]
actor["nickname"] = user.get("nickname") or user.get("username")
actor["port"] = "opc"
actor["role"] = identity.get("role") or "opc_member"
task = await TaskService(pdb).claim(code, actor, source="scan")
return {"ok": True, "task": task}
@app.get("/api/tasks/my")
async def tasks_my_tasks(request: Request):
"""我的接单(扫码接单领取的任务)。"""
auth = request.headers.get("Authorization", "")
async with AsyncSessionLocal() as session:
pdb = Database(session=session)
payload = require_auth(auth)
username = payload.get("username") or payload.get("sub") or ""
acct = await asyncio.to_thread(db.fetch_one, "accounts", username=username)
if not acct:
raise HTTPException(status_code=401, detail="请先登录")
key = (acct.get("phone") or "").strip() or username
user = await pdb.users.get_by_username(key)
if user is None:
return {"items": []}
items = await pdb.tasks.list(status="claimed") + await pdb.tasks.list(status="doing")
mine = [t for t in items if t.get("claimed_by") == user["id"]]
return {"items": mine}
@app.post("/api/auth/send-code")
async def send_code(req: Request):
b = await req.json()
phone = str(b.get("phone", "")).strip()
if not PHONE_RE.match(phone):
raise HTTPException(400, "请输入正确的 11 位手机号")
code = send_sms_code(phone)
return {"ok": True, "sent": True, "debugCode": code}
def _same_phone_accounts(phone):
"""找出占用同一手机号的所有账号(username=phone 为主账号,或 phone=phone 的其它账号)"""
conn = db.get_conn()
rows = conn.execute("SELECT * FROM accounts WHERE username=? OR phone=?", (phone, phone)).fetchall()
conn.close()
return [dict(r) for r in rows]
def _adopt_phone(acct, phone):
"""把同一手机号下其它账号的数据合并进 acct(保留 acct),最后只留一个账号、双向可命中。
合并规则:若被并入账号带 wxid 而 acct 没有,则把 wxid 迁移到 acct(保证微信登录按 wxid 仍能命中);
再把重复账号的报名/测评/日志等归属数据挪到 acct,删除重复账号。最终同一手机号只对应一个账号。
"""
phone = str(phone or "")
conn = db.get_conn()
keep_id = acct["id"]
acct_username = acct["username"]
conn.execute("UPDATE accounts SET phone=?, phone_bound=1 WHERE id=?", (phone, keep_id))
dups = [dict(r) for r in conn.execute(
"SELECT * FROM accounts WHERE (username=? OR phone=?) AND id != ?", (phone, phone, keep_id)
).fetchall()]
for d in dups:
# 迁移微信标识,避免 acct 属手机号账号时丢失 wxid 而微信登录失效
if d.get("wxid") and not conn.execute("SELECT wxid FROM accounts WHERE id=?", (keep_id,)).fetchone()["wxid"]:
conn.execute("UPDATE accounts SET wxid=? WHERE id=?", (d["wxid"], keep_id))
# 迁移归属数据(报名 + 各日志)
conn.execute("UPDATE bookings SET username=?, contact=? WHERE username=? OR contact=?",
(acct_username, phone, d["username"], d.get("phone") or ""))
for t in ("tests", "policy_logs", "plan_logs", "survey_logs"):
conn.execute(f"UPDATE {t} SET username=? WHERE username=?", (acct_username, d["username"]))
conn.execute("DELETE FROM accounts WHERE id=?", (d["id"],))
conn.commit()
conn.close()
acct = db.fetch_one("accounts", id=keep_id)
return acct
def _resolve_phone_account(phone):
"""返回该手机号对应的唯一账号(优先按 username=phone,否则按 phone=phone),并合并重复账号。"""
acct = db.fetch_one("accounts", username=phone)
if not acct:
for a in _same_phone_accounts(phone):
if a.get("phone") == phone and a["id"]:
acct = a
break
if not acct:
return None
return _adopt_phone(acct, phone)
def auth_by_phone(body: dict):
phone = str(body.get("phone", "")).strip()
code = str(body.get("code", "")).strip()
if not PHONE_RE.match(phone):
raise HTTPException(400, "请输入正确的 11 位手机号")
if not check_sms_code(phone, code):
raise HTTPException(401, "验证码错误或已过期,请重新获取")
# 统一按手机号解析:命中现有账号(含已绑该号的微信账号)则合并复用,避免同一手机号多个账号
acct = _resolve_phone_account(phone)
is_new = False
if not acct:
acct = {"id": db.gen_id("U"), "username": phone, "phone": phone, "name": str(body.get("name", "")).strip() or phone,
"phone_bound": 1, "identities": json.dumps(["opc_member|certified"]), "created_at": now_iso()}
db.insert("accounts", acct)
is_new = True
return acct, is_new
@app.post("/api/auth/phone-login")
async def phone_login(req: Request):
b = await req.json()
acct, is_new = await asyncio.to_thread(auth_by_phone, b)
return {"ok": True, "token": make_token(acct["username"]), "username": acct["username"],
"name": acct.get("name") or acct["username"], "isNew": is_new}
@app.post("/api/auth/logout")
async def logout():
return {"ok": True}
@app.get("/api/auth/verify")
async def verify(authorization: str = Header(default="")):
payload = require_auth(authorization)
return {"ok": True, "username": payload["username"]}
# -------- 微信登录 / 我的 / 绑定手机 --------
@app.post("/api/auth/wx-login")
async def wx_login(req: Request):
b = await req.json()
code = str(b.get("code", ""))
if not code:
raise HTTPException(400, "缺少微信登录凭证")
# 严格真实登录:必须配置有效 AppID/Secret,且 code2session 成功拿到 openid,否则一律明确失败(绝不用伪 openid 冒充)
if not WX_HAS_CONFIG:
raise HTTPException(500, "后端未配置微信 AppID/Secret,无法进行微信登录")
try:
wresp = await _wx_code2session(code)
except Exception:
raise HTTPException(502, "微信服务调用失败,请稍后重试")
if wresp.get("errcode") or not wresp.get("openid"):
errmsg = wresp.get("errmsg", "invalid code")
raise HTTPException(401, f"微信登录失败:{errmsg}")
openid = wresp.get("openid")
acct = await asyncio.to_thread(db.fetch_one, "accounts", wxid=openid)
if not acct:
name = str(b.get("nickName", "")).strip() or "微信用户"
acct = {"id": db.gen_id("U"), "username": openid, "wxid": openid, "phone": "", "name": name,
"avatar": str(b.get("avatarUrl", "")), "phone_bound": 0, "identities": json.dumps(["wx"]),
"created_at": now_iso()}
await asyncio.to_thread(db.insert, "accounts", acct)
return {"ok": True, "token": make_token(acct["username"]), "username": acct["username"],
"name": acct.get("name") or "微信用户", "avatar": abs_url(acct.get("avatar")), "phoneBound": bool(acct.get("phone_bound")),
"configured": WX_HAS_CONFIG}
@app.get("/api/auth/me")
async def me(authorization: str = Header(default="")):
payload = require_auth(authorization)
acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"])
if not acct:
raise HTTPException(404, "账号不存在")
return {"ok": True, "user": _user_payload(acct)}
@app.post("/api/auth/update-profile")
async def update_profile(req: Request, authorization: str = Header(default="")):
"""更新当前用户资料:昵称/头像 + 报名资料(status/topics/source"""
payload = require_auth(authorization)
b = await req.json()
acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"])
if not acct:
raise HTTPException(404, "账号不存在")
patch = {}
if b.get("name") is not None and str(b["name"]).strip():
patch["name"] = str(b["name"]).strip()
if b.get("avatar") is not None:
patch["avatar"] = str(b["avatar"]).strip()
if b.get("status") is not None:
patch["status_label"] = str(b["status"]).strip()
if b.get("source") is not None:
patch["source"] = str(b["source"]).strip()
if b.get("topics") is not None:
patch["topics"] = json.dumps(b["topics"], ensure_ascii=False) if isinstance(b["topics"], list) else str(b["topics"])
if patch:
await asyncio.to_thread(db.update_row, "accounts", acct["id"], patch)
acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"])
return {"ok": True, "user": _user_payload(acct)}
@app.post("/api/auth/wx-phone")
async def wx_phone(req: Request, authorization: str = Header(default="")):
"""微信官方手机号一键绑定:前端 <Button openType="getPhoneNumber"> 回调的 code → 手机号"""
if not WX_HAS_CONFIG:
raise HTTPException(400, "后端未配置微信 AppID/Secret,无法使用微信手机号绑定")
payload = require_auth(authorization)
b = await req.json()
pcode = str(b.get("code", ""))
if not pcode:
raise HTTPException(400, "缺少手机号授权凭证")
token = await _wx_get_access_token()
if not token:
raise HTTPException(400, "微信 access_token 获取失败,请检查 AppID/Secret 配置")
url = f"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={token}"
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(url, json={"code": pcode})
data = r.json()
if data.get("errcode"):
raise HTTPException(401, f"获取手机号失败:{data.get('errmsg', '')}")
phone = (data.get("phone_info") or {}).get("purePhoneNumber", "")
if not phone:
raise HTTPException(400, "未获取到手机号")
acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"])
if not acct:
raise HTTPException(404, "账号不存在")
# 绑号即合并:若该手机号下有其它账号(如网页手机号登录账号),把其数据并入当前微信账号
acct = await asyncio.to_thread(_adopt_phone, acct, phone)
return {"ok": True, "phoneBound": True, "phone": phone}
@app.post("/api/auth/bind-phone")
async def bind_phone(req: Request, authorization: str = Header(default="")):
payload = require_auth(authorization)
b = await req.json()
phone = str(b.get("phone", "")).strip()
code = str(b.get("code", "")).strip()
if not PHONE_RE.match(phone):
raise HTTPException(400, "请输入正确的 11 位手机号")
if not check_sms_code(phone, code):
raise HTTPException(401, "验证码错误或已过期")
acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"])
if not acct:
raise HTTPException(404, "账号不存在")
# 短信绑定同样合并同一手机号下的其它账号
acct = await asyncio.to_thread(_adopt_phone, acct, phone)
return {"ok": True, "phoneBound": True, "phone": phone}
# ================= 排期 / 活动 =================
def _upcoming(all_events):
now = int(__import__("time").time() * 1000)
upcoming = []
for e in all_events:
from datetime import datetime
try:
t = datetime.fromisoformat(e["start_at"]).timestamp() * 1000
except Exception:
t = 0
if t >= now - 1000 * 60 * 30:
upcoming.append(e)
upcoming.sort(key=lambda e: e["start_at"])
return upcoming
def event_out(e):
"""SQLite 行(snake_case) → 前端契约(camelCase) 统一映射"""
if not e:
return e
return {
"id": e.get("id"),
"type": e.get("type"),
"mode": e.get("mode"),
"title": e.get("title"),
"subtitle": e.get("subtitle"),
"desc": e.get("desc"),
"location": e.get("location"),
"host": e.get("host"),
"image": abs_url(e.get("image")),
"link": e.get("link"),
"startAt": e.get("start_at"),
"endAt": e.get("end_at"),
"checkinAt": e.get("checkin_at"),
"durationMin": e.get("duration_min"),
"capacity": e.get("capacity"),
"status": e.get("status"),
"auditMode": e.get("audit_mode") or "auto",
"showCapacity": bool(e.get("show_capacity")),
"enrolled": _count_enrolled(e.get("id")),
}
@app.get("/api/events")
async def events(request: Request, current: Optional[str] = None, bookable: Optional[str] = None):
all_events = await asyncio.to_thread(db.list_all, "events")
if current is not None or bookable is not None:
upcoming = _upcoming(all_events)
if current is not None:
free = next((e for e in upcoming if e["type"] == "free"), None)
salon = next((e for e in upcoming if e["type"] == "salon"), None)
return {"ok": True, "next": await asyncio.to_thread(event_out, upcoming[0] if upcoming else None),
"free": await asyncio.to_thread(event_out, free), "salon": await asyncio.to_thread(event_out, salon),
"upcoming": [await asyncio.to_thread(event_out, e) for e in upcoming[:6]]}
if bookable is not None:
# 可公开报名:排除 待开放(pending)/邀请中(invite)/已结束(done)/已满员(full)
lst = [e for e in upcoming if e["status"] not in ("closed", "done", "pending", "invite", "full")][:30]
return {"ok": True, "list": [await asyncio.to_thread(event_out, e) for e in lst]}
return {"ok": True, "list": [await asyncio.to_thread(event_out, e) for e in all_events]}
@app.get("/api/events/{eid}")
async def event_detail(eid: str):
e = await asyncio.to_thread(db.fetch_by_id, "events", eid)
if not e:
raise HTTPException(404, "活动不存在")
return {"ok": True, "event": await asyncio.to_thread(event_out, e)}
@app.post("/api/events")
async def create_event(req: Request, authorization: str = Header(default="")):
require_auth(authorization)
b = await req.json()
if not b.get("type") or not b.get("title") or not b.get("startAt"):
raise HTTPException(400, "请填写类型 / 主题 / 开始时间")
entry = {
"id": db.gen_id("E-S" if b.get("type") == "salon" else "E-F"),
"type": "salon" if b.get("type") == "salon" else "free",
"mode": "online" if b.get("mode") == "online" else "offline",
"title": str(b.get("title", "")).strip(),
"subtitle": str(b.get("subtitle", "") or ""),
"desc": str(b.get("desc", "") or ""),
"location": str(b.get("location", "") or ""),
"host": str(b.get("host", "") or "").strip(),
"image": str(b.get("image", "") or "").strip(),
"link": str(b.get("link", "") or ""),
"start_at": str(b.get("startAt", "") or ""),
"end_at": str(b.get("endAt", "") or ""),
"checkin_at": str(b.get("checkinAt", "") or ""),
"duration_min": int(b.get("durationMin") or 90),
"capacity": int(b.get("capacity") or 0),
"status": str(b.get("status", "") or "open"),
"audit_mode": 'manual' if b.get("auditMode") == "manual" else "auto",
"show_capacity": 1 if b.get("showCapacity") else 0,
}
await asyncio.to_thread(db.insert, "events", entry)
return {"ok": True, "entry": await asyncio.to_thread(event_out, entry)}
@app.put("/api/events/{eid}")
async def update_event(eid: str, req: Request, authorization: str = Header(default="")):
require_auth(authorization)
b = await req.json()
patch = {}
for k in ["title", "subtitle", "desc", "location", "link", "start_at", "end_at", "checkin_at", "status", "host", "image"]:
# 前端发 startAt/start_at、endAt/end_at、checkinAt/checkin_at 兼容
alias = {"start_at": "startAt", "end_at": "endAt", "checkin_at": "checkinAt"}.get(k)
src = k if k in b else (alias if alias else None)
if src and b.get(src) is not None:
patch[k] = b[src]
if b.get("type") in ("salon", "free"):
patch["type"] = b["type"]
if b.get("mode") in ("online", "offline"):
patch["mode"] = b["mode"]
if b.get("durationMin") is not None:
patch["duration_min"] = int(b.get("durationMin") or 90)
if b.get("capacity") is not None:
patch["capacity"] = int(b.get("capacity") or 0)
if b.get("auditMode") is not None:
patch["audit_mode"] = "manual" if b["auditMode"] == "manual" else "auto"
if b.get("showCapacity") is not None:
patch["show_capacity"] = 1 if b["showCapacity"] else 0
if not patch:
raise HTTPException(400, "无更新字段")
if not await asyncio.to_thread(db.fetch_by_id, "events", eid):
raise HTTPException(404, "排期不存在")
await asyncio.to_thread(db.update_row, "events", eid, patch)
return {"ok": True, "entry": await asyncio.to_thread(event_out, await asyncio.to_thread(db.fetch_by_id, "events", eid))}
@app.delete("/api/events/{eid}")
async def delete_event(eid: str, authorization: str = Header(default="")):
require_auth(authorization)
await asyncio.to_thread(db.delete_row, "events", eid)
return {"ok": True}
# ================= 报名 =================
@app.post("/api/bookings")
async def create_booking(req: Request, authorization: str = Header(default="")):
b = await req.json()
event = await asyncio.to_thread(db.fetch_by_id, "events", str(b.get("eventId", "")).strip())
# 校验活动可报名:公开报名仅 open/full;待开放/邀请中/已结束不可报名
if not event:
raise HTTPException(404, "活动不存在")
if event["status"] not in ("open", "full"):
label = {"pending": "活动待开放", "invite": "本场为定向邀请", "done": "本场已结束"}.get(event["status"], "暂不可报名")
raise HTTPException(400, label)
# 强制登录:必须携带有效 token(无登录 → 401)
payload = require_auth(authorization)
acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"])
if not acct:
raise HTTPException(401, "登录状态异常,请重新登录")
# 报名必须已绑定手机号(登录后自动/引导绑定),保证报名有联系方式
if not acct.get("phone"):
raise HTTPException(400, "请先绑定手机号后再报名")
username = acct["username"]
# 报名资料(姓名/状态/主题/来源)取账号中已保存的个人中心设置,报名区无需再填;question 按场次从请求体取
name = (acct.get("name") or "") or (str(b.get("name", "")).strip() or username)
status_label = (acct.get("status_label") or "") or str(b.get("status", "") or "").strip()
topics_val = _parse_topics(acct.get("topics"))
if not topics_val:
topics_val = b.get("topics", []) if isinstance(b.get("topics"), list) else []
source = (acct.get("source") or "") or str(b.get("source", "") or "").strip()
entry = {
"id": db.gen_id("B"), "created_at": now_iso(), "status": "pending", "username": username, "name": name,
"contact": (acct.get("phone") or ""), "status_label": status_label,
"want": b.get("want", ""),
"event_id": event["id"], "event_title": event["title"], "event_start": event["start_at"],
"topics": json.dumps(topics_val, ensure_ascii=False),
"question": b.get("question", ""), "source": source,
"audit_status": "approved" if event.get("audit_mode") != "manual" else "pending",
}
await asyncio.to_thread(db.insert, "bookings", entry)
return {"ok": True, "id": entry["id"], "createdAt": entry["created_at"],
"username": username, "name": name,
"auditStatus": entry["audit_status"]}
def _decode_bk(row):
d = dict(row)
try:
d["topics"] = json.loads(d.get("topics") or "[]")
except Exception:
d["topics"] = []
return d
@app.get("/api/bookings/mine")
async def my_bookings(authorization: str = Header(default="")):
payload = require_auth(authorization)
acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"])
ids = {payload["username"]}
if acct and acct.get("phone"):
ids.add(acct["phone"])
conn = await asyncio.to_thread(db.get_conn, )
rows = conn.execute("SELECT * FROM bookings").fetchall()
events = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM events").fetchall()}
conn.close()
lst = [_decode_bk(r) for r in rows if r["username"] in ids or (r["contact"] or "") in ids]
now_ms = int(time.time() * 1000)
for x in lst:
# 前端(我的报名)用 camelCase,补别名保证一致的契约(snake 亦保留)
x["eventTitle"] = x.get("event_title")
x["eventStart"] = x.get("event_start")
x["createdAt"] = x.get("created_at")
x["checkinAt"] = x.get("checkin_at")
x["auditStatus"] = x.get("audit_status") or "pending"
ev = events.get(x.get("event_id"))
if ev:
open_ms = _parse_ms(ev.get("checkin_at") or ev.get("start_at"))
end_ms = _parse_ms(ev.get("end_at")) or (open_ms + (ev.get("duration_min") or 90) * 60000)
x["event_status"] = ev.get("status")
x["event_end"] = ev.get("end_at")
x["event_checkin_at"] = ev.get("checkin_at")
x["can_checkin"] = bool(open_ms <= now_ms <= end_ms and ev.get("status") != "done")
else:
x["event_status"] = None
x["event_end"] = None
x["event_checkin_at"] = None
x["can_checkin"] = False
lst.sort(key=lambda x: x["created_at"], reverse=True)
return {"ok": True, "list": lst}
@app.get("/api/bookings/by-event/{event_id}")
async def my_booking_for_event(event_id: str, authorization: str = Header(default="")):
"""查当前登录用户是否已报名该活动及其审核状态(详情页判断用)"""
payload = require_auth(authorization)
acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"])
ids = {payload["username"]}
if acct and acct.get("phone"):
ids.add(acct["phone"])
conn = await asyncio.to_thread(db.get_conn, )
rows = conn.execute("SELECT * FROM bookings WHERE event_id=?", (event_id,)).fetchall()
conn.close()
hit = None
for r in rows:
if r["username"] in ids or (r["contact"] or "") in ids:
hit = _decode_bk(r)
break
if not hit:
return {"ok": True, "booked": False, "auditStatus": None}
return {"ok": True, "booked": True, "id": hit["id"], "auditStatus": hit.get("audit_status") or "pending",
"status": hit.get("status"), "checkinAt": hit.get("checkin_at")}
@app.post("/api/checkins")
async def checkin(req: Request, authorization: str = Header(default="")):
payload = require_auth(authorization)
b = await req.json()
acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"])
ids = {payload["username"]}
if acct and acct.get("phone"):
ids.add(acct["phone"])
bk = await asyncio.to_thread(db.fetch_by_id, "bookings", b.get("bookingId", ""))
if not bk:
raise HTTPException(404, "报名记录不存在")
if bk["username"] not in ids and (bk.get("contact") or "") not in ids:
raise HTTPException(403, "无权操作该记录")
if bk.get("checkin_at"):
raise HTTPException(400, "该场次已签到")
# 时间闸:只能在「签到开放时间 ≤ now ≤ 结束时间」内签到
ev = await asyncio.to_thread(db.fetch_by_id, "events", bk.get("event_id", ""))
open_ms = _parse_ms(ev.get("checkin_at") or ev.get("start_at")) if ev else 0
end_ms = (_parse_ms(ev.get("end_at")) if ev and ev.get("end_at") else 0) or (_parse_ms(ev.get("start_at")) + (ev.get("duration_min") or 90) * 60000 if ev else 0)
now_ms = int(time.time() * 1000)
if now_ms < open_ms:
raise HTTPException(400, "未到签到时间,活动开始后再签到")
if now_ms > end_ms or (ev and ev.get("status") == "done"):
raise HTTPException(400, "本场已结束,无法签到")
await asyncio.to_thread(db.update_row, "bookings", bk["id"], {"checkin_at": now_iso()})
return {"ok": True, "entry": _decode_bk(await asyncio.to_thread(db.fetch_by_id, "bookings", bk["id"]))}
@app.get("/api/bookings")
async def admin_bookings(authorization: str = Header(default=""), status: Optional[str] = None, audit: Optional[str] = None):
require_auth(authorization)
lst = [_decode_bk(r) for r in await asyncio.to_thread(db.list_all, "bookings")]
lst.sort(key=lambda x: x["created_at"], reverse=True)
if status:
lst = [x for x in lst if x["status"] == status]
if audit:
lst = [x for x in lst if x.get("audit_status") == audit]
return {"ok": True, "list": lst}
@app.patch("/api/bookings/{bid}")
async def update_booking(bid: str, req: Request, authorization: str = Header(default="")):
require_auth(authorization)
b = await req.json()
if not await asyncio.to_thread(db.fetch_by_id, "bookings", bid):
raise HTTPException(404, "预约不存在")
patch = {}
if b.get("status") in ("pending", "confirmed", "arrived", "converted"):
patch["status"] = b["status"]
if b.get("auditStatus") in ("pending", "approved", "rejected"):
patch["audit_status"] = b["auditStatus"]
if patch:
await asyncio.to_thread(db.update_row, "bookings", bid, patch)
return {"ok": True, "entry": _decode_bk(await asyncio.to_thread(db.fetch_by_id, "bookings", bid))}
@app.delete("/api/bookings/{bid}")
async def delete_booking(bid: str, authorization: str = Header(default="")):
require_auth(authorization)
await asyncio.to_thread(db.delete_row, "bookings", bid)
return {"ok": True}
# ================= OPC 测评 =================
@app.get("/api/tests/opc/questions")
async def opc_questions(version: Optional[str] = "full"):
v = "quick" if version == "quick" else "full"
return {
"ok": True, "version": v,
"questions": [{"id": q["id"], "part": q["part"], "question": q["question"], "A": q["A"], "B": q["B"]}
for q in opc_engine.current_questions(v)],
"sections": SECTIONS, "axisNames": AXIS_NAMES, "dimLabels": ADAPT_LABELS,
"disclaimer": PROFILES["disclaimer"], "quickNote": PROFILES["quickNote"],
}
@app.post("/api/tests/opc/calculate")
async def opc_calculate(req: Request):
b = await req.json()
version = "quick" if b.get("version") == "quick" else "full"
answers = read_answers(b)
try:
result = opc_engine.expand_result(opc_engine.calculate(answers, version))
return {"ok": True, "result": result}
except Exception:
raise HTTPException(400, "测评计算失败,请稍后重试")
@app.post("/api/tests")
async def report_test(req: Request):
b = await req.json()
if not b.get("typeCode"):
raise HTTPException(400, "缺少测评结果")
entry = {"id": db.gen_id("T"), "created_at": now_iso(), "username": str(b.get("username", "")).strip(),
"type_code": b.get("typeCode"), "persona": b.get("persona", ""),
"adapt_index": int(b.get("adaptIndex") or 0), "adapt_level": b.get("adaptLevel", ""),
"tracks": json.dumps(b.get("tracks", []), ensure_ascii=False), "version": b.get("version", "")}
await asyncio.to_thread(db.insert, "tests", entry)
return {"ok": True, "id": entry["id"]}
@app.get("/api/tests")
async def admin_tests(authorization: str = Header(default="")):
require_auth(authorization)
return {"ok": True, "list": await asyncio.to_thread(db.list_all, "tests")}
# ================= 政策 / 流程 / 调研:题目下发 + 结果生成(逻辑后置于后端) =================
@app.get("/api/policy/questions")
async def policy_questions():
return {"ok": True, "questions": policy_data.PT_QUESTIONS}
@app.post("/api/policy/calculate")
async def policy_calculate(req: Request):
b = await req.json()
ans = b.get("answers") if isinstance(b.get("answers"), dict) else {}
result = policy_engine.compute_policy(ans)
return {"ok": True, "result": result}
@app.get("/api/plan/config")
async def plan_config():
return {"ok": True, "regions": policy_data.SP_REGIONS, "status": policy_data.SP_STATUS}
@app.post("/api/plan/generate")
async def plan_generate(req: Request):
b = await req.json()
plan = policy_engine.build_plan({
"needPark": bool(b.get("needPark")),
"needRegister": bool(b.get("needRegister")),
"hasStaff": bool(b.get("hasStaff")),
})
return {"ok": True, "plan": plan}
@app.get("/api/survey/questions")
async def survey_questions():
return {"ok": True, "likert": survey_data.LIKERT_OPTIONS,
"sections": survey_data.SURVEY_SECTIONS, "questions": survey_data.SURVEY_QUESTIONS}
@app.post("/api/survey/submit")
async def survey_submit(req: Request):
b = await req.json()
if not isinstance(b.get("answers"), dict):
raise HTTPException(400, "缺少调研答案")
entry = {"id": db.gen_id("S"), "created_at": now_iso(), "username": str(b.get("username", "")).strip(),
"source": str(b.get("source", "")).strip() or "web", "answers": json.dumps(b.get("answers"), ensure_ascii=False)}
await asyncio.to_thread(db.insert, "survey_logs", entry)
return {"ok": True, "id": entry["id"], "createdAt": entry["created_at"]}
# ================= 日志上报(政策 / 流程 / 调研) =================
@app.post("/api/policy-logs")
async def report_policy(req: Request):
b = await req.json()
if not b.get("answers"):
raise HTTPException(400, "缺少测评结果")
entry = {"id": db.gen_id("P"), "created_at": now_iso(), "username": str(b.get("username", "")).strip(),
"answers": json.dumps(b.get("answers"), ensure_ascii=False),
"policies_count": int(b.get("policiesCount") or 0), "subsidies_count": int(b.get("subsidiesCount") or 0),
"loans_count": int(b.get("loansCount") or 0), "summary": str(b.get("summary", "") or "")[:200]}
await asyncio.to_thread(db.insert, "policy_logs", entry)
return {"ok": True, "id": entry["id"]}
@app.get("/api/policy-logs")
async def admin_policy(authorization: str = Header(default="")):
require_auth(authorization)
return {"ok": True, "list": await asyncio.to_thread(db.list_all, "policy_logs")}
@app.post("/api/plan-logs")
async def report_plan(req: Request):
b = await req.json()
if not b.get("region"):
raise HTTPException(400, "缺少流程信息")
entry = {"id": db.gen_id("L"), "created_at": now_iso(), "username": str(b.get("username", "")).strip(),
"region": str(b.get("region", "")).strip(), "status": str(b.get("status", "")).strip(),
"need_park": 1 if b.get("needPark") else 0, "has_staff": 1 if b.get("hasStaff") else 0,
"steps_count": int(b.get("stepsCount") or 0)}
await asyncio.to_thread(db.insert, "plan_logs", entry)
return {"ok": True, "id": entry["id"]}
@app.get("/api/plan-logs")
async def admin_plan(authorization: str = Header(default="")):
require_auth(authorization)
return {"ok": True, "list": await asyncio.to_thread(db.list_all, "plan_logs")}
@app.post("/api/survey-logs")
async def report_survey(req: Request):
b = await req.json()
if not isinstance(b.get("answers"), dict):
raise HTTPException(400, "缺少调研答案")
entry = {"id": db.gen_id("S"), "created_at": now_iso(), "username": str(b.get("username", "")).strip(),
"source": str(b.get("source", "")).strip() or "web", "answers": json.dumps(b.get("answers"), ensure_ascii=False)}
await asyncio.to_thread(db.insert, "survey_logs", entry)
return {"ok": True, "id": entry["id"], "createdAt": entry["created_at"]}
@app.get("/api/survey-logs")
async def admin_survey(authorization: str = Header(default="")):
require_auth(authorization)
return {"ok": True, "list": await asyncio.to_thread(db.list_all, "survey_logs")}
# ================= 运营统计 =================
@app.get("/api/ops/stats")
async def ops_stats(authorization: str = Header(default="")):
require_auth(authorization)
conn = await asyncio.to_thread(db.get_conn, )
def count(t):
return conn.execute(f"SELECT COUNT(*) c FROM {t}").fetchone()["c"]
def count_where(t, where, val):
return conn.execute(f"SELECT COUNT(*) c FROM {t} WHERE {where}", (val,)).fetchone()["c"]
now_ms = int(__import__("time").time() * 1000)
upcoming = sum(1 for e in await asyncio.to_thread(db.list_all, "events") if _parse_ms(e["start_at"]) >= now_ms)
free = sum(1 for e in await asyncio.to_thread(db.list_all, "events") if e["type"] == "free" and _parse_ms(e["start_at"]) >= now_ms)
salon = sum(1 for e in await asyncio.to_thread(db.list_all, "events") if e["type"] == "salon" and _parse_ms(e["start_at"]) >= now_ms)
# 先全部算完再关连接
stats = {
"bookings": count("bookings"),
"pending": await asyncio.to_thread(count_where, "bookings", "status=?", "pending"),
"confirmed": await asyncio.to_thread(count_where, "bookings", "status=?", "confirmed"),
"arrived": await asyncio.to_thread(count_where, "bookings", "status=?", "arrived"),
"converted": await asyncio.to_thread(count_where, "bookings", "status=?", "converted"),
"events": count("events"), "upcomingEvents": upcoming, "free": free, "salon": salon,
"tests": count("tests"), "policyLogs": count("policy_logs"), "planLogs": count("plan_logs"),
"surveyLogs": count("survey_logs"),
}
conn.close()
return {"ok": True, "stats": stats}
def _parse_ms(iso):
from datetime import datetime
try:
return datetime.fromisoformat(iso).timestamp() * 1000
except Exception:
return 0
# ================= 图像上传 =================
@app.post("/api/upload")
async def upload(request: Request, file: UploadFile = File(...), authorization: str = Header(default="")):
# 管理端上传需登录(pine 后台);公开端也可保留(依据调用方),这里允许管理端 token
require_auth(authorization)
if not file.filename:
raise HTTPException(400, "未选择文件")
ext = os.path.splitext(file.filename or "")[1].lower()
if ext not in (".jpg", ".jpeg", ".png", ".webp", ".gif"):
raise HTTPException(400, "仅支持 jpg/png/webp/gif 图片")
fname = f"up_{int(time.time())}_{os.urandom(4).hex()}{ext}"
dest = os.path.join(UPLOAD_DIR, fname)
content = await file.read()
if len(content) > 5 * 1024 * 1024:
raise HTTPException(400, "文件过大(>5MB")
with open(dest, "wb") as f:
f.write(content)
url = f"/uploads/{fname}"
return {"ok": True, "url": abs_url(url)}