aps-agent/server/gateway/license_admin_api.py

219 lines
8.1 KiB
Python
Raw Normal View History

# ============================================================
# 超级管理员授权码管理 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}