219 lines
8.1 KiB
Python
219 lines
8.1 KiB
Python
|
|
# ============================================================
|
|||
|
|
# 超级管理员授权码管理 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}
|