"""云超服 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 from ..jwt import decode_access_token from ..services import sms as platform_sms @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): """校验并返回当前用户(统一账号)。令牌为平台 JWT(/auth/* 登录签发)。""" token = (authorization or "").removeprefix("Bearer ").strip() payload = decode_access_token(token) if not payload: raise HTTPException(status_code=401, detail="未授权或登录已过期") return {"username": payload.get("username", ""), "sub": payload.get("sub", ""), "user_id": payload.get("sub", "")} def _current_user(payload: dict) -> dict | None: """按平台 JWT( sub/username )取平台 users 行(统一账号源)。""" u = None if payload.get("sub"): u = db.fetch_one("users", id=payload["sub"]) if u is None: u = db.fetch_one("users", username=payload.get("username")) return u # ================= 工具 ================= 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(u): """统一构建 /me 与 /update-profile 返回的用户资料(读取平台 users,含报名资料)""" return { "username": u.get("username", ""), "name": u.get("nickname") or u.get("username", ""), "avatar": abs_url(u.get("avatar", "")), "phone": u.get("phone", "") or "", "phoneBound": bool(u.get("phone", "")), "status": u.get("opc_status", "") or "", "topics": _parse_topics(u.get("topics", "")), "source": u.get("source", "") or "", } def _set_user_phone(username: str, phone: str) -> None: """把手机号写入平台 users(统一账号)。""" u = db.fetch_one("users", username=username) or db.fetch_one("users", id=username) if u: db.update_row("users", u["id"], {"phone": phone}) 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(): # 统一账号:注册改由平台 /auth/register 等,避免双套账号 raise HTTPException(410, "请使用 /auth(统一账号)") 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(): # 统一账号:登录改由平台 /auth/login 等,避免双套账号 raise HTTPException(410, "登录请使用 /auth(统一账号)") # ============================= 扫码接单(任务中心)============================= @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(): # 统一账号:验证码改由平台 /auth/send-code 下发,避免双套账号 raise HTTPException(410, "登录请使用 /auth/send-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(): # 统一账号:手机号登录改由平台 /auth/phone-login,避免双套账号 raise HTTPException(410, "登录请使用 /auth/phone-login(统一账号)") @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(): # 统一账号:微信登录改由平台 /auth/wx-login,避免双套账号 raise HTTPException(410, "登录请使用 /auth/wx-login(统一账号)") @app.get("/api/auth/me") async def me(authorization: str = Header(default="")): payload = require_auth(authorization) u = await asyncio.to_thread(_current_user, payload) if not u: raise HTTPException(404, "账号不存在") return {"ok": True, "user": _user_payload(u)} @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() u = await asyncio.to_thread(_current_user, payload) if not u: raise HTTPException(404, "账号不存在") patch = {} if b.get("name") is not None and str(b["name"]).strip(): patch["nickname"] = 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["opc_status"] = 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, "users", u["id"], patch) u = await asyncio.to_thread(_current_user, payload) return {"ok": True, "user": _user_payload(u)} @app.post("/api/auth/wx-phone") async def wx_phone(req: Request, authorization: str = Header(default="")): """微信官方手机号一键绑定:前端