Harden redemption flow and improve operational safety
This commit is contained in:
@@ -9,6 +9,7 @@ from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.engine.url import make_url
|
||||
|
||||
from .services import Office365Service
|
||||
from .settings import Settings, load_settings
|
||||
@@ -17,6 +18,13 @@ from .settings import Settings, load_settings
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
def _ensure_sqlite_directory(database_url: str) -> None:
|
||||
url = make_url(database_url)
|
||||
if url.drivername != "sqlite" or not url.database or url.database == ":memory:":
|
||||
return
|
||||
Path(url.database).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _configure_logging(app: Flask) -> None:
|
||||
log_dir = Path(app.root_path).parent / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -62,11 +70,12 @@ def create_app(
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
|
||||
_configure_logging(app)
|
||||
_ensure_sqlite_directory(settings.database_url)
|
||||
|
||||
db.init_app(app)
|
||||
|
||||
with app.app_context():
|
||||
from .models import RedemptionCode
|
||||
from .models import AuditEvent, RedemptionCode
|
||||
db.create_all()
|
||||
|
||||
if service_factory is None:
|
||||
@@ -83,4 +92,4 @@ def create_app(
|
||||
app.register_blueprint(bp_admin)
|
||||
app.register_blueprint(bp_user)
|
||||
|
||||
return app
|
||||
return app
|
||||
|
||||
@@ -30,9 +30,29 @@ class TokenManager:
|
||||
"client_secret": self.client_secret,
|
||||
"scope": self.scope,
|
||||
}
|
||||
response = requests.post(self.token_endpoint, data=data, timeout=30)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
try:
|
||||
response = requests.post(self.token_endpoint, data=data, timeout=30)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
status_code = getattr(getattr(exc, "response", None), "status_code", 0) or 0
|
||||
response_payload = None
|
||||
response_text = ""
|
||||
if getattr(exc, "response", None) is not None:
|
||||
response_text = exc.response.text[:200]
|
||||
try:
|
||||
response_payload = exc.response.json()
|
||||
except ValueError:
|
||||
response_payload = None
|
||||
message = "获取访问令牌失败"
|
||||
if response_text:
|
||||
message = f"{message}: {response_text}"
|
||||
raise GraphAPIError(message, status_code=status_code, response=response_payload) from exc
|
||||
|
||||
try:
|
||||
token_data = response.json()
|
||||
except ValueError as exc:
|
||||
raise GraphAPIError("解析访问令牌响应失败", response.status_code) from exc
|
||||
|
||||
self._token = token_data["access_token"]
|
||||
expires_in = token_data.get("expires_in", 3600)
|
||||
self._token_expires_at = time.time() + expires_in
|
||||
@@ -127,4 +147,4 @@ class GraphClient:
|
||||
else:
|
||||
payload["addLicenses"] = []
|
||||
payload["removeLicenses"] = remove_licenses if remove_licenses else []
|
||||
return self.post(f"/users/{user_id}/assignLicense", json=payload)
|
||||
return self.post(f"/users/{user_id}/assignLicense", json=payload)
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from . import db
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def serialize_datetime(value: datetime | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
class RedemptionCode(db.Model):
|
||||
__tablename__ = "redemption_codes"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
code = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||
status = db.Column(db.String(16), nullable=False, default="available")
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now())
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=utc_now)
|
||||
used_at = db.Column(db.DateTime, nullable=True)
|
||||
used_by_username = db.Column(db.String(256), nullable=True)
|
||||
used_by_principal_name = db.Column(db.String(256), nullable=True)
|
||||
@@ -21,8 +34,42 @@ class RedemptionCode(db.Model):
|
||||
"id": self.id,
|
||||
"code": self.code,
|
||||
"status": self.status,
|
||||
"createdAt": self.created_at.isoformat() if self.created_at else None,
|
||||
"usedAt": self.used_at.isoformat() if self.used_at else None,
|
||||
"createdAt": serialize_datetime(self.created_at),
|
||||
"usedAt": serialize_datetime(self.used_at),
|
||||
"usedByUsername": self.used_by_username,
|
||||
"usedByPrincipalName": self.used_by_principal_name,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AuditEvent(db.Model):
|
||||
__tablename__ = "audit_events"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
event_type = db.Column(db.String(64), nullable=False, index=True)
|
||||
status = db.Column(db.String(16), nullable=False, default="success", index=True)
|
||||
actor = db.Column(db.String(128), nullable=False, default="system")
|
||||
code = db.Column(db.String(64), nullable=True, index=True)
|
||||
username = db.Column(db.String(256), nullable=True)
|
||||
principal_name = db.Column(db.String(256), nullable=True)
|
||||
details = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=utc_now, index=True)
|
||||
|
||||
def to_dict(self):
|
||||
parsed_details = None
|
||||
if self.details:
|
||||
try:
|
||||
parsed_details = json.loads(self.details)
|
||||
except ValueError:
|
||||
parsed_details = {"raw": self.details}
|
||||
|
||||
return {
|
||||
"id": self.id,
|
||||
"eventType": self.event_type,
|
||||
"status": self.status,
|
||||
"actor": self.actor,
|
||||
"code": self.code,
|
||||
"username": self.username,
|
||||
"principalName": self.principal_name,
|
||||
"details": parsed_details,
|
||||
"createdAt": serialize_datetime(self.created_at),
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -43,16 +43,16 @@ class Office365Service:
|
||||
return self._graph_client
|
||||
|
||||
def create_user(self, username: str, password: str | None = None, display_name: str | None = None, retry: bool = True) -> dict[str, Any]:
|
||||
upn, mail_nickname = self._build_user_identifiers(username)
|
||||
client = self._ensure_client()
|
||||
upn = f"{username}@{self.settings.default_domain}"
|
||||
|
||||
password = password or self.settings.default_password
|
||||
display_name = display_name or username
|
||||
display_name = display_name or mail_nickname
|
||||
|
||||
create_payload = {
|
||||
"accountEnabled": True,
|
||||
"displayName": display_name,
|
||||
"mailNickname": username,
|
||||
"mailNickname": mail_nickname,
|
||||
"userPrincipalName": upn,
|
||||
"passwordProfile": {
|
||||
"password": password,
|
||||
@@ -71,8 +71,17 @@ class Office365Service:
|
||||
raise self._translate_graph_error(exc, f"创建用户 {upn} 失败")
|
||||
|
||||
license_result = None
|
||||
license_message = None
|
||||
if self.settings.default_license_sku:
|
||||
license_result = self._assign_license(user["id"])
|
||||
license_result, license_message, license_status = self._assign_license(user["id"])
|
||||
if license_message and self.settings.license_assignment_required:
|
||||
self._rollback_user_for_license_failure(
|
||||
client=client,
|
||||
user_id=user["id"],
|
||||
user_principal_name=upn,
|
||||
license_message=license_message,
|
||||
license_status=license_status,
|
||||
)
|
||||
|
||||
return {
|
||||
"user": user,
|
||||
@@ -80,37 +89,98 @@ class Office365Service:
|
||||
"temporaryPassword": password,
|
||||
"licenseAssigned": bool(license_result),
|
||||
"licenseResult": license_result,
|
||||
"licenseMessage": license_message,
|
||||
}
|
||||
|
||||
def _assign_license(self, user_id: str) -> dict[str, Any]:
|
||||
def _build_user_identifiers(self, username: str) -> tuple[str, str]:
|
||||
normalized = (username or "").strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("请输入用户名。")
|
||||
|
||||
if "@" in normalized:
|
||||
local_part, _, domain = normalized.partition("@")
|
||||
if not local_part or not domain:
|
||||
raise ValueError("请输入有效的完整邮箱地址。")
|
||||
return normalized, local_part
|
||||
|
||||
if not self.settings.default_domain:
|
||||
raise ServiceConfigurationError("DEFAULT_DOMAIN 未配置,请输入完整邮箱地址后重试。")
|
||||
|
||||
return f"{normalized}@{self.settings.default_domain}", normalized
|
||||
|
||||
def _assign_license(self, user_id: str) -> tuple[dict[str, Any] | None, str | None, int]:
|
||||
client = self._ensure_client()
|
||||
sku_part_number = self.settings.default_license_sku
|
||||
|
||||
try:
|
||||
skus = client.list_subscribed_skus()
|
||||
except GraphAPIError as exc:
|
||||
logger.warning("获取许可证列表失败: %s", exc)
|
||||
return None
|
||||
message = f"获取许可证列表失败: {exc.message or exc}"
|
||||
logger.warning(message)
|
||||
return None, message, exc.status_code or 502
|
||||
|
||||
matched = next(
|
||||
(sku for sku in skus if (sku.get("skuPartNumber") or "").upper() == sku_part_number.upper()),
|
||||
None,
|
||||
)
|
||||
if not matched:
|
||||
logger.warning("未找到许可证 SKU: %s", sku_part_number)
|
||||
return None
|
||||
message = f"未找到许可证 SKU: {sku_part_number}"
|
||||
logger.warning(message)
|
||||
return None, message, 409
|
||||
if int(matched.get("consumedUnits", 0) or 0) >= int(matched.get("prepaidUnits", {}).get("enabled", 0) or 0):
|
||||
logger.warning("许可证 %s 已无可用席位", sku_part_number)
|
||||
return None
|
||||
message = f"许可证 {sku_part_number} 已无可用席位"
|
||||
logger.warning(message)
|
||||
return None, message, 409
|
||||
|
||||
try:
|
||||
return client.assign_license(
|
||||
user_id,
|
||||
add_licenses=[{"skuId": matched["skuId"], "disabledPlans": []}],
|
||||
return (
|
||||
client.assign_license(
|
||||
user_id,
|
||||
add_licenses=[{"skuId": matched["skuId"], "disabledPlans": []}],
|
||||
),
|
||||
None,
|
||||
200,
|
||||
)
|
||||
except GraphAPIError as exc:
|
||||
logger.warning("分配许可证失败: %s", exc)
|
||||
return None
|
||||
message = f"分配许可证失败: {exc.message or exc}"
|
||||
logger.warning(message)
|
||||
return None, message, exc.status_code or 502
|
||||
|
||||
def _rollback_user_for_license_failure(
|
||||
self,
|
||||
client: GraphClient,
|
||||
user_id: str,
|
||||
user_principal_name: str,
|
||||
license_message: str,
|
||||
license_status: int,
|
||||
) -> None:
|
||||
try:
|
||||
client.delete_user(user_id)
|
||||
except GraphAPIError as exc:
|
||||
delete_message = exc.message or str(exc)
|
||||
raise ServiceOperationError(
|
||||
message=(
|
||||
f"账号 {user_principal_name} 已创建,但许可证分配失败且回滚删除失败。"
|
||||
f"{license_message};删除失败: {delete_message}"
|
||||
),
|
||||
status_code=502,
|
||||
details={
|
||||
"userPrincipalName": user_principal_name,
|
||||
"licenseError": license_message,
|
||||
"rollbackDeleteError": delete_message,
|
||||
"rolledBack": False,
|
||||
},
|
||||
) from exc
|
||||
|
||||
raise ServiceOperationError(
|
||||
message=f"许可证分配失败,已回滚删除账号 {user_principal_name}。{license_message}",
|
||||
status_code=license_status or 409,
|
||||
details={
|
||||
"userPrincipalName": user_principal_name,
|
||||
"licenseError": license_message,
|
||||
"rolledBack": True,
|
||||
},
|
||||
)
|
||||
|
||||
def _translate_graph_error(self, exc: GraphAPIError, fallback_message: str) -> ServiceOperationError:
|
||||
message = fallback_message
|
||||
@@ -120,4 +190,4 @@ class Office365Service:
|
||||
lowered = message.lower()
|
||||
if "already exists" in lowered or "another object with the same value" in lowered:
|
||||
status_code = 409
|
||||
return ServiceOperationError(message=message, status_code=status_code, details=exc.response)
|
||||
return ServiceOperationError(message=message, status_code=status_code, details=exc.response)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -22,6 +23,24 @@ def _env_int(name: str, default: int) -> int:
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_database_url(database_url: str, warnings: list[str]) -> str:
|
||||
normalized = database_url.strip()
|
||||
if not normalized:
|
||||
return "sqlite:///redemption.db"
|
||||
|
||||
container_prefix = "sqlite:////app/"
|
||||
if normalized.startswith(container_prefix) and not Path("/.dockerenv").exists():
|
||||
local_relative = normalized.removeprefix(container_prefix)
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
local_path = (project_root / local_relative).resolve()
|
||||
warnings.append(
|
||||
f"DATABASE_URL 使用容器路径时,已自动映射到本地路径 {local_path}。"
|
||||
)
|
||||
return f"sqlite:///{local_path}"
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
app_name: str
|
||||
@@ -39,6 +58,7 @@ class Settings:
|
||||
default_domain: str
|
||||
default_usage_location: str
|
||||
default_license_sku: str
|
||||
license_assignment_required: bool
|
||||
force_change_password: bool
|
||||
graph_base_url: str
|
||||
token_endpoint: str
|
||||
@@ -68,6 +88,7 @@ class Settings:
|
||||
"defaultDomain": self.default_domain,
|
||||
"defaultUsageLocation": self.default_usage_location,
|
||||
"defaultLicenseSku": self.default_license_sku,
|
||||
"licenseAssignmentRequired": self.license_assignment_required,
|
||||
"forceChangePassword": self.force_change_password,
|
||||
"pageSize": self.default_page_size,
|
||||
"maxPageSize": self.max_page_size,
|
||||
@@ -84,6 +105,7 @@ def load_settings() -> Settings:
|
||||
|
||||
validation_errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
database_url = _normalize_database_url(os.getenv("DATABASE_URL", "sqlite:///redemption.db"), warnings)
|
||||
|
||||
required_fields = {
|
||||
"CLIENT_ID": os.getenv("CLIENT_ID", "").strip(),
|
||||
@@ -121,13 +143,14 @@ def load_settings() -> Settings:
|
||||
default_domain=os.getenv("DEFAULT_DOMAIN", "").strip(),
|
||||
default_usage_location=os.getenv("DEFAULT_USAGE_LOCATION", "US").strip() or "US",
|
||||
default_license_sku=os.getenv("DEFAULT_LICENSE_SKU", "").strip(),
|
||||
license_assignment_required=_env_bool("LICENSE_ASSIGNMENT_REQUIRED", False),
|
||||
force_change_password=_env_bool("FORCE_CHANGE_PASSWORD", True),
|
||||
graph_base_url=graph_base_url,
|
||||
token_endpoint=token_endpoint,
|
||||
scope=scope,
|
||||
database_url=os.getenv("DATABASE_URL", "sqlite:///redemption.db").strip(),
|
||||
database_url=database_url,
|
||||
default_page_size=min(max(_env_int("DEFAULT_PAGE_SIZE", 25), 1), 100),
|
||||
max_page_size=min(max(_env_int("MAX_PAGE_SIZE", 100), 10), 500),
|
||||
validation_errors=tuple(validation_errors),
|
||||
warnings=tuple(warnings),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<nav class="nav flex-column">
|
||||
<a class="nav-link active" href="#" data-tab="codes">兑换码管理</a>
|
||||
<a class="nav-link" href="#" data-tab="records">兑换记录</a>
|
||||
<a class="nav-link" href="#" data-tab="audit">审计日志</a>
|
||||
<a class="nav-link" href="#" id="logoutBtn">退出登录</a>
|
||||
</nav>
|
||||
<div class="px-3 mt-3">
|
||||
@@ -42,6 +43,7 @@
|
||||
<div class="mb-3">
|
||||
<button class="btn btn-sm btn-outline-secondary filter-btn" data-filter="all">全部</button>
|
||||
<button class="btn btn-sm btn-outline-secondary filter-btn" data-filter="available">可用</button>
|
||||
<button class="btn btn-sm btn-outline-secondary filter-btn" data-filter="processing">处理中</button>
|
||||
<button class="btn btn-sm btn-outline-secondary filter-btn" data-filter="used">已使用</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
@@ -59,6 +61,18 @@
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<label class="form-label mb-0" for="codesPageSize">每页</label>
|
||||
<select class="form-select form-select-sm w-auto" id="codesPageSize"></select>
|
||||
<small class="text-muted" id="codesSummary">共 0 条</small>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-outline-secondary" id="codesPrevBtn">上一页</button>
|
||||
<button class="btn btn-sm btn-outline-secondary disabled" id="codesPageIndicator">第 1 / 1 页</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="codesNextBtn">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,6 +94,54 @@
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<label class="form-label mb-0" for="recordsPageSize">每页</label>
|
||||
<select class="form-select form-select-sm w-auto" id="recordsPageSize"></select>
|
||||
<small class="text-muted" id="recordsSummary">共 0 条</small>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-outline-secondary" id="recordsPrevBtn">上一页</button>
|
||||
<button class="btn btn-sm btn-outline-secondary disabled" id="recordsPageIndicator">第 1 / 1 页</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="recordsNextBtn">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content d-none" id="auditTab">
|
||||
<h4 class="mb-3">审计日志</h4>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover" id="auditTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>事件</th>
|
||||
<th>状态</th>
|
||||
<th>操作人</th>
|
||||
<th>兑换码</th>
|
||||
<th>账号</th>
|
||||
<th>详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<label class="form-label mb-0" for="auditPageSize">每页</label>
|
||||
<select class="form-select form-select-sm w-auto" id="auditPageSize"></select>
|
||||
<small class="text-muted" id="auditSummary">共 0 条</small>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-outline-secondary" id="auditPrevBtn">上一页</button>
|
||||
<button class="btn btn-sm btn-outline-secondary disabled" id="auditPageIndicator">第 1 / 1 页</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="auditNextBtn">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,42 +176,174 @@
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
const defaultPageSize = {{ settings.default_page_size }};
|
||||
const maxPageSize = {{ settings.max_page_size }};
|
||||
let currentFilter = 'all';
|
||||
const paginationState = {
|
||||
codes: { page: 1, pageSize: defaultPageSize, total: 0, pages: 0 },
|
||||
records: { page: 1, pageSize: defaultPageSize, total: 0, pages: 0 },
|
||||
audit: { page: 1, pageSize: defaultPageSize, total: 0, pages: 0 }
|
||||
};
|
||||
|
||||
async function loadCodes() {
|
||||
const url = currentFilter === 'all' ? '/admin/api/codes' : `/admin/api/codes?status=${currentFilter}`;
|
||||
function renderStatusBadge(status) {
|
||||
if (status === 'available') {
|
||||
return '<span class="badge bg-success">可用</span>';
|
||||
}
|
||||
if (status === 'processing') {
|
||||
return '<span class="badge bg-warning text-dark">处理中</span>';
|
||||
}
|
||||
return '<span class="badge bg-secondary">已使用</span>';
|
||||
}
|
||||
|
||||
function renderAuditStatus(status) {
|
||||
if (status === 'success') {
|
||||
return '<span class="badge bg-success">成功</span>';
|
||||
}
|
||||
if (status === 'warning') {
|
||||
return '<span class="badge bg-warning text-dark">警告</span>';
|
||||
}
|
||||
return '<span class="badge bg-danger">失败</span>';
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function renderAuditDetails(details) {
|
||||
if (!details) return '-';
|
||||
if (details.message) return escapeHtml(details.message);
|
||||
return escapeHtml(JSON.stringify(details));
|
||||
}
|
||||
|
||||
function pageSizeOptions() {
|
||||
return [...new Set([10, 25, 50, 100, defaultPageSize])]
|
||||
.filter(size => size <= maxPageSize)
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function initPageSizeSelect(id, value, onChange) {
|
||||
const select = document.getElementById(id);
|
||||
select.innerHTML = pageSizeOptions().map(size => `
|
||||
<option value="${size}" ${size === value ? 'selected' : ''}>${size}</option>
|
||||
`).join('');
|
||||
select.addEventListener('change', () => onChange(parseInt(select.value, 10) || defaultPageSize));
|
||||
}
|
||||
|
||||
function updatePager(prefix, state) {
|
||||
const pageCount = state.pages || 1;
|
||||
document.getElementById(`${prefix}Summary`).textContent = `共 ${state.total} 条`;
|
||||
document.getElementById(`${prefix}PageIndicator`).textContent = `第 ${state.page} / ${pageCount} 页`;
|
||||
document.getElementById(`${prefix}PrevBtn`).disabled = state.page <= 1;
|
||||
document.getElementById(`${prefix}NextBtn`).disabled = state.total === 0 || state.page >= pageCount;
|
||||
}
|
||||
|
||||
function renderEmptyRow(tableBodySelector, colspan, message) {
|
||||
document.querySelector(tableBodySelector).innerHTML = `
|
||||
<tr><td colspan="${colspan}" class="text-center text-muted py-4">${message}</td></tr>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadCodes(page = paginationState.codes.page) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(paginationState.codes.pageSize)
|
||||
});
|
||||
if (currentFilter !== 'all') {
|
||||
params.set('status', currentFilter);
|
||||
}
|
||||
|
||||
const url = `/admin/api/codes?${params.toString()}`;
|
||||
const response = await fetch(url, { credentials: 'same-origin' });
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
paginationState.codes.page = data.data.page;
|
||||
paginationState.codes.pageSize = data.data.pageSize;
|
||||
paginationState.codes.total = data.data.total;
|
||||
paginationState.codes.pages = data.data.pages;
|
||||
const tbody = document.querySelector('#codesTable tbody');
|
||||
tbody.innerHTML = data.data.codes.map(code => `
|
||||
<tr>
|
||||
<td><code>${code.code}</code></td>
|
||||
<td><span class="badge ${code.status === 'available' ? 'bg-success' : 'bg-secondary'}">${code.status === 'available' ? '可用' : '已使用'}</span></td>
|
||||
<td>${code.createdAt ? new Date(code.createdAt).toLocaleString() : '-'}</td>
|
||||
<td>${code.usedAt ? new Date(code.usedAt).toLocaleString() : '-'}</td>
|
||||
<td>${code.usedByUsername || '-'}</td>
|
||||
<td class="table-actions">
|
||||
${code.status === 'available' ? `<button class="btn btn-danger btn-sm" onclick="deleteCode('${code.code}')">删除</button>` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
if (!data.data.codes.length) {
|
||||
renderEmptyRow('#codesTable tbody', 6, '当前筛选条件下暂无兑换码');
|
||||
} else {
|
||||
tbody.innerHTML = data.data.codes.map(code => `
|
||||
<tr>
|
||||
<td><code>${escapeHtml(code.code)}</code></td>
|
||||
<td>${renderStatusBadge(code.status)}</td>
|
||||
<td>${code.createdAt ? new Date(code.createdAt).toLocaleString() : '-'}</td>
|
||||
<td>${code.usedAt ? new Date(code.usedAt).toLocaleString() : '-'}</td>
|
||||
<td>${escapeHtml(code.usedByUsername || '-')}</td>
|
||||
<td class="table-actions">
|
||||
${code.status === 'available' ? `<button class="btn btn-danger btn-sm" onclick="deleteCode('${code.code}')">删除</button>` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
updatePager('codes', paginationState.codes);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecords() {
|
||||
const response = await fetch('/admin/api/records', { credentials: 'same-origin' });
|
||||
async function loadRecords(page = paginationState.records.page) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(paginationState.records.pageSize)
|
||||
});
|
||||
const response = await fetch(`/admin/api/records?${params.toString()}`, { credentials: 'same-origin' });
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
paginationState.records.page = data.data.page;
|
||||
paginationState.records.pageSize = data.data.pageSize;
|
||||
paginationState.records.total = data.data.total;
|
||||
paginationState.records.pages = data.data.pages;
|
||||
const tbody = document.querySelector('#recordsTable tbody');
|
||||
tbody.innerHTML = data.data.records.map(code => `
|
||||
<tr>
|
||||
<td><code>${code.code}</code></td>
|
||||
<td>${code.usedByUsername || '-'}</td>
|
||||
<td>${code.usedByPrincipalName || '-'}</td>
|
||||
<td>${code.usedAt ? new Date(code.usedAt).toLocaleString() : '-'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
if (!data.data.records.length) {
|
||||
renderEmptyRow('#recordsTable tbody', 4, '暂无兑换记录');
|
||||
} else {
|
||||
tbody.innerHTML = data.data.records.map(code => `
|
||||
<tr>
|
||||
<td><code>${escapeHtml(code.code)}</code></td>
|
||||
<td>${escapeHtml(code.usedByUsername || '-')}</td>
|
||||
<td>${escapeHtml(code.usedByPrincipalName || '-')}</td>
|
||||
<td>${code.usedAt ? new Date(code.usedAt).toLocaleString() : '-'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
updatePager('records', paginationState.records);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAudit(page = paginationState.audit.page) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(paginationState.audit.pageSize)
|
||||
});
|
||||
const response = await fetch(`/admin/api/audit-events?${params.toString()}`, { credentials: 'same-origin' });
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
paginationState.audit.page = data.data.page;
|
||||
paginationState.audit.pageSize = data.data.pageSize;
|
||||
paginationState.audit.total = data.data.total;
|
||||
paginationState.audit.pages = data.data.pages;
|
||||
const tbody = document.querySelector('#auditTable tbody');
|
||||
if (!data.data.events.length) {
|
||||
renderEmptyRow('#auditTable tbody', 7, '暂无审计日志');
|
||||
} else {
|
||||
tbody.innerHTML = data.data.events.map(event => `
|
||||
<tr>
|
||||
<td>${event.createdAt ? new Date(event.createdAt).toLocaleString() : '-'}</td>
|
||||
<td>${escapeHtml(event.eventType)}</td>
|
||||
<td>${renderAuditStatus(event.status)}</td>
|
||||
<td>${escapeHtml(event.actor || '-')}</td>
|
||||
<td>${event.code ? `<code>${escapeHtml(event.code)}</code>` : '-'}</td>
|
||||
<td>${escapeHtml(event.principalName || event.username || '-')}</td>
|
||||
<td>${renderAuditDetails(event.details)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
updatePager('audit', paginationState.audit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +352,7 @@
|
||||
const response = await fetch(`/admin/api/codes/${code}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
loadCodes();
|
||||
loadCodes(paginationState.codes.page);
|
||||
} else {
|
||||
alert(data.message);
|
||||
}
|
||||
@@ -169,7 +363,8 @@
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('btn-secondary', 'active'));
|
||||
btn.classList.add('btn-secondary', 'active');
|
||||
currentFilter = btn.dataset.filter;
|
||||
loadCodes();
|
||||
paginationState.codes.page = 1;
|
||||
loadCodes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,6 +377,7 @@
|
||||
document.getElementById(link.dataset.tab + 'Tab').classList.remove('d-none');
|
||||
if (link.dataset.tab === 'codes') loadCodes();
|
||||
if (link.dataset.tab === 'records') loadRecords();
|
||||
if (link.dataset.tab === 'audit') loadAudit();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,7 +394,8 @@
|
||||
const textarea = document.querySelector('#generatedCodes textarea');
|
||||
textarea.value = data.data.codes.join('\n');
|
||||
document.getElementById('generatedCodes').classList.remove('d-none');
|
||||
loadCodes();
|
||||
paginationState.codes.page = 1;
|
||||
loadCodes(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -207,7 +404,29 @@
|
||||
window.location.href = '/admin/';
|
||||
});
|
||||
|
||||
initPageSizeSelect('codesPageSize', paginationState.codes.pageSize, (value) => {
|
||||
paginationState.codes.pageSize = value;
|
||||
paginationState.codes.page = 1;
|
||||
loadCodes(1);
|
||||
});
|
||||
initPageSizeSelect('recordsPageSize', paginationState.records.pageSize, (value) => {
|
||||
paginationState.records.pageSize = value;
|
||||
paginationState.records.page = 1;
|
||||
loadRecords(1);
|
||||
});
|
||||
initPageSizeSelect('auditPageSize', paginationState.audit.pageSize, (value) => {
|
||||
paginationState.audit.pageSize = value;
|
||||
paginationState.audit.page = 1;
|
||||
loadAudit(1);
|
||||
});
|
||||
document.getElementById('codesPrevBtn').addEventListener('click', () => loadCodes(paginationState.codes.page - 1));
|
||||
document.getElementById('codesNextBtn').addEventListener('click', () => loadCodes(paginationState.codes.page + 1));
|
||||
document.getElementById('recordsPrevBtn').addEventListener('click', () => loadRecords(paginationState.records.page - 1));
|
||||
document.getElementById('recordsNextBtn').addEventListener('click', () => loadRecords(paginationState.records.page + 1));
|
||||
document.getElementById('auditPrevBtn').addEventListener('click', () => loadAudit(paginationState.audit.page - 1));
|
||||
document.getElementById('auditNextBtn').addEventListener('click', () => loadAudit(paginationState.audit.page + 1));
|
||||
|
||||
loadCodes();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -27,6 +27,15 @@
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
@@ -40,9 +49,9 @@
|
||||
if (data.success) {
|
||||
window.location.href = '/admin/';
|
||||
} else {
|
||||
document.getElementById('message').innerHTML = `<div class="alert alert-danger">${data.message}</div>`;
|
||||
document.getElementById('message').innerHTML = `<div class="alert alert-danger">${escapeHtml(data.message)}</div>`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -25,11 +25,16 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">用户名</label>
|
||||
{% if settings.default_domain %}
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="usernameInput" placeholder="请输入用户名" required>
|
||||
<span class="input-group-text">@{{ settings.default_domain }}</span>
|
||||
</div>
|
||||
<div class="form-text">请输入您想要的用户名,将自动拼接域名为完整邮箱地址</div>
|
||||
{% else %}
|
||||
<input type="text" class="form-control" id="usernameInput" placeholder="请输入完整邮箱地址,例如 alice@example.com" required>
|
||||
<div class="form-text">当前未配置默认域名,请直接输入完整邮箱地址。</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100" id="redeemBtn">立即开通</button>
|
||||
</div>
|
||||
@@ -50,11 +55,21 @@
|
||||
<div class="alert alert-info">
|
||||
<strong>提示:</strong>首次登录后系统会要求您更改密码,请使用临时密码登录。
|
||||
</div>
|
||||
<div class="alert alert-warning d-none" id="licenseWarning"></div>
|
||||
<button class="btn btn-outline-secondary w-100" onclick="location.reload()">开通另一个账号</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
document.getElementById('redeemBtn').addEventListener('click', async () => {
|
||||
const code = document.getElementById('codeInput').value.trim();
|
||||
const username = document.getElementById('usernameInput').value.trim();
|
||||
@@ -81,8 +96,16 @@
|
||||
document.getElementById('successResult').classList.remove('d-none');
|
||||
document.getElementById('resultEmail').textContent = data.data.userPrincipalName;
|
||||
document.getElementById('resultPassword').textContent = data.data.temporaryPassword;
|
||||
const licenseWarning = document.getElementById('licenseWarning');
|
||||
if (data.data.licenseAssigned === false && data.data.licenseMessage) {
|
||||
licenseWarning.textContent = data.data.licenseMessage;
|
||||
licenseWarning.classList.remove('d-none');
|
||||
} else {
|
||||
licenseWarning.classList.add('d-none');
|
||||
licenseWarning.textContent = '';
|
||||
}
|
||||
} else {
|
||||
document.getElementById('message').innerHTML = `<div class="alert alert-danger">${data.message}</div>`;
|
||||
document.getElementById('message').innerHTML = `<div class="alert alert-danger">${escapeHtml(data.message)}</div>`;
|
||||
btn.disabled = false;
|
||||
btn.textContent = '立即开通';
|
||||
}
|
||||
@@ -94,4 +117,4 @@
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user