0c0e2586ed
- 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 测试全绿
922 lines
38 KiB
Python
922 lines
38 KiB
Python
"""云超服 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)}
|