commit 8f5a643ed39e3383ce3d9f76a69e055c9928c04a Author: youbin Date: Sat Mar 28 00:32:30 2026 +0800 Initial commit: Office 365 Self Service - 兑换码自助开通系统 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..77a67ab --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +.venv/ +ENV/ +env/ +.env +*.db +*.log +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo +.git/ +instance/ +data/ +logs/ +*.md +Dockerfile +docker-compose.yml \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..01c52f8 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# Office 365 Self Service 配置 + +# 应用名称 +APP_NAME=Office 365 Self Service + +# 服务监听地址和端口 +HOST=0.0.0.0 +PORT=8000 +DEBUG=false + +# 数据库 (SQLite) +DATABASE_URL=sqlite:///redemption.db + +# Flask会话密钥 (建议使用随机长字符串) +SESSION_SECRET=your-secret-key-here-change-me + +# 后台登录保护 +WEB_AUTH_ENABLED=true +ADMIN_USERNAME=admin +ADMIN_PASSWORD=ChangeMe123! + +# Microsoft Graph API 配置 (必填) +# 在 Azure AD 中注册应用后获取 +CLIENT_ID= +TENANT_ID= +CLIENT_SECRET= + +# 默认配置 +DEFAULT_DOMAIN=yourtenant.onmicrosoft.com +DEFAULT_PASSWORD=P@ssw0rd123! +DEFAULT_USAGE_LOCATION=US +DEFAULT_LICENSE_SKU=ENTERPRISEPACK +FORCE_CHANGE_PASSWORD=true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b883b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +.venv/ +ENV/ +env/ +.env +*.db +*.log +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bfe6f96 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.9-slim + +WORKDIR /app + +RUN pip install --no-cache-dir gunicorn + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p logs instance + +EXPOSE 8000 + +CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:8000", "app:app"] \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..a4c928c --- /dev/null +++ b/app.py @@ -0,0 +1,10 @@ +from office365_self_service import create_app + +app = create_app() + +if __name__ == "__main__": + app.run( + host=app.config["SETTINGS"].host, + port=app.config["SETTINGS"].port, + debug=app.config["SETTINGS"].debug, + ) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e109eb9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +version: '3.8' + +services: + app: + build: . + ports: + - "8000:8000" + volumes: + - ./data:/app/data + - ./logs:/app/logs + env_file: + - .env + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s \ No newline at end of file diff --git a/office365_self_service/__init__.py b/office365_self_service/__init__.py new file mode 100644 index 0000000..a3993b6 --- /dev/null +++ b/office365_self_service/__init__.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import logging +from datetime import timedelta +from logging.handlers import RotatingFileHandler +from pathlib import Path + +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import event +from sqlalchemy.engine import Engine + +from .services import Office365Service +from .settings import Settings, load_settings + + +db = SQLAlchemy() + + +def _configure_logging(app: Flask) -> None: + log_dir = Path(app.root_path).parent / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / "office365_self_service.log" + + root_logger = logging.getLogger() + formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s - %(message)s") + + if not any(isinstance(handler, RotatingFileHandler) for handler in root_logger.handlers): + file_handler = RotatingFileHandler( + log_path, + maxBytes=2 * 1024 * 1024, + backupCount=5, + encoding="utf-8", + ) + file_handler.setFormatter(formatter) + root_logger.addHandler(file_handler) + + root_logger.setLevel(logging.INFO) + app.logger.info("Logging initialized at %s", log_path) + + +@event.listens_for(Engine, "connect") +def set_sqlite_pragma(dbapi_conn, connection_record): + if "sqlite" in str(type(dbapi_conn)): + cursor = dbapi_conn.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + +def create_app( + settings_override: Settings | None = None, + service_factory=None, +) -> Flask: + settings = settings_override or load_settings() + app = Flask(__name__, template_folder="templates", static_folder="static") + app.config["SETTINGS"] = settings + app.config["JSON_AS_ASCII"] = False + app.config["SECRET_KEY"] = settings.session_secret + app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(hours=8) + app.config["SESSION_COOKIE_SAMESITE"] = "Lax" + app.config["SQLALCHEMY_DATABASE_URI"] = settings.database_url + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + + _configure_logging(app) + + db.init_app(app) + + with app.app_context(): + from .models import RedemptionCode + db.create_all() + + if service_factory is None: + service = Office365Service(settings) + elif callable(service_factory): + service = service_factory(settings) + else: + service = service_factory + + app.extensions["office365_service"] = service + + from .routes import bp_admin, bp_user + + app.register_blueprint(bp_admin) + app.register_blueprint(bp_user) + + return app \ No newline at end of file diff --git a/office365_self_service/graph.py b/office365_self_service/graph.py new file mode 100644 index 0000000..46eaccf --- /dev/null +++ b/office365_self_service/graph.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import logging +from typing import Any + +import requests + + +logger = logging.getLogger("office365_self_service.graph") + + +class TokenManager: + def __init__(self, client_id: str, client_secret: str, token_endpoint: str, scope: str): + self.client_id = client_id + self.client_secret = client_secret + self.token_endpoint = token_endpoint + self.scope = scope + self._token: str | None = None + + def get_token(self) -> str: + if self._token: + return self._token + + data = { + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + "scope": self.scope, + } + response = requests.post(self.token_endpoint, data=data, timeout=30) + response.raise_for_status() + self._token = response.json()["access_token"] + return self._token + + def clear_token(self) -> None: + self._token = None + + +class GraphAPIError(Exception): + def __init__(self, message: str, status_code: int = 0, response: dict | None = None): + super().__init__(message) + self.message = message + self.status_code = status_code + self.response = response + + +class GraphClient: + def __init__(self, token_manager: TokenManager, base_url: str): + self.token_manager = token_manager + self.base_url = base_url + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self.token_manager.get_token()}", + "Content-Type": "application/json", + } + + def _request(self, method: str, path: str, **kwargs) -> dict[str, Any]: + url = f"{self.base_url}{path}" + headers = self._headers() + headers.update(kwargs.pop("headers", {})) + + try: + response = requests.request(method, url, headers=headers, timeout=60, **kwargs) + except requests.RequestException as exc: + raise GraphAPIError(f"请求失败: {exc}") + + try: + payload = response.json() + except ValueError: + if response.status_code == 204: + return {} + raise GraphAPIError(f"解析响应失败: {response.text[:200]}", response.status_code) + + if response.status_code >= 400: + error_message = payload.get("error", {}).get("message") or str(payload) + raise GraphAPIError(error_message, response.status_code, payload) + + return payload + + def get(self, path: str, **kwargs) -> dict[str, Any]: + return self._request("GET", path, **kwargs) + + def post(self, path: str, **kwargs) -> dict[str, Any]: + return self._request("POST", path, **kwargs) + + def patch(self, path: str, **kwargs) -> dict[str, Any]: + return self._request("PATCH", path, **kwargs) + + def delete(self, path: str, **kwargs) -> dict[str, Any]: + return self._request("DELETE", path, **kwargs) + + def list_subscribed_skus(self) -> list[dict[str, Any]]: + result = self.get("/subscribedSkus") + return result.get("value", []) + + def create_user(self, payload: dict[str, Any]) -> dict[str, Any]: + result = self.post("/users", json=payload) + return result + + def get_user(self, user_id: str, select: list[str] | None = None) -> dict[str, Any]: + params = {} + if select: + params["$select"] = ",".join(select) + result = self.get(f"/users/{user_id}", params=params) + return result + + def update_user(self, user_id: str, payload: dict[str, Any]) -> dict[str, Any]: + result = self.patch(f"/users/{user_id}", json=payload) + return result + + def delete_user(self, user_id: str) -> None: + self.delete(f"/users/{user_id}") + + def assign_license(self, user_id: str, add_licenses: list[dict] = None, remove_licenses: list[str] = None) -> dict[str, Any]: + payload: dict[str, list] = {} + if add_licenses: + payload["addLicenses"] = add_licenses + else: + payload["addLicenses"] = [] + payload["removeLicenses"] = remove_licenses if remove_licenses else [] + return self.post(f"/users/{user_id}/assignLicense", json=payload) \ No newline at end of file diff --git a/office365_self_service/models.py b/office365_self_service/models.py new file mode 100644 index 0000000..8134e9f --- /dev/null +++ b/office365_self_service/models.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from datetime import datetime + +from . import db + + +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()) + 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) + + def to_dict(self): + return { + "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, + "usedByUsername": self.used_by_username, + "usedByPrincipalName": self.used_by_principal_name, + } \ No newline at end of file diff --git a/office365_self_service/routes.py b/office365_self_service/routes.py new file mode 100644 index 0000000..ac85f37 --- /dev/null +++ b/office365_self_service/routes.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +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 . import db +from .models import RedemptionCode +from .services import Office365Service, ServiceConfigurationError, ServiceOperationError + + +bp_admin = Blueprint("admin", __name__, url_prefix="/admin") +bp_user = Blueprint("user", __name__) + + +def _settings(): + return current_app.config["SETTINGS"] + + +def _service() -> Office365Service: + return current_app.extensions["office365_service"] + + +def _success(data=None, message: str = "ok", status: int = 200): + return jsonify({"success": True, "message": message, "data": data}), status + + +def _error(message: str, status: int = 400, details=None): + payload = {"success": False, "message": message} + if details is not None: + payload["details"] = details + return jsonify(payload), status + + +def _authenticated() -> bool: + settings = _settings() + if not settings.effective_auth_enabled: + return True + return bool(session.get("authenticated")) + + +def require_auth(view_func): + @wraps(view_func) + def wrapped(*args, **kwargs): + if not _authenticated(): + return _error("请先登录后台管理平台。", status=401) + return view_func(*args, **kwargs) + + return wrapped + + +def _handle_service_call(callback): + try: + return callback() + except ServiceConfigurationError as exc: + return _error(str(exc), status=503) + except ServiceOperationError as exc: + return _error(exc.message, status=exc.status_code, details=exc.details) + except ValueError as exc: + return _error(str(exc), status=400) + + +def _json_payload() -> dict: + return request.get_json(silent=True) or {} + + +@bp_admin.get("/") +def admin_index(): + if not _authenticated(): + return render_template("admin_login.html", settings=_settings()) + return render_template("admin_dashboard.html", settings=_settings()) + + +@bp_admin.get("/api/health") +def health(): + settings = _settings() + return _success( + { + "platform": settings.to_public_dict(), + "authenticated": _authenticated(), + } + ) + + +@bp_admin.get("/api/session") +def session_info(): + return _success( + { + "authenticated": _authenticated(), + "authEnabled": _settings().effective_auth_enabled, + } + ) + + +@bp_admin.post("/api/login") +def login(): + settings = _settings() + if not settings.effective_auth_enabled: + session["authenticated"] = True + session.permanent = True + return _success({"authenticated": True}, message="当前平台未启用登录保护。") + + payload = _json_payload() + username = str(payload.get("username", "")).strip() + password = str(payload.get("password", "")).strip() + + if username == settings.admin_username and password == settings.admin_password: + session["authenticated"] = True + session.permanent = True + return _success({"authenticated": True}, message="登录成功。") + return _error("用户名或密码错误。", status=401) + + +@bp_admin.post("/api/logout") +def logout(): + session.clear() + return _success({"authenticated": False}, message="已退出登录。") + + +@bp_admin.get("/api/config") +@require_auth +def config_info(): + return _success(_settings().to_public_dict()) + + +@bp_admin.get("/api/codes") +@require_auth +def list_codes(): + status = request.args.get("status") + query = db.select(RedemptionCode) + + if status == "available": + query = query.where(RedemptionCode.status == "available") + elif status == "used": + query = query.where(RedemptionCode.status == "used") + + result = db.session.execute(query.order_by(RedemptionCode.created_at.desc())).scalars().all() + codes = [code.to_dict() for code in result] + return _success({"codes": codes, "total": len(codes)}) + + +@bp_admin.post("/api/codes/generate") +@require_auth +def generate_codes(): + payload = _json_payload() + count = payload.get("count", 1) + if count < 1: + count = 1 + if count > 100: + count = 100 + + codes = [] + for _ in range(count): + code = secrets.token_urlsafe(12) + while RedemptionCode.query.filter_by(code=code).first(): + code = secrets.token_urlsafe(12) + + redemption_code = RedemptionCode(code=code) + db.session.add(redemption_code) + codes.append(code) + + db.session.commit() + return _success({"codes": codes, "count": len(codes)}, f"成功生成 {count} 个兑换码。") + + +@bp_admin.delete("/api/codes/") +@require_auth +def delete_code(code: str): + redemption_code = RedemptionCode.query.filter_by(code=code).first() + if not redemption_code: + return _error("兑换码不存在。", status=404) + + db.session.delete(redemption_code) + db.session.commit() + 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")) + + 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] + + return _success({ + "records": [code.to_dict() for code in records], + "page": page, + "pageSize": page_size, + "total": total, + }) + + +@bp_user.get("/") +def index(): + return render_template("user_redemption.html", settings=_settings()) + + +@bp_user.post("/api/redeem") +def redeem(): + payload = _json_payload() + code = str(payload.get("code", "")).strip().upper() + username = str(payload.get("username", "")).strip().lower() + + 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: + return _error("兑换码无效或已被使用。", status=404) + + try: + user_result = _service().create_user(username=username) + except ServiceOperationError as exc: + return _error(str(exc), status=500) + except Exception as 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() + + return _success({ + "userPrincipalName": user_result.get("userPrincipalName"), + "temporaryPassword": user_result.get("temporaryPassword"), + }, "账号开通成功!", status=201) + + +@bp_user.get("/api/config") +def config(): + return _success(_settings().to_public_dict()) \ No newline at end of file diff --git a/office365_self_service/services.py b/office365_self_service/services.py new file mode 100644 index 0000000..feecb60 --- /dev/null +++ b/office365_self_service/services.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import logging +from typing import Any + +from .graph import GraphAPIError, GraphClient, TokenManager +from .settings import Settings + + +logger = logging.getLogger("office365_self_service.service") + + +class ServiceOperationError(RuntimeError): + def __init__(self, message: str, status_code: int = 400, details=None): + super().__init__(message) + self.message = message + self.status_code = status_code + self.details = details + + +class ServiceConfigurationError(RuntimeError): + pass + + +class Office365Service: + def __init__(self, settings: Settings): + self.settings = settings + self._graph_client: GraphClient | None = None + + def _ensure_client(self) -> GraphClient: + if not self.settings.graph_ready: + joined = ";".join(self.settings.validation_errors) + raise ServiceConfigurationError(f"Graph 配置不完整: {joined}") + + if self._graph_client is None: + token_manager = TokenManager( + client_id=self.settings.client_id, + client_secret=self.settings.client_secret, + token_endpoint=self.settings.token_endpoint, + scope=self.settings.scope, + ) + self._graph_client = GraphClient(token_manager, self.settings.graph_base_url) + return self._graph_client + + def create_user(self, username: str, password: str | None = None, display_name: str | None = None) -> dict[str, Any]: + client = self._ensure_client() + upn = f"{username}@{self.settings.default_domain}" + + password = password or self.settings.default_password + display_name = display_name or username + + create_payload = { + "accountEnabled": True, + "displayName": display_name, + "mailNickname": username, + "userPrincipalName": upn, + "passwordProfile": { + "password": password, + "forceChangePasswordNextSignIn": self.settings.force_change_password, + }, + "usageLocation": self.settings.default_usage_location, + } + + try: + user = client.create_user(create_payload) + except GraphAPIError as exc: + raise self._translate_graph_error(exc, f"创建用户 {upn} 失败") + + license_result = None + logger.info(f"Creating user: {upn}, default_license_sku: {self.settings.default_license_sku}") + if self.settings.default_license_sku: + license_result = self._assign_license(user["id"]) + logger.info(f"License assignment result: {license_result}") + + return { + "user": user, + "userPrincipalName": upn, + "temporaryPassword": password, + "licenseAssigned": bool(license_result), + "licenseResult": license_result, + } + + def _assign_license(self, user_id: str) -> dict[str, Any]: + 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 + + 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 + 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 + + try: + return client.assign_license( + user_id, + add_licenses=[{"skuId": matched["skuId"], "disabledPlans": []}], + ) + except GraphAPIError as exc: + logger.warning("分配许可证失败: %s", exc) + return None + + def _translate_graph_error(self, exc: GraphAPIError, fallback_message: str) -> ServiceOperationError: + message = fallback_message + if exc.message: + message = f"{fallback_message}: {exc.message}" + status_code = exc.status_code or 502 + 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) \ No newline at end of file diff --git a/office365_self_service/settings.py b/office365_self_service/settings.py new file mode 100644 index 0000000..41ee4d9 --- /dev/null +++ b/office365_self_service/settings.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass, field + +from dotenv import load_dotenv + + +GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0" +GRAPH_SCOPE = "https://graph.microsoft.com/.default" +TOKEN_ENDPOINT_TEMPLATE = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" + + +def _env_bool(name: str, default: bool = False) -> bool: + return os.getenv(name, str(default)).strip().lower() in {"1", "true", "yes", "on"} + + +def _env_int(name: str, default: int) -> int: + try: + return int(os.getenv(name, str(default)).strip()) + except ValueError: + return default + + +@dataclass +class Settings: + app_name: str + host: str + port: int + debug: bool + session_secret: str + auth_enabled: bool + admin_username: str + admin_password: str + client_id: str + tenant_id: str + client_secret: str + default_password: str + default_domain: str + default_usage_location: str + default_license_sku: str + force_change_password: bool + graph_base_url: str + token_endpoint: str + scope: str + database_url: str + default_page_size: int = 25 + max_page_size: int = 100 + validation_errors: tuple[str, ...] = field(default_factory=tuple) + warnings: tuple[str, ...] = field(default_factory=tuple) + + @property + def graph_ready(self) -> bool: + return not self.validation_errors + + @property + def effective_auth_enabled(self) -> bool: + return self.auth_enabled and bool(self.admin_username and self.admin_password) + + def to_public_dict(self) -> dict: + return { + "appName": self.app_name, + "graphFlavor": "Microsoft Graph Global", + "graphReady": self.graph_ready, + "validationErrors": list(self.validation_errors), + "warnings": list(self.warnings), + "authEnabled": self.effective_auth_enabled, + "defaultDomain": self.default_domain, + "defaultUsageLocation": self.default_usage_location, + "defaultLicenseSku": self.default_license_sku, + "forceChangePassword": self.force_change_password, + "pageSize": self.default_page_size, + "maxPageSize": self.max_page_size, + } + + +def load_settings() -> Settings: + load_dotenv() + + tenant_id = os.getenv("TENANT_ID", "").strip() + graph_base_url = GRAPH_BASE_URL + token_endpoint = TOKEN_ENDPOINT_TEMPLATE.format(tenant_id=tenant_id) if tenant_id else "" + scope = GRAPH_SCOPE + + validation_errors: list[str] = [] + warnings: list[str] = [] + + required_fields = { + "CLIENT_ID": os.getenv("CLIENT_ID", "").strip(), + "TENANT_ID": tenant_id, + "CLIENT_SECRET": os.getenv("CLIENT_SECRET", "").strip(), + "DEFAULT_PASSWORD": os.getenv("DEFAULT_PASSWORD", "").strip(), + } + + for field_name, value in required_fields.items(): + if not value: + validation_errors.append(f"{field_name} 未配置") + + if not os.getenv("DEFAULT_DOMAIN", "").strip(): + warnings.append("DEFAULT_DOMAIN 未配置,创建账号时必须填写完整 userPrincipalName。") + + auth_enabled = _env_bool("WEB_AUTH_ENABLED", True) + admin_username = os.getenv("ADMIN_USERNAME", "").strip() + admin_password = os.getenv("ADMIN_PASSWORD", "").strip() + if auth_enabled and not (admin_username and admin_password): + warnings.append("WEB_AUTH_ENABLED=true 但未配置后台登录账号,已自动退回为无登录保护模式。") + + return Settings( + app_name=os.getenv("APP_NAME", "Office 365 Self Service").strip(), + host=os.getenv("HOST", "0.0.0.0").strip(), + port=_env_int("PORT", 8000), + debug=_env_bool("DEBUG", False), + session_secret=os.getenv("SESSION_SECRET", "office365-self-service-dev-secret").strip(), + auth_enabled=auth_enabled, + admin_username=admin_username, + admin_password=admin_password, + client_id=required_fields["CLIENT_ID"], + tenant_id=required_fields["TENANT_ID"], + client_secret=required_fields["CLIENT_SECRET"], + default_password=required_fields["DEFAULT_PASSWORD"], + 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(), + 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(), + 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), + ) \ No newline at end of file diff --git a/office365_self_service/templates/admin_dashboard.html b/office365_self_service/templates/admin_dashboard.html new file mode 100644 index 0000000..a6bfe10 --- /dev/null +++ b/office365_self_service/templates/admin_dashboard.html @@ -0,0 +1,213 @@ + + + + + + 管理后台 - {{ settings.app_name }} + + + + +
+
+ +
+
+ +
+
+

