# ============================================================ # 超级管理员授权码管理 API(moduleId: gateway-license-admin-api, 可重生 ✅) # codes 模式(APS_LICENSE_PROVIDER=codes)下,超管在设置面板「用户与授权」 # 发放/停用/删除授权码;每个授权码即一个用户身份,数据按码隔离。 # ============================================================ from __future__ import annotations import hashlib import os import secrets import time import uuid from datetime import datetime, timezone from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from sqlalchemy import select from server.auth.context import IdentityContext, get_identity from server.auth.licenses import ADMIN_LICENSE_CODE, LICENSE_DURATIONS, LICENSE_LABELS from server.db.database import get_session from server.db.models import LicenseActivation, LicenseCode router = APIRouter(prefix="/api/admin/license", tags=["license-admin"]) # 去掉 0/O/1/I/L 等易混淆字符,便于人工抄录下发 _CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" def _raise(status_code: int, code: str, message: str) -> None: raise HTTPException(status_code=status_code, detail={"code": code, "message": message}) def _require_admin() -> IdentityContext: identity = get_identity(required=True) if "admin" not in identity.roles: _raise(403, "LICENSE_ADMIN_REQUIRED", "仅超级管理员可管理用户与授权码") return identity def _codes_enabled() -> None: if (os.environ.get("APS_LICENSE_PROVIDER") or "").strip().lower() != "codes": _raise(409, "LICENSE_MODE_DISABLED", "当前部署未启用授权码模式(APS_LICENSE_PROVIDER=codes)") def _tenant() -> str: tenant = (os.environ.get("APS_LICENSE_TENANT_UUID") or "platform").strip() return tenant or "platform" def _code_hash(tenant: str, code: str) -> str: # 与 CodeLicenseProvider 的派生口径一致:codes:{tenant}:{code} return hashlib.sha256(f"codes:{tenant}:{code}".encode()).hexdigest() def _generate_code() -> str: return "-".join("".join(secrets.choice(_CODE_ALPHABET) for _ in range(4)) for _ in range(4)) def _utc_text(epoch: int) -> str: return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat() class LicenseCodeCreateRequest(BaseModel): name: str = Field(min_length=1, max_length=64) mobile: str = Field(default="", max_length=32) company: str = Field(default="", max_length=128) licenseType: str = Field(min_length=1, max_length=16) code: str = Field(default="", max_length=128) expiresAt: int | None = Field(default=None) class LicenseCodeStatusRequest(BaseModel): status: str = Field(min_length=1, max_length=16) def _serialize(row: LicenseCode, activation: LicenseActivation | None) -> dict: return { "id": row.id, "code": row.code, "name": row.name, "mobile": row.mobile, "company": row.company, "licenseType": row.license_type, "licenseLabel": LICENSE_LABELS.get(row.license_type, row.license_type), "expiresAt": row.expires_at, "status": row.status, "createdAt": row.created_at, "activation": None if activation is None else { "status": activation.status, "activatedAt": activation.activated_at, "lastSeenAt": activation.last_seen_at, "expiresAt": activation.expires_at, }, } @router.get("/codes") async def list_license_codes() -> dict: _codes_enabled() _require_admin() tenant = _tenant() with get_session() as session: rows = list(session.scalars(select(LicenseCode).where( LicenseCode.tenant_uuid == tenant, LicenseCode.deleted == 0, ).order_by(LicenseCode.created_at.desc())).all()) activations: dict[str, LicenseActivation] = {} hashes = [row.code_hash for row in rows] if hashes: for act in session.scalars(select(LicenseActivation).where( LicenseActivation.code_hash.in_(hashes), LicenseActivation.deleted == 0, )).all(): activations[act.code_hash] = act return {"codes": [_serialize(row, activations.get(row.code_hash)) for row in rows]} @router.post("/codes") async def create_license_code(req: LicenseCodeCreateRequest) -> dict: _codes_enabled() identity = _require_admin() license_type = req.licenseType.strip().lower() now = int(time.time()) expires_at = req.expiresAt if license_type == "custom": if not expires_at: _raise(400, "LICENSE_EXPIRY_REQUIRED", "自定义授权必须选择到期时间") elif license_type not in LICENSE_DURATIONS: _raise(400, "LICENSE_TYPE_INVALID", "授权时长类型无效") if expires_at is not None and expires_at <= now: _raise(400, "LICENSE_EXPIRY_PAST", "到期时间必须晚于当前时间") tenant = _tenant() code = req.code.strip() with get_session() as session: if code: if code == ADMIN_LICENSE_CODE: _raise(409, "LICENSE_CODE_DUPLICATED", "该授权码为内置超级管理员码") code_hash = _code_hash(tenant, code) clash = session.scalar(select(LicenseCode).where( LicenseCode.code_hash == code_hash, LicenseCode.deleted == 0, )) if clash is not None: _raise(409, "LICENSE_CODE_DUPLICATED", "授权码已存在") else: code_hash = "" for _ in range(20): candidate = _generate_code() if candidate == ADMIN_LICENSE_CODE: continue candidate_hash = _code_hash(tenant, candidate) clash = session.scalar(select(LicenseCode).where( LicenseCode.code_hash == candidate_hash, LicenseCode.deleted == 0, )) if clash is None: code, code_hash = candidate, candidate_hash break if not code: _raise(500, "LICENSE_CODE_GEN_FAILED", "授权码生成失败,请重试") now = int(time.time()) row = LicenseCode( id=uuid.uuid4().hex, code=code, code_hash=code_hash, name=req.name.strip(), mobile=req.mobile.strip(), company=req.company.strip(), license_type=license_type, status="active", expires_at=expires_at, tenant_uuid=tenant, creator_id=identity.user_id or None, updater_id=identity.user_id or None, updated_at=_utc_text(now), deleted=0, created_at=_utc_text(now), ) session.add(row) session.commit() session.refresh(row) return {"code": _serialize(row, None)} @router.post("/codes/{code_id}/status") async def set_license_code_status(code_id: str, req: LicenseCodeStatusRequest) -> dict: _codes_enabled() identity = _require_admin() status = req.status.strip().lower() if status not in {"active", "disabled"}: _raise(400, "LICENSE_STATUS_INVALID", "状态无效(active/disabled)") now = int(time.time()) with get_session() as session: row = session.get(LicenseCode, code_id) if row is None or row.deleted != 0: _raise(404, "LICENSE_CODE_NOT_FOUND", "授权码不存在") row.status = status row.updater_id = identity.user_id or None row.updated_at = _utc_text(now) session.commit() return {"ok": True, "status": status} @router.delete("/codes/{code_id}") async def delete_license_code(code_id: str) -> dict: _codes_enabled() identity = _require_admin() now = int(time.time()) with get_session() as session: row = session.get(LicenseCode, code_id) if row is None or row.deleted != 0: _raise(404, "LICENSE_CODE_NOT_FOUND", "授权码不存在") row.deleted = 1 row.updater_id = identity.user_id or None row.updated_at = _utc_text(now) session.commit() return {"ok": True}