Harden redemption flow and improve operational safety
This commit is contained in:
@@ -1,19 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, current_app, jsonify, render_template, request, session
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import func, update
|
||||
|
||||
from . import db
|
||||
from .models import RedemptionCode
|
||||
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():
|
||||
@@ -67,6 +73,133 @@ 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():
|
||||
@@ -76,13 +209,7 @@ def admin_index():
|
||||
|
||||
@bp_admin.get("/api/health")
|
||||
def health():
|
||||
settings = _settings()
|
||||
return _success(
|
||||
{
|
||||
"platform": settings.to_public_dict(),
|
||||
"authenticated": _authenticated(),
|
||||
}
|
||||
)
|
||||
return _success(_health_payload())
|
||||
|
||||
|
||||
@bp_admin.get("/api/session")
|
||||
@@ -110,6 +237,7 @@ def login():
|
||||
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)
|
||||
|
||||
@@ -126,20 +254,54 @@ 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 == "available":
|
||||
query = query.where(RedemptionCode.status == "available")
|
||||
elif status == "used":
|
||||
query = query.where(RedemptionCode.status == "used")
|
||||
if status in {STATUS_AVAILABLE, STATUS_PROCESSING, STATUS_USED}:
|
||||
query = query.where(RedemptionCode.status == status)
|
||||
count_query = count_query.where(RedemptionCode.status == status)
|
||||
|
||||
result = db.session.execute(query.order_by(RedemptionCode.created_at.desc())).scalars().all()
|
||||
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, "total": len(codes)})
|
||||
return _success({"codes": codes, **_pagination_payload(page, page_size, total)})
|
||||
|
||||
|
||||
@bp_admin.post("/api/codes/generate")
|
||||
@@ -147,6 +309,7 @@ def list_codes():
|
||||
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:
|
||||
@@ -163,40 +326,58 @@ def generate_codes():
|
||||
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 = int(request.args.get("page", "1"))
|
||||
page_size = int(request.args.get("pageSize", "25"))
|
||||
page, page_size, offset = _pagination_params()
|
||||
|
||||
query = db.select(RedemptionCode).where(RedemptionCode.status == "used")
|
||||
result = db.session.execute(query.order_by(RedemptionCode.used_at.desc())).scalars().all()
|
||||
|
||||
total = len(result)
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
records = result[start:end]
|
||||
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],
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"total": total,
|
||||
**_pagination_payload(page, page_size, total),
|
||||
})
|
||||
|
||||
|
||||
@@ -205,43 +386,121 @@ 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)
|
||||
|
||||
redemption_code = RedemptionCode.query.filter(
|
||||
func.lower(RedemptionCode.code) == code.lower(),
|
||||
RedemptionCode.status == "available"
|
||||
).first()
|
||||
if not redemption_code:
|
||||
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:
|
||||
return _error(str(exc), status=500)
|
||||
_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)
|
||||
|
||||
redemption_code.status = "used"
|
||||
redemption_code.used_at = datetime.now(timezone.utc)
|
||||
redemption_code.used_by_username = username
|
||||
redemption_code.used_by_principal_name = user_result.get("userPrincipalName")
|
||||
db.session.commit()
|
||||
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())
|
||||
return _success(_settings().to_public_dict())
|
||||
|
||||
Reference in New Issue
Block a user