from __future__ import annotations import json import logging import secrets from functools import wraps from flask import Blueprint, current_app, jsonify, render_template, request, session from sqlalchemy import func, update from . import db from .models import AuditEvent, RedemptionCode, utc_now from .services import Office365Service, ServiceConfigurationError, ServiceOperationError bp_admin = Blueprint("admin", __name__, url_prefix="/admin") bp_user = Blueprint("user", __name__) logger = logging.getLogger("office365_self_service.routes") STATUS_AVAILABLE = "available" STATUS_PROCESSING = "processing" STATUS_USED = "used" def _settings(): return current_app.config["SETTINGS"] def _service() -> Office365Service: return current_app.extensions["office365_service"] def _success(data=None, message: str = "ok", status: int = 200): return jsonify({"success": True, "message": message, "data": data}), status def _error(message: str, status: int = 400, details=None): payload = {"success": False, "message": message} if details is not None: payload["details"] = details return jsonify(payload), status def _authenticated() -> bool: settings = _settings() if not settings.effective_auth_enabled: return True return bool(session.get("authenticated")) def require_auth(view_func): @wraps(view_func) def wrapped(*args, **kwargs): if not _authenticated(): return _error("请先登录后台管理平台。", status=401) return view_func(*args, **kwargs) return wrapped def _handle_service_call(callback): try: return callback() except ServiceConfigurationError as exc: return _error(str(exc), status=503) except ServiceOperationError as exc: return _error(exc.message, status=exc.status_code, details=exc.details) except ValueError as exc: return _error(str(exc), status=400) def _json_payload() -> dict: return request.get_json(silent=True) or {} def _code_match(code: str): return func.lower(RedemptionCode.code) == code.lower() def _health_payload() -> dict: settings = _settings() return { "platform": settings.to_public_dict(), "authenticated": _authenticated(), } def _current_actor(default: str = "system") -> str: if _authenticated(): return session.get("admin_username") or _settings().admin_username or default return default def _build_audit_event( event_type: str, *, status: str = "success", actor: str | None = None, code: str | None = None, username: str | None = None, principal_name: str | None = None, details: dict | None = None, ) -> AuditEvent: return AuditEvent( event_type=event_type, status=status, actor=actor or "system", code=code, username=username, principal_name=principal_name, details=json.dumps(details, ensure_ascii=False, sort_keys=True) if details is not None else None, ) def _record_audit_events(*events: AuditEvent) -> None: pending = [event for event in events if event is not None] if not pending: return try: db.session.add_all(pending) db.session.commit() except Exception: db.session.rollback() logger.exception("写入审计日志失败,共 %s 条事件。", len(pending)) def _pagination_params() -> tuple[int, int, int]: settings = _settings() try: page = int(request.args.get("page", "1")) except ValueError: page = 1 try: page_size = int(request.args.get("pageSize", str(settings.default_page_size))) except ValueError: page_size = settings.default_page_size page = max(page, 1) page_size = min(max(page_size, 1), settings.max_page_size) return page, page_size, (page - 1) * page_size def _pagination_payload(page: int, page_size: int, total: int) -> dict[str, int]: pages = (total + page_size - 1) // page_size if total else 0 return { "page": page, "pageSize": page_size, "total": total, "pages": pages, } def _normalize_pagination(page: int, page_size: int, total: int) -> tuple[int, int, int]: pages = (total + page_size - 1) // page_size if total else 0 if pages and page > pages: page = pages return page, (page - 1) * page_size, pages def _reserve_code(code: str) -> bool: result = db.session.execute( update(RedemptionCode) .where(_code_match(code), RedemptionCode.status == STATUS_AVAILABLE) .values(status=STATUS_PROCESSING) ) db.session.commit() return result.rowcount == 1 def _release_code(code: str) -> None: db.session.rollback() db.session.execute( update(RedemptionCode) .where(_code_match(code), RedemptionCode.status == STATUS_PROCESSING) .values( status=STATUS_AVAILABLE, used_at=None, used_by_username=None, used_by_principal_name=None, ) ) db.session.commit() def _complete_redemption(code: str, username: str, principal_name: str | None) -> bool: result = db.session.execute( update(RedemptionCode) .where(_code_match(code), RedemptionCode.status == STATUS_PROCESSING) .values( status=STATUS_USED, used_at=utc_now(), used_by_username=username, used_by_principal_name=principal_name, ) ) db.session.commit() return result.rowcount == 1 @bp_admin.get("/") def admin_index(): if not _authenticated(): return render_template("admin_login.html", settings=_settings()) return render_template("admin_dashboard.html", settings=_settings()) @bp_admin.get("/api/health") def health(): return _success(_health_payload()) @bp_admin.get("/api/session") def session_info(): return _success( { "authenticated": _authenticated(), "authEnabled": _settings().effective_auth_enabled, } ) @bp_admin.post("/api/login") def login(): settings = _settings() if not settings.effective_auth_enabled: session["authenticated"] = True session.permanent = True return _success({"authenticated": True}, message="当前平台未启用登录保护。") payload = _json_payload() username = str(payload.get("username", "")).strip() password = str(payload.get("password", "")).strip() if username == settings.admin_username and password == settings.admin_password: session["authenticated"] = True session.permanent = True session["admin_username"] = username return _success({"authenticated": True}, message="登录成功。") return _error("用户名或密码错误。", status=401) @bp_admin.post("/api/logout") def logout(): session.clear() return _success({"authenticated": False}, message="已退出登录。") @bp_admin.get("/api/config") @require_auth def config_info(): return _success(_settings().to_public_dict()) @bp_admin.get("/api/audit-events") @require_auth def list_audit_events(): event_type = request.args.get("eventType", "").strip() status = request.args.get("status", "").strip() page, page_size, offset = _pagination_params() query = db.select(AuditEvent) count_query = db.select(func.count()).select_from(AuditEvent) if event_type: query = query.where(AuditEvent.event_type == event_type) count_query = count_query.where(AuditEvent.event_type == event_type) if status: query = query.where(AuditEvent.status == status) count_query = count_query.where(AuditEvent.status == status) total = db.session.execute(count_query).scalar_one() page, offset, _ = _normalize_pagination(page, page_size, total) events = db.session.execute( query.order_by(AuditEvent.created_at.desc(), AuditEvent.id.desc()).offset(offset).limit(page_size) ).scalars().all() return _success({ "events": [event.to_dict() for event in events], **_pagination_payload(page, page_size, total), }) @bp_admin.get("/api/codes") @require_auth def list_codes(): status = request.args.get("status") page, page_size, offset = _pagination_params() query = db.select(RedemptionCode) count_query = db.select(func.count()).select_from(RedemptionCode) if status in {STATUS_AVAILABLE, STATUS_PROCESSING, STATUS_USED}: query = query.where(RedemptionCode.status == status) count_query = count_query.where(RedemptionCode.status == status) total = db.session.execute(count_query).scalar_one() page, offset, _ = _normalize_pagination(page, page_size, total) result = db.session.execute( query.order_by(RedemptionCode.created_at.desc()).offset(offset).limit(page_size) ).scalars().all() codes = [code.to_dict() for code in result] return _success({"codes": codes, **_pagination_payload(page, page_size, total)}) @bp_admin.post("/api/codes/generate") @require_auth def generate_codes(): payload = _json_payload() count = payload.get("count", 1) actor = _current_actor("auth-disabled-admin") if count < 1: count = 1 if count > 100: count = 100 codes = [] for _ in range(count): code = secrets.token_urlsafe(12) while RedemptionCode.query.filter_by(code=code).first(): code = secrets.token_urlsafe(12) redemption_code = RedemptionCode(code=code) db.session.add(redemption_code) codes.append(code) db.session.commit() _record_audit_events( *[ _build_audit_event( "code_generated", actor=actor, code=generated_code, details={"batchCount": len(codes)}, ) for generated_code in codes ] ) return _success({"codes": codes, "count": len(codes)}, f"成功生成 {count} 个兑换码。") @bp_admin.delete("/api/codes/") @require_auth def delete_code(code: str): actor = _current_actor("auth-disabled-admin") redemption_code = RedemptionCode.query.filter_by(code=code).first() if not redemption_code: return _error("兑换码不存在。", status=404) if redemption_code.status != STATUS_AVAILABLE: return _error("仅可删除未使用的兑换码。", status=409) db.session.delete(redemption_code) db.session.commit() _record_audit_events( _build_audit_event( "code_deleted", actor=actor, code=code, ) ) return _success(message="兑换码已删除。") @bp_admin.get("/api/records") @require_auth def list_records(): page, page_size, offset = _pagination_params() query = db.select(RedemptionCode).where(RedemptionCode.status == STATUS_USED) count_query = db.select(func.count()).select_from(RedemptionCode).where(RedemptionCode.status == STATUS_USED) total = db.session.execute(count_query).scalar_one() page, offset, _ = _normalize_pagination(page, page_size, total) records = db.session.execute( query.order_by(RedemptionCode.used_at.desc()).offset(offset).limit(page_size) ).scalars().all() return _success({ "records": [code.to_dict() for code in records], **_pagination_payload(page, page_size, total), }) @bp_user.get("/") def index(): return render_template("user_redemption.html", settings=_settings()) @bp_user.get("/api/health") def user_health(): return _success(_health_payload()) @bp_user.post("/api/redeem") def redeem(): payload = _json_payload() code = str(payload.get("code", "")).strip().upper() username = str(payload.get("username", "")).strip().lower() actor = _current_actor("public") if not code: return _error("请输入兑换码。", status=400) if not username: return _error("请输入用户名。", status=400) if not _reserve_code(code): _record_audit_events( _build_audit_event( "redeem_completed", status="failed", actor=actor, code=code, username=username, details={"message": "兑换码无效或已被使用。"}, ) ) return _error("兑换码无效或已被使用。", status=404) try: user_result = _service().create_user(username=username) except ServiceConfigurationError as exc: _release_code(code) _record_audit_events( _build_audit_event( "redeem_completed", status="failed", actor=actor, code=code, username=username, details={"message": str(exc)}, ) ) return _error(str(exc), status=503) except ServiceOperationError as exc: _release_code(code) _record_audit_events( _build_audit_event( "redeem_completed", status="failed", actor=actor, code=code, username=username, principal_name=(exc.details or {}).get("userPrincipalName") if isinstance(exc.details, dict) else None, details={"message": exc.message, "serviceDetails": exc.details}, ) ) return _error(exc.message, status=exc.status_code, details=exc.details) except Exception as exc: logger.exception("兑换码 %s 开通账号时发生未预期错误", code) _release_code(code) _record_audit_events( _build_audit_event( "redeem_completed", status="failed", actor=actor, code=code, username=username, details={"message": f"创建账号失败: {exc}"}, ) ) return _error(f"创建账号失败: {exc}", status=500) if not _complete_redemption(code, username, user_result.get("userPrincipalName")): logger.error("账号 %s 已创建,但兑换码 %s 未能完成最终状态更新。", user_result.get("userPrincipalName"), code) _record_audit_events( _build_audit_event( "redeem_completed", status="warning", actor=actor, code=code, username=username, principal_name=user_result.get("userPrincipalName"), details={"message": "账号已创建,但兑换码状态更新失败。"}, ) ) return _error( "账号已创建,但兑换码状态更新失败,请联系管理员处理。", status=500, details={"userPrincipalName": user_result.get("userPrincipalName")}, ) _record_audit_events( _build_audit_event( "redeem_completed", status="success", actor=actor, code=code, username=username, principal_name=user_result.get("userPrincipalName"), details={ "licenseAssigned": user_result.get("licenseAssigned"), "licenseMessage": user_result.get("licenseMessage"), }, ) ) return _success({ "userPrincipalName": user_result.get("userPrincipalName"), "temporaryPassword": user_result.get("temporaryPassword"), "licenseAssigned": user_result.get("licenseAssigned"), "licenseMessage": user_result.get("licenseMessage"), }, "账号开通成功!", status=201) @bp_user.get("/api/config") def config(): return _success(_settings().to_public_dict())