"""云超服 OPC 培训站 · FastAPI 子应用(已迁入 server-core 总后端) 由 server-core/dispatcher.py 统一对外(opc.pinesound.cn):`/api/*` 路由到本子应用, `/auth`、`/opc`、`/admin` 等由平台应用(app.main)服务。独立使用 data/opc.db 业务库。 """ import asyncio import json import os import secrets import time from typing import Optional from fastapi import FastAPI, Header, Request, HTTPException, UploadFile, File from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from . import db, opc_engine, policy_engine, policy_data, survey_data from .auth import hash_password, make_token, verify_token, send_sms_code, check_sms_code, PHONE_RE from .opc_engine import SECTIONS, AXIS_NAMES, ADAPT_LABELS, PROFILES from contextlib import asynccontextmanager # 平台域(任务/OPC 身份)访问:复用唯一总库 + 四层 Repository from ..infrastructure.db import AsyncSessionLocal from ..infrastructure.repositories import Database from ..services.task_service import TaskService from ..api.routers.auth import _ensure_opc_identity @asynccontextmanager async def lifespan(app): # 建表/种子由 alembic + scripts/db/seed.py 非运行态完成 yield app = FastAPI(title="云超服 OPC 培训站后端", version="0.1", lifespan=lifespan) ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # server-core 根 # ---- 上传目录 + 静态挂载(供活动封面图等) ---- UPLOAD_DIR = os.path.join(ROOT, "serverdata", "uploads") os.makedirs(UPLOAD_DIR, exist_ok=True) app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads") # ---- 微信小程序配置(真实登录) ---- # 微信 AppID / AppSecret 由 server-core 环境变量或 server-core/.env 提供。 def _load_dotenv(): """轻量 .env 加载(server-core/.env),不依赖 python-dotenv""" env_path = os.path.join(ROOT, ".env") if os.path.exists(env_path): with open(env_path, encoding="utf-8") as f: for line in f: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue k, _, v = line.partition("=") os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'")) _load_dotenv() WX_APPID = os.environ.get("WX_APPID", "") WX_SECRET = os.environ.get("WX_SECRET", "") WX_HAS_CONFIG = bool(WX_APPID and WX_SECRET) # 站点公开基础地址(用于把 /uploads 相对路径补全为完整 HTTPS 地址,供小程序/网页加载图片) PUBLIC_BASE = os.environ.get("PUBLIC_BASE", "https://opc.pinesound.cn").rstrip("/") def abs_url(u): """把相对路径(如 /uploads/x.png)补全为绝对地址;已是完整 URL 则原样返回""" if not u: return u u = str(u).strip() if u.startswith("http://") or u.startswith("https://"): return u return PUBLIC_BASE + u import httpx # 微信 code2session 请求用 async def _wx_code2session(code: str): """用登录 code 换 openid/session_key(真实微信登录核心)""" url = ( "https://api.weixin.qq.com/sns/jscode2session" f"?appid={WX_APPID}&secret={WX_SECRET}&js_code={code}&grant_type=authorization_code" ) async with httpx.AsyncClient(timeout=10) as client: r = await client.get(url) return r.json() # {openid, session_key} 或 {errcode, errmsg} async def _wx_get_access_token(): url = f"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={WX_APPID}&secret={WX_SECRET}" async with httpx.AsyncClient(timeout=10) as client: r = await client.get(url) return r.json().get("access_token") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) def now_iso(): return db.now_iso() def require_auth(authorization: str): token = (authorization or "").removeprefix("Bearer ").strip() payload = verify_token(token) if not payload: raise HTTPException(status_code=401, detail="未授权或登录已过期") return payload # ================= 工具 ================= def read_answers(body: dict, key="answers"): a = body.get(key) return a if isinstance(a, dict) else {} def _parse_topics(t): """accounts.topics 为 JSON 文本 → list,解析失败兜底 []""" if not t: return [] if isinstance(t, list): return t try: v = json.loads(t) return v if isinstance(v, list) else [] except Exception: return [] def _count_enrolled(event_id): """已报名人数 = 该活动全部报名数(含审核中)""" conn = db.get_conn() n = conn.execute("SELECT COUNT(*) AS c FROM bookings WHERE event_id=?", (event_id,)).fetchone()["c"] conn.close() return n def _user_payload(acct): """统一构建 /me 与 /update-profile 返回的用户资料(含报名资料)""" return { "username": acct["username"], "name": acct.get("name") or acct["username"], "avatar": abs_url(acct.get("avatar")), "phone": acct.get("phone") or "", "phoneBound": bool(acct.get("phone_bound")), "status": acct.get("status_label") or "", "topics": _parse_topics(acct.get("topics")), "source": acct.get("source") or "", } def jsonify(obj): # 处理 datetime 等 → dict return obj # ================= 域名验证文件(微信/腾讯域名校验,须在根路径返回) ================= @app.get("/SpXvScDiDT.txt") async def domain_verify(): from fastapi.responses import Response return Response(content="d813c7bbab566807a833fbc3b0e20a9c", media_type="text/plain") # ================= 认证 ================= @app.get("/api/health") async def health(): return {"ok": True, "service": "opc-fastapi"} @app.post("/api/auth/register") async def register(req: Request): b = await req.json() username = str(b.get("username", "")).strip() password = str(b.get("password", "")) name = str(b.get("name", "")).strip() contact = str(b.get("contact", "")).strip() if not username or len(username) < 2: raise HTTPException(400, "账号至少 2 个字符") if len(password) < 4: raise HTTPException(400, "密码至少 4 个字符") if await asyncio.to_thread(db.fetch_one, "accounts", username=username): raise HTTPException(400, "账号已存在,请直接登录") entry = {"id": db.gen_id("U"), "username": username, "password": hash_password(password), "name": name or username, "contact": contact, "identities": json.dumps(["opc_member|certified"]), "created_at": now_iso()} await asyncio.to_thread(db.insert, "accounts", entry) return {"ok": True, "token": make_token(username), "username": username, "name": name or username} @app.post("/api/auth/login") async def login(req: Request): b = await req.json() username = str(b.get("username", "")).strip() password = str(b.get("password", "")) if username == "pine": if password == "123456": return {"ok": True, "token": make_token("pine"), "username": "pine", "name": "Pine"} raise HTTPException(401, "密码错误,请重新输入") acct = await asyncio.to_thread(db.fetch_one, "accounts", username=username) if not acct: raise HTTPException(404, "账号不存在,请先注册") if acct["password"] != hash_password(password): raise HTTPException(401, "密码错误,请重新输入") return {"ok": True, "token": make_token(username), "username": username, "name": acct.get("name") or username} # ============================= 扫码接单(任务中心)============================= @app.post("/api/tasks/claim-by-code") async def tasks_claim_by_code(request: Request): """扫码接单:task_code → 领单(published → claimed,记录 claimed_by/流水)。""" body = await request.json() code = str(body.get("task_code") or body.get("code") or "").strip() if not code: raise HTTPException(status_code=400, detail="缺少任务码") auth = request.headers.get("Authorization", "") async with AsyncSessionLocal() as session: pdb = Database(session=session) payload = require_auth(auth) username = payload.get("username") or payload.get("sub") or "" acct = await asyncio.to_thread(db.fetch_one, "accounts", username=username) if not acct: raise HTTPException(status_code=401, detail="请先登录") key = (acct.get("phone") or "").strip() or username user = await pdb.users.get_by_username(key) if user is None: user = await pdb.users.create( key, password=secrets.token_hex(16), phone=(acct.get("phone") or "").strip() or "", nickname=(acct.get("name") or "").strip() or "", role="opc_member", source="mini_program", auth_type="phone", ) identity = await _ensure_opc_identity(pdb, user["id"]) actor = dict(user) actor["id"] = user["id"] actor["username"] = user["username"] actor["nickname"] = user.get("nickname") or user.get("username") actor["port"] = "opc" actor["role"] = identity.get("role") or "opc_member" task = await TaskService(pdb).claim(code, actor, source="scan") return {"ok": True, "task": task} @app.get("/api/tasks/my") async def tasks_my_tasks(request: Request): """我的接单(扫码接单领取的任务)。""" auth = request.headers.get("Authorization", "") async with AsyncSessionLocal() as session: pdb = Database(session=session) payload = require_auth(auth) username = payload.get("username") or payload.get("sub") or "" acct = await asyncio.to_thread(db.fetch_one, "accounts", username=username) if not acct: raise HTTPException(status_code=401, detail="请先登录") key = (acct.get("phone") or "").strip() or username user = await pdb.users.get_by_username(key) if user is None: return {"items": []} items = await pdb.tasks.list(status="claimed") + await pdb.tasks.list(status="doing") mine = [t for t in items if t.get("claimed_by") == user["id"]] return {"items": mine} @app.post("/api/auth/send-code") async def send_code(req: Request): b = await req.json() phone = str(b.get("phone", "")).strip() if not PHONE_RE.match(phone): raise HTTPException(400, "请输入正确的 11 位手机号") code = send_sms_code(phone) return {"ok": True, "sent": True, "debugCode": code} def _same_phone_accounts(phone): """找出占用同一手机号的所有账号(username=phone 为主账号,或 phone=phone 的其它账号)""" conn = db.get_conn() rows = conn.execute("SELECT * FROM accounts WHERE username=? OR phone=?", (phone, phone)).fetchall() conn.close() return [dict(r) for r in rows] def _adopt_phone(acct, phone): """把同一手机号下其它账号的数据合并进 acct(保留 acct),最后只留一个账号、双向可命中。 合并规则:若被并入账号带 wxid 而 acct 没有,则把 wxid 迁移到 acct(保证微信登录按 wxid 仍能命中); 再把重复账号的报名/测评/日志等归属数据挪到 acct,删除重复账号。最终同一手机号只对应一个账号。 """ phone = str(phone or "") conn = db.get_conn() keep_id = acct["id"] acct_username = acct["username"] conn.execute("UPDATE accounts SET phone=?, phone_bound=1 WHERE id=?", (phone, keep_id)) dups = [dict(r) for r in conn.execute( "SELECT * FROM accounts WHERE (username=? OR phone=?) AND id != ?", (phone, phone, keep_id) ).fetchall()] for d in dups: # 迁移微信标识,避免 acct 属手机号账号时丢失 wxid 而微信登录失效 if d.get("wxid") and not conn.execute("SELECT wxid FROM accounts WHERE id=?", (keep_id,)).fetchone()["wxid"]: conn.execute("UPDATE accounts SET wxid=? WHERE id=?", (d["wxid"], keep_id)) # 迁移归属数据(报名 + 各日志) conn.execute("UPDATE bookings SET username=?, contact=? WHERE username=? OR contact=?", (acct_username, phone, d["username"], d.get("phone") or "")) for t in ("tests", "policy_logs", "plan_logs", "survey_logs"): conn.execute(f"UPDATE {t} SET username=? WHERE username=?", (acct_username, d["username"])) conn.execute("DELETE FROM accounts WHERE id=?", (d["id"],)) conn.commit() conn.close() acct = db.fetch_one("accounts", id=keep_id) return acct def _resolve_phone_account(phone): """返回该手机号对应的唯一账号(优先按 username=phone,否则按 phone=phone),并合并重复账号。""" acct = db.fetch_one("accounts", username=phone) if not acct: for a in _same_phone_accounts(phone): if a.get("phone") == phone and a["id"]: acct = a break if not acct: return None return _adopt_phone(acct, phone) def auth_by_phone(body: dict): phone = str(body.get("phone", "")).strip() code = str(body.get("code", "")).strip() if not PHONE_RE.match(phone): raise HTTPException(400, "请输入正确的 11 位手机号") if not check_sms_code(phone, code): raise HTTPException(401, "验证码错误或已过期,请重新获取") # 统一按手机号解析:命中现有账号(含已绑该号的微信账号)则合并复用,避免同一手机号多个账号 acct = _resolve_phone_account(phone) is_new = False if not acct: acct = {"id": db.gen_id("U"), "username": phone, "phone": phone, "name": str(body.get("name", "")).strip() or phone, "phone_bound": 1, "identities": json.dumps(["opc_member|certified"]), "created_at": now_iso()} db.insert("accounts", acct) is_new = True return acct, is_new @app.post("/api/auth/phone-login") async def phone_login(req: Request): b = await req.json() acct, is_new = await asyncio.to_thread(auth_by_phone, b) return {"ok": True, "token": make_token(acct["username"]), "username": acct["username"], "name": acct.get("name") or acct["username"], "isNew": is_new} @app.post("/api/auth/logout") async def logout(): return {"ok": True} @app.get("/api/auth/verify") async def verify(authorization: str = Header(default="")): payload = require_auth(authorization) return {"ok": True, "username": payload["username"]} # -------- 微信登录 / 我的 / 绑定手机 -------- @app.post("/api/auth/wx-login") async def wx_login(req: Request): b = await req.json() code = str(b.get("code", "")) if not code: raise HTTPException(400, "缺少微信登录凭证") # 严格真实登录:必须配置有效 AppID/Secret,且 code2session 成功拿到 openid,否则一律明确失败(绝不用伪 openid 冒充) if not WX_HAS_CONFIG: raise HTTPException(500, "后端未配置微信 AppID/Secret,无法进行微信登录") try: wresp = await _wx_code2session(code) except Exception: raise HTTPException(502, "微信服务调用失败,请稍后重试") if wresp.get("errcode") or not wresp.get("openid"): errmsg = wresp.get("errmsg", "invalid code") raise HTTPException(401, f"微信登录失败:{errmsg}") openid = wresp.get("openid") acct = await asyncio.to_thread(db.fetch_one, "accounts", wxid=openid) if not acct: name = str(b.get("nickName", "")).strip() or "微信用户" acct = {"id": db.gen_id("U"), "username": openid, "wxid": openid, "phone": "", "name": name, "avatar": str(b.get("avatarUrl", "")), "phone_bound": 0, "identities": json.dumps(["wx"]), "created_at": now_iso()} await asyncio.to_thread(db.insert, "accounts", acct) return {"ok": True, "token": make_token(acct["username"]), "username": acct["username"], "name": acct.get("name") or "微信用户", "avatar": abs_url(acct.get("avatar")), "phoneBound": bool(acct.get("phone_bound")), "configured": WX_HAS_CONFIG} @app.get("/api/auth/me") async def me(authorization: str = Header(default="")): payload = require_auth(authorization) acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"]) if not acct: raise HTTPException(404, "账号不存在") return {"ok": True, "user": _user_payload(acct)} @app.post("/api/auth/update-profile") async def update_profile(req: Request, authorization: str = Header(default="")): """更新当前用户资料:昵称/头像 + 报名资料(status/topics/source)""" payload = require_auth(authorization) b = await req.json() acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"]) if not acct: raise HTTPException(404, "账号不存在") patch = {} if b.get("name") is not None and str(b["name"]).strip(): patch["name"] = str(b["name"]).strip() if b.get("avatar") is not None: patch["avatar"] = str(b["avatar"]).strip() if b.get("status") is not None: patch["status_label"] = str(b["status"]).strip() if b.get("source") is not None: patch["source"] = str(b["source"]).strip() if b.get("topics") is not None: patch["topics"] = json.dumps(b["topics"], ensure_ascii=False) if isinstance(b["topics"], list) else str(b["topics"]) if patch: await asyncio.to_thread(db.update_row, "accounts", acct["id"], patch) acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"]) return {"ok": True, "user": _user_payload(acct)} @app.post("/api/auth/wx-phone") async def wx_phone(req: Request, authorization: str = Header(default="")): """微信官方手机号一键绑定:前端