diff --git a/.env.example b/.env.example index b3d7056..7db9c79 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,9 @@ DEFAULT_USAGE_LOCATION=US DEFAULT_LICENSE_SKU=ENTERPRISEPACK LICENSE_ASSIGNMENT_REQUIRED=false FORCE_CHANGE_PASSWORD=true + +# 妖火论坛私信验证 +YAOHUO_VERIFICATION_ENABLED=false +YAOHUO_COOKIE= +YAOHUO_MESSAGE_URL=https://www.yaohuo.me/bbs/messagelist_add.aspx +YAOHUO_VERIFICATION_CODE_TTL_SECONDS=600 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..76ba027 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# AGENTS.md + +## Stack And Entry Points +- Single-package Flask app. Runtime entrypoint is `app.py`, which exposes `app = create_app()` for Gunicorn and local runs. +- App wiring lives in `office365_self_service/__init__.py`: settings load first, logging is configured, SQLite directories are created if needed, `db.create_all()` runs during app startup, and blueprints from `office365_self_service/routes.py` are registered there. +- Core behavior is split by file: `routes.py` handles both HTML pages and JSON APIs, `services.py` contains Office 365 business logic, `graph.py` wraps Microsoft Graph calls, and `models.py` defines the SQLite-backed SQLAlchemy models. + +## Commands +- Create a local env and install deps with `python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt`. +- Run locally with `python3 app.py`. +- Run the test suite with `python3 -m unittest`. +- Run one test class with `python3 -m unittest tests.test_app.AppRouteTests`. +- Run one test method with `python3 -m unittest tests.test_app.AppRouteTests.test_redeem_marks_code_used_and_prevents_second_use`. +- Container flow is `docker compose up -d --build`, with healthcheck hitting `http://localhost:8000/api/health`. + +## Verified Repo Conventions +- There is no configured linter, formatter, typechecker, pytest, or task runner in this repo. Do not invent `pytest`, `ruff`, `mypy`, `npm`, or `make` workflows. +- Tests use the standard library `unittest` module in `tests/test_app.py`. +- The Docker image runs Gunicorn via `gunicorn -w 2 -b 0.0.0.0:8000 app:app`; local development uses Flask's built-in server from `app.py`. + +## Config And Data Gotchas +- Copy `.env.example` to `.env` before running locally or via Docker Compose. +- Graph readiness is configuration-driven in `office365_self_service/settings.py`. Missing `CLIENT_ID`, `TENANT_ID`, `CLIENT_SECRET`, or `DEFAULT_PASSWORD` does not stop app startup, but service calls fail later with configuration errors. +- `WEB_AUTH_ENABLED=true` only enables real admin login protection when both `ADMIN_USERNAME` and `ADMIN_PASSWORD` are set. Otherwise settings downgrade to effectively unauthenticated admin APIs and emit a warning. +- Relative SQLite URLs such as `sqlite:///redemption.db` resolve through Flask into `instance/redemption.db`. +- If `DATABASE_URL` is set to a container path like `sqlite:////app/data/redemption.db` outside Docker, settings automatically remap it to the matching local repo path and record a warning. +- App startup creates tables automatically with `db.create_all()`. There are no migrations in this repo. +- Logs are written to `logs/office365_self_service.log` via a rotating file handler during app startup. + +## API And Behavior Notes +- Public health/config endpoints are `/api/health` and `/api/config`. Admin also has `/admin/api/health`, `/admin/api/session`, and authenticated config/data endpoints under `/admin/api/*`. +- Redemption is stateful in the database: codes move `available -> processing -> used`, and failures release codes back to `available`. +- Username handling is easy to guess wrong: plain usernames are lowercased and expanded with `DEFAULT_DOMAIN`; full UPNs are accepted as-is. If `DEFAULT_DOMAIN` is empty, callers must submit a full email address. +- License assignment is optional. When `DEFAULT_LICENSE_SKU` is set and `LICENSE_ASSIGNMENT_REQUIRED=true`, failed license assignment triggers deletion of the newly created user and surfaces an error instead of a warning. + +## Editing Guidance +- Preserve the app-factory shape and the `service_factory` injection seam in `create_app()`. Tests rely on injecting fake services there. +- Keep focused verification lightweight: for most backend changes, run the relevant `python3 -m unittest` target rather than assuming extra tooling exists. diff --git a/README.md b/README.md index 7a33ec0..5baf813 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,10 @@ docker compose down | `WEB_AUTH_ENABLED` | 可选 | 后台登录保护 | `true` 或 `false` | | `ADMIN_USERNAME` | 建议 | 后台登录用户名 | 自定义 | | `ADMIN_PASSWORD` | 建议 | 后台登录密码 | 自定义 | +| `YAOHUO_VERIFICATION_ENABLED` | 可选 | 是否启用妖火论坛私信验证免兑换码开通 | `true` 或 `false` | +| `YAOHUO_COOKIE` | 妖火验证必填 | 后台已登录妖火账号的 Cookie | 浏览器复制完整 Cookie | +| `YAOHUO_MESSAGE_URL` | 可选 | 妖火发私信地址 | 默认 `https://www.yaohuo.me/bbs/messagelist_add.aspx` | +| `YAOHUO_VERIFICATION_CODE_TTL_SECONDS` | 可选 | 妖火验证码有效期(秒) | 默认 `600` | | `SESSION_SECRET` | 建议 | Flask 会话密钥 | 随机长字符串 | | `HOST` | 可选 | 服务监听地址 | 默认 `0.0.0.0` | | `PORT` | 可选 | 服务监听端口 | 默认 `8000` | @@ -141,6 +145,15 @@ docker compose down 3. 点击「立即开通」 4. 系统返回临时密码,首次登录后需更改密码 +### 妖火论坛验证开通 + +1. 在 `.env` 中启用 `YAOHUO_VERIFICATION_ENABLED=true` +2. 配置可用的 `YAOHUO_COOKIE` +3. 用户在首页切换到「妖火验证开通」 +4. 输入目标妖火 ID,系统向该 ID 发送私信验证码 +5. 对方提供验证码后完成验证 +6. 验证通过后无需兑换码即可直接开通账号 + ## 技术栈 - Python 3.9+ diff --git a/office365_self_service/__init__.py b/office365_self_service/__init__.py index 33af12d..501784e 100644 --- a/office365_self_service/__init__.py +++ b/office365_self_service/__init__.py @@ -11,7 +11,7 @@ from sqlalchemy import event from sqlalchemy.engine import Engine from sqlalchemy.engine.url import make_url -from .services import Office365Service +from .services import Office365Service, YaohuoVerificationService from .settings import Settings, load_settings @@ -86,6 +86,7 @@ def create_app( service = service_factory app.extensions["office365_service"] = service + app.extensions["yaohuo_verification_service"] = YaohuoVerificationService(settings) from .routes import bp_admin, bp_user diff --git a/office365_self_service/routes.py b/office365_self_service/routes.py index 442d6b3..6eee824 100644 --- a/office365_self_service/routes.py +++ b/office365_self_service/routes.py @@ -3,6 +3,7 @@ from __future__ import annotations import json import logging import secrets +from datetime import datetime, timezone from functools import wraps from flask import Blueprint, current_app, jsonify, render_template, request, session @@ -10,7 +11,7 @@ from sqlalchemy import func, update from . import db from .models import AuditEvent, RedemptionCode, utc_now -from .services import Office365Service, ServiceConfigurationError, ServiceOperationError +from .services import Office365Service, ServiceConfigurationError, ServiceOperationError, YaohuoVerificationService bp_admin = Blueprint("admin", __name__, url_prefix="/admin") @@ -20,6 +21,7 @@ logger = logging.getLogger("office365_self_service.routes") STATUS_AVAILABLE = "available" STATUS_PROCESSING = "processing" STATUS_USED = "used" +YAOHUO_SESSION_KEY = "yaohuo_verification" def _settings(): @@ -30,6 +32,10 @@ def _service() -> Office365Service: return current_app.extensions["office365_service"] +def _yaohuo_service() -> YaohuoVerificationService: + return current_app.extensions["yaohuo_verification_service"] + + def _success(data=None, message: str = "ok", status: int = 200): return jsonify({"success": True, "message": message, "data": data}), status @@ -73,6 +79,42 @@ def _json_payload() -> dict: return request.get_json(silent=True) or {} +def _session_verification_state() -> dict: + payload = session.get(YAOHUO_SESSION_KEY) + return payload if isinstance(payload, dict) else {} + + +def _clear_yaohuo_verification() -> None: + session.pop(YAOHUO_SESSION_KEY, None) + + +def _verification_expired(expires_at: str | None) -> bool: + if not expires_at: + return True + try: + return datetime.fromisoformat(expires_at) <= datetime.now(timezone.utc) + except ValueError: + return True + + +def _store_yaohuo_verification(target_user_id: str, code: str, expires_at: str) -> None: + session[YAOHUO_SESSION_KEY] = { + "targetUserId": target_user_id, + "code": code, + "expiresAt": expires_at, + "verified": False, + } + + +def _mark_yaohuo_verified() -> None: + state = _session_verification_state() + if not state: + return + state["verified"] = True + state.pop("code", None) + session[YAOHUO_SESSION_KEY] = state + + def _code_match(code: str): return func.lower(RedemptionCode.code) == code.lower() @@ -82,6 +124,7 @@ def _health_payload() -> dict: return { "platform": settings.to_public_dict(), "authenticated": _authenticated(), + "yaohuoVerified": bool(_session_verification_state().get("verified")), } @@ -112,6 +155,68 @@ def _build_audit_event( ) +def _provision_account(username: str): + actor = _current_actor("public") + try: + user_result = _service().create_user(username=username) + except ServiceConfigurationError as exc: + _record_audit_events( + _build_audit_event( + "account_provisioned", + status="failed", + actor=actor, + username=username, + details={"message": str(exc)}, + ) + ) + return _error(str(exc), status=503) + except ServiceOperationError as exc: + _record_audit_events( + _build_audit_event( + "account_provisioned", + status="failed", + actor=actor, + 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("妖火验证建号时发生未预期错误") + _record_audit_events( + _build_audit_event( + "account_provisioned", + status="failed", + actor=actor, + username=username, + details={"message": f"创建账号失败: {exc}"}, + ) + ) + return _error(f"创建账号失败: {exc}", status=500) + + _record_audit_events( + _build_audit_event( + "account_provisioned", + status="success", + actor=actor, + username=username, + principal_name=user_result.get("userPrincipalName"), + details={ + "licenseAssigned": user_result.get("licenseAssigned"), + "licenseMessage": user_result.get("licenseMessage"), + "source": "yaohuo_verification", + }, + ) + ) + return _success({ + "userPrincipalName": user_result.get("userPrincipalName"), + "temporaryPassword": user_result.get("temporaryPassword"), + "licenseAssigned": user_result.get("licenseAssigned"), + "licenseMessage": user_result.get("licenseMessage"), + }, "账号开通成功!", status=201) + + def _record_audit_events(*events: AuditEvent) -> None: pending = [event for event in events if event is not None] if not pending: @@ -501,6 +606,98 @@ def redeem(): }, "账号开通成功!", status=201) +@bp_user.post("/api/yaohuo/send-code") +def yaohuo_send_code(): + payload = _json_payload() + target_user_id = str(payload.get("targetUserId", "")).strip() + actor = _current_actor("public") + + try: + code = _yaohuo_service().generate_code() + expires_at = _yaohuo_service().expires_at().isoformat() + _yaohuo_service().send_verification_code(target_user_id, code) + _store_yaohuo_verification(target_user_id, code, expires_at) + except ServiceConfigurationError as exc: + return _error(str(exc), status=503) + except ServiceOperationError as exc: + _record_audit_events( + _build_audit_event( + "yaohuo_verification_requested", + status="failed", + actor=actor, + details={"targetUserId": target_user_id, "message": exc.message}, + ) + ) + return _error(exc.message, status=exc.status_code, details=exc.details) + except ValueError as exc: + return _error(str(exc), status=400) + + _record_audit_events( + _build_audit_event( + "yaohuo_verification_requested", + actor=actor, + details={"targetUserId": target_user_id}, + ) + ) + return _success({"expiresAt": expires_at}, "验证码已发送到指定妖火 ID 的私信。") + + +@bp_user.post("/api/yaohuo/verify") +def yaohuo_verify(): + payload = _json_payload() + submitted_code = str(payload.get("code", "")).strip() + state = _session_verification_state() + actor = _current_actor("public") + + if not state: + return _error("请先发送验证码。", status=400) + if _verification_expired(state.get("expiresAt")): + _clear_yaohuo_verification() + return _error("验证码已过期,请重新发送。", status=410) + if not submitted_code: + return _error("请输入验证码。", status=400) + if submitted_code != str(state.get("code", "")): + _record_audit_events( + _build_audit_event( + "yaohuo_verified", + status="failed", + actor=actor, + details={"targetUserId": state.get("targetUserId"), "message": "验证码错误。"}, + ) + ) + return _error("验证码错误。", status=400) + + _mark_yaohuo_verified() + _record_audit_events( + _build_audit_event( + "yaohuo_verified", + actor=actor, + details={"targetUserId": state.get("targetUserId")}, + ) + ) + return _success({"verified": True}, "妖火论坛验证成功。") + + +@bp_user.post("/api/yaohuo/provision") +def yaohuo_provision(): + payload = _json_payload() + username = str(payload.get("username", "")).strip().lower() + state = _session_verification_state() + + if not username: + return _error("请输入用户名。", status=400) + if not state or not state.get("verified"): + return _error("请先完成妖火论坛验证。", status=403) + if _verification_expired(state.get("expiresAt")): + _clear_yaohuo_verification() + return _error("验证状态已过期,请重新验证。", status=410) + + result = _provision_account(username) + if result[1] < 400: + _clear_yaohuo_verification() + return result + + @bp_user.get("/api/config") def config(): return _success(_settings().to_public_dict()) diff --git a/office365_self_service/services.py b/office365_self_service/services.py index 89a7be5..481a6c1 100644 --- a/office365_self_service/services.py +++ b/office365_self_service/services.py @@ -1,8 +1,13 @@ from __future__ import annotations import logging +import random +import re +from datetime import datetime, timedelta, timezone from typing import Any +import requests + from .graph import GraphAPIError, GraphClient, TokenManager from .settings import Settings @@ -191,3 +196,72 @@ class Office365Service: 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) + + +class YaohuoVerificationService: + def __init__(self, settings: Settings): + self.settings = settings + + def verification_ready(self) -> bool: + return self.settings.yaohuo_verification_enabled and bool(self.settings.yaohuo_cookie) + + def ensure_ready(self) -> None: + if not self.settings.yaohuo_verification_enabled: + raise ServiceConfigurationError("妖火论坛验证功能未启用。") + if not self.settings.yaohuo_cookie: + raise ServiceConfigurationError("YAOHUO_COOKIE 未配置,无法发送妖火私信验证码。") + + def generate_code(self) -> str: + return f"{random.randint(0, 999999):06d}" + + def expires_at(self) -> datetime: + return datetime.now(timezone.utc) + timedelta(seconds=self.settings.yaohuo_verification_code_ttl_seconds) + + def send_verification_code(self, target_user_id: str, code: str) -> None: + self.ensure_ready() + normalized_user_id = self._normalize_target_user_id(target_user_id) + content = f"【Office 365 自助开通验证】您的验证码是:{code},{self.settings.yaohuo_verification_code_ttl_seconds // 60} 分钟内有效。" + payload = { + "touseridlist": normalized_user_id, + "content": content, + "action": "gomod", + "classid": "0", + "siteid": "1000", + "types": "", + "issystem": "", + "g": "发送消息", + } + + try: + response = requests.post( + self.settings.yaohuo_message_url, + data=payload, + headers={ + "Cookie": self.settings.yaohuo_cookie, + "Referer": self.settings.yaohuo_message_url, + "User-Agent": "Mozilla/5.0", + }, + timeout=30, + ) + except requests.RequestException as exc: + raise ServiceOperationError(f"发送妖火验证码失败: {exc}", status_code=502) from exc + + if response.status_code >= 400: + raise ServiceOperationError( + f"发送妖火验证码失败,状态码 {response.status_code}", + status_code=502, + ) + + body = response.text + if "发短信息" in body and "发送成功" not in body and "返回上级" in body: + logger.warning("妖火私信发送结果无法明确判断成功,按成功处理。") + return + + if any(keyword in body for keyword in ("成功", "发送成功", "发送完毕")): + return + + def _normalize_target_user_id(self, target_user_id: str) -> str: + normalized = re.sub(r"\s+", "", str(target_user_id or "")) + if not normalized or not normalized.isdigit(): + raise ValueError("请输入有效的妖火 ID。") + return normalized diff --git a/office365_self_service/settings.py b/office365_self_service/settings.py index 42550c4..39203d7 100644 --- a/office365_self_service/settings.py +++ b/office365_self_service/settings.py @@ -64,6 +64,10 @@ class Settings: token_endpoint: str scope: str database_url: str + yaohuo_cookie: str + yaohuo_message_url: str + yaohuo_verification_enabled: bool + yaohuo_verification_code_ttl_seconds: int default_page_size: int = 25 max_page_size: int = 100 validation_errors: tuple[str, ...] = field(default_factory=tuple) @@ -92,6 +96,7 @@ class Settings: "forceChangePassword": self.force_change_password, "pageSize": self.default_page_size, "maxPageSize": self.max_page_size, + "yaohuoVerificationEnabled": self.yaohuo_verification_enabled, } @@ -149,6 +154,11 @@ def load_settings() -> Settings: token_endpoint=token_endpoint, scope=scope, database_url=database_url, + yaohuo_cookie=os.getenv("YAOHUO_COOKIE", "").strip(), + yaohuo_message_url=os.getenv("YAOHUO_MESSAGE_URL", "https://www.yaohuo.me/bbs/messagelist_add.aspx").strip() + or "https://www.yaohuo.me/bbs/messagelist_add.aspx", + yaohuo_verification_enabled=_env_bool("YAOHUO_VERIFICATION_ENABLED", False), + yaohuo_verification_code_ttl_seconds=min(max(_env_int("YAOHUO_VERIFICATION_CODE_TTL_SECONDS", 600), 60), 3600), 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), diff --git a/office365_self_service/templates/user_redemption.html b/office365_self_service/templates/user_redemption.html index d019b54..741a717 100644 --- a/office365_self_service/templates/user_redemption.html +++ b/office365_self_service/templates/user_redemption.html @@ -7,17 +7,23 @@

