Files
server-core/app/training/main.py
T
Pine 0c0e2586ed feat: serverdata 资源目录 + serverrun 运维编排
- serverdata/:统一承载全部资源目录(data/logs/files/keys/prompts/uploads)
- data/→serverdata/data、templates/→serverdata/prompts、uploads/→serverdata/uploads
- config.py 集中 serverdata 路径;培训子应用 db/uploads 指到 serverdata
- serverrun/:start.sh(初始化资源目录+启动 dispatcher)、Dockerfile、docker-compose(core+compute-engine+redis+mysql)
- 66 测试全绿
2026-08-24 00:42:35 +08:00

922 lines
38 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 json
import os
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
app = FastAPI(title="云超服 OPC 培训站后端", version="0.1")
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=["*"],
)
db.init_db()
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")
def domain_verify():
from fastapi.responses import Response
return Response(content="d813c7bbab566807a833fbc3b0e20a9c", media_type="text/plain")
# ================= 认证 =================
@app.get("/api/health")
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 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()}
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 = 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/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 = 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")
def logout():
return {"ok": True}
@app.get("/api/auth/verify")
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 = 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()}
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")
def me(authorization: str = Header(default="")):
payload = require_auth(authorization)
acct = 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 = 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:
db.update_row("accounts", acct["id"], patch)
acct = 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 = db.fetch_one("accounts", username=payload["username"])
if not acct:
raise HTTPException(404, "账号不存在")
# 绑号即合并:若该手机号下有其它账号(如网页手机号登录账号),把其数据并入当前微信账号
acct = _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 = db.fetch_one("accounts", username=payload["username"])
if not acct:
raise HTTPException(404, "账号不存在")
# 短信绑定同样合并同一手机号下的其它账号
acct = _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")
def events(request: Request, current: Optional[str] = None, bookable: Optional[str] = None):
all_events = 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": event_out(upcoming[0] if upcoming else None),
"free": event_out(free), "salon": event_out(salon),
"upcoming": [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": [event_out(e) for e in lst]}
return {"ok": True, "list": [event_out(e) for e in all_events]}
@app.get("/api/events/{eid}")
def event_detail(eid: str):
e = db.fetch_by_id("events", eid)
if not e:
raise HTTPException(404, "活动不存在")
return {"ok": True, "event": 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,
}
db.insert("events", entry)
return {"ok": True, "entry": 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 db.fetch_by_id("events", eid):
raise HTTPException(404, "排期不存在")
db.update_row("events", eid, patch)
return {"ok": True, "entry": event_out(db.fetch_by_id("events", eid))}
@app.delete("/api/events/{eid}")
def delete_event(eid: str, authorization: str = Header(default="")):
require_auth(authorization)
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 = 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 = 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",
}
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")
def my_bookings(authorization: str = Header(default="")):
payload = require_auth(authorization)
acct = db.fetch_one("accounts", username=payload["username"])
ids = {payload["username"]}
if acct and acct.get("phone"):
ids.add(acct["phone"])
conn = 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}")
def my_booking_for_event(event_id: str, authorization: str = Header(default="")):
"""查当前登录用户是否已报名该活动及其审核状态(详情页判断用)"""
payload = require_auth(authorization)
acct = db.fetch_one("accounts", username=payload["username"])
ids = {payload["username"]}
if acct and acct.get("phone"):
ids.add(acct["phone"])
conn = 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 = db.fetch_one("accounts", username=payload["username"])
ids = {payload["username"]}
if acct and acct.get("phone"):
ids.add(acct["phone"])
bk = 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 = 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, "本场已结束,无法签到")
db.update_row("bookings", bk["id"], {"checkin_at": now_iso()})
return {"ok": True, "entry": _decode_bk(db.fetch_by_id("bookings", bk["id"]))}
@app.get("/api/bookings")
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 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 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:
db.update_row("bookings", bid, patch)
return {"ok": True, "entry": _decode_bk(db.fetch_by_id("bookings", bid))}
@app.delete("/api/bookings/{bid}")
def delete_booking(bid: str, authorization: str = Header(default="")):
require_auth(authorization)
db.delete_row("bookings", bid)
return {"ok": True}
# ================= OPC 测评 =================
@app.get("/api/tests/opc/questions")
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", "")}
db.insert("tests", entry)
return {"ok": True, "id": entry["id"]}
@app.get("/api/tests")
def admin_tests(authorization: str = Header(default="")):
require_auth(authorization)
return {"ok": True, "list": db.list_all("tests")}
# ================= 政策 / 流程 / 调研:题目下发 + 结果生成(逻辑后置于后端) =================
@app.get("/api/policy/questions")
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")
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")
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)}
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]}
db.insert("policy_logs", entry)
return {"ok": True, "id": entry["id"]}
@app.get("/api/policy-logs")
def admin_policy(authorization: str = Header(default="")):
require_auth(authorization)
return {"ok": True, "list": 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)}
db.insert("plan_logs", entry)
return {"ok": True, "id": entry["id"]}
@app.get("/api/plan-logs")
def admin_plan(authorization: str = Header(default="")):
require_auth(authorization)
return {"ok": True, "list": 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)}
db.insert("survey_logs", entry)
return {"ok": True, "id": entry["id"], "createdAt": entry["created_at"]}
@app.get("/api/survey-logs")
def admin_survey(authorization: str = Header(default="")):
require_auth(authorization)
return {"ok": True, "list": db.list_all("survey_logs")}
# ================= 运营统计 =================
@app.get("/api/ops/stats")
def ops_stats(authorization: str = Header(default="")):
require_auth(authorization)
conn = 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 db.list_all("events") if _parse_ms(e["start_at"]) >= now_ms)
free = sum(1 for e in db.list_all("events") if e["type"] == "free" and _parse_ms(e["start_at"]) >= now_ms)
salon = sum(1 for e in db.list_all("events") if e["type"] == "salon" and _parse_ms(e["start_at"]) >= now_ms)
# 先全部算完再关连接
stats = {
"bookings": count("bookings"),
"pending": count_where("bookings", "status=?", "pending"),
"confirmed": count_where("bookings", "status=?", "confirmed"),
"arrived": count_where("bookings", "status=?", "arrived"),
"converted": 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)}