Initial commit: Office 365 Self Service - 兑换码自助开通系统
This commit is contained in:
24
.dockerignore
Normal file
24
.dockerignore
Normal file
@@ -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
|
||||||
33
.env.example
Normal file
33
.env.example
Normal file
@@ -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
|
||||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@@ -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"]
|
||||||
10
app.py
Normal file
10
app.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
@@ -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
|
||||||
86
office365_self_service/__init__.py
Normal file
86
office365_self_service/__init__.py
Normal file
@@ -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
|
||||||
122
office365_self_service/graph.py
Normal file
122
office365_self_service/graph.py
Normal file
@@ -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)
|
||||||
28
office365_self_service/models.py
Normal file
28
office365_self_service/models.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
247
office365_self_service/routes.py
Normal file
247
office365_self_service/routes.py
Normal file
@@ -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/<code>")
|
||||||
|
@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())
|
||||||
121
office365_self_service/services.py
Normal file
121
office365_self_service/services.py
Normal file
@@ -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)
|
||||||
133
office365_self_service/settings.py
Normal file
133
office365_self_service/settings.py
Normal file
@@ -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),
|
||||||
|
)
|
||||||
213
office365_self_service/templates/admin_dashboard.html
Normal file
213
office365_self_service/templates/admin_dashboard.html
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>管理后台 - {{ settings.app_name }}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { background-color: #f5f5f5; }
|
||||||
|
.sidebar { min-height: 100vh; background: #fff; border-right: 1px solid #ddd; }
|
||||||
|
.sidebar .nav-link { color: #333; padding: 0.75rem 1rem; }
|
||||||
|
.sidebar .nav-link.active { background: #e9ecef; font-weight: 500; }
|
||||||
|
.table-actions .btn { padding: 0.25rem 0.5rem; font-size: 0.875rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-2 sidebar py-3">
|
||||||
|
<h5 class="px-3 mb-3">{{ settings.app_name }}</h5>
|
||||||
|
<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="#" id="logoutBtn">退出登录</a>
|
||||||
|
</nav>
|
||||||
|
<div class="px-3 mt-3">
|
||||||
|
<small class="text-muted">默认域名: {{ settings.default_domain }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-10 py-4">
|
||||||
|
<div id="message"></div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="codesTab">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4>兑换码管理</h4>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#generateModal">生成兑换码</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<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="used">已使用</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover" id="codesTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>兑换码</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
<th>使用时间</th>
|
||||||
|
<th>使用账号</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content d-none" id="recordsTab">
|
||||||
|
<h4 class="mb-3">兑换记录</h4>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover" id="recordsTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>兑换码</th>
|
||||||
|
<th>开通账号</th>
|
||||||
|
<th>完整邮箱</th>
|
||||||
|
<th>使用时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="generateModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">生成兑换码</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">生成数量</label>
|
||||||
|
<input type="number" class="form-control" id="generateCount" value="1" min="1" max="100">
|
||||||
|
</div>
|
||||||
|
<div id="generatedCodes" class="d-none">
|
||||||
|
<label class="form-label">生成的兑换码</label>
|
||||||
|
<textarea class="form-control" rows="5" readonly></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="generateBtn">生成</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
let currentFilter = 'all';
|
||||||
|
|
||||||
|
async function loadCodes() {
|
||||||
|
const url = currentFilter === 'all' ? '/admin/api/codes' : `/admin/api/codes?status=${currentFilter}`;
|
||||||
|
const response = await fetch(url, { credentials: 'same-origin' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
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('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecords() {
|
||||||
|
const response = await fetch('/admin/api/records', { credentials: 'same-origin' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
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('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCode(code) {
|
||||||
|
if (!confirm('确定要删除此兑换码吗?')) return;
|
||||||
|
const response = await fetch(`/admin/api/codes/${code}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
loadCodes();
|
||||||
|
} else {
|
||||||
|
alert(data.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('btn-secondary', 'active'));
|
||||||
|
btn.classList.add('btn-secondary', 'active');
|
||||||
|
currentFilter = btn.dataset.filter;
|
||||||
|
loadCodes();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.nav-link[data-tab]').forEach(link => {
|
||||||
|
link.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
|
||||||
|
link.classList.add('active');
|
||||||
|
document.querySelectorAll('.tab-content').forEach(t => t.classList.add('d-none'));
|
||||||
|
document.getElementById(link.dataset.tab + 'Tab').classList.remove('d-none');
|
||||||
|
if (link.dataset.tab === 'codes') loadCodes();
|
||||||
|
if (link.dataset.tab === 'records') loadRecords();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('generateBtn').addEventListener('click', async () => {
|
||||||
|
const count = parseInt(document.getElementById('generateCount').value) || 1;
|
||||||
|
const response = await fetch('/admin/api/codes/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ count }),
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
const textarea = document.querySelector('#generatedCodes textarea');
|
||||||
|
textarea.value = data.data.codes.join('\n');
|
||||||
|
document.getElementById('generatedCodes').classList.remove('d-none');
|
||||||
|
loadCodes();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('logoutBtn').addEventListener('click', async () => {
|
||||||
|
await fetch('/admin/api/logout', { method: 'POST', credentials: 'same-origin' });
|
||||||
|
window.location.href = '/admin/';
|
||||||
|
});
|
||||||
|
|
||||||
|
loadCodes();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
48
office365_self_service/templates/admin_login.html
Normal file
48
office365_self_service/templates/admin_login.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>管理后台登录 - {{ settings.app_name }}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { background-color: #f5f5f5; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.login-card { max-width: 400px; width: 100%; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-card">
|
||||||
|
<h3 class="text-center mb-4">管理后台登录</h3>
|
||||||
|
<div id="message"></div>
|
||||||
|
<form id="loginForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">用户名</label>
|
||||||
|
<input type="text" class="form-control" name="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">密码</label>
|
||||||
|
<input type="password" class="form-control" name="password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">登录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const response = await fetch('/admin/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(Object.fromEntries(formData)),
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
window.location.href = '/admin/';
|
||||||
|
} else {
|
||||||
|
document.getElementById('message').innerHTML = `<div class="alert alert-danger">${data.message}</div>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
97
office365_self_service/templates/user_redemption.html
Normal file
97
office365_self_service/templates/user_redemption.html
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>账号开通 - {{ settings.app_name }}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { background-color: #f5f5f5; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 2rem 0; }
|
||||||
|
.redemption-card { max-width: 500px; width: 100%; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||||
|
.result-box { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="redemption-card">
|
||||||
|
<h3 class="text-center mb-4">{{ settings.app_name }}</h3>
|
||||||
|
<p class="text-center text-muted mb-4">兑换码开通 Office 365 账号</p>
|
||||||
|
|
||||||
|
<div id="message"></div>
|
||||||
|
|
||||||
|
<div id="redeemForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">兑换码</label>
|
||||||
|
<input type="text" class="form-control" id="codeInput" placeholder="请输入兑换码" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">用户名</label>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100" id="redeemBtn">立即开通</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="successResult" class="d-none">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<div class="text-success mb-3">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.55a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h4>账号开通成功!</h4>
|
||||||
|
</div>
|
||||||
|
<div class="result-box mb-3">
|
||||||
|
<p class="mb-1"><strong>账号:</strong><code id="resultEmail"></code></p>
|
||||||
|
<p class="mb-0"><strong>临时密码:</strong><code id="resultPassword"></code></p>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>提示:</strong>首次登录后系统会要求您更改密码,请使用临时密码登录。
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-secondary w-100" onclick="location.reload()">开通另一个账号</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
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 = '<div class="alert alert-danger">请填写完整的兑换码和用户名</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('redeemBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '开通中...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/redeem', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ code, username })
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
} else {
|
||||||
|
document.getElementById('message').innerHTML = `<div class="alert alert-danger">${data.message}</div>`;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '立即开通';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('message').innerHTML = '<div class="alert alert-danger">网络错误,请稍后重试</div>';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '立即开通';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user