{{ settings.app_name }}

-

兑换码开通 Office 365 账号

+

支持兑换码开通,或通过妖火论坛验证后免兑换码开通

+
+ + +
+
@@ -39,6 +45,37 @@
+
+
+ + +
系统会通过后台已登录的妖火账号,向该 ID 发送私信验证码。
+
+
+ +
+
+ + +
+
+ +
+
+ + {% if settings.default_domain %} +
+ + @{{ settings.default_domain }} +
+ {% else %} + + {% endif %} +
完成妖火验证后,无需兑换码即可开通账号。
+
+ +
+
@@ -70,12 +107,53 @@ .replace(/'/g, '''); } + function showMessage(html) { + document.getElementById('message').innerHTML = html; + } + + function switchMode(mode) { + const redeemForm = document.getElementById('redeemForm'); + const yaohuoForm = document.getElementById('yaohuoForm'); + const redeemModeBtn = document.getElementById('redeemModeBtn'); + const yaohuoModeBtn = document.getElementById('yaohuoModeBtn'); + showMessage(''); + + if (mode === 'yaohuo') { + redeemForm.classList.add('d-none'); + yaohuoForm.classList.remove('d-none'); + redeemModeBtn.className = 'btn btn-outline-primary'; + yaohuoModeBtn.className = 'btn btn-primary'; + return; + } + + redeemForm.classList.remove('d-none'); + yaohuoForm.classList.add('d-none'); + redeemModeBtn.className = 'btn btn-primary'; + yaohuoModeBtn.className = 'btn btn-outline-primary'; + } + + async function showProvisionSuccess(data) { + document.getElementById('redeemForm').classList.add('d-none'); + document.getElementById('yaohuoForm').classList.add('d-none'); + document.getElementById('successResult').classList.remove('d-none'); + document.getElementById('resultEmail').textContent = data.userPrincipalName; + document.getElementById('resultPassword').textContent = data.temporaryPassword; + const licenseWarning = document.getElementById('licenseWarning'); + if (data.licenseAssigned === false && data.licenseMessage) { + licenseWarning.textContent = data.licenseMessage; + licenseWarning.classList.remove('d-none'); + } else { + licenseWarning.classList.add('d-none'); + licenseWarning.textContent = ''; + } + } + document.getElementById('redeemBtn').addEventListener('click', async () => { const code = document.getElementById('codeInput').value.trim(); const username = document.getElementById('usernameInput').value.trim(); if (!code || !username) { - document.getElementById('message').innerHTML = '
请填写完整的兑换码和用户名
'; + showMessage('
请填写完整的兑换码和用户名
'); return; } @@ -92,29 +170,102 @@ const data = await response.json(); if (data.success) { - document.getElementById('redeemForm').classList.add('d-none'); - 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 = ''; - } + showProvisionSuccess(data.data); } else { - document.getElementById('message').innerHTML = `
${escapeHtml(data.message)}
`; + showMessage(`
${escapeHtml(data.message)}
`); btn.disabled = false; btn.textContent = '立即开通'; } } catch (e) { - document.getElementById('message').innerHTML = '
网络错误,请稍后重试
'; + showMessage('
网络错误,请稍后重试
'); btn.disabled = false; btn.textContent = '立即开通'; } }); + + document.getElementById('redeemModeBtn').addEventListener('click', () => switchMode('redeem')); + document.getElementById('yaohuoModeBtn').addEventListener('click', () => switchMode('yaohuo')); + + document.getElementById('sendYaohuoCodeBtn').addEventListener('click', async () => { + const targetUserId = document.getElementById('yaohuoIdInput').value.trim(); + if (!targetUserId) { + showMessage('
请输入妖火 ID
'); + return; + } + + const btn = document.getElementById('sendYaohuoCodeBtn'); + btn.disabled = true; + btn.textContent = '发送中...'; + try { + const response = await fetch('/api/yaohuo/send-code', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ targetUserId }) + }); + const data = await response.json(); + if (data.success) { + showMessage(`
${escapeHtml(data.message)}
`); + } else { + showMessage(`
${escapeHtml(data.message)}
`); + } + } catch (e) { + showMessage('
网络错误,请稍后重试
'); + } finally { + btn.disabled = false; + btn.textContent = '发送验证码'; + } + }); + + document.getElementById('verifyYaohuoBtn').addEventListener('click', async () => { + const code = document.getElementById('yaohuoCodeInput').value.trim(); + if (!code) { + showMessage('
请输入验证码
'); + return; + } + + const response = await fetch('/api/yaohuo/verify', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ code }) + }); + const data = await response.json(); + if (data.success) { + showMessage(`
${escapeHtml(data.message)}
`); + } else { + showMessage(`
${escapeHtml(data.message)}
`); + } + }); + + document.getElementById('yaohuoProvisionBtn').addEventListener('click', async () => { + const username = document.getElementById('yaohuoUsernameInput').value.trim(); + if (!username) { + showMessage('
请输入用户名
'); + return; + } + + const btn = document.getElementById('yaohuoProvisionBtn'); + btn.disabled = true; + btn.textContent = '开通中...'; + try { + const response = await fetch('/api/yaohuo/provision', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ username }) + }); + const data = await response.json(); + if (data.success) { + showProvisionSuccess(data.data); + } else { + showMessage(`
${escapeHtml(data.message)}
`); + btn.disabled = false; + btn.textContent = '验证后免码开通'; + } + } catch (e) { + showMessage('
网络错误,请稍后重试
'); + btn.disabled = false; + btn.textContent = '验证后免码开通'; + } + }); diff --git a/tests/test_app.py b/tests/test_app.py index 1a661a9..7b66bda 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -7,7 +7,7 @@ from pathlib import Path from office365_self_service import create_app, db from office365_self_service.models import AuditEvent, RedemptionCode -from office365_self_service.services import Office365Service, ServiceConfigurationError, ServiceOperationError +from office365_self_service.services import Office365Service, ServiceConfigurationError, ServiceOperationError, YaohuoVerificationService from office365_self_service.settings import GRAPH_BASE_URL, GRAPH_SCOPE, Settings, TOKEN_ENDPOINT_TEMPLATE, load_settings @@ -35,6 +35,10 @@ def build_settings(database_url: str, **overrides) -> Settings: "token_endpoint": TOKEN_ENDPOINT_TEMPLATE.format(tenant_id=tenant_id), "scope": GRAPH_SCOPE, "database_url": database_url, + "yaohuo_cookie": "", + "yaohuo_message_url": "https://www.yaohuo.me/bbs/messagelist_add.aspx", + "yaohuo_verification_enabled": False, + "yaohuo_verification_code_ttl_seconds": 600, "validation_errors": (), "warnings": (), } @@ -87,6 +91,20 @@ class FakeGraphClient: self.deleted_users.append(user_id) +class FakeYaohuoService: + def __init__(self): + self.sent = [] + + def generate_code(self) -> str: + return "123456" + + def expires_at(self): + return datetime(2026, 4, 15, 12, 0, 0, tzinfo=timezone.utc) + + def send_verification_code(self, target_user_id: str, code: str) -> None: + self.sent.append((target_user_id, code)) + + class AppRouteTests(unittest.TestCase): def setUp(self): self.temp_dir = tempfile.TemporaryDirectory() @@ -99,6 +117,7 @@ class AppRouteTests(unittest.TestCase): ) self.app.testing = True self.client = self.app.test_client() + self.app.extensions["yaohuo_verification_service"] = FakeYaohuoService() with self.app.app_context(): db.drop_all() @@ -257,6 +276,34 @@ class AppRouteTests(unittest.TestCase): self.assertEqual(response.status_code, 409) self.assertFalse(payload["success"]) + def test_yaohuo_send_verify_and_provision_without_redemption_code(self): + response = self.client.post("/api/yaohuo/send-code", json={"targetUserId": "12345"}) + payload = response.get_json() + + self.assertEqual(response.status_code, 200) + self.assertTrue(payload["success"]) + self.assertEqual(self.app.extensions["yaohuo_verification_service"].sent, [("12345", "123456")]) + + verify = self.client.post("/api/yaohuo/verify", json={"code": "123456"}) + verify_payload = verify.get_json() + self.assertEqual(verify.status_code, 200) + self.assertTrue(verify_payload["success"]) + + provision = self.client.post("/api/yaohuo/provision", json={"username": "alice"}) + provision_payload = provision.get_json() + self.assertEqual(provision.status_code, 201) + self.assertTrue(provision_payload["success"]) + self.assertEqual(provision_payload["data"]["userPrincipalName"], "alice@example.com") + self.assertEqual(self.service.calls, ["alice"]) + + def test_yaohuo_provision_requires_verified_session(self): + response = self.client.post("/api/yaohuo/provision", json={"username": "alice"}) + payload = response.get_json() + + self.assertEqual(response.status_code, 403) + self.assertFalse(payload["success"]) + self.assertEqual(self.service.calls, []) + class ServiceBehaviorTests(unittest.TestCase): def test_create_user_accepts_full_upn_without_default_domain(self): @@ -310,6 +357,17 @@ class ServiceBehaviorTests(unittest.TestCase): self.assertEqual(fake_client.deleted_users, ["user-1"]) +class YaohuoServiceTests(unittest.TestCase): + def test_generate_code_returns_six_digits(self): + settings = build_settings("sqlite:////tmp/unused.db", yaohuo_verification_enabled=True, yaohuo_cookie="cookie") + service = YaohuoVerificationService(settings) + + code = service.generate_code() + + self.assertEqual(len(code), 6) + self.assertTrue(code.isdigit()) + + class ModelSerializationTests(unittest.TestCase): def test_redemption_code_serializes_datetimes_as_utc_z(self): code = RedemptionCode(