兑换码管理

+
+ +
+
+
+
+
+ + + +
+
+ + + + + + + + + + + + +
兑换码状态创建时间使用时间使用账号操作
+
+
+
+
+ +
+

兑换记录

+
+
+
+ + + + + + + + + + +
兑换码开通账号完整邮箱使用时间
+
+
+
+
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/office365_self_service/templates/admin_login.html b/office365_self_service/templates/admin_login.html new file mode 100644 index 0000000..ee4800e --- /dev/null +++ b/office365_self_service/templates/admin_login.html @@ -0,0 +1,48 @@ + + + + + + 管理后台登录 - {{ settings.app_name }} + + + + + + + + \ No newline at end of file diff --git a/office365_self_service/templates/user_redemption.html b/office365_self_service/templates/user_redemption.html new file mode 100644 index 0000000..0228256 --- /dev/null +++ b/office365_self_service/templates/user_redemption.html @@ -0,0 +1,97 @@ + + + + + + 账号开通 - {{ settings.app_name }} + + + + +
+

{{ settings.app_name }}

+

兑换码开通 Office 365 账号

+ +
+ +
+
+ + +
+
+ +
+ + @{{ settings.default_domain }} +
+
请输入您想要的用户名,将自动拼接域名为完整邮箱地址
+
+ +
+ +
+
+
+ + + +
+

账号开通成功!

+
+
+

账号:

+

临时密码:

+
+
+ 提示:首次登录后系统会要求您更改密码,请使用临时密码登录。 +
+ +
+
+ + + + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..950a1fd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Flask>=3.0.0 +Flask-SQLAlchemy>=3.1.0 +python-dotenv>=1.0.0 +requests>=2.31.0 +gunicorn>=21.0.0 \ No newline at end of file