feat: implement PRD-00009 MT-1 internal email pool with full P0 contract (claim/release/complete/stats, ownership checks, lease expiry, cooldown recovery, 38 pool tests)
This commit is contained in:
1 parent
9594cdd443
commit
0d92a613f5
8 files changed
+1841
-38
No files matched your search
+16
-4
@@ -36,6 +36,7 @@ def create_app(*, autostart_scheduler: Optional[bool] = None):
|
||||
groups,
|
||||
oauth,
|
||||
pages,
|
||||
pool,
|
||||
scheduler,
|
||||
settings,
|
||||
system,
|
||||
@@ -88,7 +89,9 @@ def create_app(*, autostart_scheduler: Optional[bool] = None):
|
||||
# ProxyFix 中间件(仅在配置启用时应用)
|
||||
# 注意:启用前必须配置 TRUSTED_PROXIES 环境变量
|
||||
if config.get_proxy_fix_enabled():
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1
|
||||
)
|
||||
|
||||
# DB teardown(请求结束释放连接)
|
||||
register_db(app)
|
||||
@@ -98,7 +101,11 @@ def create_app(*, autostart_scheduler: Optional[bool] = None):
|
||||
import sys
|
||||
|
||||
_log_handler = logging.StreamHandler(sys.stderr)
|
||||
_log_handler.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s", datefmt="%H:%M:%S"))
|
||||
_log_handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(asctime)s %(name)s %(levelname)s %(message)s", datefmt="%H:%M:%S"
|
||||
)
|
||||
)
|
||||
_ow_logger = logging.getLogger("outlook_web")
|
||||
if not _ow_logger.handlers:
|
||||
_ow_logger.addHandler(_log_handler)
|
||||
@@ -125,6 +132,7 @@ def create_app(*, autostart_scheduler: Optional[bool] = None):
|
||||
app.register_blueprint(scheduler.create_blueprint())
|
||||
app.register_blueprint(system.create_blueprint())
|
||||
app.register_blueprint(audit.create_blueprint())
|
||||
app.register_blueprint(pool.create_blueprint())
|
||||
|
||||
# 打印初始化信息
|
||||
print("=" * 60)
|
||||
@@ -141,11 +149,15 @@ def create_app(*, autostart_scheduler: Optional[bool] = None):
|
||||
from outlook_web.services import scheduler as scheduler_service
|
||||
|
||||
if scheduler_service.should_autostart_scheduler():
|
||||
scheduler_service.init_scheduler(_APP_INSTANCE, graph_service.test_refresh_token_with_rotation)
|
||||
scheduler_service.init_scheduler(
|
||||
_APP_INSTANCE, graph_service.test_refresh_token_with_rotation
|
||||
)
|
||||
elif autostart_scheduler:
|
||||
from outlook_web.services import graph as graph_service
|
||||
from outlook_web.services import scheduler as scheduler_service
|
||||
|
||||
scheduler_service.init_scheduler(_APP_INSTANCE, graph_service.test_refresh_token_with_rotation)
|
||||
scheduler_service.init_scheduler(
|
||||
_APP_INSTANCE, graph_service.test_refresh_token_with_rotation
|
||||
)
|
||||
|
||||
return _APP_INSTANCE
|
||||
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
邮箱池控制器(PRD-00009 MT-1)
|
||||
|
||||
职责:
|
||||
- 解析 HTTP 请求参数
|
||||
- 调用 services/pool.py 的业务方法
|
||||
- 将 service 层业务结果映射为 FD 统一响应格式
|
||||
- 将 PoolServiceError 映射为统一错误响应
|
||||
|
||||
响应格式(FD-00009 §6):
|
||||
- 成功:{"success": true, "data": {...}}
|
||||
- 失败:{"success": false, "error": "error_code", "message": "错误说明"}
|
||||
|
||||
HTTP 状态码映射(TD-00009 §6.3):
|
||||
- 参数错误 → 400
|
||||
- 归属/凭据错误 → 403
|
||||
- 状态冲突 → 409
|
||||
- 业务失败(如 no_available_account) → 200 + success=false
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import jsonify, request
|
||||
|
||||
from outlook_web.services.pool import (
|
||||
PoolServiceError,
|
||||
claim_random,
|
||||
complete_claim,
|
||||
get_pool_stats,
|
||||
release_claim,
|
||||
)
|
||||
|
||||
|
||||
def _success_response(data: dict, http_status: int = 200):
|
||||
"""统一成功响应:{"success": true, "data": {...}}"""
|
||||
return jsonify({"success": True, "data": data}), http_status
|
||||
|
||||
|
||||
def _error_response(error_code: str, message: str, http_status: int = 400):
|
||||
"""统一失败响应:{"success": false, "error": "...", "message": "..."}"""
|
||||
return jsonify(
|
||||
{"success": False, "error": error_code, "message": message}
|
||||
), http_status
|
||||
|
||||
|
||||
def api_claim_random():
|
||||
body = request.get_json(silent=True) or {}
|
||||
caller_id = body.get("caller_id", "")
|
||||
task_id = body.get("task_id", "")
|
||||
provider = body.get("provider")
|
||||
|
||||
try:
|
||||
account = claim_random(
|
||||
caller_id=caller_id,
|
||||
task_id=task_id,
|
||||
provider=provider,
|
||||
)
|
||||
return _success_response(
|
||||
{
|
||||
"account_id": account["id"],
|
||||
"email": account["email"],
|
||||
"claim_token": account["claim_token"],
|
||||
"lease_expires_at": account["lease_expires_at"],
|
||||
}
|
||||
)
|
||||
except PoolServiceError as exc:
|
||||
return _error_response(exc.error_code, str(exc), exc.http_status)
|
||||
|
||||
|
||||
def api_claim_release():
|
||||
body = request.get_json(silent=True) or {}
|
||||
account_id = body.get("account_id")
|
||||
claim_token = body.get("claim_token", "")
|
||||
caller_id = body.get("caller_id", "")
|
||||
task_id = body.get("task_id", "")
|
||||
reason = body.get("reason")
|
||||
|
||||
if account_id is None:
|
||||
return _error_response("account_id_missing", "account_id 不能为空", 400)
|
||||
try:
|
||||
account_id = int(account_id)
|
||||
except (TypeError, ValueError):
|
||||
return _error_response("account_id_invalid", "account_id 必须为整数", 400)
|
||||
|
||||
try:
|
||||
release_claim(
|
||||
account_id=account_id,
|
||||
claim_token=claim_token,
|
||||
caller_id=caller_id,
|
||||
task_id=task_id,
|
||||
reason=reason,
|
||||
)
|
||||
return _success_response({"account_id": account_id, "pool_status": "available"})
|
||||
except PoolServiceError as exc:
|
||||
return _error_response(exc.error_code, str(exc), exc.http_status)
|
||||
|
||||
|
||||
def api_claim_complete():
|
||||
body = request.get_json(silent=True) or {}
|
||||
account_id = body.get("account_id")
|
||||
claim_token = body.get("claim_token", "")
|
||||
caller_id = body.get("caller_id", "")
|
||||
task_id = body.get("task_id", "")
|
||||
result = body.get("result", "")
|
||||
detail = body.get("detail")
|
||||
|
||||
if account_id is None:
|
||||
return _error_response("account_id_missing", "account_id 不能为空", 400)
|
||||
try:
|
||||
account_id = int(account_id)
|
||||
except (TypeError, ValueError):
|
||||
return _error_response("account_id_invalid", "account_id 必须为整数", 400)
|
||||
|
||||
try:
|
||||
new_status = complete_claim(
|
||||
account_id=account_id,
|
||||
claim_token=claim_token,
|
||||
caller_id=caller_id,
|
||||
task_id=task_id,
|
||||
result=result,
|
||||
detail=detail,
|
||||
)
|
||||
return _success_response({"account_id": account_id, "pool_status": new_status})
|
||||
except PoolServiceError as exc:
|
||||
return _error_response(exc.error_code, str(exc), exc.http_status)
|
||||
|
||||
|
||||
def api_pool_stats():
|
||||
try:
|
||||
stats = get_pool_stats()
|
||||
return _success_response(stats)
|
||||
except Exception as exc:
|
||||
return _error_response("internal_error", str(exc), 500)
|
||||
+87
-10
@@ -24,7 +24,8 @@ from outlook_web.security.crypto import (
|
||||
# v8:PRD-00008 P1 — 上游真实探测结果缓存表
|
||||
# v9:PRD-00008 P2 — 多 API Key 表
|
||||
# v10:PRD-00008 P2 — 调用方日级使用统计表
|
||||
DB_SCHEMA_VERSION = 10
|
||||
# v11:PRD-00009 MT-1 — 邮箱池字段(pool_status/claimed_by/...)+ account_claim_logs 表 + pool settings
|
||||
DB_SCHEMA_VERSION = 11
|
||||
DB_SCHEMA_VERSION_KEY = "db_schema_version"
|
||||
DB_SCHEMA_LAST_UPGRADE_TRACE_ID_KEY = "db_schema_last_upgrade_trace_id"
|
||||
DB_SCHEMA_LAST_UPGRADE_ERROR_KEY = "db_schema_last_upgrade_error"
|
||||
@@ -124,7 +125,9 @@ def init_db(database_path: Optional[str] = None):
|
||||
""")
|
||||
|
||||
# 在锁内读取当前 schema 版本(保证一致性)
|
||||
row = cursor.execute("SELECT value FROM settings WHERE key = ?", (DB_SCHEMA_VERSION_KEY,)).fetchone()
|
||||
row = cursor.execute(
|
||||
"SELECT value FROM settings WHERE key = ?", (DB_SCHEMA_VERSION_KEY,)
|
||||
).fetchone()
|
||||
current_version = int(row["value"]) if row and row["value"] is not None else 0
|
||||
|
||||
upgrading = current_version < DB_SCHEMA_VERSION
|
||||
@@ -133,7 +136,9 @@ def init_db(database_path: Optional[str] = None):
|
||||
if db_existed:
|
||||
try:
|
||||
print("=" * 60)
|
||||
print(f"[升级提示] 检测到数据库需要升级:v{current_version} -> v{DB_SCHEMA_VERSION}")
|
||||
print(
|
||||
f"[升级提示] 检测到数据库需要升级:v{current_version} -> v{DB_SCHEMA_VERSION}"
|
||||
)
|
||||
print(f"[升级提示] 强烈建议先备份数据库文件:{path}")
|
||||
print(f'[升级提示] 示例:cp "{path}" "{path}.backup"')
|
||||
print(f"[升级提示] trace_id={migration_trace_id}")
|
||||
@@ -347,25 +352,39 @@ def init_db(database_path: Optional[str] = None):
|
||||
if "remark" not in columns:
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN remark TEXT")
|
||||
if "status" not in columns:
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN status TEXT DEFAULT 'active'")
|
||||
cursor.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN status TEXT DEFAULT 'active'"
|
||||
)
|
||||
if "updated_at" not in columns:
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
|
||||
cursor.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
|
||||
)
|
||||
if "last_refresh_at" not in columns:
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN last_refresh_at TIMESTAMP")
|
||||
if "account_type" not in columns:
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN account_type TEXT DEFAULT 'outlook'")
|
||||
cursor.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN account_type TEXT DEFAULT 'outlook'"
|
||||
)
|
||||
if "provider" not in columns:
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN provider TEXT DEFAULT 'outlook'")
|
||||
cursor.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN provider TEXT DEFAULT 'outlook'"
|
||||
)
|
||||
if "imap_host" not in columns:
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN imap_host TEXT")
|
||||
if "imap_port" not in columns:
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN imap_port INTEGER DEFAULT 993")
|
||||
cursor.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN imap_port INTEGER DEFAULT 993"
|
||||
)
|
||||
if "imap_password" not in columns:
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN imap_password TEXT")
|
||||
if "telegram_push_enabled" not in columns:
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN telegram_push_enabled INTEGER NOT NULL DEFAULT 0")
|
||||
cursor.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN telegram_push_enabled INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
if "telegram_last_checked_at" not in columns:
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN telegram_last_checked_at TEXT DEFAULT NULL")
|
||||
cursor.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN telegram_last_checked_at TEXT DEFAULT NULL"
|
||||
)
|
||||
|
||||
cursor.execute("PRAGMA table_info(groups)")
|
||||
group_columns = [col[1] for col in cursor.fetchall()]
|
||||
@@ -640,6 +659,64 @@ def init_db(database_path: Optional[str] = None):
|
||||
ON external_api_consumer_usage_daily(consumer_key, usage_date)
|
||||
""")
|
||||
|
||||
# v11: 邮箱池字段 + account_claim_logs 表(PRD-00009 MT-1)
|
||||
cursor.execute("PRAGMA table_info(accounts)")
|
||||
accounts_columns_v11 = [col[1] for col in cursor.fetchall()]
|
||||
for col_def in [
|
||||
("pool_status", "TEXT DEFAULT NULL"),
|
||||
("claimed_by", "TEXT DEFAULT NULL"),
|
||||
("claimed_at", "TEXT DEFAULT NULL"),
|
||||
("lease_expires_at", "TEXT DEFAULT NULL"),
|
||||
("claim_token", "TEXT DEFAULT NULL"),
|
||||
("last_claimed_at", "TEXT DEFAULT NULL"),
|
||||
("last_result", "TEXT DEFAULT NULL"),
|
||||
("last_result_detail", "TEXT DEFAULT NULL"),
|
||||
("success_count", "INTEGER DEFAULT 0"),
|
||||
("fail_count", "INTEGER DEFAULT 0"),
|
||||
]:
|
||||
if col_def[0] not in accounts_columns_v11:
|
||||
cursor.execute(
|
||||
f"ALTER TABLE accounts ADD COLUMN {col_def[0]} {col_def[1]}"
|
||||
)
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS account_claim_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
claim_token TEXT NOT NULL,
|
||||
caller_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
result TEXT DEFAULT NULL,
|
||||
detail TEXT DEFAULT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id)
|
||||
)
|
||||
""")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_claim_logs_account_id
|
||||
ON account_claim_logs(account_id)
|
||||
""")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_claim_logs_task_id
|
||||
ON account_claim_logs(task_id)
|
||||
""")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_claim_logs_claim_token
|
||||
ON account_claim_logs(claim_token)
|
||||
""")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_pool_status
|
||||
ON accounts(pool_status)
|
||||
""")
|
||||
|
||||
cursor.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES ('pool_cooldown_seconds', '86400')"
|
||||
)
|
||||
cursor.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES ('pool_default_lease_seconds', '600')"
|
||||
)
|
||||
|
||||
# 迁移现有明文数据为加密数据
|
||||
migrate_sensitive_data(conn)
|
||||
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
RESULT_TO_POOL_STATUS: Dict[str, str] = {
|
||||
"success": "used",
|
||||
"verification_timeout": "cooldown",
|
||||
"provider_blocked": "frozen",
|
||||
"credential_invalid": "retired",
|
||||
"network_error": "available",
|
||||
}
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def claim_atomic(
|
||||
conn: sqlite3.Connection,
|
||||
caller_id: str,
|
||||
task_id: str,
|
||||
lease_seconds: int,
|
||||
provider: Optional[str] = None,
|
||||
group_id: Optional[int] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
exclude_recent_minutes: Optional[int] = None,
|
||||
) -> Optional[dict]:
|
||||
sql = """
|
||||
SELECT a.* FROM accounts a
|
||||
WHERE a.pool_status = 'available'
|
||||
AND a.status = 'active'
|
||||
"""
|
||||
params: list = []
|
||||
|
||||
if provider:
|
||||
sql += " AND a.provider = ?"
|
||||
params.append(provider)
|
||||
|
||||
if group_id is not None:
|
||||
sql += " AND a.group_id = ?"
|
||||
params.append(group_id)
|
||||
|
||||
if tags:
|
||||
for tag_name in tags:
|
||||
sql += """
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM account_tags at2
|
||||
JOIN tags t2 ON at2.tag_id = t2.id
|
||||
WHERE at2.account_id = a.id AND t2.name = ?
|
||||
)
|
||||
"""
|
||||
params.append(tag_name)
|
||||
|
||||
if exclude_recent_minutes and exclude_recent_minutes > 0:
|
||||
cutoff = (
|
||||
_utcnow() - timedelta(minutes=exclude_recent_minutes)
|
||||
).isoformat() + "Z"
|
||||
sql += " AND (a.last_claimed_at IS NULL OR a.last_claimed_at < ?)"
|
||||
params.append(cutoff)
|
||||
|
||||
sql += " ORDER BY RANDOM() LIMIT 1"
|
||||
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
account = conn.execute(sql, params).fetchone()
|
||||
|
||||
if account is None:
|
||||
conn.execute("ROLLBACK")
|
||||
return None
|
||||
|
||||
now_str = _utcnow().isoformat() + "Z"
|
||||
lease_expires_at_str = (
|
||||
_utcnow() + timedelta(seconds=lease_seconds)
|
||||
).isoformat() + "Z"
|
||||
token = "clm_" + secrets.token_urlsafe(9)
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE accounts SET
|
||||
pool_status = 'claimed',
|
||||
claimed_by = ?,
|
||||
claimed_at = ?,
|
||||
lease_expires_at = ?,
|
||||
claim_token = ?,
|
||||
last_claimed_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
f"{caller_id}:{task_id}",
|
||||
now_str,
|
||||
lease_expires_at_str,
|
||||
token,
|
||||
now_str,
|
||||
now_str,
|
||||
account["id"],
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO account_claim_logs
|
||||
(account_id, claim_token, caller_id, task_id, action, result, detail, created_at)
|
||||
VALUES (?, ?, ?, ?, 'claim', NULL, NULL, ?)
|
||||
""",
|
||||
(account["id"], token, caller_id, task_id, now_str),
|
||||
)
|
||||
conn.execute("COMMIT")
|
||||
return dict(account) | {
|
||||
"claim_token": token,
|
||||
"lease_expires_at": lease_expires_at_str,
|
||||
}
|
||||
|
||||
|
||||
def release(
|
||||
conn: sqlite3.Connection,
|
||||
account_id: int,
|
||||
claim_token: str,
|
||||
caller_id: str,
|
||||
task_id: str,
|
||||
reason: Optional[str],
|
||||
) -> None:
|
||||
now_str = _utcnow().isoformat() + "Z"
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE accounts SET
|
||||
pool_status = 'available',
|
||||
claimed_by = NULL,
|
||||
claimed_at = NULL,
|
||||
lease_expires_at = NULL,
|
||||
claim_token = NULL,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now_str, account_id),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO account_claim_logs
|
||||
(account_id, claim_token, caller_id, task_id, action, result, detail, created_at)
|
||||
VALUES (?, ?, ?, ?, 'release', 'manual_release', ?, ?)
|
||||
""",
|
||||
(account_id, claim_token, caller_id, task_id, reason, now_str),
|
||||
)
|
||||
conn.execute("COMMIT")
|
||||
|
||||
|
||||
def complete(
|
||||
conn: sqlite3.Connection,
|
||||
account_id: int,
|
||||
claim_token: str,
|
||||
caller_id: str,
|
||||
task_id: str,
|
||||
result: str,
|
||||
detail: Optional[str],
|
||||
) -> str:
|
||||
new_pool_status = RESULT_TO_POOL_STATUS[result]
|
||||
is_success = result == "success"
|
||||
now_str = _utcnow().isoformat() + "Z"
|
||||
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE accounts SET
|
||||
pool_status = ?,
|
||||
claimed_by = NULL,
|
||||
claimed_at = NULL,
|
||||
lease_expires_at = NULL,
|
||||
claim_token = NULL,
|
||||
last_result = ?,
|
||||
last_result_detail = ?,
|
||||
success_count = success_count + ?,
|
||||
fail_count = fail_count + ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
new_pool_status,
|
||||
result,
|
||||
detail,
|
||||
1 if is_success else 0,
|
||||
0 if is_success else 1,
|
||||
now_str,
|
||||
account_id,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO account_claim_logs
|
||||
(account_id, claim_token, caller_id, task_id, action, result, detail, created_at)
|
||||
VALUES (?, ?, ?, ?, 'complete', ?, ?, ?)
|
||||
""",
|
||||
(account_id, claim_token, caller_id, task_id, result, detail, now_str),
|
||||
)
|
||||
conn.execute("COMMIT")
|
||||
return new_pool_status
|
||||
|
||||
|
||||
def expire_stale_claims(conn: sqlite3.Connection) -> int:
|
||||
now_str = _utcnow().isoformat() + "Z"
|
||||
expired = conn.execute(
|
||||
"""
|
||||
SELECT id, claim_token, claimed_by FROM accounts
|
||||
WHERE pool_status = 'claimed' AND lease_expires_at < ?
|
||||
""",
|
||||
(now_str,),
|
||||
).fetchall()
|
||||
|
||||
for account in expired:
|
||||
parts = (account["claimed_by"] or ":").split(":", 1)
|
||||
caller_id = parts[0]
|
||||
task_id = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE accounts SET
|
||||
pool_status = 'cooldown',
|
||||
claimed_by = NULL,
|
||||
claimed_at = NULL,
|
||||
lease_expires_at = NULL,
|
||||
claim_token = NULL,
|
||||
fail_count = fail_count + 1,
|
||||
last_result = 'lease_expired',
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now_str, account["id"]),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO account_claim_logs
|
||||
(account_id, claim_token, caller_id, task_id, action, result, detail, created_at)
|
||||
VALUES (?, ?, ?, ?, 'expire', 'lease_expired', 'lease timeout, auto moved to cooldown', ?)
|
||||
""",
|
||||
(account["id"], account["claim_token"], caller_id, task_id, now_str),
|
||||
)
|
||||
conn.execute("COMMIT")
|
||||
|
||||
return len(expired)
|
||||
|
||||
|
||||
def recover_cooldown(conn: sqlite3.Connection, cooldown_seconds: int) -> int:
|
||||
cutoff_str = (_utcnow() - timedelta(seconds=cooldown_seconds)).isoformat() + "Z"
|
||||
now_str = _utcnow().isoformat() + "Z"
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
UPDATE accounts SET pool_status = 'available', updated_at = ?
|
||||
WHERE pool_status = 'cooldown' AND updated_at < ?
|
||||
""",
|
||||
(now_str, cutoff_str),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
def get_stats(conn: sqlite3.Connection) -> dict:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT pool_status, COUNT(*) as cnt FROM accounts
|
||||
GROUP BY pool_status
|
||||
"""
|
||||
).fetchall()
|
||||
pool_counts: dict = {
|
||||
"available": 0,
|
||||
"claimed": 0,
|
||||
"used": 0,
|
||||
"cooldown": 0,
|
||||
"frozen": 0,
|
||||
"retired": 0,
|
||||
"not_in_pool": 0,
|
||||
}
|
||||
for row in rows:
|
||||
key = row["pool_status"] if row["pool_status"] is not None else "not_in_pool"
|
||||
if key in pool_counts:
|
||||
pool_counts[key] = row["cnt"]
|
||||
|
||||
return {"pool_counts": pool_counts}
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
from outlook_web.controllers import pool as pool_controller
|
||||
|
||||
|
||||
def create_blueprint() -> Blueprint:
|
||||
bp = Blueprint("pool", __name__)
|
||||
bp.add_url_rule(
|
||||
"/api/pool/claim-random",
|
||||
view_func=pool_controller.api_claim_random,
|
||||
methods=["POST"],
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/api/pool/claim-release",
|
||||
view_func=pool_controller.api_claim_release,
|
||||
methods=["POST"],
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/api/pool/claim-complete",
|
||||
view_func=pool_controller.api_claim_complete,
|
||||
methods=["POST"],
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/api/pool/stats",
|
||||
view_func=pool_controller.api_pool_stats,
|
||||
methods=["GET"],
|
||||
)
|
||||
return bp
|
||||
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
邮箱池服务层(PRD-00009 MT-1)
|
||||
|
||||
职责:
|
||||
- 输入校验(caller_id / task_id / lease_seconds / result / detail 长度)
|
||||
- 读取 settings(在 Flask app_context 下用 get_db,或直接接受 conn)
|
||||
- 调用 repositories/pool.py 的原子操作
|
||||
- 将 repository 层的异常转换为业务错误码
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from outlook_web.db import create_sqlite_connection
|
||||
from outlook_web.repositories import pool as pool_repo
|
||||
|
||||
CALLER_ID_MAX_LEN = 64
|
||||
TASK_ID_MAX_LEN = 128
|
||||
REASON_MAX_LEN = 256
|
||||
DETAIL_MAX_LEN = 512
|
||||
|
||||
VALID_RESULTS = set(pool_repo.RESULT_TO_POOL_STATUS.keys())
|
||||
|
||||
|
||||
class PoolServiceError(Exception):
|
||||
"""业务错误,包含 HTTP 状态码和错误码。"""
|
||||
|
||||
def __init__(self, message: str, error_code: str, http_status: int = 400):
|
||||
super().__init__(message)
|
||||
self.error_code = error_code
|
||||
self.http_status = http_status
|
||||
|
||||
|
||||
def _validate_caller_id(caller_id: str) -> None:
|
||||
if not caller_id or not caller_id.strip():
|
||||
raise PoolServiceError("caller_id 不能为空", "caller_id_empty")
|
||||
if len(caller_id) > CALLER_ID_MAX_LEN:
|
||||
raise PoolServiceError(
|
||||
f"caller_id 超过最大长度 {CALLER_ID_MAX_LEN}", "caller_id_too_long"
|
||||
)
|
||||
|
||||
|
||||
def _validate_task_id(task_id: str) -> None:
|
||||
if not task_id or not task_id.strip():
|
||||
raise PoolServiceError("task_id 不能为空", "task_id_empty")
|
||||
if len(task_id) > TASK_ID_MAX_LEN:
|
||||
raise PoolServiceError(
|
||||
f"task_id 超过最大长度 {TASK_ID_MAX_LEN}", "task_id_too_long"
|
||||
)
|
||||
|
||||
|
||||
def _validate_lease_seconds(lease_seconds: int, max_lease: int = 3600) -> None:
|
||||
if lease_seconds <= 0:
|
||||
raise PoolServiceError("lease_seconds 必须大于 0", "lease_seconds_invalid")
|
||||
if lease_seconds > max_lease:
|
||||
raise PoolServiceError(
|
||||
f"lease_seconds 不能超过 {max_lease} 秒", "lease_seconds_too_large"
|
||||
)
|
||||
|
||||
|
||||
def _read_settings_via_conn(conn) -> dict:
|
||||
"""在独立连接场景下直接从 settings 表读取池相关配置。"""
|
||||
rows = conn.execute(
|
||||
"SELECT key, value FROM settings WHERE key IN (?, ?)",
|
||||
("pool_cooldown_seconds", "pool_default_lease_seconds"),
|
||||
).fetchall()
|
||||
result = {"pool_cooldown_seconds": 86400, "pool_default_lease_seconds": 600}
|
||||
for row in rows:
|
||||
try:
|
||||
result[row["key"]] = int(row["value"])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def claim_random(
|
||||
*,
|
||||
caller_id: str,
|
||||
task_id: str,
|
||||
provider: Optional[str] = None,
|
||||
) -> dict:
|
||||
_validate_caller_id(caller_id)
|
||||
_validate_task_id(task_id)
|
||||
|
||||
conn = create_sqlite_connection()
|
||||
try:
|
||||
settings = _read_settings_via_conn(conn)
|
||||
default_lease = settings["pool_default_lease_seconds"]
|
||||
_validate_lease_seconds(default_lease)
|
||||
|
||||
account = pool_repo.claim_atomic(
|
||||
conn,
|
||||
caller_id=caller_id,
|
||||
task_id=task_id,
|
||||
lease_seconds=default_lease,
|
||||
provider=provider,
|
||||
)
|
||||
if account is None:
|
||||
raise PoolServiceError(
|
||||
"池中没有符合条件的可用邮箱", "no_available_account", http_status=200
|
||||
)
|
||||
return account
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def release_claim(
|
||||
*,
|
||||
account_id: int,
|
||||
claim_token: str,
|
||||
caller_id: str,
|
||||
task_id: str,
|
||||
reason: Optional[str] = None,
|
||||
) -> None:
|
||||
"""释放已领取的邮箱账号(不计入成功/失败统计,直接回 available)。"""
|
||||
_validate_caller_id(caller_id)
|
||||
_validate_task_id(task_id)
|
||||
if not claim_token or not claim_token.strip():
|
||||
raise PoolServiceError("claim_token 不能为空", "claim_token_empty")
|
||||
if reason and len(reason) > REASON_MAX_LEN:
|
||||
raise PoolServiceError(
|
||||
f"reason 超过最大长度 {REASON_MAX_LEN}", "reason_too_long"
|
||||
)
|
||||
|
||||
conn = create_sqlite_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT id, claim_token, claimed_by, pool_status FROM accounts WHERE id = ?",
|
||||
(account_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise PoolServiceError("账号不存在", "account_not_found", http_status=400)
|
||||
if row["pool_status"] != "claimed":
|
||||
raise PoolServiceError(
|
||||
f"账号当前状态为 '{row['pool_status']}',无法 release",
|
||||
"not_claimed",
|
||||
http_status=409,
|
||||
)
|
||||
if row["claim_token"] != claim_token:
|
||||
raise PoolServiceError(
|
||||
"claim_token 不匹配", "token_mismatch", http_status=403
|
||||
)
|
||||
expected_claimed_by = f"{caller_id}:{task_id}"
|
||||
if row["claimed_by"] != expected_claimed_by:
|
||||
raise PoolServiceError(
|
||||
"caller_id 或 task_id 与领取记录不一致",
|
||||
"caller_mismatch",
|
||||
http_status=403,
|
||||
)
|
||||
|
||||
pool_repo.release(conn, account_id, claim_token, caller_id, task_id, reason)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def complete_claim(
|
||||
*,
|
||||
account_id: int,
|
||||
claim_token: str,
|
||||
caller_id: str,
|
||||
task_id: str,
|
||||
result: str,
|
||||
detail: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
标记领取结果并驱动状态机流转。
|
||||
|
||||
返回账号的新 pool_status。
|
||||
"""
|
||||
_validate_caller_id(caller_id)
|
||||
_validate_task_id(task_id)
|
||||
if not claim_token or not claim_token.strip():
|
||||
raise PoolServiceError("claim_token 不能为空", "claim_token_empty")
|
||||
if result not in VALID_RESULTS:
|
||||
raise PoolServiceError(
|
||||
f"result 必须是 {sorted(VALID_RESULTS)} 之一",
|
||||
"invalid_result",
|
||||
)
|
||||
if detail and len(detail) > DETAIL_MAX_LEN:
|
||||
raise PoolServiceError(
|
||||
f"detail 超过最大长度 {DETAIL_MAX_LEN}", "detail_too_long"
|
||||
)
|
||||
|
||||
conn = create_sqlite_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT id, claim_token, claimed_by, pool_status FROM accounts WHERE id = ?",
|
||||
(account_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise PoolServiceError("账号不存在", "account_not_found", http_status=400)
|
||||
if row["pool_status"] != "claimed":
|
||||
raise PoolServiceError(
|
||||
f"账号当前状态为 '{row['pool_status']}',无法 complete",
|
||||
"not_claimed",
|
||||
http_status=409,
|
||||
)
|
||||
if row["claim_token"] != claim_token:
|
||||
raise PoolServiceError(
|
||||
"claim_token 不匹配", "token_mismatch", http_status=403
|
||||
)
|
||||
expected_claimed_by = f"{caller_id}:{task_id}"
|
||||
if row["claimed_by"] != expected_claimed_by:
|
||||
raise PoolServiceError(
|
||||
"caller_id 或 task_id 与领取记录不一致",
|
||||
"caller_mismatch",
|
||||
http_status=403,
|
||||
)
|
||||
|
||||
new_status = pool_repo.complete(
|
||||
conn, account_id, claim_token, caller_id, task_id, result, detail
|
||||
)
|
||||
return new_status
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_pool_stats() -> dict:
|
||||
"""返回池状态统计(不修改任何数据)。"""
|
||||
conn = create_sqlite_connection()
|
||||
try:
|
||||
return pool_repo.get_stats(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -97,7 +97,10 @@ def _configure_telegram_push_job(scheduler, app) -> None:
|
||||
|
||||
def _configure_probe_poll_job(scheduler, app) -> None:
|
||||
"""P2: 注册异步探测轮询 Job。每 5 秒执行一次,处理 pending 探测并清理过期记录。"""
|
||||
from outlook_web.services.external_api import cleanup_expired_probes, poll_pending_probes
|
||||
from outlook_web.services.external_api import (
|
||||
cleanup_expired_probes,
|
||||
poll_pending_probes,
|
||||
)
|
||||
|
||||
try:
|
||||
scheduler.remove_job("external_probe_poll")
|
||||
@@ -125,20 +128,94 @@ def _configure_probe_poll_job(scheduler, app) -> None:
|
||||
print("✓ 对外 API 异步探测 Job 已配置(轮询间隔:5 秒)")
|
||||
|
||||
|
||||
def _configure_pool_maintenance_jobs(scheduler) -> None:
|
||||
try:
|
||||
scheduler.remove_job("pool_expire_stale_claims")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
scheduler.remove_job("pool_recover_cooldown")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _expire_task():
|
||||
try:
|
||||
from outlook_web.db import create_sqlite_connection
|
||||
from outlook_web.repositories import pool as pool_repo
|
||||
|
||||
conn = create_sqlite_connection()
|
||||
try:
|
||||
expired = pool_repo.expire_stale_claims(conn)
|
||||
if expired:
|
||||
print(f"[pool] 过期 claims 回收:{expired} 条")
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _recover_task():
|
||||
try:
|
||||
from outlook_web.db import create_sqlite_connection
|
||||
from outlook_web.repositories import pool as pool_repo
|
||||
|
||||
conn = create_sqlite_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM settings WHERE key = 'pool_cooldown_seconds'"
|
||||
).fetchone()
|
||||
cooldown_seconds = int(row["value"]) if row and row["value"] else 86400
|
||||
recovered = pool_repo.recover_cooldown(conn, cooldown_seconds)
|
||||
if recovered:
|
||||
print(f"[pool] cooldown 恢复:{recovered} 条")
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
scheduler.add_job(
|
||||
func=_expire_task,
|
||||
trigger="interval",
|
||||
seconds=60,
|
||||
id="pool_expire_stale_claims",
|
||||
name="邮箱池过期 Claim 回收",
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
misfire_grace_time=60,
|
||||
)
|
||||
scheduler.add_job(
|
||||
func=_recover_task,
|
||||
trigger="interval",
|
||||
seconds=300,
|
||||
id="pool_recover_cooldown",
|
||||
name="邮箱池 Cooldown 恢复",
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
misfire_grace_time=120,
|
||||
)
|
||||
print("✓ 邮箱池维护 Job 已配置(expire: 60s,recover: 300s)")
|
||||
|
||||
|
||||
def configure_scheduler_jobs(scheduler, app, test_refresh_token) -> None:
|
||||
"""根据当前 settings 重新配置定时刷新 Job(配置变更即时生效)"""
|
||||
try:
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
except Exception:
|
||||
return
|
||||
|
||||
with app.app_context():
|
||||
enable_scheduled = settings_repo.get_setting("enable_scheduled_refresh", "true").lower() == "true"
|
||||
use_cron = settings_repo.get_setting("use_cron_schedule", "false").lower() == "true"
|
||||
refresh_interval_days = int(settings_repo.get_setting("refresh_interval_days", "30"))
|
||||
enable_scheduled = (
|
||||
settings_repo.get_setting("enable_scheduled_refresh", "true").lower()
|
||||
== "true"
|
||||
)
|
||||
use_cron = (
|
||||
settings_repo.get_setting("use_cron_schedule", "false").lower() == "true"
|
||||
)
|
||||
refresh_interval_days = int(
|
||||
settings_repo.get_setting("refresh_interval_days", "30")
|
||||
)
|
||||
cron_expr = settings_repo.get_setting("refresh_cron", "0 2 * * *")
|
||||
|
||||
# 心跳 Job:始终存在(可服务/可刷新可验证)
|
||||
try:
|
||||
scheduler.remove_job("scheduler_heartbeat")
|
||||
except Exception:
|
||||
@@ -155,11 +232,9 @@ def configure_scheduler_jobs(scheduler, app, test_refresh_token) -> None:
|
||||
misfire_grace_time=60,
|
||||
)
|
||||
|
||||
# Telegram 推送 Job
|
||||
_configure_telegram_push_job(scheduler, app)
|
||||
|
||||
# P2: 异步探测轮询 Job(每 5 秒检查一次 pending 探测)
|
||||
_configure_probe_poll_job(scheduler, app)
|
||||
_configure_pool_maintenance_jobs(scheduler)
|
||||
|
||||
# 刷新 Job:根据 enable_scheduled 决定是否启用
|
||||
try:
|
||||
@@ -184,7 +259,9 @@ def configure_scheduler_jobs(scheduler, app, test_refresh_token) -> None:
|
||||
if len(parts) != 5:
|
||||
raise ValueError("Cron 表达式格式错误,应为 5 段")
|
||||
minute, hour, day, month, day_of_week = parts
|
||||
trigger = CronTrigger(minute=minute, hour=hour, day=day, month=month, day_of_week=day_of_week)
|
||||
trigger = CronTrigger(
|
||||
minute=minute, hour=hour, day=day, month=month, day_of_week=day_of_week
|
||||
)
|
||||
scheduler.add_job(
|
||||
func=scheduled_task,
|
||||
trigger=trigger,
|
||||
@@ -210,7 +287,9 @@ def configure_scheduler_jobs(scheduler, app, test_refresh_token) -> None:
|
||||
coalesce=True,
|
||||
misfire_grace_time=600,
|
||||
)
|
||||
print(f"✓ 定时任务已配置:每天凌晨 2:00 检查刷新(周期:{refresh_interval_days} 天)")
|
||||
print(
|
||||
f"✓ 定时任务已配置:每天凌晨 2:00 检查刷新(周期:{refresh_interval_days} 天)"
|
||||
)
|
||||
|
||||
|
||||
def init_scheduler(app, test_refresh_token):
|
||||
@@ -253,9 +332,17 @@ def scheduled_refresh_task(app, test_refresh_token):
|
||||
|
||||
try:
|
||||
with app.app_context():
|
||||
enable_scheduled = settings_repo.get_setting("enable_scheduled_refresh", "true").lower() == "true"
|
||||
use_cron = settings_repo.get_setting("use_cron_schedule", "false").lower() == "true"
|
||||
refresh_interval_days = int(settings_repo.get_setting("refresh_interval_days", "30"))
|
||||
enable_scheduled = (
|
||||
settings_repo.get_setting("enable_scheduled_refresh", "true").lower()
|
||||
== "true"
|
||||
)
|
||||
use_cron = (
|
||||
settings_repo.get_setting("use_cron_schedule", "false").lower()
|
||||
== "true"
|
||||
)
|
||||
refresh_interval_days = int(
|
||||
settings_repo.get_setting("refresh_interval_days", "30")
|
||||
)
|
||||
delay_seconds = int(settings_repo.get_setting("refresh_delay_seconds", "5"))
|
||||
|
||||
run_id = create_refresh_run(conn, "scheduled", trace_id, total=0)
|
||||
@@ -297,13 +384,19 @@ def scheduled_refresh_task(app, test_refresh_token):
|
||||
return
|
||||
|
||||
# 获取刷新间隔配置
|
||||
delay_row = conn.execute("SELECT value FROM settings WHERE key = 'refresh_delay_seconds'").fetchone()
|
||||
delay_row = conn.execute(
|
||||
"SELECT value FROM settings WHERE key = 'refresh_delay_seconds'"
|
||||
).fetchone()
|
||||
delay_seconds = int(delay_row["value"]) if delay_row else 5
|
||||
|
||||
# 清理超过半年的刷新记录
|
||||
try:
|
||||
conn.execute("DELETE FROM account_refresh_logs WHERE created_at < datetime('now', '-6 months')")
|
||||
conn.execute("DELETE FROM distributed_locks WHERE expires_at < ?", (time.time(),))
|
||||
conn.execute(
|
||||
"DELETE FROM account_refresh_logs WHERE created_at < datetime('now', '-6 months')"
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM distributed_locks WHERE expires_at < ?", (time.time(),)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -326,9 +419,13 @@ def scheduled_refresh_task(app, test_refresh_token):
|
||||
ttl_seconds = max(60 * 60 * 2, estimated)
|
||||
ttl_seconds = min(ttl_seconds, 60 * 60 * 24)
|
||||
|
||||
ok, lock_info = acquire_distributed_lock(conn, REFRESH_LOCK_NAME, lock_owner_id, ttl_seconds)
|
||||
ok, lock_info = acquire_distributed_lock(
|
||||
conn, REFRESH_LOCK_NAME, lock_owner_id, ttl_seconds
|
||||
)
|
||||
if not ok:
|
||||
finish_refresh_run(conn, run_id, "skipped", total, 0, 0, "刷新任务冲突:已有刷新在执行")
|
||||
finish_refresh_run(
|
||||
conn, run_id, "skipped", total, 0, 0, "刷新任务冲突:已有刷新在执行"
|
||||
)
|
||||
return
|
||||
lock_acquired = True
|
||||
|
||||
@@ -343,7 +440,11 @@ def scheduled_refresh_task(app, test_refresh_token):
|
||||
|
||||
# 解密 refresh_token
|
||||
try:
|
||||
refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token
|
||||
refresh_token = (
|
||||
decrypt_data(encrypted_refresh_token)
|
||||
if encrypted_refresh_token
|
||||
else encrypted_refresh_token
|
||||
)
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
error_msg = f"解密 token 失败: {str(e)}"
|
||||
@@ -372,13 +473,17 @@ def scheduled_refresh_task(app, test_refresh_token):
|
||||
group_id = account["group_id"]
|
||||
if group_id:
|
||||
try:
|
||||
group_row = conn.execute("SELECT proxy_url FROM groups WHERE id = ?", (group_id,)).fetchone()
|
||||
group_row = conn.execute(
|
||||
"SELECT proxy_url FROM groups WHERE id = ?", (group_id,)
|
||||
).fetchone()
|
||||
if group_row:
|
||||
proxy_url = group_row["proxy_url"] or ""
|
||||
except Exception:
|
||||
proxy_url = ""
|
||||
|
||||
success, error_msg, new_refresh_token = test_refresh_token(client_id, refresh_token, proxy_url)
|
||||
success, error_msg, new_refresh_token = test_refresh_token(
|
||||
client_id, refresh_token, proxy_url
|
||||
)
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
@@ -398,7 +503,11 @@ def scheduled_refresh_task(app, test_refresh_token):
|
||||
|
||||
if success:
|
||||
# refresh token 可能滚动更新:保存新的 refresh_token(加密存储)
|
||||
if isinstance(new_refresh_token, str) and new_refresh_token.strip() and new_refresh_token != refresh_token:
|
||||
if (
|
||||
isinstance(new_refresh_token, str)
|
||||
and new_refresh_token.strip()
|
||||
and new_refresh_token != refresh_token
|
||||
):
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE accounts
|
||||
@@ -461,7 +570,9 @@ def should_autostart_scheduler() -> bool:
|
||||
return False
|
||||
|
||||
# Flask CLI (flask run) + reloader:父进程不启动
|
||||
if config.env_true("FLASK_RUN_FROM_CLI", False) and not config.env_true("WERKZEUG_RUN_MAIN", False):
|
||||
if config.env_true("FLASK_RUN_FROM_CLI", False) and not config.env_true(
|
||||
"WERKZEUG_RUN_MAIN", False
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,933 @@
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from tests._import_app import import_web_app_module
|
||||
|
||||
|
||||
class PoolRepositoryTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.module = import_web_app_module()
|
||||
from outlook_web.repositories import pool as pool_repo
|
||||
from outlook_web.db import create_sqlite_connection
|
||||
|
||||
cls.pool_repo = pool_repo
|
||||
cls.create_conn = staticmethod(lambda: create_sqlite_connection())
|
||||
|
||||
def _make_account(self, conn, email_suffix="", pool_status="available"):
|
||||
import secrets
|
||||
|
||||
email = f"pool_test_{secrets.token_hex(4)}{email_suffix}@example.com"
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO accounts (email, client_id, refresh_token, status, pool_status)
|
||||
VALUES (?, 'test_client', 'test_token', 'active', ?)
|
||||
""",
|
||||
(email, pool_status),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT id FROM accounts WHERE email = ?", (email,)
|
||||
).fetchone()
|
||||
return row["id"]
|
||||
|
||||
def test_claim_and_complete_success(self):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
self._make_account(conn)
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="reg_bot", task_id="task_001", lease_seconds=60
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertTrue(result["claim_token"].startswith("clm_"))
|
||||
claimed_id = result["id"]
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT pool_status, claim_token FROM accounts WHERE id = ?",
|
||||
(claimed_id,),
|
||||
).fetchone()
|
||||
self.assertEqual(row["pool_status"], "claimed")
|
||||
|
||||
new_status = self.pool_repo.complete(
|
||||
conn,
|
||||
account_id=claimed_id,
|
||||
claim_token=result["claim_token"],
|
||||
caller_id="reg_bot",
|
||||
task_id="task_001",
|
||||
result="success",
|
||||
detail="注册成功",
|
||||
)
|
||||
self.assertEqual(new_status, "used")
|
||||
|
||||
row2 = conn.execute(
|
||||
"SELECT pool_status, success_count FROM accounts WHERE id = ?",
|
||||
(claimed_id,),
|
||||
).fetchone()
|
||||
self.assertEqual(row2["pool_status"], "used")
|
||||
self.assertEqual(row2["success_count"], 1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_claim_and_release(self):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn)
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="reg_bot", task_id="task_002", lease_seconds=60
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
|
||||
self.pool_repo.release(
|
||||
conn,
|
||||
account_id=account_id,
|
||||
claim_token=result["claim_token"],
|
||||
caller_id="reg_bot",
|
||||
task_id="task_002",
|
||||
reason="任务取消",
|
||||
)
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT pool_status, claim_token FROM accounts WHERE id = ?",
|
||||
(account_id,),
|
||||
).fetchone()
|
||||
self.assertEqual(row["pool_status"], "available")
|
||||
self.assertIsNone(row["claim_token"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_claim_result_maps_to_cooldown(self):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
self._make_account(conn)
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="reg_bot", task_id="task_003", lease_seconds=60
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
claimed_id = result["id"]
|
||||
|
||||
before = conn.execute(
|
||||
"SELECT fail_count FROM accounts WHERE id = ?", (claimed_id,)
|
||||
).fetchone()["fail_count"]
|
||||
|
||||
new_status = self.pool_repo.complete(
|
||||
conn,
|
||||
account_id=claimed_id,
|
||||
claim_token=result["claim_token"],
|
||||
caller_id="reg_bot",
|
||||
task_id="task_003",
|
||||
result="verification_timeout",
|
||||
detail=None,
|
||||
)
|
||||
self.assertEqual(new_status, "cooldown")
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT pool_status, fail_count FROM accounts WHERE id = ?",
|
||||
(claimed_id,),
|
||||
).fetchone()
|
||||
self.assertEqual(row["pool_status"], "cooldown")
|
||||
self.assertEqual(row["fail_count"], before + 1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_no_available_account_returns_none(self):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn,
|
||||
caller_id="reg_bot",
|
||||
task_id="task_none",
|
||||
lease_seconds=60,
|
||||
provider="nonexistent_provider_xyz",
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_expire_stale_claims(self):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE accounts SET
|
||||
pool_status = 'claimed',
|
||||
claimed_by = 'reg_bot:task_exp',
|
||||
claim_token = 'clm_expired_test',
|
||||
lease_expires_at = '2000-01-01T00:00:00Z',
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
""",
|
||||
(account_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
expired_count = self.pool_repo.expire_stale_claims(conn)
|
||||
self.assertGreaterEqual(expired_count, 1)
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT pool_status FROM accounts WHERE id = ?", (account_id,)
|
||||
).fetchone()
|
||||
self.assertEqual(row["pool_status"], "cooldown")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_recover_cooldown(self):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn, pool_status="cooldown")
|
||||
conn.execute(
|
||||
"UPDATE accounts SET updated_at = '2000-01-01T00:00:00' WHERE id = ?",
|
||||
(account_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
recovered = self.pool_repo.recover_cooldown(conn, cooldown_seconds=3600)
|
||||
self.assertGreaterEqual(recovered, 1)
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT pool_status FROM accounts WHERE id = ?", (account_id,)
|
||||
).fetchone()
|
||||
self.assertEqual(row["pool_status"], "available")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_get_stats_shape(self):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
stats = self.pool_repo.get_stats(conn)
|
||||
self.assertIn("pool_counts", stats)
|
||||
self.assertNotIn("today", stats)
|
||||
self.assertNotIn("overall", stats)
|
||||
self.assertIn("available", stats["pool_counts"])
|
||||
self.assertIn("claimed", stats["pool_counts"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_complete_network_error_returns_available_and_increments_fail_count(self):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn)
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="reg_bot", task_id="task_ne", lease_seconds=60
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
claimed_id = result["id"]
|
||||
|
||||
before = conn.execute(
|
||||
"SELECT fail_count FROM accounts WHERE id = ?", (claimed_id,)
|
||||
).fetchone()["fail_count"]
|
||||
|
||||
new_status = self.pool_repo.complete(
|
||||
conn,
|
||||
account_id=claimed_id,
|
||||
claim_token=result["claim_token"],
|
||||
caller_id="reg_bot",
|
||||
task_id="task_ne",
|
||||
result="network_error",
|
||||
detail=None,
|
||||
)
|
||||
self.assertEqual(new_status, "available")
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT pool_status, fail_count FROM accounts WHERE id = ?",
|
||||
(claimed_id,),
|
||||
).fetchone()
|
||||
self.assertEqual(row["pool_status"], "available")
|
||||
self.assertEqual(row["fail_count"], before + 1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_complete_provider_blocked_returns_frozen(self):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn)
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="reg_bot", task_id="task_pb", lease_seconds=60
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
claimed_id = result["id"]
|
||||
|
||||
before = conn.execute(
|
||||
"SELECT fail_count FROM accounts WHERE id = ?", (claimed_id,)
|
||||
).fetchone()["fail_count"]
|
||||
|
||||
new_status = self.pool_repo.complete(
|
||||
conn,
|
||||
account_id=claimed_id,
|
||||
claim_token=result["claim_token"],
|
||||
caller_id="reg_bot",
|
||||
task_id="task_pb",
|
||||
result="provider_blocked",
|
||||
detail="IP 被封",
|
||||
)
|
||||
self.assertEqual(new_status, "frozen")
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT pool_status, fail_count FROM accounts WHERE id = ?",
|
||||
(claimed_id,),
|
||||
).fetchone()
|
||||
self.assertEqual(row["pool_status"], "frozen")
|
||||
self.assertEqual(row["fail_count"], before + 1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_complete_credential_invalid_returns_retired(self):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn)
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="reg_bot", task_id="task_ci", lease_seconds=60
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
claimed_id = result["id"]
|
||||
|
||||
before = conn.execute(
|
||||
"SELECT fail_count FROM accounts WHERE id = ?", (claimed_id,)
|
||||
).fetchone()["fail_count"]
|
||||
|
||||
new_status = self.pool_repo.complete(
|
||||
conn,
|
||||
account_id=claimed_id,
|
||||
claim_token=result["claim_token"],
|
||||
caller_id="reg_bot",
|
||||
task_id="task_ci",
|
||||
result="credential_invalid",
|
||||
detail=None,
|
||||
)
|
||||
self.assertEqual(new_status, "retired")
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT pool_status, fail_count FROM accounts WHERE id = ?",
|
||||
(claimed_id,),
|
||||
).fetchone()
|
||||
self.assertEqual(row["pool_status"], "retired")
|
||||
self.assertEqual(row["fail_count"], before + 1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_claim_log_written_on_claim(self):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn)
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="log_bot", task_id="log_task_001", lease_seconds=60
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
claimed_id = result["id"]
|
||||
log_row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM account_claim_logs
|
||||
WHERE account_id = ? AND action = 'claim'
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""",
|
||||
(claimed_id,),
|
||||
).fetchone()
|
||||
self.assertIsNotNone(log_row)
|
||||
self.assertEqual(log_row["caller_id"], "log_bot")
|
||||
self.assertEqual(log_row["task_id"], "log_task_001")
|
||||
self.assertEqual(log_row["claim_token"], result["claim_token"])
|
||||
self.assertIsNone(log_row["result"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_complete_log_written_on_complete(self):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn)
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="log_bot", task_id="log_task_002", lease_seconds=60
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
claimed_id = result["id"]
|
||||
self.pool_repo.complete(
|
||||
conn,
|
||||
account_id=claimed_id,
|
||||
claim_token=result["claim_token"],
|
||||
caller_id="log_bot",
|
||||
task_id="log_task_002",
|
||||
result="success",
|
||||
detail="ok",
|
||||
)
|
||||
log_row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM account_claim_logs
|
||||
WHERE account_id = ? AND action = 'complete'
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""",
|
||||
(claimed_id,),
|
||||
).fetchone()
|
||||
self.assertIsNotNone(log_row)
|
||||
self.assertEqual(log_row["result"], "success")
|
||||
self.assertEqual(log_row["detail"], "ok")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_expire_stale_claims_increments_fail_count(self):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE accounts SET
|
||||
pool_status = 'claimed',
|
||||
claimed_by = 'bot:task_exp2',
|
||||
claim_token = 'clm_exp2_test',
|
||||
lease_expires_at = '2000-01-01T00:00:00Z',
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
""",
|
||||
(account_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
self.pool_repo.expire_stale_claims(conn)
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT pool_status, fail_count FROM accounts WHERE id = ?",
|
||||
(account_id,),
|
||||
).fetchone()
|
||||
self.assertEqual(row["pool_status"], "cooldown")
|
||||
self.assertEqual(row["fail_count"], 1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_exclude_recent_minutes_skips_recently_claimed_account(self):
|
||||
import time as _time
|
||||
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn)
|
||||
result1 = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="bot", task_id="excl_t1", lease_seconds=60
|
||||
)
|
||||
self.assertIsNotNone(result1)
|
||||
claimed_id = result1["id"]
|
||||
self.pool_repo.release(
|
||||
conn,
|
||||
account_id=claimed_id,
|
||||
claim_token=result1["claim_token"],
|
||||
caller_id="bot",
|
||||
task_id="excl_t1",
|
||||
reason="test",
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"UPDATE accounts SET pool_status = 'used' WHERE pool_status = 'available' AND id != ?",
|
||||
(claimed_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
result2 = self.pool_repo.claim_atomic(
|
||||
conn,
|
||||
caller_id="bot",
|
||||
task_id="excl_t2",
|
||||
lease_seconds=60,
|
||||
exclude_recent_minutes=60,
|
||||
)
|
||||
self.assertIsNone(result2)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_multiple_claim_tokens_are_unique(self):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
tokens = set()
|
||||
for i in range(5):
|
||||
account_id = self._make_account(conn)
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="bot", task_id=f"uniq_t{i}", lease_seconds=60
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
tokens.add(result["claim_token"])
|
||||
self.pool_repo.complete(
|
||||
conn,
|
||||
account_id=result["id"],
|
||||
claim_token=result["claim_token"],
|
||||
caller_id="bot",
|
||||
task_id=f"uniq_t{i}",
|
||||
result="success",
|
||||
detail=None,
|
||||
)
|
||||
self.assertEqual(len(tokens), 5, "5 次 claim 应产生 5 个不同的 token")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_concurrent_claim_unique(self):
|
||||
"""并发场景:多个 claim 不应领到同一个邮箱(P0-6)"""
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
import secrets
|
||||
|
||||
iso_provider = f"conc_iso_{secrets.token_hex(4)}"
|
||||
email = f"pool_conc_{secrets.token_hex(4)}@example.com"
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO accounts (email, client_id, refresh_token, status, pool_status, provider)
|
||||
VALUES (?, 'test_client', 'test_token', 'active', 'available', ?)
|
||||
""",
|
||||
(email, iso_provider),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
r1 = self.pool_repo.claim_atomic(
|
||||
conn,
|
||||
caller_id="bot_a",
|
||||
task_id="conc_1",
|
||||
lease_seconds=60,
|
||||
provider=iso_provider,
|
||||
)
|
||||
self.assertIsNotNone(r1)
|
||||
|
||||
r2 = self.pool_repo.claim_atomic(
|
||||
conn,
|
||||
caller_id="bot_b",
|
||||
task_id="conc_2",
|
||||
lease_seconds=60,
|
||||
provider=iso_provider,
|
||||
)
|
||||
self.assertIsNone(r2, "只有 1 个可用邮箱时,第二个 claim 应返回 None")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_release_log_written_on_release(self):
|
||||
"""release 动作应写入 claim log(P0-6)"""
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn)
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="log_bot", task_id="log_rel_001", lease_seconds=60
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
claimed_id = result["id"]
|
||||
|
||||
self.pool_repo.release(
|
||||
conn,
|
||||
account_id=claimed_id,
|
||||
claim_token=result["claim_token"],
|
||||
caller_id="log_bot",
|
||||
task_id="log_rel_001",
|
||||
reason="任务取消",
|
||||
)
|
||||
|
||||
log_row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM account_claim_logs
|
||||
WHERE account_id = ? AND action = 'release'
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""",
|
||||
(claimed_id,),
|
||||
).fetchone()
|
||||
self.assertIsNotNone(log_row, "release 应写入 claim log")
|
||||
self.assertEqual(log_row["caller_id"], "log_bot")
|
||||
self.assertEqual(log_row["task_id"], "log_rel_001")
|
||||
self.assertEqual(log_row["claim_token"], result["claim_token"])
|
||||
self.assertEqual(log_row["result"], "manual_release")
|
||||
self.assertEqual(log_row["detail"], "任务取消")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_expire_log_written_on_expire(self):
|
||||
"""expire(租约超时)动作应写入 claim log(P0-6)"""
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn)
|
||||
token = "clm_expire_log_test"
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE accounts SET
|
||||
pool_status = 'claimed',
|
||||
claimed_by = 'exp_bot:exp_task',
|
||||
claim_token = ?,
|
||||
lease_expires_at = '2000-01-01T00:00:00Z',
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
""",
|
||||
(token, account_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
self.pool_repo.expire_stale_claims(conn)
|
||||
|
||||
log_row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM account_claim_logs
|
||||
WHERE account_id = ? AND action = 'expire'
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""",
|
||||
(account_id,),
|
||||
).fetchone()
|
||||
self.assertIsNotNone(log_row, "expire 应写入 claim log")
|
||||
self.assertEqual(log_row["caller_id"], "exp_bot")
|
||||
self.assertEqual(log_row["task_id"], "exp_task")
|
||||
self.assertEqual(log_row["claim_token"], token)
|
||||
self.assertEqual(log_row["result"], "lease_expired")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_expire_affects_stats(self):
|
||||
"""expire 后 stats 中 cooldown 应增加、claimed 应减少(P0-6)"""
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE accounts SET
|
||||
pool_status = 'claimed',
|
||||
claimed_by = 'stat_bot:stat_task',
|
||||
claim_token = 'clm_stat_test',
|
||||
lease_expires_at = '2000-01-01T00:00:00Z',
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
""",
|
||||
(account_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
stats_before = self.pool_repo.get_stats(conn)
|
||||
claimed_before = stats_before["pool_counts"]["claimed"]
|
||||
cooldown_before = stats_before["pool_counts"]["cooldown"]
|
||||
|
||||
self.pool_repo.expire_stale_claims(conn)
|
||||
|
||||
stats_after = self.pool_repo.get_stats(conn)
|
||||
claimed_after = stats_after["pool_counts"]["claimed"]
|
||||
cooldown_after = stats_after["pool_counts"]["cooldown"]
|
||||
|
||||
self.assertLess(
|
||||
claimed_after, claimed_before, "expire 后 claimed 数量应减少"
|
||||
)
|
||||
self.assertGreater(
|
||||
cooldown_after, cooldown_before, "expire 后 cooldown 数量应增加"
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class PoolServiceTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.module = import_web_app_module()
|
||||
from outlook_web.services import pool as pool_service
|
||||
from outlook_web.db import create_sqlite_connection
|
||||
|
||||
cls.pool_service = pool_service
|
||||
cls.create_conn = staticmethod(lambda: create_sqlite_connection())
|
||||
|
||||
def _make_account(self, pool_status="available"):
|
||||
import secrets
|
||||
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
email = f"svc_test_{secrets.token_hex(4)}@example.com"
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO accounts (email, client_id, refresh_token, status, pool_status)
|
||||
VALUES (?, 'test_client', 'test_token', 'active', ?)
|
||||
""",
|
||||
(email, pool_status),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT id FROM accounts WHERE email = ?", (email,)
|
||||
).fetchone()
|
||||
return row["id"]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_claim_random_returns_account(self):
|
||||
self._make_account()
|
||||
result = self.pool_service.claim_random(caller_id="bot", task_id="t1")
|
||||
self.assertIn("claim_token", result)
|
||||
self.assertIn("lease_expires_at", result)
|
||||
|
||||
def test_claim_random_caller_id_validation(self):
|
||||
with self.assertRaises(self.pool_service.PoolServiceError) as ctx:
|
||||
self.pool_service.claim_random(caller_id="", task_id="t1")
|
||||
self.assertEqual(ctx.exception.error_code, "caller_id_empty")
|
||||
|
||||
def test_claim_random_no_account_200(self):
|
||||
with self.assertRaises(self.pool_service.PoolServiceError) as ctx:
|
||||
self.pool_service.claim_random(
|
||||
caller_id="bot",
|
||||
task_id="t_no_acct",
|
||||
provider="provider_that_does_not_exist_xyz",
|
||||
)
|
||||
self.assertEqual(ctx.exception.http_status, 200)
|
||||
self.assertEqual(ctx.exception.error_code, "no_available_account")
|
||||
|
||||
def test_complete_claim_invalid_result(self):
|
||||
account_id = self._make_account()
|
||||
with self.assertRaises(self.pool_service.PoolServiceError) as ctx:
|
||||
self.pool_service.complete_claim(
|
||||
account_id=account_id,
|
||||
claim_token="clm_fake",
|
||||
caller_id="bot",
|
||||
task_id="t2",
|
||||
result="not_a_valid_result",
|
||||
)
|
||||
self.assertEqual(ctx.exception.error_code, "invalid_result")
|
||||
|
||||
def test_release_claim_token_mismatch(self):
|
||||
self._make_account()
|
||||
result = self.pool_service.claim_random(caller_id="bot", task_id="t_rel")
|
||||
claimed_id = result["id"]
|
||||
with self.assertRaises(self.pool_service.PoolServiceError) as ctx:
|
||||
self.pool_service.release_claim(
|
||||
account_id=claimed_id,
|
||||
claim_token="clm_wrong_token",
|
||||
caller_id="bot",
|
||||
task_id="t_rel",
|
||||
)
|
||||
self.assertEqual(ctx.exception.error_code, "token_mismatch")
|
||||
|
||||
def test_release_account_not_found(self):
|
||||
"""release 对不存在的 account_id 应返回 account_not_found (400)"""
|
||||
with self.assertRaises(self.pool_service.PoolServiceError) as ctx:
|
||||
self.pool_service.release_claim(
|
||||
account_id=999999,
|
||||
claim_token="clm_any",
|
||||
caller_id="bot",
|
||||
task_id="t_release_anf",
|
||||
)
|
||||
self.assertEqual(ctx.exception.error_code, "account_not_found")
|
||||
self.assertEqual(ctx.exception.http_status, 400)
|
||||
|
||||
def test_release_not_claimed(self):
|
||||
"""release 对 pool_status != claimed 的账号应返回 not_claimed (409)"""
|
||||
account_id = self._make_account(pool_status="available")
|
||||
with self.assertRaises(self.pool_service.PoolServiceError) as ctx:
|
||||
self.pool_service.release_claim(
|
||||
account_id=account_id,
|
||||
claim_token="clm_any",
|
||||
caller_id="bot",
|
||||
task_id="t_release_nc",
|
||||
)
|
||||
self.assertEqual(ctx.exception.error_code, "not_claimed")
|
||||
self.assertEqual(ctx.exception.http_status, 409)
|
||||
|
||||
def test_release_caller_mismatch(self):
|
||||
"""release 使用正确 token 但不同 caller_id:task_id 应返回 caller_mismatch (403)"""
|
||||
self._make_account()
|
||||
result = self.pool_service.claim_random(caller_id="bot_a", task_id="t_cm")
|
||||
claimed_id = result["id"]
|
||||
with self.assertRaises(self.pool_service.PoolServiceError) as ctx:
|
||||
self.pool_service.release_claim(
|
||||
account_id=claimed_id,
|
||||
claim_token=result["claim_token"],
|
||||
caller_id="bot_b",
|
||||
task_id="t_cm",
|
||||
)
|
||||
self.assertEqual(ctx.exception.error_code, "caller_mismatch")
|
||||
self.assertEqual(ctx.exception.http_status, 403)
|
||||
|
||||
def test_complete_account_not_found(self):
|
||||
"""complete 对不存在的 account_id 应返回 account_not_found (400)"""
|
||||
with self.assertRaises(self.pool_service.PoolServiceError) as ctx:
|
||||
self.pool_service.complete_claim(
|
||||
account_id=999999,
|
||||
claim_token="clm_any",
|
||||
caller_id="bot",
|
||||
task_id="t_comp_anf",
|
||||
result="success",
|
||||
)
|
||||
self.assertEqual(ctx.exception.error_code, "account_not_found")
|
||||
self.assertEqual(ctx.exception.http_status, 400)
|
||||
|
||||
def test_complete_not_claimed(self):
|
||||
"""complete 对 pool_status != claimed 的账号应返回 not_claimed (409)"""
|
||||
account_id = self._make_account(pool_status="available")
|
||||
with self.assertRaises(self.pool_service.PoolServiceError) as ctx:
|
||||
self.pool_service.complete_claim(
|
||||
account_id=account_id,
|
||||
claim_token="clm_any",
|
||||
caller_id="bot",
|
||||
task_id="t_comp_nc",
|
||||
result="success",
|
||||
)
|
||||
self.assertEqual(ctx.exception.error_code, "not_claimed")
|
||||
self.assertEqual(ctx.exception.http_status, 409)
|
||||
|
||||
def test_complete_token_mismatch(self):
|
||||
"""complete 使用错误 token 应返回 token_mismatch (403)"""
|
||||
self._make_account()
|
||||
result = self.pool_service.claim_random(caller_id="bot", task_id="t_comp_tm")
|
||||
claimed_id = result["id"]
|
||||
with self.assertRaises(self.pool_service.PoolServiceError) as ctx:
|
||||
self.pool_service.complete_claim(
|
||||
account_id=claimed_id,
|
||||
claim_token="clm_wrong_token",
|
||||
caller_id="bot",
|
||||
task_id="t_comp_tm",
|
||||
result="success",
|
||||
)
|
||||
self.assertEqual(ctx.exception.error_code, "token_mismatch")
|
||||
self.assertEqual(ctx.exception.http_status, 403)
|
||||
|
||||
def test_complete_caller_mismatch(self):
|
||||
"""complete 使用正确 token 但不同 caller_id:task_id 应返回 caller_mismatch (403)"""
|
||||
self._make_account()
|
||||
result = self.pool_service.claim_random(caller_id="bot_a", task_id="t_comp_cm")
|
||||
claimed_id = result["id"]
|
||||
with self.assertRaises(self.pool_service.PoolServiceError) as ctx:
|
||||
self.pool_service.complete_claim(
|
||||
account_id=claimed_id,
|
||||
claim_token=result["claim_token"],
|
||||
caller_id="bot_b",
|
||||
task_id="t_comp_cm",
|
||||
result="success",
|
||||
)
|
||||
self.assertEqual(ctx.exception.error_code, "caller_mismatch")
|
||||
self.assertEqual(ctx.exception.http_status, 403)
|
||||
|
||||
def test_ownership_check_order_release(self):
|
||||
"""验证 release 校验顺序:not_claimed 应优先于 token_mismatch(TD §5.4)"""
|
||||
account_id = self._make_account(pool_status="available")
|
||||
with self.assertRaises(self.pool_service.PoolServiceError) as ctx:
|
||||
self.pool_service.release_claim(
|
||||
account_id=account_id,
|
||||
claim_token="clm_wrong",
|
||||
caller_id="bot_wrong",
|
||||
task_id="t_wrong",
|
||||
)
|
||||
self.assertEqual(ctx.exception.error_code, "not_claimed")
|
||||
|
||||
def test_ownership_check_order_complete(self):
|
||||
"""验证 complete 校验顺序:token_mismatch 应优先于 caller_mismatch(TD §5.4)"""
|
||||
self._make_account()
|
||||
result = self.pool_service.claim_random(caller_id="bot_a", task_id="t_order")
|
||||
claimed_id = result["id"]
|
||||
with self.assertRaises(self.pool_service.PoolServiceError) as ctx:
|
||||
self.pool_service.complete_claim(
|
||||
account_id=claimed_id,
|
||||
claim_token="clm_wrong",
|
||||
caller_id="bot_b",
|
||||
task_id="t_order",
|
||||
result="success",
|
||||
)
|
||||
self.assertEqual(ctx.exception.error_code, "token_mismatch")
|
||||
|
||||
|
||||
class PoolApiTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.module = import_web_app_module()
|
||||
cls.client = cls.module.app.test_client()
|
||||
from outlook_web.db import create_sqlite_connection
|
||||
|
||||
cls.create_conn = staticmethod(lambda: create_sqlite_connection())
|
||||
|
||||
def _make_account(self):
|
||||
import secrets
|
||||
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
email = f"api_test_{secrets.token_hex(4)}@example.com"
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO accounts (email, client_id, refresh_token, status, pool_status)
|
||||
VALUES (?, 'test_client', 'test_token', 'active', 'available')
|
||||
""",
|
||||
(email,),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT id FROM accounts WHERE email = ?", (email,)
|
||||
).fetchone()
|
||||
return row["id"]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_stats_endpoint(self):
|
||||
resp = self.client.get("/api/pool/stats")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = json.loads(resp.data)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertIn("data", data)
|
||||
self.assertIn("pool_counts", data["data"])
|
||||
|
||||
def test_claim_random_endpoint(self):
|
||||
self._make_account()
|
||||
resp = self.client.post(
|
||||
"/api/pool/claim-random",
|
||||
json={
|
||||
"caller_id": "test_bot",
|
||||
"task_id": "api_task_1",
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = json.loads(resp.data)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertIn("data", data)
|
||||
self.assertIn("account_id", data["data"])
|
||||
self.assertIn("email", data["data"])
|
||||
self.assertIn("claim_token", data["data"])
|
||||
self.assertIn("lease_expires_at", data["data"])
|
||||
|
||||
def test_claim_random_missing_caller_id(self):
|
||||
resp = self.client.post(
|
||||
"/api/pool/claim-random",
|
||||
json={"task_id": "api_task_2"},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
data = json.loads(resp.data)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertIn("error", data)
|
||||
|
||||
def test_claim_complete_flow(self):
|
||||
self._make_account()
|
||||
claim_resp = self.client.post(
|
||||
"/api/pool/claim-random",
|
||||
json={"caller_id": "test_bot", "task_id": "flow_task"},
|
||||
)
|
||||
self.assertEqual(claim_resp.status_code, 200)
|
||||
claim_data = json.loads(claim_resp.data)
|
||||
account_data = claim_data["data"]
|
||||
|
||||
complete_resp = self.client.post(
|
||||
"/api/pool/claim-complete",
|
||||
json={
|
||||
"account_id": account_data["account_id"],
|
||||
"claim_token": account_data["claim_token"],
|
||||
"caller_id": "test_bot",
|
||||
"task_id": "flow_task",
|
||||
"result": "success",
|
||||
"detail": "test ok",
|
||||
},
|
||||
)
|
||||
self.assertEqual(complete_resp.status_code, 200)
|
||||
complete_data = json.loads(complete_resp.data)
|
||||
self.assertTrue(complete_data["success"])
|
||||
self.assertEqual(
|
||||
complete_data["data"]["account_id"], account_data["account_id"]
|
||||
)
|
||||
self.assertEqual(complete_data["data"]["pool_status"], "used")
|
||||
|
||||
def test_claim_release_flow(self):
|
||||
self._make_account()
|
||||
claim_resp = self.client.post(
|
||||
"/api/pool/claim-random",
|
||||
json={"caller_id": "test_bot", "task_id": "rel_task"},
|
||||
)
|
||||
self.assertEqual(claim_resp.status_code, 200)
|
||||
claim_data = json.loads(claim_resp.data)
|
||||
account_data = claim_data["data"]
|
||||
|
||||
release_resp = self.client.post(
|
||||
"/api/pool/claim-release",
|
||||
json={
|
||||
"account_id": account_data["account_id"],
|
||||
"claim_token": account_data["claim_token"],
|
||||
"caller_id": "test_bot",
|
||||
"task_id": "rel_task",
|
||||
"reason": "test cancel",
|
||||
},
|
||||
)
|
||||
self.assertEqual(release_resp.status_code, 200)
|
||||
data = json.loads(release_resp.data)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["data"]["account_id"], account_data["account_id"])
|
||||
self.assertEqual(data["data"]["pool_status"], "available")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user