507 lines
15 KiB
Python
507 lines
15 KiB
Python
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/<code>")
|
|
@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())
|