fix(tests): 修复 sys.modules 污染导致 test_generated_video_management 间歇失败
Deploy / Staging E2E Tests (push) Failing after 107h15m3s
Deploy / Deploy Staging (push) Failing after 107h16m41s
CI/CD Pipeline / Frontend Lint (push) Failing after 107h17m11s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 107h17m18s
Deploy / Production Browser E2E (push) Failing after 1765h46m3s
Deploy / Deploy Production (push) Failing after 1765h46m5s
Deploy / Build Production Runtime Images (push) Failing after 1765h51m12s
Deploy / Staging E2E Tests (push) Failing after 107h15m3s
Deploy / Deploy Staging (push) Failing after 107h16m41s
CI/CD Pipeline / Frontend Lint (push) Failing after 107h17m11s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 107h17m18s
Deploy / Production Browser E2E (push) Failing after 1765h46m3s
Deploy / Deploy Production (push) Failing after 1765h46m5s
Deploy / Build Production Runtime Images (push) Failing after 1765h51m12s
根因:test_subscription_api._install_mocks() 在模块导入时替换了 packages.domain / packages.ports 等命名空间包,导致后续测试文件 无法导入真实的 GeneratedVideo 等实体类。 修复:移除所有 packages.* 命名空间包的 fake module 创建,仅保留 必要的叶子 mock(redis/smtp 适配器、app.config/auth/dependencies)。 让 packages.* 模块从磁盘正常加载。 同时包含: - P2: asset_diagnosis 单素材诊断支持 - P2: edit_plans/edit_templates 管理员权限校验 - P2: edit_plans project 归属鉴权 - P3: test_error_scenarios 限流计数器重置 - P3: test_asset_diagnosis/test_edit_templates 测试修复 - P3: Alembic 迁移 024_add_user_is_admin
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
"""Task: Add is_admin to users
|
||||
|
||||
Revision ID: 024
|
||||
Revises: 023
|
||||
Create Date: 2026-07-05
|
||||
|
||||
新增 is_admin 字段到 users 表,用于模板管理等管理员权限校验。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "024"
|
||||
down_revision = "023"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "is_admin")
|
||||
@@ -321,6 +321,8 @@ def get_project_asset_diagnosis(
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
if not project.can_access(authenticated_user.user.id):
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
|
||||
# 单素材诊断模式
|
||||
if asset_id:
|
||||
|
||||
@@ -39,6 +39,9 @@ from packages.application.generation_tasks import (
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
|
||||
# AI 推荐 & 封面生成(轻量模块,无 Celery 依赖,可安全静态导入)
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend, run_generate_cover
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -196,7 +199,7 @@ class GenerateCoverResponse(BaseModel):
|
||||
|
||||
def _check_project_access(project_id: str, user_id: str, project_repository: Any) -> None:
|
||||
"""校验用户对项目的访问权限(参照 assets.py 的 can_access 模式)"""
|
||||
if not project_id:
|
||||
if not project_id or not project_id.strip():
|
||||
return
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
@@ -241,6 +244,10 @@ def list_plans(
|
||||
"""获取剪辑计划列表(支持分页、按模板/状态/项目筛选)"""
|
||||
svc = EditPlanService(db)
|
||||
|
||||
# 空串 project_id 视为未传(避免 DB 中匹配到空串记录)
|
||||
if project_id is not None and not project_id.strip():
|
||||
project_id = None
|
||||
|
||||
# 解析状态筛选
|
||||
status_enum: Optional[EditPlanStatus] = None
|
||||
if status_filter:
|
||||
@@ -308,9 +315,11 @@ def create_plan(
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanResponse:
|
||||
"""创建剪辑计划"""
|
||||
# 空串 project_id 统一为 ""
|
||||
project_id = (body.project_id or "").strip()
|
||||
# 项目鉴权
|
||||
if body.project_id:
|
||||
_check_project_access(body.project_id, current_user.user.id, project_repository)
|
||||
if project_id:
|
||||
_check_project_access(project_id, current_user.user.id, project_repository)
|
||||
svc = EditPlanService(db)
|
||||
# 标准化 config,填充 cover/title/subtitle/bgm 默认值
|
||||
normalized_config = normalize_plan_config(body.config)
|
||||
@@ -320,7 +329,7 @@ def create_plan(
|
||||
name=body.name,
|
||||
config=normalized_config,
|
||||
total_duration=body.total_duration,
|
||||
project_id=body.project_id,
|
||||
project_id=project_id,
|
||||
created_by_user_id=current_user.user.id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
@@ -654,15 +663,6 @@ def ai_recommend_clips(
|
||||
)
|
||||
|
||||
# 调用 AI 推荐服务(同步调用 stub,后续改为 Celery 异步)
|
||||
try:
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
|
||||
except ImportError:
|
||||
logger.error("ai_tasks 模块不可用,无法执行 AI 推荐")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="AI 推荐服务暂不可用",
|
||||
)
|
||||
|
||||
result = run_ai_recommend(
|
||||
plan_id=plan_id,
|
||||
template_id=plan.template_id,
|
||||
@@ -671,32 +671,45 @@ def ai_recommend_clips(
|
||||
target_duration=body.target_duration,
|
||||
)
|
||||
|
||||
# 清除现有片段
|
||||
svc.delete_all_clips(plan_id)
|
||||
# ── 事务保护:清除 → 重建 → 更新 必须在同一逻辑事务中 ──
|
||||
# TODO: 当前各 repo 方法内部 commit(),无法真正回滚。
|
||||
# 后续重构 repo 为 flush() 模式后,此处改为统一 commit。
|
||||
try:
|
||||
# 清除现有片段
|
||||
svc.delete_all_clips(plan_id)
|
||||
|
||||
# 按推荐方案创建新片段
|
||||
for clip_data in result["clips"]:
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_data["clip_type"],
|
||||
order=clip_data["order"],
|
||||
text_content=clip_data.get("text_content", ""),
|
||||
duration=clip_data["duration"],
|
||||
transition_effect=clip_data.get("transition_effect", "cut"),
|
||||
asset_id=clip_data.get("asset_id", ""),
|
||||
start_time=clip_data.get("start_time", 0.0),
|
||||
config=clip_data.get("config", {}),
|
||||
# 按推荐方案创建新片段
|
||||
for clip_data in result["clips"]:
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_data["clip_type"],
|
||||
order=clip_data["order"],
|
||||
text_content=clip_data.get("text_content", ""),
|
||||
duration=clip_data["duration"],
|
||||
transition_effect=clip_data.get("transition_effect", "cut"),
|
||||
asset_id=clip_data.get("asset_id", ""),
|
||||
start_time=clip_data.get("start_time", 0.0),
|
||||
config=clip_data.get("config", {}),
|
||||
)
|
||||
|
||||
# 更新计划 config 和 total_duration
|
||||
normalized_config = normalize_plan_config(result.get("config", {}))
|
||||
svc.update_plan(
|
||||
plan_id,
|
||||
config=normalized_config,
|
||||
total_duration=result["total_duration"],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
|
||||
# 尝试回滚未提交的变更
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"AI 推荐结果写入失败: {exc}",
|
||||
)
|
||||
|
||||
# 更新计划 config 和 total_duration
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
normalized_config = normalize_plan_config(result.get("config", {}))
|
||||
svc.update_plan(
|
||||
plan_id,
|
||||
config=normalized_config,
|
||||
total_duration=result["total_duration"],
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
|
||||
@@ -770,15 +783,6 @@ def generate_cover(
|
||||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 调用 AI 封面生成服务
|
||||
try:
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
||||
except ImportError:
|
||||
logger.error("ai_tasks 模块不可用,无法生成封面")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="AI 封面生成服务暂不可用",
|
||||
)
|
||||
|
||||
cover_data = run_generate_cover(
|
||||
plan_id=plan_id,
|
||||
asset_ids=body.asset_ids,
|
||||
@@ -787,8 +791,6 @@ def generate_cover(
|
||||
)
|
||||
|
||||
# 更新 plan.config["cover"]
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
current_config = dict(plan.config)
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
|
||||
@@ -86,6 +86,15 @@ class EditTemplateListResponse(BaseModel):
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _require_admin(current_user: AuthenticatedUser) -> None:
|
||||
"""校验当前用户是否为管理员,非管理员返回 403"""
|
||||
if not getattr(current_user.user, "is_admin", False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="仅管理员可执行此操作",
|
||||
)
|
||||
|
||||
|
||||
def _to_response(t: EditTemplate) -> EditTemplateResponse:
|
||||
return EditTemplateResponse(
|
||||
id=t.id,
|
||||
@@ -176,6 +185,7 @@ def create_template(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> EditTemplateResponse:
|
||||
"""创建模板(管理员)"""
|
||||
_require_admin(current_user)
|
||||
svc = EditTemplateService(db)
|
||||
# 标准化 config,填充 cover/title/subtitle/bgm 默认值
|
||||
normalized_config = normalize_template_config(body.config)
|
||||
@@ -205,6 +215,7 @@ def update_template(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> EditTemplateResponse:
|
||||
"""更新模板"""
|
||||
_require_admin(current_user)
|
||||
svc = EditTemplateService(db)
|
||||
|
||||
# 解析状态
|
||||
@@ -254,6 +265,7 @@ def delete_template(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> None:
|
||||
"""删除模板(软删除 → 设为 inactive)"""
|
||||
_require_admin(current_user)
|
||||
svc = EditTemplateService(db)
|
||||
try:
|
||||
svc.deactivate_template(template_id)
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
|
||||
.xx-dashboard-welcome h2 {
|
||||
margin: 0 0 4px;
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-primary);
|
||||
@@ -51,7 +51,7 @@
|
||||
);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
padding: var(--space-lg);
|
||||
transition: var(--transition-all);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -81,8 +81,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
margin-bottom: 12px;
|
||||
font-size: var(--font-size-lg);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-kpi-value {
|
||||
@@ -90,17 +90,17 @@
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.xx-kpi-label {
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-kpi-trend {
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@@ -181,17 +181,17 @@
|
||||
.xx-task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-task-item {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
gap: 16px;
|
||||
gap: var(--space-md);
|
||||
align-items: center;
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
@@ -202,14 +202,14 @@
|
||||
}
|
||||
|
||||
.xx-task-info h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-task-info span {
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -250,7 +250,7 @@
|
||||
|
||||
.xx-task-time {
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
min-width: 100px;
|
||||
}
|
||||
@@ -261,7 +261,7 @@
|
||||
}
|
||||
|
||||
.xx-task-action {
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -274,7 +274,7 @@
|
||||
.xx-chart-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
height: 160px;
|
||||
padding-top: var(--space-sm);
|
||||
}
|
||||
@@ -308,7 +308,7 @@
|
||||
top: -20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
@@ -322,14 +322,14 @@
|
||||
|
||||
.xx-chart-labels {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-chart-label {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -347,7 +347,7 @@
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
padding: var(--space-lg);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
text-align: center;
|
||||
@@ -366,14 +366,14 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
margin: 0 auto 14px;
|
||||
font-size: var(--font-size-xl);
|
||||
margin: 0 auto var(--space-md);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.xx-quick-card h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
margin: 0 0 var(--space-sm);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -381,7 +381,7 @@
|
||||
.xx-quick-card p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -390,13 +390,13 @@
|
||||
.xx-announcement-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-announcement-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -412,7 +412,7 @@
|
||||
flex-shrink: 0;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
height: fit-content;
|
||||
}
|
||||
@@ -438,15 +438,15 @@
|
||||
}
|
||||
|
||||
.xx-announcement-content h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 13px;
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.xx-announcement-content time {
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -454,7 +454,7 @@
|
||||
存储用量条
|
||||
============================================================ */
|
||||
.xx-storage-bar {
|
||||
margin-top: 12px;
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-storage-bar-track {
|
||||
@@ -474,9 +474,9 @@
|
||||
.xx-storage-bar-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -511,7 +511,7 @@
|
||||
|
||||
.xx-task-item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-task-time {
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-inverse);
|
||||
font-size: 20px;
|
||||
font-size: var(--font-size-lg);
|
||||
flex-shrink: 0;
|
||||
grid-row: 1 / 3;
|
||||
transition: var(--transition-all);
|
||||
@@ -385,7 +385,7 @@
|
||||
}
|
||||
|
||||
.xx-voices-empty-icon {
|
||||
font-size: 56px;
|
||||
font-size: var(--font-size-3xl);
|
||||
margin-bottom: var(--space-lg);
|
||||
opacity: 0.3;
|
||||
color: var(--text-tertiary);
|
||||
@@ -472,7 +472,7 @@
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
@@ -507,7 +507,7 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-inverse);
|
||||
font-size: 18px;
|
||||
font-size: var(--font-size-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -794,7 +794,7 @@
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 16px;
|
||||
font-size: var(--font-size-md);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ class UserModel(Base):
|
||||
max_projects = Column(Integer, nullable=False, default=3)
|
||||
max_storage_gb = Column(Integer, nullable=False, default=10)
|
||||
used_storage_gb = Column(Integer, nullable=False, default=0)
|
||||
# 管理员标识
|
||||
is_admin = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ class SQLAlchemyUserRepository(UserRepository):
|
||||
model.subscription_expires_at = user.subscription_expires_at
|
||||
model.max_projects = user.max_projects
|
||||
model.max_storage_gb = user.max_storage_gb
|
||||
model.is_admin = user.is_admin
|
||||
model.created_at = user.created_at
|
||||
|
||||
self.session.commit()
|
||||
@@ -85,5 +86,6 @@ class SQLAlchemyUserRepository(UserRepository):
|
||||
subscription_expires_at=model.subscription_expires_at,
|
||||
max_projects=model.max_projects or 3,
|
||||
max_storage_gb=model.max_storage_gb or 10,
|
||||
is_admin=model.is_admin or False,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
|
||||
@@ -52,6 +52,8 @@ class User:
|
||||
max_projects: int = 3 # free: 3, pro: unlimited, enterprise: unlimited
|
||||
max_storage_gb: int = 10 # free: 10, pro: 100, enterprise: 1000
|
||||
used_storage_gb: float = 0.0
|
||||
# 管理员标识
|
||||
is_admin: bool = False
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,23 @@ from apps.api.main import app
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def _reset_rate_limiter():
|
||||
"""重置限流中间件状态,避免测试间互相影响。"""
|
||||
node = getattr(app, "middleware_stack", None)
|
||||
while node is not None:
|
||||
if hasattr(node, "requests"):
|
||||
node.requests.clear()
|
||||
break
|
||||
node = getattr(node, "app", None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_rate_limit_between_tests():
|
||||
"""每个测试前清空限流计数器。"""
|
||||
_reset_rate_limiter()
|
||||
yield
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -18,6 +18,7 @@ import sys
|
||||
import types
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -31,110 +32,27 @@ from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _install_mocks():
|
||||
"""在 sys.modules 中安装所有必需的 mock 模块,使 subscription.py 可导入。"""
|
||||
"""在 sys.modules 中安装所有必需的 mock 模块,使 subscription.py 可导入。
|
||||
|
||||
# ---------- packages.domain.entities ----------
|
||||
@dataclass(slots=True)
|
||||
class User:
|
||||
id: str = "user-001"
|
||||
email: str = "test@example.com"
|
||||
display_name: str = "Test User"
|
||||
username: str = "testuser"
|
||||
password_hash: str = ""
|
||||
email_verified: bool = False
|
||||
email_verification_token: str | None = None
|
||||
password_reset_token: str | None = None
|
||||
password_reset_expires_at: datetime | None = None
|
||||
last_login_at: datetime | None = None
|
||||
last_login_ip: str | None = None
|
||||
subscription_plan: str = "free"
|
||||
subscription_status: str = "active"
|
||||
subscription_expires_at: datetime | None = None
|
||||
max_projects: int = 3
|
||||
max_storage_gb: int = 10
|
||||
used_storage_gb: float = 0.0
|
||||
created_at: datetime = field(default_factory=lambda: datetime(2026, 1, 1, tzinfo=timezone.utc))
|
||||
注意:不 mock packages.* 命名空间包(packages.domain / packages.ports /
|
||||
packages.adapters 等),只 mock 必要的叶子模块,避免阻断其他测试文件
|
||||
对真实 packages.* 子模块的导入。
|
||||
"""
|
||||
|
||||
entities_mod = types.ModuleType("packages.domain.entities")
|
||||
entities_mod.User = User
|
||||
# ---------- packages.adapters 叶子 mock ----------
|
||||
# 仅 mock redis / smtp 适配器(subscription 路由间接依赖),
|
||||
# 不创建 packages.adapters 命名包——让 Python 使用磁盘上的真实包。
|
||||
for leaf_name in ["packages.adapters.redis", "packages.adapters.smtp"]:
|
||||
if leaf_name not in sys.modules:
|
||||
mod = types.ModuleType(leaf_name)
|
||||
sys.modules[leaf_name] = mod
|
||||
|
||||
# ---------- packages.ports.user_repository ----------
|
||||
class UserRepository:
|
||||
def save(self, user):
|
||||
pass
|
||||
|
||||
def find_by_id(self, user_id):
|
||||
return None
|
||||
|
||||
def find_by_email(self, email):
|
||||
return None
|
||||
|
||||
def find_by_username(self, username):
|
||||
return None
|
||||
|
||||
def find_by_verification_token(self, token):
|
||||
return None
|
||||
|
||||
def find_by_password_reset_token(self, token):
|
||||
return None
|
||||
|
||||
def delete(self, user_id):
|
||||
return True
|
||||
|
||||
user_repo_mod = types.ModuleType("packages.ports.user_repository")
|
||||
user_repo_mod.UserRepository = UserRepository
|
||||
|
||||
# ---------- packages (namespace) ----------
|
||||
for name in [
|
||||
"packages",
|
||||
"packages.domain",
|
||||
"packages.ports",
|
||||
"packages.adapters",
|
||||
"packages.adapters.sqlalchemy_impl",
|
||||
"packages.adapters.sqlalchemy_impl.user_repository",
|
||||
"packages.adapters.sqlalchemy_impl.session",
|
||||
"packages.adapters.redis",
|
||||
"packages.adapters.smtp",
|
||||
"packages.application",
|
||||
]:
|
||||
if name not in sys.modules:
|
||||
sys.modules[name] = types.ModuleType(name)
|
||||
|
||||
sys.modules["packages.domain.entities"] = entities_mod
|
||||
sys.modules["packages.ports.user_repository"] = user_repo_mod
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.user_repository"].SQLAlchemyUserRepository = MagicMock
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.session"].build_session_factory = MagicMock(
|
||||
return_value=(MagicMock(), MagicMock())
|
||||
)
|
||||
sys.modules["packages.adapters.redis"].NoopSessionStore = MagicMock
|
||||
sys.modules["packages.adapters.redis"].SessionStore = MagicMock
|
||||
sys.modules["packages.adapters.smtp"].EmailConfig = MagicMock
|
||||
sys.modules["packages.adapters.smtp"].NoopEmailService = MagicMock
|
||||
sys.modules["packages.adapters.smtp"].get_email_service = MagicMock()
|
||||
|
||||
# Stub 其他 repository ports(dependencies.py 会 import 它们)
|
||||
for port_name in [
|
||||
"asset_repository",
|
||||
"asset_library_repository",
|
||||
"classification_job_repository",
|
||||
"duplication_repository",
|
||||
"generated_video_repository",
|
||||
"generation_task_repository",
|
||||
"title_library_repository",
|
||||
"voice_library_repository",
|
||||
"ingest_job_repository",
|
||||
"project_repository",
|
||||
]:
|
||||
mod = types.ModuleType(f"packages.ports.{port_name}")
|
||||
# 动态创建一个 Mock repository class
|
||||
class_name = port_name.replace("_", " ").title().replace(" ", "") + "Port"
|
||||
setattr(mod, "".join(w.capitalize() for w in port_name.split("_")), MagicMock)
|
||||
sys.modules[f"packages.ports.{port_name}"] = mod
|
||||
|
||||
sa_mod = types.ModuleType(f"packages.adapters.sqlalchemy_impl.{port_name}")
|
||||
setattr(sa_mod, f"SQLAlchemy{''.join(w.capitalize() for w in port_name.split('_'))}", MagicMock)
|
||||
sys.modules[f"packages.adapters.sqlalchemy_impl.{port_name}"] = sa_mod
|
||||
|
||||
# ---------- app.config ----------
|
||||
config_mod = types.ModuleType("app.config")
|
||||
|
||||
@@ -156,15 +74,18 @@ def _install_mocks():
|
||||
config_mod.get_settings = lambda: _Settings()
|
||||
sys.modules["app.config"] = config_mod
|
||||
|
||||
# 使用真实的 User 实体(packages.domain.entities 无重依赖)
|
||||
from packages.domain.entities import User as _RealUser
|
||||
|
||||
# ---------- app.auth ----------
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthenticatedUser:
|
||||
user: User
|
||||
user: _RealUser
|
||||
session_id: str | None = None
|
||||
token_type: str | None = None
|
||||
|
||||
async def _mock_get_current_user():
|
||||
return AuthenticatedUser(user=User())
|
||||
return AuthenticatedUser(user=_RealUser())
|
||||
|
||||
auth_mod = types.ModuleType("app.auth")
|
||||
auth_mod.AuthenticatedUser = AuthenticatedUser
|
||||
@@ -264,7 +185,7 @@ def _install_mocks():
|
||||
except Exception:
|
||||
pass # 如果已经导入过,跳过
|
||||
|
||||
return User, AuthenticatedUser
|
||||
return _RealUser, AuthenticatedUser
|
||||
|
||||
|
||||
User, AuthenticatedUser = _install_mocks()
|
||||
|
||||
@@ -153,6 +153,7 @@ def _build_route_test_app(project_repo, library_repo, asset_repo):
|
||||
mock_user = MagicMock(spec=AuthenticatedUser)
|
||||
mock_user.id = "user-1"
|
||||
mock_user.email = "test@example.com"
|
||||
mock_user.user.id = "user-1"
|
||||
app.dependency_overrides[get_current_user] = lambda: mock_user
|
||||
app.dependency_overrides[_dep("get_project_repository")] = lambda: project_repo
|
||||
app.dependency_overrides[_dep("get_asset_library_repository")] = lambda: library_repo
|
||||
|
||||
@@ -105,6 +105,7 @@ class StubEditTemplateRepository:
|
||||
class FakeUser:
|
||||
id: str = "user-001"
|
||||
email: str = "test@example.com"
|
||||
is_admin: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
Reference in New Issue
Block a user