"""云超服 OPC 培训站 · FastAPI 后端(迁移自 website/server/mock-api.mjs) 启动:uv run uvicorn app.main:app --port 8091 --reload (在 server/ 目录下) """ 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") # ---- 上传目录 + 静态挂载(供活动封面图等) ---- UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "uploads") os.makedirs(UPLOAD_DIR, exist_ok=True) app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads") # ---- 微信小程序配置(真实登录) ---- # 在小程序后台 mp.weixin.qq.com → 开发 → 开发设置 获取 AppID / AppSecret;填入环境变量或 server/.env。 def _load_dotenv(): """轻量 .env 加载(server/.env),不依赖 python-dotenv""" env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".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="")): """微信官方手机号一键绑定:前端