Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20fe447efb | |||
| ac28530528 |
@@ -1187,8 +1187,6 @@ jobs:
|
||||
COSYVOICE_API_KEY: ${{ secrets.COSYVOICE_API_KEY }}
|
||||
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
|
||||
MEDIAKIT_API_KEY: ${{ secrets.MEDIAKIT_API_KEY }}
|
||||
WECHAT_APP_ID: ${{ secrets.WECHAT_APP_ID }}
|
||||
WECHAT_APP_SECRET: ${{ secrets.WECHAT_APP_SECRET }}
|
||||
run: |
|
||||
set -eu
|
||||
echo "Rendering .env from template + secrets..."
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"""add client_upload_id to assets and asset_id to ingest_jobs
|
||||
|
||||
Issue #1714:上传 complete 幂等 + worker 转码回写关联。
|
||||
- assets.client_upload_id:客户端幂等 token(complete 去重)
|
||||
- ingest_jobs.asset_id:complete 阶段创建的占位 asset id(worker 回写关联,
|
||||
防止 HEVC 转码改写 storage_key 后找不到占位而兜底新建 READY 记录)
|
||||
|
||||
Revision ID: 066_upload_idempotency
|
||||
Revises: 065_dup_record_sim_match
|
||||
Create Date: 2026-09-05
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "066_upload_idempotency"
|
||||
down_revision = "065_dup_record_sim_match"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("assets", sa.Column("client_upload_id", sa.String(64), nullable=True))
|
||||
op.create_index("ix_assets_client_upload_id", "assets", ["client_upload_id"])
|
||||
op.add_column("ingest_jobs", sa.Column("asset_id", sa.String(36), nullable=False, server_default=""))
|
||||
op.create_index("ix_ingest_jobs_asset_id", "ingest_jobs", ["asset_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ingest_jobs_asset_id", table_name="ingest_jobs")
|
||||
op.drop_column("ingest_jobs", "asset_id")
|
||||
op.drop_index("ix_assets_client_upload_id", table_name="assets")
|
||||
op.drop_column("assets", "client_upload_id")
|
||||
@@ -1,35 +0,0 @@
|
||||
"""add celery_task_id to generation_tasks and ingest_jobs
|
||||
|
||||
Issue #1714:孤儿恢复/超时清理撤销队列消息。
|
||||
- generation_tasks.celery_task_id:入队时记录的 Celery 消息 ID,清理时 revoke
|
||||
- ingest_jobs.celery_task_id:同上(素材转码任务)
|
||||
|
||||
Revision ID: 067_celery_task_id
|
||||
Revises: 066_upload_idempotency
|
||||
Create Date: 2026-09-05
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "067_celery_task_id"
|
||||
down_revision = "066_upload_idempotency"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("celery_task_id", sa.String(64), nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column(
|
||||
"ingest_jobs",
|
||||
sa.Column("celery_task_id", sa.String(64), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ingest_jobs", "celery_task_id")
|
||||
op.drop_column("generation_tasks", "celery_task_id")
|
||||
@@ -1,26 +0,0 @@
|
||||
"""add profile_completed to users
|
||||
|
||||
Issue #1718:微信新用户首次登录需设置昵称(PATCH /auth/me)。
|
||||
- users.profile_completed:资料是否已完善;存量行默认 True(不触发引导),
|
||||
微信新建用户在应用层置 False。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "068_user_profile_completed"
|
||||
down_revision = "067_celery_task_id"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("profile_completed", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "profile_completed")
|
||||
@@ -1,5 +1,4 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
Canonical authentication API routes.
|
||||
|
||||
The route layer is intentionally thin: repository construction lives in
|
||||
@@ -14,9 +13,9 @@ import jwt
|
||||
from app.auth import AuthenticatedUser, blacklist_token, get_current_user
|
||||
from app.config import settings
|
||||
from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from packages.adapters.redis import NoopSessionStore
|
||||
from packages.adapters.smtp import NoopEmailService
|
||||
@@ -85,23 +84,6 @@ class CurrentUserResponse(BaseModel):
|
||||
phone: str = ""
|
||||
phone_verified: bool = False
|
||||
binding_complete: bool = False
|
||||
wechat_bound: bool = False
|
||||
profile_completed: bool = True
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
"""用户资料负载(PATCH /me、绑定/解绑接口复用;字段与 GET /auth/me 一致,前端 normalizeUser 直接消费)"""
|
||||
|
||||
user_id: str
|
||||
email: str
|
||||
username: str
|
||||
display_name: str
|
||||
email_verified: bool
|
||||
phone: str = ""
|
||||
phone_verified: bool = False
|
||||
binding_complete: bool = False
|
||||
wechat_bound: bool = False
|
||||
profile_completed: bool = True
|
||||
|
||||
|
||||
class PasswordResetRequestModel(BaseModel):
|
||||
@@ -290,52 +272,9 @@ async def get_current_user_info(
|
||||
phone=user.phone or "",
|
||||
phone_verified=user.phone_verified,
|
||||
binding_complete=binding_complete,
|
||||
wechat_bound=bool(user.wechat_openid),
|
||||
profile_completed=user.profile_completed,
|
||||
)
|
||||
|
||||
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
"""更新个人资料请求(当前仅支持昵称)"""
|
||||
|
||||
display_name: str
|
||||
|
||||
@field_validator("display_name")
|
||||
@classmethod
|
||||
def _validate_display_name(cls, v: str) -> str:
|
||||
name = (v or "").strip()
|
||||
if not name:
|
||||
raise ValueError("昵称不能为空白")
|
||||
if len(name) > 20:
|
||||
raise ValueError("昵称长度需在 1-20 个字符之间")
|
||||
return name
|
||||
|
||||
|
||||
class UpdateProfileResponse(BaseModel):
|
||||
"""更新资料响应:前端 normalizeUser(response.user) 直接消费"""
|
||||
|
||||
user: UserProfileResponse
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UpdateProfileResponse)
|
||||
async def update_current_user_profile(
|
||||
request: UpdateProfileRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> UpdateProfileResponse:
|
||||
"""更新当前登录用户昵称(微信新用户首次设置昵称后置 profile_completed=True)。"""
|
||||
user = current_user.user
|
||||
user.display_name = request.display_name # 已 strip(validator)
|
||||
if not user.profile_completed:
|
||||
user.profile_completed = True
|
||||
user_repository.save(user)
|
||||
|
||||
logger.info("[资料更新] 用户 %s 更新昵称,profile_completed=%s", user.id, user.profile_completed)
|
||||
# 重新读取,确保返回的是持久化后的最新状态
|
||||
fresh = user_repository.find_by_id(user.id) or user
|
||||
return UpdateProfileResponse(user=_user_profile(fresh))
|
||||
|
||||
|
||||
class _NoopSessionStore(NoopSessionStore):
|
||||
pass
|
||||
|
||||
@@ -487,7 +426,6 @@ async def get_wechat_auth_url() -> WechatAuthUrlResponse:
|
||||
@router.post("/wechat/callback", response_model=WechatLoginResponse)
|
||||
async def wechat_callback(
|
||||
request: WechatCallbackRequest,
|
||||
http_request: Request,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> WechatLoginResponse:
|
||||
"""微信登录回调处理"""
|
||||
@@ -495,30 +433,11 @@ async def wechat_callback(
|
||||
from packages.application.auth.wechat_sync_use_case import WechatSyncRequest as SyncRequest
|
||||
from packages.application.auth.wechat_sync_use_case import WechatSyncUseCase
|
||||
|
||||
# 回调可观测性:记录 UA(区分微信内置浏览器 MicroMessenger)与 state,
|
||||
# 便于排查"停留 open.weixin.qq.com / 回调失败"类问题(#1718)
|
||||
user_agent = http_request.headers.get("User-Agent", "")
|
||||
is_wechat_browser = "MicroMessenger" in user_agent
|
||||
logger.info(
|
||||
"[微信回调] 收到回调: state=%s code_len=%d UA=%r 微信内置浏览器=%s",
|
||||
(request.state or "")[:8],
|
||||
len(request.code or ""),
|
||||
user_agent[:200],
|
||||
is_wechat_browser,
|
||||
)
|
||||
|
||||
# 1. 用 code 换微信用户信息
|
||||
oauth_service = get_wechat_oauth_service()
|
||||
wechat_user, err = oauth_service.handle_callback(request.code, request.state)
|
||||
if err:
|
||||
# state 校验失败 / 微信 errcode 等错误原文已在 service 内 log,这里带上 UA 上下文
|
||||
logger.warning("[微信回调] 处理失败: err=%s 微信内置浏览器=%s", err, is_wechat_browser)
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
logger.info(
|
||||
"[微信回调] state 校验通过,微信用户信息获取成功: openid=%s unionid=%s",
|
||||
wechat_user.openid[:8] if wechat_user.openid else "",
|
||||
bool(wechat_user.unionid),
|
||||
)
|
||||
|
||||
# 2. 同步登录/注册(复用 wechat-sync 逻辑)
|
||||
use_case = WechatSyncUseCase(user_repository=user_repository)
|
||||
@@ -553,109 +472,6 @@ async def wechat_callback(
|
||||
)
|
||||
|
||||
|
||||
# ==================== 微信账号绑定/解绑(已登录用户) ====================
|
||||
|
||||
|
||||
class WechatBindUrlResponse(BaseModel):
|
||||
auth_url: str
|
||||
state: str
|
||||
|
||||
|
||||
class WechatBindCompleteRequest(BaseModel):
|
||||
code: str
|
||||
state: str = ""
|
||||
|
||||
|
||||
class WechatBindCompleteResponse(BaseModel):
|
||||
success: bool
|
||||
user: UserProfileResponse
|
||||
|
||||
|
||||
class WechatUnbindResponse(BaseModel):
|
||||
success: bool
|
||||
|
||||
|
||||
def _user_profile(user) -> UserProfileResponse:
|
||||
binding_complete = bool(
|
||||
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
|
||||
)
|
||||
return UserProfileResponse(
|
||||
user_id=user.id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
email_verified=user.email_verified,
|
||||
phone=user.phone or "",
|
||||
phone_verified=user.phone_verified,
|
||||
binding_complete=binding_complete,
|
||||
wechat_bound=bool(user.wechat_openid),
|
||||
profile_completed=user.profile_completed,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/wechat/bind/url", response_model=WechatBindUrlResponse)
|
||||
async def get_wechat_bind_url(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> WechatBindUrlResponse:
|
||||
"""获取微信绑定授权链接(已登录用户场景)。state 经 Redis 存储做 CSRF 校验。"""
|
||||
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
|
||||
|
||||
oauth_service = get_wechat_oauth_service()
|
||||
auth_url, state = oauth_service.generate_auth_url()
|
||||
logger.info("[微信绑定] 用户 %s 请求绑定授权链接", current_user.user.id)
|
||||
return WechatBindUrlResponse(auth_url=auth_url, state=state)
|
||||
|
||||
|
||||
@router.post("/wechat/bind", response_model=WechatBindCompleteResponse)
|
||||
async def wechat_bind(
|
||||
request: WechatBindCompleteRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> WechatBindCompleteResponse:
|
||||
"""微信绑定完成:扫码回调后用 code 换 openid,绑定到当前登录账号(不创建新用户)。"""
|
||||
from packages.application.auth.wechat_bind_use_case import WechatBindRequest, WechatBindUseCase
|
||||
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
|
||||
|
||||
oauth_service = get_wechat_oauth_service()
|
||||
wechat_user, err = oauth_service.handle_callback(request.code, request.state)
|
||||
if err:
|
||||
logger.warning("[微信绑定] 用户 %s 换取微信信息失败: %s", current_user.user.id, err)
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
|
||||
use_case = WechatBindUseCase(user_repository=user_repository)
|
||||
result, error, http_status = use_case.bind(
|
||||
WechatBindRequest(
|
||||
user_id=current_user.user.id,
|
||||
openid=wechat_user.openid,
|
||||
unionid=wechat_user.unionid or "",
|
||||
)
|
||||
)
|
||||
if error:
|
||||
logger.warning("[微信绑定] 用户 %s 绑定失败: %s", current_user.user.id, error)
|
||||
raise HTTPException(status_code=http_status, detail=error)
|
||||
|
||||
logger.info("[微信绑定] 用户 %s 绑定成功 openid=%s", current_user.user.id, wechat_user.openid[:8])
|
||||
return WechatBindCompleteResponse(success=True, user=_user_profile(result.user))
|
||||
|
||||
|
||||
@router.delete("/wechat/bind", response_model=WechatUnbindResponse)
|
||||
async def wechat_unbind(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> WechatUnbindResponse:
|
||||
"""解绑微信:需账号仍有其他登录方式(密码/手机/真实邮箱),否则拒绝。"""
|
||||
from packages.application.auth.wechat_bind_use_case import WechatUnbindUseCase
|
||||
|
||||
use_case = WechatUnbindUseCase(user_repository=user_repository)
|
||||
result, error, http_status = use_case.unbind(current_user.user.id)
|
||||
if error:
|
||||
logger.warning("[微信解绑] 用户 %s 解绑失败: %s", current_user.user.id, error)
|
||||
raise HTTPException(status_code=http_status, detail=error)
|
||||
|
||||
logger.info("[微信解绑] 用户 %s 解绑成功", current_user.user.id)
|
||||
return WechatUnbindResponse(success=True)
|
||||
|
||||
|
||||
# ==================== 验证码 & 绑定 ====================
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.api.routes._helpers import require_project_and_library
|
||||
from app.api.routes.upload import _persist_celery_task_id
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
@@ -382,8 +381,7 @@ async def complete_chunked_upload(
|
||||
file_hash=request.file_hash,
|
||||
)
|
||||
)
|
||||
celery_result = celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
_persist_celery_task_id(ingest_job_repository, job, getattr(celery_result, "id", ""))
|
||||
celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
|
||||
# Update metadata status
|
||||
meta["status"] = "completed"
|
||||
|
||||
@@ -14,7 +14,6 @@ from app.core.task_enqueue import (
|
||||
USER_PENDING_LIMIT,
|
||||
GlobalQueueFull,
|
||||
UserPendingLimitExceeded,
|
||||
build_rate_limit_detail,
|
||||
safe_enqueue_generation_task,
|
||||
)
|
||||
from app.dependencies import (
|
||||
@@ -313,12 +312,12 @@ def create_preview_generation_task(
|
||||
except UserPendingLimitExceeded as e:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(e, generation_task_repository, scope="user"),
|
||||
detail=f"您的待处理任务过多(当前 {e.pending_count - count}/{e.limit},本次提交 {count} 个),请等待后再提交",
|
||||
) from e
|
||||
except GlobalQueueFull as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=build_rate_limit_detail(e, generation_task_repository, scope="global"),
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from e
|
||||
|
||||
# 确定视频比例:优先前端传入,否则从模板 mode 推断
|
||||
@@ -499,7 +498,6 @@ def create_preview_generation_task(
|
||||
|
||||
# ── 入队 ──
|
||||
responses: list[PreviewGenerationTaskResponse] = []
|
||||
rate_limit_exc: Exception | None = None # 记录首个限流异常,全部失败时返回结构化提示
|
||||
for variant_index, task in enumerate(created_tasks):
|
||||
try:
|
||||
enqueued = safe_enqueue_generation_task(
|
||||
@@ -512,29 +510,23 @@ def create_preview_generation_task(
|
||||
if not enqueued:
|
||||
logger.warning("[预览生成] 任务入队失败: task_id=%s", task.id)
|
||||
_mark_task_failed(generation_task_repository, task, "任务入队失败")
|
||||
except UserPendingLimitExceeded as e:
|
||||
except UserPendingLimitExceeded:
|
||||
_mark_task_failed(generation_task_repository, task, "待处理任务超限")
|
||||
rate_limit_exc = rate_limit_exc or e
|
||||
except GlobalQueueFull as e:
|
||||
except GlobalQueueFull:
|
||||
_mark_task_failed(generation_task_repository, task, "系统队列已满")
|
||||
rate_limit_exc = rate_limit_exc or e
|
||||
except Exception:
|
||||
logger.exception("[预览生成] 入队异常: task_id=%s", task.id)
|
||||
_mark_task_failed(generation_task_repository, task, "任务入队异常")
|
||||
# enqueue 会原地更新 task 状态/进度,直接用 task 构造响应
|
||||
responses.append(_to_preview_response(task))
|
||||
|
||||
# 队列满/限流时若全部失败,返回结构化错误码(前端区分"排队"与"创建失败")
|
||||
if all(r.status == "failed" for r in responses) and rate_limit_exc is not None:
|
||||
if isinstance(rate_limit_exc, UserPendingLimitExceeded):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(rate_limit_exc, generation_task_repository, scope="user"),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=build_rate_limit_detail(rate_limit_exc, generation_task_repository, scope="global"),
|
||||
)
|
||||
# 队列满/限流时若全部失败,返回明确错误码
|
||||
if all(r.status == "failed" for r in responses):
|
||||
first_err = next((r.error_message for r in responses if r.error_message), "")
|
||||
if "待处理任务" in first_err:
|
||||
raise HTTPException(status_code=429, detail=first_err or "待处理任务超限")
|
||||
if "队列" in first_err:
|
||||
raise HTTPException(status_code=503, detail=first_err or "系统繁忙,请稍后再试")
|
||||
|
||||
logger.info(
|
||||
"[预览生成] 创建完成: %d 个变体任务, task_ids=%s",
|
||||
|
||||
@@ -10,7 +10,6 @@ from app.core.task_enqueue import (
|
||||
USER_PENDING_LIMIT,
|
||||
GlobalQueueFull,
|
||||
UserPendingLimitExceeded,
|
||||
build_rate_limit_detail,
|
||||
safe_enqueue_generation_task,
|
||||
)
|
||||
from app.dependencies import (
|
||||
@@ -418,12 +417,12 @@ def create_generation_task(
|
||||
except UserPendingLimitExceeded as e:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(e, generation_task_repository, scope="user"),
|
||||
detail=f"您的待处理任务过多(当前 {e.pending_count - count}/{e.limit},本次提交 {count} 个),请等待完成后再提交",
|
||||
) from e
|
||||
except GlobalQueueFull as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=build_rate_limit_detail(e, generation_task_repository, scope="global"),
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from e
|
||||
|
||||
# 画中画已下线:strategy_id 中的 pip/voice_pip 统一映射为 one_take
|
||||
@@ -580,7 +579,7 @@ def create_generation_task(
|
||||
if not created_tasks:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from _e
|
||||
break
|
||||
except GlobalQueueFull as _e:
|
||||
@@ -588,7 +587,7 @@ def create_generation_task(
|
||||
if not created_tasks:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="global"),
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from _e
|
||||
break
|
||||
except HTTPException:
|
||||
@@ -714,15 +713,15 @@ def confirm_generation(
|
||||
log_task_status=True,
|
||||
):
|
||||
logger.warning("[确认生成] 入队失败: task_id=%s", new_task.id)
|
||||
except UserPendingLimitExceeded as _e:
|
||||
except UserPendingLimitExceeded:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from None
|
||||
except GlobalQueueFull as _e:
|
||||
except GlobalQueueFull:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="global"),
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
|
||||
return BatchGenerationTaskResponse(
|
||||
@@ -804,24 +803,12 @@ def retry_generation_task(
|
||||
if user_pending >= USER_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(
|
||||
UserPendingLimitExceeded(
|
||||
user_id=user_id,
|
||||
pending_count=user_pending,
|
||||
limit=USER_PENDING_LIMIT,
|
||||
),
|
||||
generation_task_repository,
|
||||
scope="user",
|
||||
),
|
||||
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
|
||||
)
|
||||
if global_pending >= GLOBAL_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=build_rate_limit_detail(
|
||||
GlobalQueueFull(pending_count=global_pending, limit=GLOBAL_PENDING_LIMIT),
|
||||
generation_task_repository,
|
||||
scope="global",
|
||||
),
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
@@ -856,15 +843,15 @@ def retry_generation_task(
|
||||
log_task_status=True,
|
||||
):
|
||||
logger.warning("[生成任务] 重试入队失败: task_id=%s", retried.id)
|
||||
except UserPendingLimitExceeded as _e:
|
||||
except UserPendingLimitExceeded:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from None
|
||||
except GlobalQueueFull as _e:
|
||||
except GlobalQueueFull:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="global"),
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
return _to_generation_task_response(retried)
|
||||
|
||||
|
||||
@@ -43,13 +43,7 @@ def submit_ingest_job(
|
||||
)
|
||||
)
|
||||
|
||||
celery_result = celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
if getattr(celery_result, "id", ""):
|
||||
try:
|
||||
job.celery_task_id = celery_result.id
|
||||
ingest_job_repository.update(job)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
|
||||
return IngestJobResponse(
|
||||
id=job.id,
|
||||
|
||||
@@ -375,13 +375,7 @@ def retry_project_task(
|
||||
storage_key=job.storage_key,
|
||||
)
|
||||
)
|
||||
celery_result = celery_app.send_task("worker.ingest_asset", args=[retried.id])
|
||||
if getattr(celery_result, "id", ""):
|
||||
try:
|
||||
retried.celery_task_id = celery_result.id
|
||||
ingest_job_repository.update(retried)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
celery_app.send_task("worker.ingest_asset", args=[retried.id])
|
||||
return ProjectTaskResponse(
|
||||
id=f"ingest:{retried.id}",
|
||||
task_type="ingest",
|
||||
|
||||
@@ -85,22 +85,12 @@ def _infer_mime_type_from_storage_key(storage_key: str) -> str:
|
||||
"""从 storage_key 推断 MIME 类型(与 worker 端保持一致)。"""
|
||||
lower_filename = storage_key.rsplit("/", 1)[-1].lower()
|
||||
_MIME_MAP = {
|
||||
".mov": "video/quicktime",
|
||||
".mp4": "video/mp4",
|
||||
".avi": "video/x-msvideo",
|
||||
".mkv": "video/x-matroska",
|
||||
".webm": "video/webm",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".bmp": "image/bmp",
|
||||
".svg": "image/svg+xml",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".flac": "audio/flac",
|
||||
".m4a": "audio/x-m4a",
|
||||
".mov": "video/quicktime", ".mp4": "video/mp4", ".avi": "video/x-msvideo",
|
||||
".mkv": "video/x-matroska", ".webm": "video/webm",
|
||||
".png": "image/png", ".gif": "image/gif", ".bmp": "image/bmp",
|
||||
".svg": "image/svg+xml", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
|
||||
".flac": "audio/flac", ".m4a": "audio/x-m4a",
|
||||
}
|
||||
for ext, mime in _MIME_MAP.items():
|
||||
if lower_filename.endswith(ext):
|
||||
@@ -108,137 +98,10 @@ def _infer_mime_type_from_storage_key(storage_key: str) -> str:
|
||||
return "video/mp4" # default
|
||||
|
||||
|
||||
# 兜底去重:无 file_hash / client_upload_id 且大小已知时,同库同名同大小近期活动记录视为重复
|
||||
FALLBACK_DEDUP_WINDOW_MINUTES = 30
|
||||
|
||||
|
||||
def _find_duplicate_asset(
|
||||
asset_repository: Any,
|
||||
*,
|
||||
library_id: str,
|
||||
file_hash: str,
|
||||
client_upload_id: str,
|
||||
filename: str,
|
||||
file_size: int = 0,
|
||||
) -> Any:
|
||||
"""complete/上传幂等去重,按优先级查找已存在的素材。
|
||||
|
||||
1. client_upload_id(客户端幂等 token,同一次上传的重试保持一致)
|
||||
2. file_hash(内容哈希,不同上传只要内容相同即去重)
|
||||
3. 兜底(严格模式,宁可漏判不可误杀):file_hash 与 client_upload_id
|
||||
均缺失、且 file_size > 0 时,同库 + 同文件名 + **同大小** 且 30 分钟内
|
||||
仍处 uploading/processing 的记录才判重。
|
||||
- file_hash 非空时跳过兜底(hash 已代表内容;同名但内容全新的视频
|
||||
如 iPhone 的 IMG_xxxx.MOV 绝不能被同名占位误杀)
|
||||
- file_size=0(未知)时不允许仅凭同名 + processing 判重,直接放行
|
||||
|
||||
全部为鸭子类型调用:旧仓储无对应方法时静默跳过,不破坏既有实现。
|
||||
"""
|
||||
if client_upload_id:
|
||||
find = getattr(asset_repository, "find_by_library_and_client_upload_id", None)
|
||||
if callable(find):
|
||||
existing = find(library_id=library_id, client_upload_id=client_upload_id)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材幂等命中(client_upload_id): library=%s token=%s asset=%s",
|
||||
library_id,
|
||||
client_upload_id,
|
||||
getattr(existing, "id", "?"),
|
||||
)
|
||||
return existing
|
||||
if file_hash:
|
||||
existing = asset_repository.find_by_library_and_file_hash(
|
||||
library_id=library_id,
|
||||
file_hash=file_hash,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材去重命中(file_hash): library=%s hash=%s asset=%s",
|
||||
library_id,
|
||||
file_hash,
|
||||
existing.id,
|
||||
)
|
||||
return existing
|
||||
# 同名兜底去重(最后防线,严格模式):
|
||||
# - 仅当 file_hash / client_upload_id 均缺失时启用(hash 能代表内容时不靠同名猜)
|
||||
# - file_size 必须 > 0 且与记录大小严格一致;大小未知(0)直接放行
|
||||
# - 只命中近期 UPLOADING/PROCESSING 活动记录(READY 历史素材不拦)
|
||||
if filename and not file_hash and not client_upload_id and file_size and file_size > 0:
|
||||
find_recent = getattr(asset_repository, "find_recent_active_by_library_and_name", None)
|
||||
if callable(find_recent):
|
||||
existing = find_recent(
|
||||
library_id=library_id,
|
||||
name=filename,
|
||||
within_minutes=FALLBACK_DEDUP_WINDOW_MINUTES,
|
||||
file_size=file_size,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材幂等兜底命中(近期同名同大小活动记录): library=%s name=%s asset=%s status=%s size=%s",
|
||||
library_id,
|
||||
filename,
|
||||
getattr(existing, "id", "?"),
|
||||
getattr(existing, "status", None),
|
||||
file_size,
|
||||
)
|
||||
return existing
|
||||
elif filename and not file_hash and not client_upload_id and not file_size:
|
||||
logger.debug(
|
||||
"同名兜底去重跳过(file_size 未知,宁可放行不可误杀): library=%s name=%s",
|
||||
library_id,
|
||||
filename,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _create_pending_asset(
|
||||
asset_repository,
|
||||
project_id,
|
||||
library_id,
|
||||
storage_key,
|
||||
filename,
|
||||
mime_type,
|
||||
user_id,
|
||||
file_hash="",
|
||||
client_upload_id="",
|
||||
file_size: int = 0,
|
||||
asset_repository, project_id, library_id, storage_key, filename, mime_type, user_id, file_hash=""
|
||||
):
|
||||
"""立即创建或复用一条 PROCESSING 状态的 Asset 记录。
|
||||
|
||||
find-or-create:prepare 阶段已按 file_hash/client_upload_id 预建的占位记录
|
||||
会被 find_by_library_and_file_hash/find_by_library_and_client_upload_id 命中,
|
||||
直接复用并补齐字段(避免 pre-create + complete 重复建两条)。
|
||||
"""
|
||||
# 1. 按 client_upload_id / file_hash 查找现有记录
|
||||
existing = None
|
||||
if client_upload_id:
|
||||
find_by_cuid = getattr(asset_repository, "find_by_library_and_client_upload_id", None)
|
||||
if callable(find_by_cuid):
|
||||
existing = find_by_cuid(library_id=library_id, client_upload_id=client_upload_id)
|
||||
if existing is None and file_hash:
|
||||
existing = asset_repository.find_by_library_and_file_hash(library_id=library_id, file_hash=file_hash)
|
||||
if existing is not None:
|
||||
# 补齐字段(幂等:避免重复建记录,前端已拿到 asset_id)
|
||||
changed = False
|
||||
if file_hash and not existing.file_hash:
|
||||
existing.file_hash = file_hash
|
||||
changed = True
|
||||
if client_upload_id and not existing.client_upload_id:
|
||||
existing.client_upload_id = client_upload_id
|
||||
changed = True
|
||||
if file_size and not existing.file_size:
|
||||
existing.file_size = file_size
|
||||
changed = True
|
||||
if existing.status not in (AssetStatus.PROCESSING, AssetStatus.UPLOADING):
|
||||
existing.status = AssetStatus.PROCESSING
|
||||
changed = True
|
||||
if changed:
|
||||
try:
|
||||
asset_repository.update(existing)
|
||||
except Exception: # noqa: BLE001 — 字段补齐失败不阻塞主流程
|
||||
pass
|
||||
return existing
|
||||
|
||||
"""立即创建一条 PROCESSING 状态的 Asset 记录,使前端能马上看到新素材。"""
|
||||
asset = Asset.create(
|
||||
project_id=project_id,
|
||||
library_id=library_id,
|
||||
@@ -248,30 +111,16 @@ def _create_pending_asset(
|
||||
status=AssetStatus.PROCESSING,
|
||||
uploaded_by_user_id=user_id,
|
||||
file_hash=file_hash,
|
||||
client_upload_id=client_upload_id,
|
||||
file_size=file_size,
|
||||
)
|
||||
return asset_repository.create(asset)
|
||||
|
||||
|
||||
def _persist_celery_task_id(repo: Any, job: Any, celery_task_id: str) -> None:
|
||||
"""记录 celery 消息 ID 到任务行,供孤儿清理时 revoke/清除队列消息(#1714)。"""
|
||||
if not celery_task_id:
|
||||
return
|
||||
try:
|
||||
job.celery_task_id = celery_task_id
|
||||
repo.update(job)
|
||||
except Exception: # noqa: BLE001 — 记录失败不影响主流程(执行前状态守卫兜底)
|
||||
pass
|
||||
|
||||
|
||||
def _submit_ingest_job(
|
||||
project_id: str,
|
||||
library_id: str,
|
||||
storage_key: str,
|
||||
ingest_job_repository: Any,
|
||||
file_hash: str = "",
|
||||
asset_id: str = "",
|
||||
) -> Any:
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
job = use_case.execute(
|
||||
@@ -280,11 +129,9 @@ def _submit_ingest_job(
|
||||
library_id=library_id,
|
||||
storage_key=storage_key,
|
||||
file_hash=file_hash,
|
||||
asset_id=asset_id,
|
||||
)
|
||||
)
|
||||
celery_result = celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
_persist_celery_task_id(ingest_job_repository, job, getattr(celery_result, "id", ""))
|
||||
celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
return job
|
||||
|
||||
|
||||
@@ -294,15 +141,9 @@ async def prepare_direct_upload(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> DirectUploadPrepareResponse:
|
||||
"""创建浏览器直传 OSS 的短期表单签名,并在签名前按 file_hash/client_upload_id 去重。
|
||||
|
||||
命中去重:直接返回 duplicated=True + skip_transfer=True(前端跳过 OSS 直传),
|
||||
未命中:正常签名 OSS 并立即预建一条 PROCESSING 状态的 asset 记录占住
|
||||
file_hash 闸门,响应带 asset_id 供前端/后续 complete 关联。
|
||||
"""
|
||||
"""创建浏览器直传 OSS 的短期表单签名。"""
|
||||
settings = get_settings()
|
||||
max_size_bytes = settings.OSS_DIRECT_UPLOAD_MAX_MB * 1024 * 1024
|
||||
if request.file_size > max_size_bytes:
|
||||
@@ -321,39 +162,8 @@ async def prepare_direct_upload(
|
||||
asset_library_repository,
|
||||
)
|
||||
|
||||
safe_filename = request.filename.replace("/", "_").replace("\\", "_")
|
||||
|
||||
# ── prepare 阶段去重:OSS 签名之前先查已存在素材 ──
|
||||
if request.file_hash or request.client_upload_id:
|
||||
existing = _find_duplicate_asset(
|
||||
asset_repository,
|
||||
library_id=request.library_id,
|
||||
file_hash=request.file_hash,
|
||||
client_upload_id=request.client_upload_id,
|
||||
filename=request.filename,
|
||||
file_size=request.file_size,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"prepare 命中去重: library=%s hash=%s cuid=%s existing_asset=%s",
|
||||
request.library_id,
|
||||
request.file_hash,
|
||||
request.client_upload_id,
|
||||
existing.id,
|
||||
)
|
||||
return DirectUploadPrepareResponse(
|
||||
upload_url="",
|
||||
method="",
|
||||
storage_key=existing.storage_key,
|
||||
expires_at="",
|
||||
fields={},
|
||||
max_size_bytes=0,
|
||||
duplicated=True,
|
||||
skip_transfer=True,
|
||||
asset_id=existing.id,
|
||||
)
|
||||
|
||||
file_id = uuid4().hex[:8]
|
||||
safe_filename = request.filename.replace("/", "_").replace("\\", "_")
|
||||
storage_key = f"uploads/{file_id}/{safe_filename}"
|
||||
try:
|
||||
payload = storage_service.create_direct_upload_post(
|
||||
@@ -372,27 +182,6 @@ async def prepare_direct_upload(
|
||||
detail=f"Failed to prepare upload: {type(error).__name__}",
|
||||
) from error
|
||||
|
||||
# ── 预建 asset 占位:占住 file_hash/client_upload_id 闸门,避免并发重复上传 ──
|
||||
pending_asset_id = ""
|
||||
if request.file_hash or request.client_upload_id:
|
||||
try:
|
||||
pending = _create_pending_asset(
|
||||
asset_repository=asset_repository,
|
||||
project_id=request.project_id,
|
||||
library_id=request.library_id,
|
||||
storage_key=storage_key,
|
||||
filename=safe_filename,
|
||||
mime_type=validated_content_type,
|
||||
user_id=authenticated_user.user.id,
|
||||
file_hash=request.file_hash,
|
||||
client_upload_id=request.client_upload_id,
|
||||
file_size=request.file_size,
|
||||
)
|
||||
pending_asset_id = pending.id
|
||||
except Exception as error:
|
||||
# 预建失败不阻塞签名:complete 仍可按 OSS 文件 + hash 兜底去重
|
||||
logger.warning("预建 asset 占位失败,降级走 old flow: %s", error)
|
||||
|
||||
return DirectUploadPrepareResponse(
|
||||
upload_url=str(payload["url"]),
|
||||
method=str(payload["method"]),
|
||||
@@ -400,9 +189,6 @@ async def prepare_direct_upload(
|
||||
expires_at=str(payload["expires_at"]),
|
||||
fields={str(key): str(value) for key, value in dict(payload["fields"]).items()},
|
||||
max_size_bytes=max_size_bytes,
|
||||
duplicated=False,
|
||||
skip_transfer=False,
|
||||
asset_id=pending_asset_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -416,7 +202,7 @@ async def complete_direct_upload(
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> DirectUploadCompleteResponse:
|
||||
"""确认浏览器直传完成并创建导入任务(幂等:重复 complete 返回同一素材)。"""
|
||||
"""确认浏览器直传完成并创建导入任务。"""
|
||||
require_project_and_library(
|
||||
request.project_id,
|
||||
request.library_id,
|
||||
@@ -426,29 +212,6 @@ async def complete_direct_upload(
|
||||
normalized_key = storage_service._normalize_storage_key(request.storage_key)
|
||||
if not normalized_key.startswith("uploads/"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid upload key")
|
||||
|
||||
filename = normalized_key.rsplit("/", 1)[-1]
|
||||
|
||||
# ── 幂等去重(放在 OSS 检查之前):complete 超时后前端重试时,
|
||||
# 第一次 complete 可能已建好占位记录,此时即使 OSS 检查失败也必须返回
|
||||
# 已存在记录,绝不能再建第二条。─
|
||||
existing = _find_duplicate_asset(
|
||||
asset_repository,
|
||||
library_id=request.library_id,
|
||||
file_hash=request.file_hash,
|
||||
client_upload_id=request.client_upload_id,
|
||||
filename=filename,
|
||||
file_size=request.file_size,
|
||||
)
|
||||
if existing is not None:
|
||||
return DirectUploadCompleteResponse(
|
||||
storage_key=existing.storage_key,
|
||||
ingest_job_id="",
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
url=storage_service.get_url(existing.storage_key),
|
||||
)
|
||||
|
||||
try:
|
||||
file_exists = storage_service.file_exists(normalized_key)
|
||||
except Exception as error:
|
||||
@@ -460,7 +223,29 @@ async def complete_direct_upload(
|
||||
if not file_exists:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Uploaded file not found")
|
||||
|
||||
# ── 素材去重检测:同素材库 + 同 file_hash 视为重复 ──
|
||||
if request.file_hash:
|
||||
existing = asset_repository.find_by_library_and_file_hash(
|
||||
library_id=request.library_id,
|
||||
file_hash=request.file_hash,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材去重命中: library=%s hash=%s existing_asset=%s",
|
||||
request.library_id,
|
||||
request.file_hash,
|
||||
existing.id,
|
||||
)
|
||||
return DirectUploadCompleteResponse(
|
||||
storage_key=normalized_key,
|
||||
ingest_job_id="",
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
url=storage_service.get_url(normalized_key),
|
||||
)
|
||||
|
||||
# 立即创建 Asset 记录(PROCESSING 状态),使前端刷新后即可看到新素材
|
||||
filename = normalized_key.rsplit("/", 1)[-1]
|
||||
mime_type = _infer_mime_type_from_storage_key(normalized_key)
|
||||
pending_asset = _create_pending_asset(
|
||||
asset_repository=asset_repository,
|
||||
@@ -471,8 +256,6 @@ async def complete_direct_upload(
|
||||
mime_type=mime_type,
|
||||
user_id=authenticated_user.user.id,
|
||||
file_hash=request.file_hash,
|
||||
client_upload_id=request.client_upload_id,
|
||||
file_size=request.file_size,
|
||||
)
|
||||
|
||||
job = _submit_ingest_job(
|
||||
@@ -481,7 +264,6 @@ async def complete_direct_upload(
|
||||
storage_key=normalized_key,
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
file_hash=request.file_hash,
|
||||
asset_id=pending_asset.id,
|
||||
)
|
||||
return DirectUploadCompleteResponse(
|
||||
storage_key=normalized_key,
|
||||
@@ -501,8 +283,7 @@ async def upload_asset(
|
||||
project_id: str = Form(..., min_length=1, description="项目 ID"),
|
||||
library_id: str = Form(..., min_length=1, description="素材库 ID"),
|
||||
file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"),
|
||||
file_hash: str = Form(default="", description="文件哈希,用于去重检测"),
|
||||
client_upload_id: str = Form(default="", description="客户端幂等 token(同一次上传的重试保持一致)"),
|
||||
file_hash: str = Form(default="", description="文件 MD5 哈希,用于去重检测"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
@@ -513,31 +294,32 @@ async def upload_asset(
|
||||
"""上传素材文件并触发导入流水线。"""
|
||||
require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
|
||||
|
||||
# P2-5: 服务端验证 MIME 类型(先验证,再幂等去重,避免非法类型绕过)
|
||||
# ── 素材去重检测:上传前检查同素材库 + 同 file_hash ──
|
||||
if file_hash:
|
||||
existing = asset_repository.find_by_library_and_file_hash(
|
||||
library_id=library_id,
|
||||
file_hash=file_hash,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材去重命中(multipart): library=%s hash=%s existing_asset=%s",
|
||||
library_id,
|
||||
file_hash,
|
||||
existing.id,
|
||||
)
|
||||
return UploadAssetResponse(
|
||||
storage_key=existing.storage_key,
|
||||
ingest_job_id="",
|
||||
url="",
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
)
|
||||
|
||||
# P2-5: 服务端验证 MIME 类型
|
||||
validated_content_type = _validate_mime_type(file.content_type)
|
||||
|
||||
safe_filename = file.filename.replace("/", "_").replace("\\", "_") if file.filename else "unknown"
|
||||
|
||||
# ── 幂等去重:client_upload_id → file_hash → 近期活动同名记录兜底 ──
|
||||
# 放在 OSS 上传之前:重复提交直接返回,不占 OSS 流量、不建新记录。
|
||||
existing = _find_duplicate_asset(
|
||||
asset_repository,
|
||||
library_id=library_id,
|
||||
file_hash=file_hash,
|
||||
client_upload_id=client_upload_id,
|
||||
filename=safe_filename,
|
||||
file_size=0,
|
||||
)
|
||||
if existing is not None:
|
||||
return UploadAssetResponse(
|
||||
storage_key=existing.storage_key,
|
||||
ingest_job_id="",
|
||||
url="",
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
)
|
||||
|
||||
file_id = uuid4().hex[:8]
|
||||
safe_filename = file.filename.replace("/", "_").replace("\\", "_") if file.filename else "unknown"
|
||||
storage_key = f"uploads/{file_id}/{safe_filename}"
|
||||
|
||||
try:
|
||||
@@ -566,7 +348,6 @@ async def upload_asset(
|
||||
mime_type=validated_content_type,
|
||||
user_id=authenticated_user.user.id,
|
||||
file_hash=file_hash,
|
||||
client_upload_id=client_upload_id,
|
||||
)
|
||||
|
||||
job = _submit_ingest_job(
|
||||
@@ -575,7 +356,6 @@ async def upload_asset(
|
||||
storage_key=storage_key,
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
file_hash=file_hash,
|
||||
asset_id=pending_asset.id,
|
||||
)
|
||||
|
||||
return UploadAssetResponse(
|
||||
|
||||
@@ -5,11 +5,3 @@ settings = get_settings()
|
||||
celery_app = Celery("xiaoxia-saas-api")
|
||||
celery_app.conf.broker_url = settings.CELERY_BROKER_URL
|
||||
celery_app.conf.result_backend = settings.CELERY_RESULT_BACKEND
|
||||
|
||||
# #1714 队列隔离:视频生成走 generation 队列,素材转码走 transcode 队列
|
||||
try:
|
||||
from packages.shared.celery_queues import apply_queue_settings
|
||||
|
||||
apply_queue_settings(celery_app)
|
||||
except Exception: # noqa: BLE001 — 队列配置失败不阻断 API 启动
|
||||
pass
|
||||
|
||||
@@ -8,145 +8,27 @@ logger = logging.getLogger(__name__)
|
||||
# ── 限流阈值常量(全系统统一管理,不要在业务代码里硬编码) ──
|
||||
USER_PENDING_LIMIT = 3 # 单用户 pending 上限
|
||||
GLOBAL_PENDING_LIMIT = 20 # 全局 pending 上限
|
||||
WORKER_CONCURRENCY = 4 # worker 渲染并发数(infra/docker/compose.yml WORKER_CONCURRENCY 默认值)
|
||||
|
||||
# 限流错误码:前端据此区分"排队等待"与"创建失败"
|
||||
ERROR_CODE_USER_QUEUE_FULL = "USER_QUEUE_FULL" # 429:用户自己的任务排队中
|
||||
ERROR_CODE_SYSTEM_QUEUE_FULL = "SYSTEM_QUEUE_FULL" # 503:系统整体繁忙
|
||||
|
||||
|
||||
class UserPendingLimitExceeded(Exception):
|
||||
"""用户 pending 任务数超限,返回 429。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str,
|
||||
pending_count: int,
|
||||
limit: int,
|
||||
*,
|
||||
running_count: int = 0,
|
||||
requested_count: int = 1,
|
||||
queue_ahead: int = 0,
|
||||
estimated_wait_seconds: int = 0,
|
||||
):
|
||||
def __init__(self, user_id: str, pending_count: int, limit: int):
|
||||
self.user_id = user_id
|
||||
self.pending_count = pending_count
|
||||
self.limit = limit
|
||||
# 排队上下文(用于 429 结构化提示,前端展示"排队中"而非"创建失败")
|
||||
self.running_count = running_count
|
||||
self.requested_count = requested_count
|
||||
self.queue_ahead = queue_ahead
|
||||
self.estimated_wait_seconds = estimated_wait_seconds
|
||||
super().__init__(f"用户 {user_id} pending 任务数 {pending_count} 超过上限 {limit}")
|
||||
|
||||
|
||||
class GlobalQueueFull(Exception):
|
||||
"""全局限流,返回 503。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pending_count: int,
|
||||
limit: int,
|
||||
*,
|
||||
running_count: int = 0,
|
||||
queue_ahead: int = 0,
|
||||
estimated_wait_seconds: int = 0,
|
||||
):
|
||||
def __init__(self, pending_count: int, limit: int):
|
||||
self.pending_count = pending_count
|
||||
self.limit = limit
|
||||
self.running_count = running_count
|
||||
self.queue_ahead = queue_ahead
|
||||
self.estimated_wait_seconds = estimated_wait_seconds
|
||||
super().__init__(f"系统 pending 任务数 {pending_count} 超过上限 {limit}")
|
||||
|
||||
|
||||
def _estimate_wait_seconds(queue_ahead: int, generation_task_repository: Any) -> int:
|
||||
"""根据排队任务数 + worker 并发数 + 历史平均任务耗时估算等待秒数。
|
||||
|
||||
估算公式:ceil(排队任务数 / 并发数) × 平均单任务耗时。
|
||||
拿不到历史数据时仓储层返回默认 120 秒。
|
||||
"""
|
||||
import math
|
||||
|
||||
if queue_ahead <= 0:
|
||||
return 0
|
||||
try:
|
||||
estimator = getattr(generation_task_repository, "estimate_avg_duration_seconds", None)
|
||||
avg_seconds = estimator() if estimator is not None else 120.0
|
||||
except Exception:
|
||||
avg_seconds = 120.0
|
||||
return int(math.ceil(queue_ahead / WORKER_CONCURRENCY) * avg_seconds)
|
||||
|
||||
|
||||
def build_rate_limit_detail(
|
||||
exc: Exception,
|
||||
generation_task_repository: Any,
|
||||
*,
|
||||
scope: str = "user",
|
||||
) -> dict:
|
||||
"""构造结构化限流响应体(HTTPException 的 detail)。
|
||||
|
||||
前端按 detail.code 判断场景:
|
||||
- USER_QUEUE_FULL (429):用户自己的任务在排队,应提示"等待/继续排队",不是创建失败
|
||||
- SYSTEM_QUEUE_FULL (503):系统繁忙,稍后重试
|
||||
|
||||
detail 字段:
|
||||
- code: 错误码
|
||||
- message: 可读中文提示(可直接展示)
|
||||
- queued_count: 当前排队(pending)任务数
|
||||
- running_count: 当前渲染中(running)任务数
|
||||
- queue_ahead: 前方排队任务数(预计等待批次依据)
|
||||
- estimated_wait_seconds: 预计等待秒数
|
||||
- limit: 对应限流上限
|
||||
"""
|
||||
if scope == "user" and isinstance(exc, UserPendingLimitExceeded):
|
||||
running = exc.running_count
|
||||
if not running:
|
||||
try:
|
||||
counter = getattr(generation_task_repository, "count_running_by_user", None)
|
||||
running = counter(exc.user_id) if counter is not None else 0
|
||||
except Exception:
|
||||
running = 0
|
||||
queue_ahead = exc.queue_ahead or max(exc.pending_count, 0)
|
||||
wait = exc.estimated_wait_seconds or _estimate_wait_seconds(queue_ahead, generation_task_repository)
|
||||
wait_minutes = max(1, round(wait / 60))
|
||||
message = (
|
||||
f"您有 {exc.pending_count} 个任务正在排队、{running} 个正在渲染,"
|
||||
f"同一时间最多提交 {exc.limit} 个任务。请等待约 {wait_minutes} 分钟后再提交"
|
||||
)
|
||||
return {
|
||||
"code": ERROR_CODE_USER_QUEUE_FULL,
|
||||
"message": message,
|
||||
"queued_count": exc.pending_count,
|
||||
"running_count": running,
|
||||
"queue_ahead": queue_ahead,
|
||||
"estimated_wait_seconds": wait,
|
||||
"limit": exc.limit,
|
||||
}
|
||||
|
||||
# 全局繁忙
|
||||
pending = getattr(exc, "pending_count", 0)
|
||||
running = getattr(exc, "running_count", 0)
|
||||
if not running:
|
||||
try:
|
||||
counter = getattr(generation_task_repository, "count_running_total", None)
|
||||
running = counter() if counter is not None else 0
|
||||
except Exception:
|
||||
running = 0
|
||||
queue_ahead = getattr(exc, "queue_ahead", 0) or pending
|
||||
wait = getattr(exc, "estimated_wait_seconds", 0) or _estimate_wait_seconds(queue_ahead, generation_task_repository)
|
||||
wait_minutes = max(1, round(wait / 60))
|
||||
return {
|
||||
"code": ERROR_CODE_SYSTEM_QUEUE_FULL,
|
||||
"message": f"系统繁忙:当前 {pending} 个任务排队中、{running} 个渲染中,预计等待约 {wait_minutes} 分钟,请稍后再试",
|
||||
"queued_count": pending,
|
||||
"running_count": running,
|
||||
"queue_ahead": queue_ahead,
|
||||
"estimated_wait_seconds": wait,
|
||||
"limit": getattr(exc, "limit", GLOBAL_PENDING_LIMIT),
|
||||
}
|
||||
|
||||
|
||||
def check_queue_limits(
|
||||
user_id: str,
|
||||
generation_task_repository: Any,
|
||||
@@ -279,17 +161,7 @@ def safe_enqueue_generation_task(
|
||||
|
||||
# ── 发送 Celery 任务 ──
|
||||
try:
|
||||
celery_result = celery_app.send_task("worker.generate_video", args=[task.id])
|
||||
# 记录 celery 消息 ID:孤儿清理/超时作废时据此 revoke + 清除队列消息(#1714)
|
||||
celery_task_id = getattr(celery_result, "id", "")
|
||||
if celery_task_id:
|
||||
try:
|
||||
task.celery_task_id = celery_task_id
|
||||
generation_task_repository.update(task)
|
||||
except Exception as persist_err: # noqa: BLE001
|
||||
logger.warning(
|
||||
"%s 持久化 celery_task_id 失败(不影响主流程): task_id=%s err=%s", log_prefix, task.id, persist_err
|
||||
)
|
||||
celery_app.send_task("worker.generate_video", args=[task.id])
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"%s 入队失败,标记为失败: task_id=%s error=%s",
|
||||
|
||||
@@ -16,7 +16,6 @@ class DirectUploadPrepareRequest(BaseModel):
|
||||
content_type: str = Field(default="application/octet-stream", min_length=1, max_length=100)
|
||||
file_size: int = Field(..., gt=0)
|
||||
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
|
||||
client_upload_id: str = Field(default="", max_length=64, description="客户端幂等 token(同一次上传的重试保持一致)")
|
||||
|
||||
|
||||
class DirectUploadPrepareResponse(BaseModel):
|
||||
@@ -26,25 +25,20 @@ class DirectUploadPrepareResponse(BaseModel):
|
||||
expires_at: str
|
||||
fields: dict[str, str]
|
||||
max_size_bytes: int
|
||||
duplicated: bool = False
|
||||
skip_transfer: bool = False
|
||||
asset_id: str = ""
|
||||
|
||||
|
||||
class DirectUploadCompleteRequest(BaseModel):
|
||||
project_id: str = Field(..., min_length=1)
|
||||
library_id: str = Field(..., min_length=1)
|
||||
storage_key: str = Field(..., min_length=1, max_length=255)
|
||||
file_hash: str = Field(default="", max_length=64, description="文件哈希,用于去重检测")
|
||||
client_upload_id: str = Field(default="", max_length=64, description="客户端幂等 token(同一次上传的重试保持一致)")
|
||||
file_size: int = Field(default=0, ge=0, description="文件大小(字节),用于无 hash 时的兜底去重")
|
||||
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
|
||||
|
||||
|
||||
class DirectUploadCompleteResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材/重复 complete(命中幂等去重)")
|
||||
asset_id: str = Field(default="", description="素材 asset_id(重复 complete 时返回已存在记录)")
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
|
||||
asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
|
||||
url: str = Field(default="", description="Public URL of uploaded file")
|
||||
|
||||
|
||||
@@ -52,5 +46,5 @@ class UploadAssetResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
url: str = Field(..., description="Public URL of uploaded file")
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材/重复提交(命中幂等去重)")
|
||||
asset_id: str = Field(default="", description="素材 asset_id(重复提交时返回已存在记录)")
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
|
||||
asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#3b82f6"/>
|
||||
<text x="32" y="44" font-size="34" text-anchor="middle">🦐</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 194 B |
@@ -139,17 +139,6 @@ export interface DirectUploadPrepareResult {
|
||||
* 旧后端不返回该字段,前端降级为无预建卡片的原有行为。
|
||||
*/
|
||||
asset_id?: string
|
||||
/**
|
||||
* 后端 file_hash 命中素材库已有相同文件时为 true,前端应跳过 transfer + complete 阶段
|
||||
* 直接按「去重命中」处理(不调 transfer、不调 complete、立即刷新素材列表)。
|
||||
* 旧后端不返回该字段,前端降级为走老流程。
|
||||
*/
|
||||
duplicated?: boolean
|
||||
/**
|
||||
* 与 duplicated 语义一致:true 表示跳过传输,前端据此短路。
|
||||
* 两个字段是同一语义的别名(后端可能只返回其一),前端任意为 true 即视为命中去重。
|
||||
*/
|
||||
skip_transfer?: boolean
|
||||
}
|
||||
|
||||
/** 直传完成确认返回 */
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import apiClient from "../client"
|
||||
import { getOrCreateDefaultProject } from "../projects"
|
||||
import type { DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types"
|
||||
import { computeFileHash, makeClientUploadId } from "./uploadDedup"
|
||||
|
||||
/** 预签名直传准备 */
|
||||
export const prepareDirectUpload = async (data: {
|
||||
@@ -13,13 +12,8 @@ export const prepareDirectUpload = async (data: {
|
||||
filename: string
|
||||
content_type: string
|
||||
file_size: number
|
||||
/** 前端算好的文件内容哈希(SHA-256 hex),打开后端 file_hash 去重闸门 */
|
||||
file_hash?: string
|
||||
/** 前端生成的上传幂等 token,同一次逻辑上传(含重试)保持不变 */
|
||||
client_upload_id?: string
|
||||
}): Promise<DirectUploadPrepareResult> => {
|
||||
// prepare 单独放宽到 30s(全局 axios 实例只有 10s,staging 抖动时易超时)
|
||||
const response = await apiClient.post("/upload/direct/prepare", data, { timeout: 30_000 })
|
||||
const response = await apiClient.post("/upload/direct/prepare", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -28,16 +22,8 @@ export const completeDirectUpload = async (data: {
|
||||
project_id: string
|
||||
library_id: string
|
||||
storage_key: string
|
||||
/** 前端算好的文件内容哈希(与 prepare 一致),后端按 hash 幂等去重 */
|
||||
file_hash?: string
|
||||
/** 前端上传幂等 token(与 prepare 一致),同一次上传重发 complete 不重复建记录 */
|
||||
client_upload_id?: string
|
||||
/** 文件字节数;后端同名兜底去重需用它做大小校验,缺失(=0)时同名记录一律不判重 */
|
||||
file_size?: number
|
||||
}): Promise<DirectUploadCompleteResult> => {
|
||||
// complete 内含 OSS 存在性检查 + 建库 + 派单,放宽到 60s;
|
||||
// 超时不代表失败(记录可能已建成),调用方禁止超时后盲目重传整个文件
|
||||
const response = await apiClient.post("/upload/direct/complete", data, { timeout: 60_000 })
|
||||
const response = await apiClient.post("/upload/direct/complete", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -123,20 +109,8 @@ export interface DirectUploadHandle {
|
||||
export const prepareDirectUploadHandle = async (data: {
|
||||
file: File
|
||||
library_id: string
|
||||
/** 前端算好的文件内容哈希(SHA-256 hex),prepare/complete 均携带 */
|
||||
fileHash?: string
|
||||
/** 本次逻辑上传的幂等 token,prepare/complete 一致、重试复用 */
|
||||
clientUploadId?: string
|
||||
}): Promise<DirectUploadHandle> => {
|
||||
// 默认项目初始化失败(项目列表接口异常/自动创建失败)给出独立、明确的提示,
|
||||
// 不与 prepare 的签名接口错误混在一起
|
||||
let project: Awaited<ReturnType<typeof getOrCreateDefaultProject>>
|
||||
try {
|
||||
project = await getOrCreateDefaultProject()
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : "网络异常"
|
||||
throw new Error(`初始化默认项目失败,无法开始上传:${reason}`)
|
||||
}
|
||||
const project = await getOrCreateDefaultProject()
|
||||
|
||||
const prepared = await prepareDirectUpload({
|
||||
project_id: project.id,
|
||||
@@ -144,8 +118,6 @@ export const prepareDirectUploadHandle = async (data: {
|
||||
filename: data.file.name,
|
||||
content_type: data.file.type || "application/octet-stream",
|
||||
file_size: data.file.size,
|
||||
file_hash: data.fileHash,
|
||||
client_upload_id: data.clientUploadId,
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -156,10 +128,6 @@ export const prepareDirectUploadHandle = async (data: {
|
||||
project_id: project.id,
|
||||
library_id: data.library_id,
|
||||
storage_key: prepared.storage_key,
|
||||
file_hash: data.fileHash,
|
||||
client_upload_id: data.clientUploadId,
|
||||
// 透传文件字节数:后端同名兜底去重依赖大小校验,缺省会导致同名新视频被误判重复
|
||||
file_size: data.file.size,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -169,30 +137,8 @@ export const uploadAssetDirect = async (data: {
|
||||
file: File
|
||||
library_id: string
|
||||
onProgress?: (percent: number) => void
|
||||
/** 文件内容哈希;未传时自动补算(配音/封面/克隆等非队列链路统一受益) */
|
||||
fileHash?: string
|
||||
/** 幂等 token;未传时自动生成 */
|
||||
clientUploadId?: string
|
||||
}): Promise<DirectUploadCompleteResult> => {
|
||||
// 自动补算哈希与幂等 token:确保 file_hash 去重闸门对所有上传链路生效
|
||||
const fileHash = data.fileHash ?? (await computeFileHash(data.file))
|
||||
const clientUploadId = data.clientUploadId ?? makeClientUploadId()
|
||||
const handle = await prepareDirectUploadHandle({
|
||||
file: data.file,
|
||||
library_id: data.library_id,
|
||||
fileHash,
|
||||
clientUploadId,
|
||||
})
|
||||
// prepare 阶段后端 file_hash 命中素材库已有相同文件:跳过 transfer + complete
|
||||
if (handle.prepared.skip_transfer || handle.prepared.duplicated) {
|
||||
return {
|
||||
storage_key: handle.prepared.storage_key,
|
||||
ingest_job_id: "",
|
||||
url: "",
|
||||
duplicated: true,
|
||||
asset_id: handle.prepared.asset_id,
|
||||
}
|
||||
}
|
||||
const handle = await prepareDirectUploadHandle({ file: data.file, library_id: data.library_id })
|
||||
await handle.transfer(data.onProgress)
|
||||
return handle.complete()
|
||||
}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* 上传去重 / 幂等工具(Issue #1714)
|
||||
*
|
||||
* 背景:同一文件被反复入队、complete 超时后盲目重传,导致后端创建大量重复
|
||||
* PROCESSING 素材记录。本模块提供两类纯函数:
|
||||
*
|
||||
* 1. 文件指纹:
|
||||
* - makeFileFingerprint():文件名+大小+lastModified,入队去重用(同步、零开销)
|
||||
* - computeFileHash():SHA-256 内容哈希(小文件全量、大文件抽样头尾),
|
||||
* prepare/complete 时发给后端打开 file_hash 去重闸门
|
||||
* 2. 队列去重:findDuplicateInQueue() 判断文件是否已在队列中
|
||||
* 3. 幂等 token:makeClientUploadId() 生成上传幂等 ID(每次"一次逻辑上传"一个,
|
||||
* 重试复用同一 ID,重新入队才生成新 ID)
|
||||
*/
|
||||
|
||||
/** 全量哈希阈值:≤64MB 全量读入计算;超过即走头尾抽样,避免 100~256MB 视频被整文件读进内存卡死页面 */
|
||||
export const HASH_FULL_READ_LIMIT = 64 * 1024 * 1024 // 64MB
|
||||
/** 抽样读取的头尾片段大小(各 16MB) */
|
||||
export const HASH_SAMPLE_CHUNK = 16 * 1024 * 1024
|
||||
|
||||
/** 计算指纹时,文件在队列中已存在的状态(已失败的可以重试,不算重复) */
|
||||
export type DedupExcludeStatus = "error" | "done"
|
||||
|
||||
/**
|
||||
* 文件入队指纹:同库 + 文件名 + 大小 + 修改时间。
|
||||
* 同一文件(File 对象由 <input> 重选或拖拽重复触发时三个字段均一致)稳定复现;
|
||||
* 不同文件极小概率碰撞时可由后端 file_hash 内容去重兜底。
|
||||
*/
|
||||
export function makeFileFingerprint(file: Pick<File, "name" | "size" | "lastModified">): string {
|
||||
return `${file.name}::${file.size}::${file.lastModified}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 在现有队列项中查找同一文件的在途记录。
|
||||
* 已失败(error)的项允许重试路径复用、已完成(done)的可跳过;
|
||||
* 处于 preparing/uploading/ingesting 的在途项一律视为重复,禁止重复入队。
|
||||
*
|
||||
* 返回命中的队列项 id(tempId),未命中返回 null。
|
||||
*/
|
||||
export function findDuplicateInQueue<T extends { fileKey: string; status: string }>(
|
||||
queue: T[],
|
||||
fileKey: string,
|
||||
excludeStatuses: DedupExcludeStatus[] = [],
|
||||
): T | null {
|
||||
const exclude = new Set<string>(excludeStatuses)
|
||||
return queue.find((it) => it.fileKey === fileKey && !exclude.has(it.status)) ?? null
|
||||
}
|
||||
|
||||
/** 生成上传幂等 token:一次"逻辑上传"一个,重试复用、重新入队换新 */
|
||||
export function makeClientUploadId(): string {
|
||||
const rand =
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 10)}`
|
||||
return `up_${Date.now().toString(36)}_${rand.replace(/-/g, "").slice(0, 16)}`
|
||||
}
|
||||
|
||||
/** 读取 Blob/File 片段为 ArrayBuffer:优先 Blob.arrayBuffer(),老环境回退 FileReader */
|
||||
function readAsArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
|
||||
if (typeof blob.arrayBuffer === "function") {
|
||||
return blob.arrayBuffer()
|
||||
}
|
||||
return new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
||||
reader.onerror = () => reject(reader.error ?? new Error("FileReader read failed"))
|
||||
reader.readAsArrayBuffer(blob)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 buffer 复制到当前 JS realm 的 Uint8Array 再哈希。
|
||||
* jsdom/测试环境中 Blob.arrayBuffer() 可能返回另一 realm 的 ArrayBuffer,
|
||||
* Node WebCrypto 的 WebIDL instanceof 校验会拒绝跨 realm 参数。
|
||||
*/
|
||||
async function digestSha256(buffer: ArrayBuffer): Promise<ArrayBuffer> {
|
||||
const subtle =
|
||||
typeof globalThis !== "undefined" && globalThis.crypto ? globalThis.crypto.subtle : null
|
||||
if (!subtle) throw new Error("crypto.subtle unavailable")
|
||||
const local = new Uint8Array(buffer.byteLength)
|
||||
local.set(new Uint8Array(buffer))
|
||||
return subtle.digest("SHA-256", local)
|
||||
}
|
||||
|
||||
function toHex(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let hex = ""
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
hex += bytes[i].toString(16).padStart(2, "0")
|
||||
}
|
||||
return hex
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算文件内容 SHA-256(hex,64 字符,与后端 file_hash 字段长度一致)。
|
||||
* - ≤64MB:全量哈希,内容一致必然一致
|
||||
* - >64MB:哈希「头部 16MB + 尾部 16MB + 文件大小」,视频素材体积大、
|
||||
* 头部含 moov 元数据、尾部含 mdat 结尾,抽样碰撞概率可忽略,
|
||||
* 且避免 100~256MB 视频被整文件读进内存导致页面卡死/崩溃
|
||||
*
|
||||
* 运行环境不支持 crypto.subtle(非安全上下文/老浏览器)时返回空字符串,
|
||||
* 调用方据此降级为不传 hash(后端仍有幂等 token + 同文件名兜底去重)。
|
||||
*/
|
||||
export async function computeFileHash(file: File): Promise<string> {
|
||||
try {
|
||||
const subtle =
|
||||
typeof globalThis !== "undefined" &&
|
||||
globalThis.crypto &&
|
||||
typeof globalThis.crypto.subtle?.digest === "function"
|
||||
? globalThis.crypto.subtle
|
||||
: null
|
||||
if (!subtle) return ""
|
||||
|
||||
if (file.size <= HASH_FULL_READ_LIMIT) {
|
||||
const data = await readAsArrayBuffer(file.slice(0, file.size))
|
||||
return toHex(await digestSha256(data))
|
||||
}
|
||||
|
||||
// 大文件:头 8MB + 尾 8MB + 大小,拼成一段后哈希
|
||||
const head = await readAsArrayBuffer(file.slice(0, HASH_SAMPLE_CHUNK))
|
||||
const tail =
|
||||
file.size > HASH_SAMPLE_CHUNK
|
||||
? await readAsArrayBuffer(file.slice(Math.max(0, file.size - HASH_SAMPLE_CHUNK), file.size))
|
||||
: new ArrayBuffer(0)
|
||||
const merged = new Uint8Array(head.byteLength + tail.byteLength + 8)
|
||||
merged.set(new Uint8Array(head), 0)
|
||||
merged.set(new Uint8Array(tail), head.byteLength)
|
||||
const sizeView = new DataView(merged.buffer, head.byteLength + tail.byteLength, 8)
|
||||
// 文件大小以 64 位大端写入(BigInt 最稳;不支持 BigInt64 时手算高低位)
|
||||
if (typeof sizeView.setBigUint64 === "function") {
|
||||
sizeView.setBigUint64(0, BigInt(file.size), false)
|
||||
} else {
|
||||
sizeView.setUint32(0, Math.floor(file.size / 0x100000000), false)
|
||||
sizeView.setUint32(4, file.size >>> 0, false)
|
||||
}
|
||||
return toHex(await digestSha256(merged.buffer))
|
||||
} catch (err) {
|
||||
console.warn("[uploadDedup] 计算文件哈希失败,降级为不传 file_hash:", err)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -12,18 +12,13 @@ export type {
|
||||
UserResponse,
|
||||
WechatAuthUrlResponse,
|
||||
WechatCallbackResponse,
|
||||
WechatBindUrlResponse,
|
||||
WechatBindCompleteResponse,
|
||||
WechatUnbindResponse,
|
||||
UpdateProfileRequest,
|
||||
UpdateProfileResponse,
|
||||
SendVerificationCodeRequest,
|
||||
BindContactRequest,
|
||||
BindContactResponse,
|
||||
} from "./types"
|
||||
|
||||
// 用户工具函数
|
||||
export { normalizeUser, updateProfile } from "./user"
|
||||
export { normalizeUser } from "./user"
|
||||
|
||||
// 登录/注册/登出/刷新
|
||||
export { login, refreshAccessToken, register, logout } from "./login"
|
||||
@@ -37,14 +32,8 @@ export { requestPasswordReset, resetPassword } from "./password"
|
||||
// 邮箱验证
|
||||
export { verifyEmail } from "./email"
|
||||
|
||||
// 微信登录 / 绑定
|
||||
export {
|
||||
getWechatAuthUrl,
|
||||
wechatCallback,
|
||||
getWechatBindUrl,
|
||||
bindWechat,
|
||||
unbindWechat,
|
||||
} from "./wechat"
|
||||
// 微信登录
|
||||
export { getWechatAuthUrl, wechatCallback } from "./wechat"
|
||||
|
||||
// 联系方式
|
||||
export { sendVerificationCode, bindContact } from "./contact"
|
||||
|
||||
@@ -34,17 +34,6 @@ export interface User {
|
||||
is_email_verified: boolean
|
||||
email_verified: boolean
|
||||
created_at?: string
|
||||
/** 微信是否已绑定 */
|
||||
wechat_bound?: boolean
|
||||
/** 微信昵称(绑定后展示) */
|
||||
wechat_nickname?: string
|
||||
/** 头像 URL(微信头像等) */
|
||||
avatar_url?: string
|
||||
/** 手机号 */
|
||||
phone?: string
|
||||
phone_verified?: boolean
|
||||
/** 资料是否完善(微信新用户首次登录为 false,需填昵称引导) */
|
||||
profile_completed?: boolean
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
@@ -56,12 +45,6 @@ export interface UserResponse {
|
||||
is_email_verified?: boolean
|
||||
email_verified?: boolean
|
||||
created_at?: string
|
||||
wechat_bound?: boolean
|
||||
wechat_nickname?: string
|
||||
avatar_url?: string
|
||||
phone?: string
|
||||
phone_verified?: boolean
|
||||
profile_completed?: boolean
|
||||
}
|
||||
|
||||
export interface WechatAuthUrlResponse {
|
||||
@@ -97,30 +80,3 @@ export interface BindContactResponse {
|
||||
success: boolean
|
||||
user: User
|
||||
}
|
||||
|
||||
/** 更新个人资料请求 */
|
||||
export interface UpdateProfileRequest {
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
/** 更新个人资料响应(返回最新用户信息) */
|
||||
export interface UpdateProfileResponse {
|
||||
user: UserResponse
|
||||
}
|
||||
|
||||
/** 微信绑定授权链接响应 */
|
||||
export interface WechatBindUrlResponse {
|
||||
auth_url: string
|
||||
state: string
|
||||
}
|
||||
|
||||
/** 微信绑定完成响应 */
|
||||
export interface WechatBindCompleteResponse {
|
||||
success: boolean
|
||||
user: UserResponse
|
||||
}
|
||||
|
||||
/** 微信解绑响应 */
|
||||
export interface WechatUnbindResponse {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import apiClient from "../client"
|
||||
import type { User, UserResponse, UpdateProfileRequest, UpdateProfileResponse } from "./types"
|
||||
import type { User, UserResponse } from "./types"
|
||||
|
||||
/**
|
||||
* 规范化用户数据,兼容不同后端返回格式
|
||||
@@ -17,19 +16,5 @@ export const normalizeUser = (data: UserResponse): User => {
|
||||
is_email_verified: emailVerified,
|
||||
email_verified: emailVerified,
|
||||
created_at: data.created_at,
|
||||
wechat_bound: data.wechat_bound,
|
||||
wechat_nickname: data.wechat_nickname,
|
||||
avatar_url: data.avatar_url,
|
||||
phone: data.phone,
|
||||
phone_verified: data.phone_verified,
|
||||
profile_completed: data.profile_completed,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新个人资料(昵称等)
|
||||
*/
|
||||
export const updateProfile = async (data: UpdateProfileRequest): Promise<User> => {
|
||||
const response = await apiClient.patch<UpdateProfileResponse>("/auth/me", data)
|
||||
return normalizeUser(response.data.user)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
WechatAuthUrlResponse,
|
||||
WechatCallbackResponse,
|
||||
WechatBindUrlResponse,
|
||||
WechatBindCompleteResponse,
|
||||
WechatUnbindResponse,
|
||||
} from "./types"
|
||||
import type { WechatAuthUrlResponse, WechatCallbackResponse } from "./types"
|
||||
|
||||
/**
|
||||
* 获取微信授权链接(登录场景)
|
||||
* 获取微信授权链接
|
||||
*/
|
||||
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
|
||||
const response = await apiClient.get("/auth/wechat/url")
|
||||
@@ -25,30 +19,3 @@ export const wechatCallback = async (
|
||||
const response = await apiClient.post("/auth/wechat/callback", { code, state })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取微信绑定授权链接(已登录用户绑定场景)
|
||||
*/
|
||||
export const getWechatBindUrl = async (): Promise<WechatBindUrlResponse> => {
|
||||
const response = await apiClient.get("/auth/wechat/bind/url")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信绑定完成(扫码回调后用 code 绑定到当前登录账号)
|
||||
*/
|
||||
export const bindWechat = async (
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<WechatBindCompleteResponse> => {
|
||||
const response = await apiClient.post("/auth/wechat/bind", { code, state })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑微信
|
||||
*/
|
||||
export const unbindWechat = async (): Promise<WechatUnbindResponse> => {
|
||||
const response = await apiClient.delete("/auth/wechat/bind")
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* 微信扫码登录 WxLogin JS-SDK 动态加载与授权参数解析
|
||||
*
|
||||
* 微信官网嵌入式二维码方案:页面引入 https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js
|
||||
* 后挂载全局 window.WxLogin,new WxLogin({...}) 会在指定容器内渲染二维码 iframe。
|
||||
* 本模块负责:动态加载该脚本(带超时/失败检测)、从后端返回的 auth_url 中解析
|
||||
* WxLogin 所需的 appid / redirect_uri / state。
|
||||
*/
|
||||
|
||||
const WX_LOGIN_SRC = "https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js"
|
||||
/** 脚本加载超时(毫秒):超时视为加载失败,调用方回退整页跳转 */
|
||||
const WX_LOGIN_LOAD_TIMEOUT = 8000
|
||||
|
||||
/** WxLogin 构造参数(微信官方字段,保持原名) */
|
||||
export interface WxLoginOptions {
|
||||
/** 是否内嵌二维码(回调在 iframe 内完成) */
|
||||
self_redirect: boolean
|
||||
/** 二维码容器元素 id */
|
||||
id: string
|
||||
/** 微信开放平台 AppID */
|
||||
appid: string
|
||||
/** 应用授权作用域,网站应用固定 snsapi_login */
|
||||
scope: "snsapi_login"
|
||||
/** 回调地址(需与微信开放平台配置一致,WxLogin 内部会 encodeURIComponent) */
|
||||
redirect_uri: string
|
||||
/** 防 CSRF 随机串,由后端 state store 生成并在回调时一次性消费 */
|
||||
state: string
|
||||
/** 二维码样式:black / white */
|
||||
style?: "black" | "white"
|
||||
/** 自定义样式链接(可选) */
|
||||
href?: string
|
||||
}
|
||||
|
||||
/** 微信脚本挂载到 window 上的全局构造函数类型 */
|
||||
export interface WxLoginConstructor {
|
||||
new (options: WxLoginOptions): unknown
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
WxLogin?: WxLoginConstructor
|
||||
}
|
||||
}
|
||||
|
||||
let loadPromise: Promise<WxLoginConstructor> | null = null
|
||||
|
||||
/**
|
||||
* 动态加载微信 WxLogin JS(单例:并发调用复用同一个 promise)。
|
||||
* 加载失败或超时会 reject,调用方应回退到整页跳转授权方式。
|
||||
*/
|
||||
export function loadWxLoginScript(): Promise<WxLoginConstructor> {
|
||||
if (window.WxLogin) return Promise.resolve(window.WxLogin)
|
||||
if (loadPromise) return loadPromise
|
||||
|
||||
loadPromise = new Promise<WxLoginConstructor>((resolve, reject) => {
|
||||
const script = document.createElement("script")
|
||||
script.src = WX_LOGIN_SRC
|
||||
script.async = true
|
||||
script.onload = () => {
|
||||
if (window.WxLogin) {
|
||||
resolve(window.WxLogin)
|
||||
} else {
|
||||
loadPromise = null
|
||||
reject(new Error("微信登录脚本加载完成但 WxLogin 未挂载"))
|
||||
}
|
||||
}
|
||||
script.onerror = () => {
|
||||
loadPromise = null
|
||||
script.remove()
|
||||
reject(new Error("微信登录脚本加载失败"))
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
|
||||
// 超时兜底:部分网络环境下脚本既不 onload 也不 onerror
|
||||
window.setTimeout(() => {
|
||||
if (window.WxLogin) {
|
||||
resolve(window.WxLogin)
|
||||
return
|
||||
}
|
||||
loadPromise = null
|
||||
script.remove()
|
||||
reject(new Error("微信登录脚本加载超时"))
|
||||
}, WX_LOGIN_LOAD_TIMEOUT)
|
||||
})
|
||||
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
/** 从微信授权链接 query 中解析出的 WxLogin 所需参数 */
|
||||
export interface ParsedWxAuthParams {
|
||||
appid: string
|
||||
/** 已 URL 解码的回调地址(传给 WxLogin 时由其内部再次编码) */
|
||||
redirect_uri: string
|
||||
state: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 从后端返回的微信授权链接(https://open.weixin.qq.com/connect/qrconnect?appid=...&redirect_uri=...&state=...)
|
||||
* 中解析 appid / redirect_uri / state。解析失败时返回 null,由调用方回退整页跳转。
|
||||
*/
|
||||
export function parseWxAuthUrl(authUrl: string, stateFallback?: string): ParsedWxAuthParams | null {
|
||||
try {
|
||||
const url = new URL(authUrl)
|
||||
const appid = url.searchParams.get("appid")
|
||||
const redirectUri = url.searchParams.get("redirect_uri")
|
||||
const state = url.searchParams.get("state") || stateFallback || ""
|
||||
if (!appid || !redirectUri || !state) return null
|
||||
return { appid, redirect_uri: redirectUri, state }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* 统一错误信息提取
|
||||
* 把 axios 错误(后端 detail / FastAPI 校验错误 / HTTP 状态码)、XHR/OSS 错误、
|
||||
* 网络/超时错误、普通 Error 统一转成「可直接展示给用户」的中文信息。
|
||||
*
|
||||
* 与 api/client.ts 响应拦截器的提示口径保持一致;拦截器负责全局 toast,
|
||||
* 页面/队列卡片用本工具把真实原因展示在持久位置(回调页、失败卡片等)。
|
||||
*/
|
||||
import type { AxiosError } from "axios"
|
||||
|
||||
/** 后端错误响应体可能出现的字段(FastAPI:detail;历史接口:message/msg) */
|
||||
interface ErrorBody {
|
||||
detail?: unknown
|
||||
message?: unknown
|
||||
msg?: unknown
|
||||
}
|
||||
|
||||
/** FastAPI 422 校验错误单项 */
|
||||
interface ValidationItem {
|
||||
loc?: (string | number)[]
|
||||
msg?: string
|
||||
}
|
||||
|
||||
/** 从后端响应体提取人类可读信息(detail 可能是字符串、对象、422 数组) */
|
||||
function extractBodyMessage(data: unknown): string {
|
||||
if (!data || typeof data !== "object") return ""
|
||||
const body = data as ErrorBody
|
||||
|
||||
const walk = (val: unknown): string => {
|
||||
if (typeof val === "string") return val
|
||||
if (Array.isArray(val)) {
|
||||
// FastAPI 422: [{loc, msg, type}, ...] → 取每条 msg 拼接
|
||||
const parts = val
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item
|
||||
if (item && typeof item === "object") {
|
||||
const v = item as ValidationItem
|
||||
if (typeof v.msg === "string") {
|
||||
const field = Array.isArray(v.loc) ? v.loc.filter((x) => x !== "body").join(".") : ""
|
||||
return field ? `${field}: ${v.msg}` : v.msg
|
||||
}
|
||||
return walk(item)
|
||||
}
|
||||
return ""
|
||||
})
|
||||
.filter(Boolean)
|
||||
return parts.join(";")
|
||||
}
|
||||
if (val && typeof val === "object") {
|
||||
const obj = val as Record<string, unknown>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.msg === "string") return obj.msg
|
||||
if (typeof obj.detail === "string") return obj.detail
|
||||
if (obj.message && typeof obj.message === "object") return walk(obj.message)
|
||||
if (obj.msg && typeof obj.msg === "object") return walk(obj.msg)
|
||||
try {
|
||||
return JSON.stringify(val)
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
return walk(body.detail) || walk(body.message) || walk(body.msg)
|
||||
}
|
||||
|
||||
/** 无响应体时按 HTTP 状态码给出兜底提示(与 client.ts 拦截器口径一致) */
|
||||
function statusFallback(status: number): string {
|
||||
switch (status) {
|
||||
case 400:
|
||||
return "请求参数有误(HTTP 400)"
|
||||
case 401:
|
||||
return "登录状态已失效,请重新登录(HTTP 401)"
|
||||
case 403:
|
||||
return "没有权限执行该操作(HTTP 403)"
|
||||
case 404:
|
||||
return "请求的资源不存在(HTTP 404)"
|
||||
case 409:
|
||||
return "操作冲突,资源状态已变化(HTTP 409)"
|
||||
case 413:
|
||||
return "文件过大,请缩小后重试(HTTP 413)"
|
||||
case 415:
|
||||
return "不支持的文件格式(HTTP 415)"
|
||||
case 429:
|
||||
return "操作过于频繁,请稍后再试(HTTP 429)"
|
||||
case 503:
|
||||
return "服务暂不可用,请稍后再试(HTTP 503)"
|
||||
default:
|
||||
if (status >= 500) return `服务器繁忙,请稍后再试(HTTP ${status})`
|
||||
return `请求失败(HTTP ${status})`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从任意抛出值提取可展示的错误信息。
|
||||
* @param fallback 全部提取失败时的兜底文案
|
||||
*/
|
||||
export function getErrorMessage(err: unknown, fallback = "操作失败,请稍后重试"): string {
|
||||
if (!err) return fallback
|
||||
|
||||
// axios 错误(后端 JSON 响应 / HTTP 错误状态)
|
||||
const ax = err as AxiosError<ErrorBody>
|
||||
if (ax.isAxiosError || (typeof ax === "object" && "response" in (ax as object))) {
|
||||
// 超时
|
||||
if (ax.code === "ECONNABORTED" || /timeout/i.test(ax.message || "")) {
|
||||
return "请求超时,请检查网络后重试"
|
||||
}
|
||||
const resp = ax.response
|
||||
if (resp) {
|
||||
const bodyMsg = extractBodyMessage(resp.data)
|
||||
if (bodyMsg) return bodyMsg
|
||||
return statusFallback(resp.status)
|
||||
}
|
||||
// 请求已发出但无响应(断网/CORS/DNS)
|
||||
if (ax.request) return "网络连接异常,请检查网络设置"
|
||||
return ax.message || fallback
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
// XHR 直传 OSS 失败等场景自带详细 message(含 HTTP 状态 + OSS Code/Message)
|
||||
if (err.message) return err.message
|
||||
}
|
||||
if (typeof err === "string") return err
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** client.ts 拦截器是否已对该错误弹过全局 toast(__msgShown 标记) */
|
||||
export function isErrorMsgShown(err: unknown): boolean {
|
||||
return Boolean((err as { __msgShown?: boolean } | null)?.__msgShown)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
.xx-wechat-qr-modal {
|
||||
position: relative;
|
||||
padding: 8px 0 4px;
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 常驻二维码容器(WxLogin 渲染目标) */
|
||||
.xx-wechat-qr-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
/* loading / error 遮罩层,覆盖在二维码容器之上 */
|
||||
.xx-wechat-qr-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-overlay p {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-container iframe {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-tip {
|
||||
margin: 12px 0 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-error {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-error-msg {
|
||||
color: #ef4444;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 16px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-error-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-fallback {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary-color, #3b82f6);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
/**
|
||||
* 微信扫码二维码弹窗(登录 / 绑定复用)
|
||||
*
|
||||
* 微信官方嵌入式二维码方案:弹窗内用 new WxLogin({ self_redirect: true }) 渲染二维码,
|
||||
* 扫码后微信重定向到本站回调页(在二维码 iframe 内加载),回调页通过 postMessage
|
||||
* 把成功/失败结果通知本弹窗(消息协议见 ./messages)。
|
||||
*
|
||||
* 兜底:获取授权链接成功但 WxLogin JS 加载失败/超时时,自动回退整页跳转授权
|
||||
* (与旧流程一致);获取授权链接本身失败时在弹窗内展示错误并提供重试。
|
||||
*/
|
||||
import React, { useEffect, useRef, useState } from "react"
|
||||
import { Spin } from "antd"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import Button from "@/components/ui/Button"
|
||||
import {
|
||||
getWechatAuthUrl,
|
||||
getWechatBindUrl,
|
||||
getCurrentUser,
|
||||
normalizeUser,
|
||||
type User,
|
||||
} from "@/api/auth"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { scheduleProactiveRefresh } from "@/api/auth/tokenRefresh"
|
||||
import { getErrorMessage } from "@/api/errors"
|
||||
import { loadWxLoginScript, parseWxAuthUrl } from "@/api/auth/wxLogin"
|
||||
import { isWechatQrMessage, type WechatQrScene } from "./messages"
|
||||
import "./WechatQrModal.css"
|
||||
|
||||
export interface WechatQrModalProps {
|
||||
open: boolean
|
||||
scene: WechatQrScene
|
||||
onClose: () => void
|
||||
/** 登录场景成功回调(needOnboarding=true 时调用方应跳昵称引导页) */
|
||||
onLoginSuccess?: (needOnboarding: boolean) => void
|
||||
/** 绑定场景成功回调(调用方刷新用户信息/提示) */
|
||||
onBindSuccess?: () => void
|
||||
}
|
||||
|
||||
type QrStatus = "loading" | "qrcode" | "error"
|
||||
|
||||
const CONTAINER_ID: Record<WechatQrScene, string> = {
|
||||
login: "wechat-qr-login-container",
|
||||
bind: "wechat-qr-bind-container",
|
||||
}
|
||||
|
||||
const STATE_STORAGE_KEY: Record<WechatQrScene, string> = {
|
||||
login: "wechat_state",
|
||||
bind: "wechat_bind_state",
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待二维码容器挂载到 DOM。antd Modal 内容通过 portal 渲染且带进场动画,
|
||||
* 父组件 effect 首次执行时容器可能尚未出现在 document 中。
|
||||
*/
|
||||
function waitForContainer(id: string, timeoutMs = 3000): Promise<HTMLElement | null> {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now()
|
||||
const check = () => {
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
resolve(el)
|
||||
return
|
||||
}
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
setTimeout(check, 50)
|
||||
}
|
||||
check()
|
||||
})
|
||||
}
|
||||
|
||||
const WechatQrModal: React.FC<WechatQrModalProps> = ({
|
||||
open,
|
||||
scene,
|
||||
onClose,
|
||||
onLoginSuccess,
|
||||
onBindSuccess,
|
||||
}) => {
|
||||
const setAuth = useAuthStore((state) => state.setAuth)
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
const [status, setStatus] = useState<QrStatus>("loading")
|
||||
const [errorMsg, setErrorMsg] = useState("")
|
||||
/** 刷新二维码计数:变化时重新请求授权链接并重渲染 */
|
||||
const [renderSeq, setRenderSeq] = useState(0)
|
||||
/** 最新授权链接,用于"整页打开"兜底 */
|
||||
const authUrlRef = useRef<string | null>(null)
|
||||
|
||||
const isLogin = scene === "login"
|
||||
|
||||
// 初始化:获取授权链接 → 加载 WxLogin JS → 内嵌渲染二维码
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
authUrlRef.current = null
|
||||
setStatus("loading")
|
||||
setErrorMsg("")
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
const fetchUrl = isLogin ? getWechatAuthUrl : getWechatBindUrl
|
||||
const result = await fetchUrl()
|
||||
if (cancelled) return
|
||||
// 写 state(整页跳转兜底路径的回调页也会清理它)
|
||||
localStorage.setItem(STATE_STORAGE_KEY[scene], result.state)
|
||||
authUrlRef.current = result.auth_url
|
||||
|
||||
const params = parseWxAuthUrl(result.auth_url, result.state)
|
||||
if (!params) {
|
||||
// 授权链接格式异常:直接整页跳转,由微信侧/回调页兜底
|
||||
window.location.href = result.auth_url
|
||||
return
|
||||
}
|
||||
|
||||
const WxLogin = await loadWxLoginScript()
|
||||
if (cancelled) return
|
||||
// 等 Modal portal 中的容器挂载完成
|
||||
const container = await waitForContainer(CONTAINER_ID[scene])
|
||||
if (cancelled) return
|
||||
if (!container) {
|
||||
window.location.href = result.auth_url
|
||||
return
|
||||
}
|
||||
container.innerHTML = ""
|
||||
new WxLogin({
|
||||
self_redirect: true,
|
||||
id: CONTAINER_ID[scene],
|
||||
appid: params.appid,
|
||||
scope: "snsapi_login",
|
||||
redirect_uri: params.redirect_uri,
|
||||
state: params.state,
|
||||
style: "black",
|
||||
})
|
||||
if (!cancelled) setStatus("qrcode")
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
if (authUrlRef.current) {
|
||||
// 授权链接已拿到但二维码脚本加载失败/超时:回退整页跳转
|
||||
window.location.href = authUrlRef.current
|
||||
return
|
||||
}
|
||||
// 授权链接接口本身失败:弹窗内展示真实原因,允许重试
|
||||
setErrorMsg(getErrorMessage(err, "微信服务暂不可用,请稍后重试"))
|
||||
setStatus("error")
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, scene, isLogin, renderSeq])
|
||||
|
||||
// 监听 iframe 内回调页 postMessage 回来的扫码结果
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
// 只接受同源消息
|
||||
if (event.origin !== window.location.origin) return
|
||||
if (!isWechatQrMessage(event.data, scene)) return
|
||||
const msg = event.data
|
||||
|
||||
if (msg.success) {
|
||||
if (isLogin) {
|
||||
// iframe 内回调页已把 token 写入 localStorage(同源共享),
|
||||
// 父窗口同步内存登录态后交给调用方跳转
|
||||
try {
|
||||
const userData = await getCurrentUser()
|
||||
const user = normalizeUser(userData) as User
|
||||
setAuth(
|
||||
user,
|
||||
localStorage.getItem("access_token") || "",
|
||||
localStorage.getItem("refresh_token"),
|
||||
)
|
||||
scheduleProactiveRefresh()
|
||||
} catch {
|
||||
// token 已持久化,即使这里失败路由守卫/刷新也能恢复登录态
|
||||
}
|
||||
onLoginSuccess?.(msg.payload?.needOnboarding ?? false)
|
||||
} else {
|
||||
try {
|
||||
const userData = await getCurrentUser()
|
||||
setUser(normalizeUser(userData) as User)
|
||||
} catch {
|
||||
// 绑定结果以后端为准,调用方 invalidateQueries 会兜底刷新
|
||||
}
|
||||
onBindSuccess?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 失败:弹窗内展示回调页透传的真实原因,提供刷新/整页跳转
|
||||
setErrorMsg(msg.detail || "微信授权失败,请重试")
|
||||
setStatus("error")
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
return () => window.removeEventListener("message", handleMessage)
|
||||
}, [open, scene, isLogin, onLoginSuccess, onBindSuccess, setAuth, setUser])
|
||||
|
||||
const handleRefresh = () => setRenderSeq((seq) => seq + 1)
|
||||
|
||||
const handleFullPageRedirect = () => {
|
||||
if (authUrlRef.current) {
|
||||
window.location.href = authUrlRef.current
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isLogin ? "微信扫码登录" : "绑定微信"}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={380}
|
||||
maskClosable={false}
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="xx-wechat-qr-modal">
|
||||
{/* 二维码容器常驻:WxLogin 在 loading 阶段就会把 iframe 渲染进来,
|
||||
不能按 status 条件渲染,否则 effect 里永远找不到容器 */}
|
||||
<div
|
||||
id={CONTAINER_ID[scene]}
|
||||
className="xx-wechat-qr-container"
|
||||
style={{ visibility: status === "qrcode" ? "visible" : "hidden" }}
|
||||
/>
|
||||
|
||||
{status === "loading" && (
|
||||
<div className="xx-wechat-qr-overlay">
|
||||
<Spin size="large" />
|
||||
<p>正在生成微信二维码...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "qrcode" && (
|
||||
<p className="xx-wechat-qr-tip">请使用微信扫描二维码{isLogin ? "登录" : "绑定账号"}</p>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="xx-wechat-qr-overlay xx-wechat-qr-error">
|
||||
<p className="xx-wechat-qr-error-msg">{errorMsg}</p>
|
||||
<div className="xx-wechat-qr-error-actions">
|
||||
<Button buttonType="primary" buttonSize="md" onClick={handleRefresh}>
|
||||
刷新二维码
|
||||
</Button>
|
||||
{authUrlRef.current && (
|
||||
<button
|
||||
type="button"
|
||||
className="xx-wechat-qr-fallback"
|
||||
onClick={handleFullPageRedirect}
|
||||
>
|
||||
使用整页方式打开
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default WechatQrModal
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* 微信扫码弹窗与 iframe 内回调页之间的 postMessage 消息协议
|
||||
*
|
||||
* 流程:弹窗内 WxLogin(self_redirect:true) 渲染的二维码 iframe 扫码后,
|
||||
* 微信重定向到本站回调页(同源,在 iframe 内加载);回调页完成换 token/绑定后,
|
||||
* 通过 window.parent.postMessage 把结果通知弹窗,弹窗负责关闭/展示错误/同步登录态。
|
||||
*/
|
||||
|
||||
/** 扫码场景:登录 / 绑定 */
|
||||
export type WechatQrScene = "login" | "bind"
|
||||
|
||||
export interface WechatQrSuccessPayload {
|
||||
/** 登录场景:是否需要昵称引导(新用户或资料未完善) */
|
||||
needOnboarding?: boolean
|
||||
}
|
||||
|
||||
export interface WechatQrMessageData {
|
||||
/** 固定协议标识,父窗口只认该 source */
|
||||
source: "xiaoxia-wechat-qr"
|
||||
/** 场景,需与弹窗发起时一致(login/bind),父窗口据此过滤 */
|
||||
scene: WechatQrScene
|
||||
/** 成功 / 失败 */
|
||||
success: boolean
|
||||
/** 失败时的真实原因(已在回调页拼好,含后端 detail) */
|
||||
detail?: string
|
||||
payload?: WechatQrSuccessPayload
|
||||
}
|
||||
|
||||
export const WECHAT_QR_MESSAGE_SOURCE = "xiaoxia-wechat-qr"
|
||||
|
||||
/** 判断收到的 message 是否为本协议消息(且场景匹配) */
|
||||
export function isWechatQrMessage(
|
||||
data: unknown,
|
||||
scene: WechatQrScene,
|
||||
): data is WechatQrMessageData {
|
||||
if (!data || typeof data !== "object") return false
|
||||
const msg = data as Partial<WechatQrMessageData>
|
||||
return msg.source === WECHAT_QR_MESSAGE_SOURCE && msg.scene === scene
|
||||
}
|
||||
|
||||
/** 当前页面是否运行在 iframe(弹窗内嵌二维码)中 */
|
||||
export function isInIframe(): boolean {
|
||||
try {
|
||||
return window.parent !== window
|
||||
} catch {
|
||||
// 跨域访问 window.parent 可能抛异常,按非 iframe 处理
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* iframe 内回调页向父窗口上报扫码结果。同源回调页加载,targetOrigin 限定本站 origin。
|
||||
*/
|
||||
export function postWechatQrResult(
|
||||
scene: WechatQrScene,
|
||||
success: boolean,
|
||||
options?: { detail?: string; needOnboarding?: boolean },
|
||||
): void {
|
||||
if (!isInIframe()) return
|
||||
const data: WechatQrMessageData = {
|
||||
source: WECHAT_QR_MESSAGE_SOURCE,
|
||||
scene,
|
||||
success,
|
||||
detail: options?.detail,
|
||||
payload:
|
||||
success && options?.needOnboarding !== undefined
|
||||
? { needOnboarding: options.needOnboarding }
|
||||
: undefined,
|
||||
}
|
||||
window.parent.postMessage(data, window.location.origin)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* 全局错误边界:专门兜底"发版后旧标签页懒加载 chunk 失效"导致的白屏,
|
||||
* 同时兜住页面级渲染崩溃,避免任何未捕获错误导致整页白屏无反馈。
|
||||
*
|
||||
* 捕获到 ChunkLoadError / Failed to fetch dynamically imported module:
|
||||
* 1. 首次:自动整页刷新一次(sessionStorage 标记,刷新后 index.html 重新拉取,
|
||||
* 拿到新 chunk 引用,白屏自愈)
|
||||
* 2. 刷新后仍失败(标记未过期):不再自动刷新,显示"系统已更新,请点击刷新"
|
||||
* 兜底界面,由用户手动点击
|
||||
*
|
||||
* 其他非 chunk 错误:显示通用错误页 + "返回首页"按钮(跳首页而非刷新当前 URL,
|
||||
* 避免刷新后再次命中同一路由崩溃形成死循环)。
|
||||
*/
|
||||
import React from "react"
|
||||
import { Button, Result } from "antd"
|
||||
import {
|
||||
getChunkReloadedAt,
|
||||
goHomeRecover,
|
||||
isChunkLoadError,
|
||||
reloadForChunkError,
|
||||
} from "@/utils/chunkLoadError"
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null
|
||||
isChunkError: boolean
|
||||
/** 捕获错误时是否已经自动刷新过(决定显示自动刷新中还是手动兜底) */
|
||||
alreadyReloaded: boolean
|
||||
}
|
||||
|
||||
class ChunkErrorBoundary extends React.Component<Props, State> {
|
||||
state: State = { error: null, isChunkError: false, alreadyReloaded: false }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
const chunk = isChunkLoadError(error)
|
||||
return {
|
||||
error,
|
||||
isChunkError: chunk,
|
||||
alreadyReloaded: chunk ? getChunkReloadedAt() !== null : false,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
// 仅 chunk 错误且本次会话没自动刷新过 → 打标记并整页刷新(自愈)
|
||||
if (isChunkLoadError(error) && getChunkReloadedAt() === null) {
|
||||
reloadForChunkError()
|
||||
}
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
const { error, isChunkError, alreadyReloaded } = this.state
|
||||
if (!error) return this.props.children
|
||||
|
||||
if (isChunkError && !alreadyReloaded) {
|
||||
// 已打标记、componentDidCatch 里已触发 reload;极短瞬间展示加载中
|
||||
return (
|
||||
<Result status="info" title="系统正在更新" subTitle="检测到新版本,正在自动刷新页面…" />
|
||||
)
|
||||
}
|
||||
|
||||
// 手动兜底统一跳首页(整页导航):chunk 失效时脱离旧 chunk 引用;
|
||||
// 业务崩溃时绕开当前报错路由,避免刷新-再崩死循环
|
||||
return (
|
||||
<Result
|
||||
status="warning"
|
||||
title={isChunkError ? "系统已更新" : "页面出现异常"}
|
||||
subTitle={
|
||||
isChunkError
|
||||
? "检测到新版本,请点击下方按钮回到首页加载最新内容。"
|
||||
: "页面加载遇到问题,点击返回首页通常可以恢复,未保存的内容可能丢失。"
|
||||
}
|
||||
extra={
|
||||
<Button type="primary" onClick={goHomeRecover}>
|
||||
{isChunkError ? "刷新并返回首页" : "返回首页"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default ChunkErrorBoundary
|
||||
@@ -9,7 +9,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { ConfigProvider, App as AntApp } from "antd"
|
||||
import zhCN from "antd/locale/zh_CN"
|
||||
import router from "./router"
|
||||
import ChunkErrorBoundary from "./components/common/ChunkErrorBoundary"
|
||||
import { scheduleProactiveRefresh } from "./api/auth/tokenRefresh"
|
||||
|
||||
// 应用启动时,如果用户已登录,立即调度主动 token 刷新
|
||||
@@ -100,9 +99,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfigProvider locale={zhCN} theme={theme}>
|
||||
<AntApp>
|
||||
<ChunkErrorBoundary>
|
||||
<RouterProvider router={router} />
|
||||
</ChunkErrorBoundary>
|
||||
<RouterProvider router={router} />
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -42,7 +42,6 @@ const AssetLibrary: React.FC = () => {
|
||||
assetsError,
|
||||
assetsErrorObj,
|
||||
refetchAssets,
|
||||
stalledAssetIds,
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterType,
|
||||
@@ -78,7 +77,6 @@ const AssetLibrary: React.FC = () => {
|
||||
removeUpload,
|
||||
clearFinished,
|
||||
uploading,
|
||||
transferActive,
|
||||
activeCount,
|
||||
pendingCount,
|
||||
} = useAssetUpload({ effectiveLibId })
|
||||
@@ -170,7 +168,6 @@ const AssetLibrary: React.FC = () => {
|
||||
{/* 上传区域 */}
|
||||
<AssetUploadZone
|
||||
uploading={uploading}
|
||||
transferActive={transferActive}
|
||||
activeCount={activeCount}
|
||||
pendingCount={pendingCount}
|
||||
onUpload={enqueueUploads}
|
||||
@@ -217,7 +214,6 @@ const AssetLibrary: React.FC = () => {
|
||||
selectedIds={selectedIds}
|
||||
diagnosingId={diagnosingId}
|
||||
uploadProgressMap={uploadProgressMap}
|
||||
stalledAssetIds={stalledAssetIds}
|
||||
onRetry={refetchAssets}
|
||||
onToggleSelect={toggleSelect}
|
||||
onDiagnose={handleDiagnose}
|
||||
|
||||
@@ -831,20 +831,6 @@
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.xx-upload-queue-error-detail {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #ef4444;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.xx-upload-queue-error-hint {
|
||||
margin-top: 2px;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.xx-upload-queue-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@@ -1055,25 +1041,3 @@
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* 上传入口禁用态(直传进行中,防重复提交,Issue #1714) */
|
||||
.xx-asset-upload-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.xx-asset-upload-btn:disabled:hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.xx-asset-upload-btn:disabled:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 处理超时遮罩:创建超过 10 分钟仍在处理中(疑似后端卡住),停止转圈并警示 */
|
||||
.xx-asset-thumb-stalled {
|
||||
background: rgba(217, 119, 6, 0.28);
|
||||
color: #fde68a;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.xx-asset-thumb-stalled :first-child {
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
|
||||
@@ -22,8 +22,6 @@ export interface AssetCardProps {
|
||||
diagnosing?: boolean
|
||||
/** 上传中实时进度(仅 uploading 态有值;ingesting 后由后端状态接管) */
|
||||
uploadProgress?: { progress: number; uploading: boolean }
|
||||
/** 处理超过 10 分钟仍未就绪(疑似后端卡住):停止转圈并提示处理超时 */
|
||||
stalled?: boolean
|
||||
onToggle: () => void
|
||||
onDiagnose: () => void
|
||||
onPlay: () => void
|
||||
@@ -35,7 +33,6 @@ const AssetCard: React.FC<AssetCardProps> = ({
|
||||
selected,
|
||||
diagnosing,
|
||||
uploadProgress,
|
||||
stalled,
|
||||
onToggle,
|
||||
onDiagnose,
|
||||
onPlay,
|
||||
@@ -70,15 +67,11 @@ const AssetCard: React.FC<AssetCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 转码/处理中遮罩(卡死超过 10 分钟时停止转圈,提示超时) */}
|
||||
{/* 转码/处理中遮罩 */}
|
||||
{asset.loading && !isUploading && (
|
||||
<div
|
||||
className={`xx-asset-thumb-overlay ${
|
||||
stalled ? "xx-asset-thumb-stalled" : "xx-asset-thumb-processing"
|
||||
}`}
|
||||
>
|
||||
{stalled ? <CloseCircleOutlined /> : <LoadingOutlined />}
|
||||
<span>{stalled ? "处理超时,可重试上传" : "转码处理中"}</span>
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
|
||||
<LoadingOutlined />
|
||||
<span>转码处理中</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -136,7 +129,7 @@ const AssetCard: React.FC<AssetCardProps> = ({
|
||||
</p>
|
||||
<div className="xx-asset-meta">
|
||||
<span className="xx-asset-meta-status">
|
||||
<StatusPill status={asset.status} label={stalled ? "处理超时" : asset.statusLabel} />
|
||||
<StatusPill status={asset.status} label={asset.statusLabel} />
|
||||
</span>
|
||||
{asset.duration && <span className="xx-asset-meta-duration">{asset.duration}</span>}
|
||||
</div>
|
||||
|
||||
@@ -19,8 +19,6 @@ export interface AssetGridSectionProps {
|
||||
selectedIds: Set<string>
|
||||
diagnosingId: string | null
|
||||
uploadProgressMap?: UploadProgressMap
|
||||
/** 创建超过 10 分钟仍在处理中的素材 id(疑似后端卡住),卡片提示处理超时 */
|
||||
stalledAssetIds?: Set<string>
|
||||
onRetry?: () => void
|
||||
onToggleSelect: (id: string) => void
|
||||
onDiagnose: (asset: AssetItem) => void
|
||||
@@ -36,7 +34,6 @@ export const AssetGridSection: React.FC<AssetGridSectionProps> = ({
|
||||
selectedIds,
|
||||
diagnosingId,
|
||||
uploadProgressMap,
|
||||
stalledAssetIds,
|
||||
onRetry,
|
||||
onToggleSelect,
|
||||
onDiagnose,
|
||||
@@ -79,7 +76,6 @@ export const AssetGridSection: React.FC<AssetGridSectionProps> = ({
|
||||
selected={selectedIds.has(asset.id)}
|
||||
diagnosing={diagnosingId === asset.id}
|
||||
uploadProgress={uploadProgressMap?.get(asset.id)}
|
||||
stalled={stalledAssetIds?.has(asset.id)}
|
||||
onToggle={() => onToggleSelect(asset.id)}
|
||||
onDiagnose={() => onDiagnose(asset)}
|
||||
onPlay={() => onPlay(asset)}
|
||||
|
||||
@@ -4,13 +4,10 @@
|
||||
* - 拖拽文件到内容区任意位置同样触发上传(不再占用大面积虚线框)
|
||||
*/
|
||||
import React, { useRef, useState } from "react"
|
||||
import { message } from "antd"
|
||||
import { PlusOutlined, CloudUploadOutlined } from "@ant-design/icons"
|
||||
|
||||
export interface AssetUploadZoneProps {
|
||||
uploading: boolean
|
||||
/** 有文件正在本地指纹/prepare/直传(非服务端转码),此时禁用入口防重复提交 */
|
||||
transferActive: boolean
|
||||
activeCount: number
|
||||
pendingCount: number
|
||||
onUpload: (files: File[]) => void
|
||||
@@ -18,7 +15,6 @@ export interface AssetUploadZoneProps {
|
||||
|
||||
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
|
||||
uploading,
|
||||
transferActive,
|
||||
activeCount,
|
||||
pendingCount,
|
||||
onUpload,
|
||||
@@ -31,11 +27,6 @@ export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
|
||||
|
||||
const pickFiles = (list: FileList | null) => {
|
||||
if (!list || list.length === 0) return
|
||||
// 直传进行中拦截重复触发:相同文件仍由入队去重兜底,这里先给明确反馈
|
||||
if (transferActive) {
|
||||
message.warning("文件正在上传中,请等待当前上传完成后再添加")
|
||||
return
|
||||
}
|
||||
onUpload(Array.from(list))
|
||||
}
|
||||
|
||||
@@ -67,18 +58,10 @@ export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
className="xx-asset-upload-btn"
|
||||
disabled={transferActive}
|
||||
title={transferActive ? "文件上传中,暂不能添加新文件" : undefined}
|
||||
onClick={() => {
|
||||
if (transferActive) {
|
||||
message.warning("文件正在上传中,请等待当前上传完成后再添加")
|
||||
return
|
||||
}
|
||||
inputRef.current?.click()
|
||||
}}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<PlusOutlined />
|
||||
{transferActive ? "上传中…" : "上传素材"}
|
||||
上传素材
|
||||
</button>
|
||||
<span className="xx-asset-upload-status">
|
||||
{uploading ? (
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
ReloadOutlined,
|
||||
CloseOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { UploadItem, UploadFailStage } from "../hooks/useAssetUpload"
|
||||
import { COMPLETE_RETRY_HINT } from "../hooks/useAssetUpload"
|
||||
import type { UploadItem } from "../hooks/useAssetUpload"
|
||||
|
||||
export interface UploadQueuePanelProps {
|
||||
items: UploadItem[]
|
||||
@@ -30,13 +29,6 @@ const STATUS_TEXT: Record<UploadItem["status"], string> = {
|
||||
error: "上传失败",
|
||||
}
|
||||
|
||||
/** 失败阶段中文名:让用户一眼看到失败发生在哪一步 */
|
||||
const FAIL_STAGE_TEXT: Record<UploadFailStage, string> = {
|
||||
prepare: "准备上传阶段",
|
||||
transfer: "文件传输阶段",
|
||||
complete: "确认入库阶段",
|
||||
}
|
||||
|
||||
const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
|
||||
items,
|
||||
onRetry,
|
||||
@@ -88,34 +80,16 @@ const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
|
||||
) : null}
|
||||
<div className="xx-upload-queue-status">
|
||||
{it.duplicated ? "素材已存在,已跳过" : STATUS_TEXT[it.status]}
|
||||
{it.status === "preparing" && it.hint ? `(${it.hint})` : ""}
|
||||
{it.status === "uploading" ? ` ${it.progress}%` : ""}
|
||||
{it.status === "error" && it.failedStage
|
||||
? `(${FAIL_STAGE_TEXT[it.failedStage]})`
|
||||
: ""}
|
||||
{it.status === "error" && it.error ? `:${it.error}` : ""}
|
||||
</div>
|
||||
{it.status === "error" && it.error ? (
|
||||
<div className="xx-upload-queue-error-detail" title={it.error}>
|
||||
{it.error.split("\n").map((line, idx) =>
|
||||
line === COMPLETE_RETRY_HINT ? (
|
||||
<div key={idx} className="xx-upload-queue-error-hint">
|
||||
{line}
|
||||
</div>
|
||||
) : (
|
||||
<div key={idx}>{line}</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="xx-upload-queue-actions">
|
||||
{it.status === "error" && (
|
||||
<button
|
||||
type="button"
|
||||
className="xx-upload-queue-btn"
|
||||
title={
|
||||
it.failedStage === "complete" ? "安全重试(只确认,不重新上传)" : "重试上传"
|
||||
}
|
||||
title="重试"
|
||||
onClick={() => onRetry(it.tempId)}
|
||||
>
|
||||
<ReloadOutlined />
|
||||
|
||||
@@ -2,65 +2,34 @@ import { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { prepareDirectUploadHandle, type DirectUploadHandle } from "@/api/assets"
|
||||
import { getErrorMessage, isErrorMsgShown } from "@/api/errors"
|
||||
import { MAX_FILE_SIZE } from "../constants"
|
||||
import {
|
||||
computeFileHash,
|
||||
findDuplicateInQueue,
|
||||
makeClientUploadId,
|
||||
makeFileFingerprint,
|
||||
} from "@/api/assets/uploadDedup"
|
||||
|
||||
/** 单文件上传状态机 */
|
||||
export type UploadItemStatus = "preparing" | "uploading" | "ingesting" | "done" | "error"
|
||||
|
||||
/** 失败发生的阶段:complete 阶段失败时记录可能已在后端建成,禁止盲目重传整个文件 */
|
||||
export type UploadFailStage = "prepare" | "transfer" | "complete"
|
||||
|
||||
export interface UploadItem {
|
||||
/** 前端临时 id(prepare 前无 asset_id 时用),同时作为队列项 key */
|
||||
/** 前端临时 id(prepare 前无 asset_id 时用) */
|
||||
tempId: string
|
||||
file: File
|
||||
fileName: string
|
||||
/** 进度 0~100(仅直传阶段有真实进度) */
|
||||
progress: number
|
||||
status: UploadItemStatus
|
||||
/** 文件指纹(name+size+lastModified),入队去重用 */
|
||||
fileKey: string
|
||||
/** 本次逻辑上传的幂等 token:重试复用、重新入队才换新 */
|
||||
clientUploadId: string
|
||||
/** 上传前算好的文件内容哈希(SHA-256),prepare/complete 都带上 */
|
||||
fileHash?: string
|
||||
/** 后端 prepare 预建的 asset id(旧后端可能为空) */
|
||||
assetId?: string
|
||||
/** 去重命中:complete 返回 duplicated,标记完成但不产生新素材 */
|
||||
duplicated?: boolean
|
||||
/** 失败发生的阶段;complete 阶段失败点重试只重发 complete,不重新上传文件 */
|
||||
failedStage?: UploadFailStage
|
||||
/** 状态行补充提示(如"正在计算文件指纹…") */
|
||||
hint?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** 批量直传最大并发数,避免多文件瓜分上行带宽 */
|
||||
const MAX_CONCURRENT = 3
|
||||
|
||||
/** complete 阶段失败后的安全提示:素材可能已在后端建成,重试只重发 complete 幂等安全 */
|
||||
export const COMPLETE_RETRY_HINT = "素材可能已在服务器处理中,点重试将安全确认,不会重新上传文件"
|
||||
|
||||
/** 失败阶段中文名(toast 提示用,明确失败发生在哪一步) */
|
||||
const STAGE_LABEL: Record<UploadFailStage, string> = {
|
||||
prepare: "准备上传",
|
||||
transfer: "文件传输",
|
||||
complete: "确认入库",
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材批量上传 Hook
|
||||
* - 入队按文件指纹(name+size+lastModified)去重:同一文件已在队列/上传中/处理中时不重复入队
|
||||
* - 上传前计算文件 SHA-256,prepare/complete 携带 file_hash + 幂等 token(clientUploadId)
|
||||
* - complete 超时/失败不盲目重传:复用 handle 只重发 complete(幂等),prepare/transfer 失败才全量重跑
|
||||
* - prepare 阶段后端预建 status=uploading 的 asset,前端拿到 asset_id 立即刷新列表
|
||||
* - OSS 直传并发限制为 3,其余排队;每个文件独立进度/状态
|
||||
* - complete 后素材进入转码(ingesting/processing),由列表轮询反映
|
||||
* - 失败卡片支持重试/移除
|
||||
*/
|
||||
export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
@@ -70,13 +39,6 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
const itemsRef = useRef<UploadItem[]>([])
|
||||
itemsRef.current = items
|
||||
|
||||
/**
|
||||
* prepare 成功后的 handle 按 tempId 留存:
|
||||
* complete 阶段失败(超时/网络)时 OSS 文件已存在、后端记录也可能已建成,
|
||||
* 重试必须复用同一 handle 只重发 complete,绝不能重新 prepare+直传。
|
||||
*/
|
||||
const handlesRef = useRef<Map<string, DirectUploadHandle>>(new Map())
|
||||
|
||||
const updateItem = useCallback((tempId: string, patch: Partial<UploadItem>) => {
|
||||
setItems((prev) => prev.map((it) => (it.tempId === tempId ? { ...it, ...patch } : it)))
|
||||
}, [])
|
||||
@@ -90,71 +52,14 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
}, [queryClient, effectiveLibId])
|
||||
|
||||
/**
|
||||
* 执行单个文件的完整上传流程。
|
||||
* @param completeOnly complete 阶段失败后的重试:跳过 hash/prepare/transfer,只重发 complete
|
||||
* (OSS 文件已传完,重发由 file_hash + clientUploadId 保证幂等)
|
||||
*/
|
||||
/** 执行单个文件的完整上传流程(prepare→transfer→complete) */
|
||||
const runUpload = useCallback(
|
||||
async (item: UploadItem, handle?: DirectUploadHandle, completeOnly = false) => {
|
||||
let stage: UploadFailStage = "prepare"
|
||||
async (item: UploadItem, handle?: DirectUploadHandle) => {
|
||||
try {
|
||||
let h = handle
|
||||
|
||||
if (completeOnly && h) {
|
||||
// ── complete 重试:文件已在 OSS,直接幂等重发确认 ──
|
||||
stage = "complete"
|
||||
updateItem(item.tempId, {
|
||||
status: "ingesting",
|
||||
progress: 100,
|
||||
error: undefined,
|
||||
failedStage: undefined,
|
||||
hint: undefined,
|
||||
})
|
||||
const result = await h.complete()
|
||||
refreshList()
|
||||
handlesRef.current.delete(item.tempId)
|
||||
if (result.duplicated) {
|
||||
updateItem(item.tempId, { status: "done", duplicated: true, assetId: result.asset_id })
|
||||
message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`)
|
||||
} else {
|
||||
updateItem(item.tempId, { status: "done", assetId: result.asset_id || item.assetId })
|
||||
message.success(`"${item.fileName}" 上传完成,正在转码处理`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 计算文件内容哈希(失败不阻塞,降级为不传 hash;后端仍有幂等 token 兜底)
|
||||
updateItem(item.tempId, { hint: "正在计算文件指纹…" })
|
||||
const fileHash = item.fileHash || (await computeFileHash(item.file))
|
||||
updateItem(item.tempId, { fileHash, hint: undefined })
|
||||
|
||||
// 2. prepare(携带 file_hash + 幂等 token;重试时复用同一 clientUploadId)
|
||||
stage = "prepare"
|
||||
h =
|
||||
h ??
|
||||
(await prepareDirectUploadHandle({
|
||||
file: item.file,
|
||||
library_id: effectiveLibId,
|
||||
fileHash,
|
||||
clientUploadId: item.clientUploadId,
|
||||
}))
|
||||
handlesRef.current.set(item.tempId, h)
|
||||
|
||||
// prepare 阶段后端 file_hash 命中素材库已有相同文件(skip_transfer / duplicated):
|
||||
// 立即标记 done、调一次 refreshList 让已存在素材立即显示,跳过 transfer + complete
|
||||
if (h.prepared.skip_transfer || h.prepared.duplicated) {
|
||||
updateItem(item.tempId, {
|
||||
status: "done",
|
||||
duplicated: true,
|
||||
assetId: h.prepared.asset_id,
|
||||
})
|
||||
handlesRef.current.delete(item.tempId)
|
||||
refreshList()
|
||||
message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. prepare(重试时复用已准备的 handle 也行,但签名可能过期,重新 prepare 最稳)
|
||||
const h =
|
||||
handle ??
|
||||
(await prepareDirectUploadHandle({ file: item.file, library_id: effectiveLibId }))
|
||||
if (h.prepared.asset_id) {
|
||||
updateItem(item.tempId, {
|
||||
status: "uploading",
|
||||
@@ -167,59 +72,26 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
updateItem(item.tempId, { status: "uploading", progress: 0 })
|
||||
}
|
||||
|
||||
// 3. OSS 直传(真实进度)
|
||||
stage = "transfer"
|
||||
// 2. OSS 直传(真实进度)
|
||||
await h.transfer((pct) => updateItem(item.tempId, { progress: pct }))
|
||||
|
||||
// 4. complete:后端确认入库并创建 ingest job(file_hash + 幂等 token 已在 handle 闭包中)
|
||||
stage = "complete"
|
||||
// 3. complete:后端创建 ingest job,素材进入转码
|
||||
updateItem(item.tempId, { status: "ingesting", progress: 100 })
|
||||
const result = await h.complete()
|
||||
refreshList()
|
||||
handlesRef.current.delete(item.tempId)
|
||||
|
||||
if (result.duplicated) {
|
||||
updateItem(item.tempId, { status: "done", duplicated: true, assetId: result.asset_id })
|
||||
message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`)
|
||||
} else {
|
||||
updateItem(item.tempId, { status: "done", assetId: result.asset_id || item.assetId })
|
||||
updateItem(item.tempId, { status: "done" })
|
||||
message.success(`"${item.fileName}" 上传完成,正在转码处理`)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// 完整失败原因:HTTP 状态码 / OSS XML 的 Code+Message / 后端 detail,
|
||||
// 由 getErrorMessage 统一提取(OSS XHR 错误自带「OSS 直传失败: HTTP xxx ...」明细)
|
||||
const detail = getErrorMessage(err, "未知错误")
|
||||
console.error("[useAssetUpload] 上传失败:", item.fileName, stage, err)
|
||||
|
||||
if (stage === "complete") {
|
||||
// complete 失败(超时/5xx/网络):后端记录可能已建成,handle 保留供幂等重试;
|
||||
// 刷新列表让用户看到可能已创建的「处理中」素材,避免误以为没传上去而重复操作。
|
||||
// 卡片同时展示真实错误原因 + 安全重试提示(重试只重发 complete,不重新上传)
|
||||
refreshList()
|
||||
updateItem(item.tempId, {
|
||||
status: "error",
|
||||
failedStage: "complete",
|
||||
error: `${detail}\n${COMPLETE_RETRY_HINT}`,
|
||||
hint: undefined,
|
||||
})
|
||||
if (!isErrorMsgShown(err)) {
|
||||
message.error(`"${item.fileName}" 确认入库失败:${detail}`)
|
||||
}
|
||||
} else {
|
||||
// prepare / transfer 失败:后端尚无素材记录,可安全全量重跑
|
||||
handlesRef.current.delete(item.tempId)
|
||||
updateItem(item.tempId, {
|
||||
status: "error",
|
||||
failedStage: stage === "transfer" ? "transfer" : "prepare",
|
||||
error: detail,
|
||||
hint: undefined,
|
||||
})
|
||||
// 拦截器已对后端错误弹过 toast(含真实 detail)时不重复弹;
|
||||
// OSS XHR 直传错误不走 axios,必须在这里弹
|
||||
if (!isErrorMsgShown(err)) {
|
||||
message.error(`"${item.fileName}" ${STAGE_LABEL[stage]}失败:${detail}`)
|
||||
}
|
||||
}
|
||||
const detail = err instanceof Error ? err.message : "上传失败"
|
||||
console.error("[useAssetUpload] 上传失败:", item.fileName, err)
|
||||
updateItem(item.tempId, { status: "error", error: detail })
|
||||
message.error(`"${item.fileName}" 上传失败:${detail}`)
|
||||
}
|
||||
},
|
||||
[effectiveLibId, refreshList, updateItem],
|
||||
@@ -241,9 +113,7 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
if (!next) return
|
||||
claimedRef.current.add(next.tempId)
|
||||
inFlightRef.current += 1
|
||||
// complete 阶段失败的重试:复用留存的 handle,只重发 complete
|
||||
const existingHandle = handlesRef.current.get(next.tempId)
|
||||
void runUpload(next, existingHandle, existingHandle !== undefined).finally(() => {
|
||||
void runUpload(next).finally(() => {
|
||||
inFlightRef.current -= 1
|
||||
claimedRef.current.delete(next.tempId)
|
||||
// 一个任务结束(成功/失败)后继续拉起排队任务
|
||||
@@ -256,11 +126,7 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
pumpRef.current()
|
||||
}, [items])
|
||||
|
||||
/**
|
||||
* 入队一个或多个文件(按文件指纹去重):
|
||||
* - 同一文件已在队列且 preparing/uploading/ingesting/done → 跳过,不重复入队
|
||||
* - 同一文件此前失败(error)→ 重新激活原队列项(复用 clientUploadId,保持幂等语义)
|
||||
*/
|
||||
/** 入队一个或多个文件 */
|
||||
const enqueueUploads = useCallback(
|
||||
(files: File[]) => {
|
||||
if (!effectiveLibId) {
|
||||
@@ -277,86 +143,37 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
}
|
||||
if (valid.length === 0) return
|
||||
|
||||
let skipped = 0
|
||||
let rearmed = 0
|
||||
const newItems: UploadItem[] = []
|
||||
|
||||
for (const file of valid) {
|
||||
const fileKey = makeFileFingerprint(file)
|
||||
// error 项允许重新激活;其余状态(preparing/uploading/ingesting/done)都算重复
|
||||
const dup = findDuplicateInQueue([...itemsRef.current, ...newItems], fileKey, ["error"])
|
||||
if (dup) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
// 失败项重新激活:复用 tempId/clientUploadId,由 pump 按留存 handle 决定重试方式
|
||||
const failed = itemsRef.current.find(
|
||||
(it) => it.fileKey === fileKey && it.status === "error",
|
||||
)
|
||||
if (failed) {
|
||||
rearmed += 1
|
||||
updateItem(failed.tempId, {
|
||||
status: "preparing",
|
||||
progress: 0,
|
||||
error: undefined,
|
||||
failedStage: undefined,
|
||||
hint: undefined,
|
||||
})
|
||||
continue
|
||||
}
|
||||
newItems.push({
|
||||
tempId: `${Date.now()}-${newItems.length}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
file,
|
||||
fileName: file.name,
|
||||
progress: 0,
|
||||
status: "preparing",
|
||||
fileKey,
|
||||
clientUploadId: makeClientUploadId(),
|
||||
})
|
||||
}
|
||||
|
||||
if (newItems.length > 0) {
|
||||
setItems((prev) => [...prev, ...newItems])
|
||||
}
|
||||
if (skipped > 0) {
|
||||
message.warning(`已跳过 ${skipped} 个重复文件(已在上传队列、处理中或本页已上传)`)
|
||||
}
|
||||
if (rearmed > 0) {
|
||||
message.info(`已重新加入 ${rearmed} 个此前失败的文件`)
|
||||
}
|
||||
const newItems: UploadItem[] = valid.map((file, idx) => ({
|
||||
tempId: `${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
file,
|
||||
fileName: file.name,
|
||||
progress: 0,
|
||||
status: "preparing",
|
||||
}))
|
||||
setItems((prev) => [...prev, ...newItems])
|
||||
},
|
||||
[effectiveLibId, updateItem],
|
||||
[effectiveLibId],
|
||||
)
|
||||
|
||||
/**
|
||||
* 重试失败任务(仅限 status=error):
|
||||
* - complete 阶段失败:复用留存 handle 只重发 complete(幂等,不重新上传)
|
||||
* - prepare/transfer 阶段失败:全量重跑(后端尚无记录,安全)
|
||||
*/
|
||||
/** 重试失败任务 */
|
||||
const retryUpload = useCallback(
|
||||
(tempId: string) => {
|
||||
const target = itemsRef.current.find((it) => it.tempId === tempId)
|
||||
if (!target || target.status !== "error") return
|
||||
updateItem(tempId, { status: "preparing", progress: 0, error: undefined, hint: undefined })
|
||||
// 状态更新后由 useEffect 触发 pump;pump 会按 handlesRef 自动选择 completeOnly / 全量
|
||||
if (!target) return
|
||||
updateItem(tempId, { status: "preparing", progress: 0, error: undefined })
|
||||
// 状态更新后由 useEffect 触发 pump
|
||||
},
|
||||
[updateItem],
|
||||
)
|
||||
|
||||
/** 从上传列表移除(已进入转码的由素材网格管理;这里只移除上传面板记录) */
|
||||
const removeUpload = useCallback((tempId: string) => {
|
||||
handlesRef.current.delete(tempId)
|
||||
setItems((prev) => prev.filter((it) => it.tempId !== tempId))
|
||||
}, [])
|
||||
|
||||
/** 清空已完成/去重记录 */
|
||||
const clearFinished = useCallback(() => {
|
||||
setItems((prev) => {
|
||||
for (const it of prev) {
|
||||
if (it.status === "done") handlesRef.current.delete(it.tempId)
|
||||
}
|
||||
return prev.filter((it) => it.status !== "done")
|
||||
})
|
||||
setItems((prev) => prev.filter((it) => it.status !== "done"))
|
||||
}, [])
|
||||
|
||||
const activeCount = items.filter(
|
||||
@@ -371,10 +188,8 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
retryUpload,
|
||||
removeUpload,
|
||||
clearFinished,
|
||||
/** 是否有进行中的上传(用于上传区文案/禁用入口) */
|
||||
/** 是否有进行中的上传(用于上传区文案) */
|
||||
uploading: hasActive,
|
||||
/** 是否有文件正在本地处理或直传(用于禁用上传入口,防重复提交) */
|
||||
transferActive: activeCount > 0,
|
||||
activeCount,
|
||||
pendingCount,
|
||||
}
|
||||
|
||||
@@ -10,26 +10,6 @@ import {
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import { mapLibrary, mapAsset, type AssetItem, type LibraryItem } from "../types"
|
||||
|
||||
/** 处理中素材快速轮询(3s)的最大持续时间:超过后停止快轮询,避免孤儿任务永久转圈 */
|
||||
const PROCESSING_POLL_MAX_MS = 10 * 60 * 1000 // 10 分钟
|
||||
|
||||
const isProcessingStatus = (st?: string | null): boolean =>
|
||||
st === "uploading" || st === "ingesting" || st === "processing" || st === "pending"
|
||||
|
||||
/** 判断列表是否存在「创建超过 maxMs 仍在处理中」的卡死素材 */
|
||||
const hasStalledProcessing = (
|
||||
list: ApiAssetItem[],
|
||||
maxMs: number = PROCESSING_POLL_MAX_MS,
|
||||
): boolean => {
|
||||
const now = Date.now()
|
||||
return list.some((a) => {
|
||||
if (!isProcessingStatus(a.status ?? "")) return false
|
||||
if (!a.created_at) return false
|
||||
const created = new Date(a.created_at).getTime()
|
||||
return Number.isFinite(created) && now - created > maxMs
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材库数据 Hook
|
||||
* 封装视频库列表、素材列表的数据查询,以及筛选、搜索状态管理
|
||||
@@ -83,15 +63,15 @@ export function useAssetsData() {
|
||||
}),
|
||||
enabled: !!effectiveLibId,
|
||||
staleTime: 30_000,
|
||||
// 列表中存在上传中/转码中素材时每 3s 轮询;全部就绪后自动停止。
|
||||
// 但若处理中素材创建已超过 10 分钟仍未就绪(疑似后端卡住/孤儿任务),
|
||||
// 停止快轮询避免无限转圈——卡死素材在网格中显示「处理超时」提示。
|
||||
// 列表中存在上传中/转码中素材时每 3s 轮询;全部就绪后自动停止
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as { items: ApiAssetItem[] } | undefined
|
||||
const items = data?.items ?? []
|
||||
const processing = items.some((a) => isProcessingStatus(a.status ?? ""))
|
||||
if (!processing) return false
|
||||
return hasStalledProcessing(items) ? false : 3000
|
||||
const processing = items.some((a) => {
|
||||
const st = a.status ?? ""
|
||||
return st === "uploading" || st === "ingesting" || st === "processing" || st === "pending"
|
||||
})
|
||||
return processing ? 3000 : false
|
||||
},
|
||||
})
|
||||
|
||||
@@ -100,21 +80,6 @@ export function useAssetsData() {
|
||||
[apiAssets],
|
||||
)
|
||||
|
||||
/** 创建超过 10 分钟仍在处理中的素材(后端可能卡住),网格提示「处理超时」 */
|
||||
const stalledAssetIds = useMemo(() => {
|
||||
const list = Array.isArray(apiAssets?.items) ? apiAssets.items : []
|
||||
const ids = new Set<string>()
|
||||
const now = Date.now()
|
||||
for (const a of list) {
|
||||
if (!isProcessingStatus(a.status ?? "") || !a.created_at) continue
|
||||
const created = new Date(a.created_at).getTime()
|
||||
if (Number.isFinite(created) && now - created > PROCESSING_POLL_MAX_MS) {
|
||||
ids.add(a.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}, [apiAssets])
|
||||
|
||||
/* ── 筛选状态 ── */
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterType, setFilterType] = useState<string>("all")
|
||||
@@ -164,8 +129,6 @@ export function useAssetsData() {
|
||||
assetsError,
|
||||
assetsErrorObj,
|
||||
refetchAssets,
|
||||
stalledAssetIds,
|
||||
hasStalledAssets: stalledAssetIds.size > 0,
|
||||
// 筛选
|
||||
searchText,
|
||||
setSearchText,
|
||||
|
||||
@@ -5,8 +5,8 @@ import React, { useState } from "react"
|
||||
import { Form, Input, Checkbox, message } from "antd"
|
||||
import { Link, useNavigate } from "react-router-dom"
|
||||
import { useLogin } from "@/hooks/useAuth"
|
||||
import { getWechatAuthUrl } from "@/api/auth"
|
||||
import Button from "@/components/ui/Button"
|
||||
import WechatQrModal from "@/components/auth/WechatQrModal"
|
||||
import "./Login.css"
|
||||
|
||||
interface LoginFormValues {
|
||||
@@ -19,7 +19,7 @@ const Login: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const loginMutation = useLogin()
|
||||
const [form] = Form.useForm()
|
||||
const [wechatQrOpen, setWechatQrOpen] = useState(false)
|
||||
const [wechatLoading, setWechatLoading] = useState(false)
|
||||
|
||||
const onFinish = async (values: LoginFormValues) => {
|
||||
try {
|
||||
@@ -35,28 +35,27 @@ const Login: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleWechatLogin = () => {
|
||||
// 记录登录前的来源页,登录成功后(弹窗回调)跳回
|
||||
const from = window.location.pathname + window.location.search
|
||||
if (from !== "/login" && from !== "/register") {
|
||||
localStorage.setItem("login_redirect", from)
|
||||
} else {
|
||||
localStorage.removeItem("login_redirect")
|
||||
const handleWechatLogin = async () => {
|
||||
try {
|
||||
setWechatLoading(true)
|
||||
const result = await getWechatAuthUrl()
|
||||
// 保存 state 到 localStorage 用于回调时验证
|
||||
localStorage.setItem("wechat_state", result.state)
|
||||
// 记录登录前的来源页,登录成功后跳回
|
||||
const from = window.location.pathname + window.location.search
|
||||
if (from !== "/login" && from !== "/register") {
|
||||
localStorage.setItem("login_redirect", from)
|
||||
} else {
|
||||
localStorage.removeItem("login_redirect")
|
||||
}
|
||||
// 跳转到微信授权页
|
||||
window.location.href = result.auth_url
|
||||
} catch (error) {
|
||||
if (!(error as { __msgShown?: boolean })?.__msgShown)
|
||||
message.error("微信登录暂不可用,请稍后重试")
|
||||
} finally {
|
||||
setWechatLoading(false)
|
||||
}
|
||||
setWechatQrOpen(true)
|
||||
// 弹窗打开期间按钮 disabled;WxLogin 脚本加载失败/超时时弹窗内会自动回退整页跳转
|
||||
}
|
||||
|
||||
// 弹窗扫码登录成功:登录态已由弹窗同步,按用户类型跳转
|
||||
const handleWechatQrSuccess = (needOnboarding: boolean) => {
|
||||
setWechatQrOpen(false)
|
||||
if (needOnboarding) {
|
||||
navigate("/welcome/wechat", { replace: true })
|
||||
return
|
||||
}
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -127,10 +126,10 @@ const Login: React.FC = () => {
|
||||
type="button"
|
||||
className="xx-btn-wechat"
|
||||
onClick={handleWechatLogin}
|
||||
disabled={wechatQrOpen}
|
||||
disabled={wechatLoading}
|
||||
>
|
||||
<span className="xx-wechat-icon">💬</span>
|
||||
微信登录
|
||||
{wechatLoading ? "加载中..." : "微信登录"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -138,13 +137,6 @@ const Login: React.FC = () => {
|
||||
还没有账号? <Link to="/register">立即注册</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WechatQrModal
|
||||
open={wechatQrOpen}
|
||||
scene="login"
|
||||
onClose={() => setWechatQrOpen(false)}
|
||||
onLoginSuccess={handleWechatQrSuccess}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
/**
|
||||
* 微信绑定回调页(已登录用户在设置页发起"绑定微信"扫码后回到这里)
|
||||
* 用 code 调绑定接口把微信关联到当前账号,成功后回设置页
|
||||
*
|
||||
* 两种运行环境:
|
||||
* - 整页跳转授权(旧流程/兜底):本页整页加载,成功/失败后 navigate 回设置页
|
||||
* - 弹窗内嵌二维码(WxLogin self_redirect):本页在同源 iframe 内加载,
|
||||
* 结果通过 postMessage 通知父窗口弹窗,不做页面导航
|
||||
*/
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { useSearchParams, useNavigate } from "react-router-dom"
|
||||
import { Spin } from "antd"
|
||||
import { bindWechat, normalizeUser } from "@/api/auth"
|
||||
import { getErrorMessage } from "@/api/errors"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { isInIframe, postWechatQrResult } from "@/components/auth/WechatQrModal/messages"
|
||||
|
||||
const WechatBindCallback: React.FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const inIframe = isInIframe()
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams.get("code")
|
||||
const state = searchParams.get("state")
|
||||
|
||||
const fail = (message: string) => {
|
||||
if (inIframe) {
|
||||
// 弹窗模式:把真实原因上报父窗口在 Modal 内展示
|
||||
postWechatQrResult("bind", false, { detail: message })
|
||||
return
|
||||
}
|
||||
setError(message)
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
fail("无效的回调参数,请回到设置页重新扫码绑定")
|
||||
return
|
||||
}
|
||||
|
||||
const handleBind = async () => {
|
||||
// state 校验由后端 state store 一次性消费兜底(前端不再比对 localStorage,
|
||||
// 微信内打开/跨浏览器场景本地无 state 会误杀);清理绑定前写入的 state
|
||||
localStorage.removeItem("wechat_bind_state")
|
||||
|
||||
try {
|
||||
const result = await bindWechat(code, state)
|
||||
setUser(normalizeUser(result.user))
|
||||
|
||||
if (inIframe) {
|
||||
// 弹窗模式:通知父窗口关闭弹窗并刷新绑定状态
|
||||
postWechatQrResult("bind", true)
|
||||
return
|
||||
}
|
||||
|
||||
// 用 replace 回设置页,query 携带成功标记由设置页提示
|
||||
navigate("/app/profile?wechat_bind=success", { replace: true })
|
||||
} catch (err) {
|
||||
// 绑定失败直接在本页展示/上报真实原因(如微信已被其他账号绑定),不静默跳走
|
||||
fail(`微信绑定失败:${getErrorMessage(err, "请回到设置页重试")}`)
|
||||
}
|
||||
}
|
||||
|
||||
handleBind()
|
||||
}, [searchParams, navigate, setUser, inIframe])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
background: "#f5f5f5",
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<p style={{ color: "#ef4444", fontSize: 16, marginBottom: 16 }}>{error}</p>
|
||||
<button
|
||||
onClick={() => navigate("/app/profile")}
|
||||
style={{
|
||||
padding: "8px 24px",
|
||||
background: "var(--primary-color, #3b82f6)",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
返回设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
background: "#f5f5f5",
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16, color: "#666" }}>正在绑定微信...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WechatBindCallback
|
||||
@@ -1,63 +1,40 @@
|
||||
/**
|
||||
* 微信登录回调页
|
||||
* 扫码授权后由微信重定向回来:用 code 换登录态,
|
||||
* 新用户/资料未完善 → 跳昵称引导页;老用户 → 回来源页/首页
|
||||
*
|
||||
* 两种运行环境:
|
||||
* - 整页跳转授权(旧流程/兜底):本页整页加载,按上述逻辑导航
|
||||
* - 弹窗内嵌二维码(WxLogin self_redirect):本页在同源 iframe 内加载,
|
||||
* 成功/失败均通过 postMessage 通知父窗口弹窗,不做页面导航
|
||||
*/
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { useSearchParams, useNavigate } from "react-router-dom"
|
||||
import { Spin } from "antd"
|
||||
import { Spin, message } from "antd"
|
||||
import { wechatCallback, getCurrentUser, normalizeUser, type User } from "@/api/auth"
|
||||
import { getErrorMessage } from "@/api/errors"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { scheduleProactiveRefresh } from "@/api/auth/tokenRefresh"
|
||||
import { isInIframe, postWechatQrResult } from "@/components/auth/WechatQrModal/messages"
|
||||
import BindContactModal from "@/components/auth/BindContactModal"
|
||||
|
||||
const WechatCallback: React.FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const setAuth = useAuthStore((state) => state.setAuth)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showBindModal, setShowBindModal] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const inIframe = isInIframe()
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams.get("code")
|
||||
const state = searchParams.get("state")
|
||||
|
||||
const fail = (message: string) => {
|
||||
if (inIframe) {
|
||||
// 弹窗模式:把真实原因上报父窗口在 Modal 内展示,本页保持"处理中"即可
|
||||
postWechatQrResult("login", false, { detail: message })
|
||||
return
|
||||
}
|
||||
setError(message)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// 微信重定向出错时(如用户拒绝授权 error=access_denied)直接展示/上报原因
|
||||
const wxErrorCode = searchParams.get("error")
|
||||
const wxErrDesc = searchParams.get("error_description")
|
||||
if (wxErrorCode || wxErrDesc) {
|
||||
const reason = [wxErrorCode, wxErrDesc].filter(Boolean).join(":")
|
||||
fail(`微信授权失败:${reason}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
fail("无效的回调参数,请重新扫码登录")
|
||||
setError("无效的回调参数")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
// state 的 CSRF 校验由后端 state store 一次性消费兜底(前端不再比对
|
||||
// localStorage——微信内打开、跨浏览器等场景本地没有 state,会误杀正常回调);
|
||||
// 清理登录前写入的 state,避免残留
|
||||
// 校验 state,防止 CSRF
|
||||
const savedState = localStorage.getItem("wechat_state")
|
||||
if (!savedState || savedState !== state) {
|
||||
setError("安全校验失败,请重新登录")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
localStorage.removeItem("wechat_state")
|
||||
|
||||
const result = await wechatCallback(code, state)
|
||||
@@ -72,34 +49,41 @@ const WechatCallback: React.FC = () => {
|
||||
const userData = await getCurrentUser()
|
||||
const user: User = normalizeUser(userData)
|
||||
setAuth(user, result.access_token, result.refresh_token)
|
||||
scheduleProactiveRefresh()
|
||||
|
||||
// 新用户 或 资料未完善(如上次中断没填昵称)→ 强制昵称引导
|
||||
const needOnboarding = result.is_new_user || user.profile_completed === false
|
||||
|
||||
if (inIframe) {
|
||||
// 弹窗模式:token 已写入同源 localStorage,通知父窗口同步登录态并跳转
|
||||
postWechatQrResult("login", true, { needOnboarding })
|
||||
return
|
||||
if (result.binding_complete) {
|
||||
// 已绑定,跳转到登录前页面或首页
|
||||
message.success("登录成功")
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
} else {
|
||||
// 未绑定,显示绑定弹窗
|
||||
setLoading(false)
|
||||
setShowBindModal(true)
|
||||
}
|
||||
|
||||
if (needOnboarding) {
|
||||
navigate("/welcome/wechat", { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
// 老用户:回登录前页面或首页
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
} catch (err) {
|
||||
// 透传后端真实错误(如 state 过期、code 已消费、接口异常),禁止吞成通用提示
|
||||
fail(`微信登录失败:${getErrorMessage(err, "请重试或更换登录方式")}`)
|
||||
setError("登录失败,请重试")
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
handleCallback()
|
||||
}, [searchParams, navigate, setAuth, inIframe])
|
||||
}, [searchParams, navigate, setAuth])
|
||||
|
||||
const handleBindSuccess = (user: User) => {
|
||||
const setUser = useAuthStore.getState().setUser
|
||||
setUser(user)
|
||||
setShowBindModal(false)
|
||||
message.success("绑定成功")
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
}
|
||||
|
||||
const handleBindCancel = () => {
|
||||
setShowBindModal(false)
|
||||
navigate("/login")
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -114,39 +98,49 @@ const WechatCallback: React.FC = () => {
|
||||
>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16, color: "#666" }}>微信登录中...</p>
|
||||
<p style={{ marginTop: 16, color: "#666" }}>正在登录...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
background: "#f5f5f5",
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<p style={{ color: "#ef4444", fontSize: 16, marginBottom: 16 }}>{error}</p>
|
||||
<button
|
||||
onClick={() => navigate("/login")}
|
||||
style={{
|
||||
padding: "8px 24px",
|
||||
background: "var(--primary-color, #3b82f6)",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
返回登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
background: "#f5f5f5",
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<p style={{ color: "#ef4444", fontSize: 16, marginBottom: 16 }}>{error}</p>
|
||||
<button
|
||||
onClick={() => navigate("/login")}
|
||||
style={{
|
||||
padding: "8px 24px",
|
||||
background: "var(--primary-color, #3b82f6)",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
返回登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<BindContactModal
|
||||
open={showBindModal}
|
||||
onSuccess={handleBindSuccess}
|
||||
onCancel={handleBindCancel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* 微信新用户昵称引导页
|
||||
* 新微信用户首次登录后强制填写昵称,完成后才进入主界面
|
||||
*/
|
||||
import React, { useRef } from "react"
|
||||
import { Form, Input, message } from "antd"
|
||||
import { Navigate, useNavigate } from "react-router-dom"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { updateProfile } from "@/api/auth"
|
||||
import { getErrorMessage, isErrorMsgShown } from "@/api/errors"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import Button from "@/components/ui/Button"
|
||||
import "./Login.css"
|
||||
|
||||
interface OnboardingFormValues {
|
||||
display_name: string
|
||||
}
|
||||
|
||||
const WechatOnboarding: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
|
||||
const [form] = Form.useForm<OnboardingFormValues>()
|
||||
// 同步防连点守卫:antd loading 要等 React 重渲染后才禁用按钮,
|
||||
// 连点两次时第一次的 mutation 刚触发、重渲染未发生,第二次 click 仍会进来
|
||||
// (截图里 PATCH /me 405 出现两次就是连点导致的重复提交)
|
||||
const submittingRef = useRef(false)
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (displayName: string) => updateProfile({ display_name: displayName }),
|
||||
})
|
||||
|
||||
// 已登录且资料已完善的用户不该停留在引导页
|
||||
if (isAuthenticated && hasAccessToken && user?.profile_completed === true) {
|
||||
return <Navigate to="/app/dashboard" replace />
|
||||
}
|
||||
// 未登录(如手动输入 URL)回登录页
|
||||
if (!isAuthenticated || !hasAccessToken) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
const onFinish = async (values: OnboardingFormValues) => {
|
||||
if (submittingRef.current) return
|
||||
submittingRef.current = true
|
||||
try {
|
||||
const updated = await saveMutation.mutateAsync(values.display_name.trim())
|
||||
// 后端返回的 profile_completed 以最新资料为准,前端同步标记完善
|
||||
setUser({ ...updated, profile_completed: true })
|
||||
message.success("欢迎加入小虾智剪!")
|
||||
const redirect = localStorage.getItem("login_redirect") || "/app/dashboard"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
} catch (err) {
|
||||
// 透传后端真实原因(如接口异常/校验失败);拦截器已弹过的不重复弹
|
||||
if (!isErrorMsgShown(err)) {
|
||||
message.error(`昵称保存失败:${getErrorMessage(err, "请稍后重试")}`)
|
||||
}
|
||||
submittingRef.current = false
|
||||
}
|
||||
// 成功时页面跳走,不复位
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-auth-page">
|
||||
<div className="xx-auth-card">
|
||||
<div className="xx-auth-header">
|
||||
<div className="xx-auth-brand">
|
||||
<span className="xx-auth-logo">🦐</span>
|
||||
<span className="xx-auth-brand-name">小虾智剪</span>
|
||||
</div>
|
||||
<p>欢迎使用微信登录,请先设置您的昵称</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
name="wechat-onboarding"
|
||||
onFinish={onFinish}
|
||||
autoComplete="off"
|
||||
layout="vertical"
|
||||
// 不预填:新微信用户必须自己输入昵称(user.display_name 可能是微信昵称/系统占位)
|
||||
initialValues={{ display_name: "" }}
|
||||
>
|
||||
<Form.Item
|
||||
name="display_name"
|
||||
label="昵称"
|
||||
rules={[
|
||||
{ required: true, message: "请输入昵称" },
|
||||
{ whitespace: true, message: "昵称不能为空白" },
|
||||
{ min: 1, max: 20, message: "昵称长度需在 1-20 个字符之间" },
|
||||
]}
|
||||
extra="昵称将展示在您的作品和账户中,之后可在个人设置中修改"
|
||||
>
|
||||
<Input placeholder="请输入您的昵称" size="large" maxLength={20} showCount />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="lg"
|
||||
htmlType="submit"
|
||||
loading={saveMutation.isPending}
|
||||
disabled={saveMutation.isPending}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{saveMutation.isPending ? "保存中..." : "进入小虾智剪"}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WechatOnboarding
|
||||
@@ -133,7 +133,7 @@ const GeneratePage: React.FC = () => {
|
||||
const [previewVoiceAudioUrl, setPreviewVoiceAudioUrl] = useState<string | null>(null)
|
||||
const ttsAbortRef = useRef<AbortController | null>(null)
|
||||
// TTS 试听文案:批量跟随变体0标题(仅取首项,避免编辑其他变体标题触发多余 TTS 请求)
|
||||
const variant0Title = isBatch ? previewTitles?.[0] || "" : ""
|
||||
const variant0Title = isBatch ? previewTitles[0] || "" : ""
|
||||
|
||||
useEffect(() => {
|
||||
const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice)
|
||||
|
||||
@@ -22,9 +22,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
titles,
|
||||
onRetryTask,
|
||||
}) => {
|
||||
const sorted = [...tasks].sort(
|
||||
(a, b) => Number(a.variantIndex || 0) - Number(b.variantIndex || 0),
|
||||
)
|
||||
const sorted = [...tasks].sort((a, b) => (a.variantIndex || 0) - (b.variantIndex || 0))
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { AssetItem } from "@/api/assets"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { TitleSettings } from "../types"
|
||||
import FrontendPreviewPlayer from "./FrontendPreviewPlayer"
|
||||
import { MAX_PREVIEW_COUNT } from "../constants"
|
||||
|
||||
interface CanvasPreviewGridProps {
|
||||
count: number
|
||||
@@ -41,11 +42,11 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
|
||||
onToggleSelect,
|
||||
selectable = true,
|
||||
}) => {
|
||||
// count 上限已在源头 PreviewCountModal 的数量选择(1~MAX_PREVIEW_COUNT=10)clamp,
|
||||
// 这里完整渲染所有变体,保证每个变体都有勾选/预览入口,UI 与数据不脱节
|
||||
// 硬上限保护:同时播放的媒体元素数量不超过 MAX_PREVIEW_COUNT(10),避免浏览器卡顿
|
||||
const safeCount = Math.max(1, Math.min(count, MAX_PREVIEW_COUNT))
|
||||
return (
|
||||
<div className="xx-canvas-grid">
|
||||
{Array.from({ length: count }, (_, i) => {
|
||||
{Array.from({ length: safeCount }, (_, i) => {
|
||||
const checked = selectedIds.includes(i)
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -9,13 +9,12 @@
|
||||
* - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一
|
||||
*/
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { Input, message } from "antd"
|
||||
import { AutoComplete, Input, message } from "antd"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
|
||||
import { useStep4Title } from "../hooks/useStep4Title"
|
||||
import AiTitleGenerator from "./title/AiTitleGenerator"
|
||||
import TitleLibraryAutoComplete from "./title/TitleLibraryAutoComplete"
|
||||
import TitleStylePanel from "./title/TitleStylePanel"
|
||||
import { AI_TITLE_TEMPLATES } from "../constants"
|
||||
|
||||
@@ -202,14 +201,21 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
</div>
|
||||
<div className="xx-form-field">
|
||||
<label>标题</label>
|
||||
<TitleLibraryAutoComplete
|
||||
placeholder="输入或从标题库选择"
|
||||
value={previewTitles?.[0] ?? t.titleSettings.title}
|
||||
<AutoComplete
|
||||
placeholder="输入标题文字…"
|
||||
allowClear
|
||||
maxLength={50}
|
||||
style={{ width: "100%" }}
|
||||
value={(previewTitles?.[0] ?? t.titleSettings.title) || undefined}
|
||||
onChange={(val) => {
|
||||
t.updateTitle(val || "")
|
||||
onPreviewTitlesChange?.([val || ""])
|
||||
}}
|
||||
options={titleOptions}
|
||||
filterOption={(inputValue, option) => {
|
||||
const title = (option?.label || option?.value || "") as string
|
||||
return title.toLowerCase().includes((inputValue || "").toLowerCase())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -263,11 +269,17 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
{Array.from({ length: previewCount }, (_, i) => (
|
||||
<div className="xx-form-field" key={i}>
|
||||
<label>视频 {i + 1} 标题</label>
|
||||
<TitleLibraryAutoComplete
|
||||
placeholder={`输入或选择视频 ${i + 1} 的标题`}
|
||||
value={previewTitles?.[i] || ""}
|
||||
onChange={(val) => updateVariantTitle(i, val)}
|
||||
<AutoComplete
|
||||
placeholder={`视频 ${i + 1} 的标题…`}
|
||||
maxLength={50}
|
||||
style={{ width: "100%" }}
|
||||
value={previewTitles?.[i] || undefined}
|
||||
onChange={(val) => updateVariantTitle(i, val || "")}
|
||||
options={titleOptions}
|
||||
filterOption={(inputValue, option) => {
|
||||
const title = (option?.label || option?.value || "") as string
|
||||
return title.toLowerCase().includes((inputValue || "").toLowerCase())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* 标题库 AutoComplete(Issue #1737)
|
||||
*
|
||||
* 原生 antd AutoComplete(combobox 模式)的两个行为不符合产品预期:
|
||||
* 1. combobox 默认 showAction=[],输入框聚焦时下拉不展开——用户必须先打字才能看到标题库,
|
||||
* 且组件无下拉箭头,视觉上是"纯输入框",不知道标题库里已有标题可选。
|
||||
* 2. 空态聚焦不展示任何标题库内容。
|
||||
*
|
||||
* 本组件封装修复:
|
||||
* - 受控 open:聚焦(且标题库非空)即展开,展示全部标题;失焦/选中/Esc 关闭
|
||||
* (rc-select 失焦会主动 onToggleOpen(false),onOpenChange 同步状态即可,不会死循环)
|
||||
* - suffixIcon 加下拉三角,视觉提示"可选择";有值时 allowClear 的清除按钮照常出现
|
||||
* - 输入文字时由 filterOption 过滤(空串展示全部)
|
||||
* - 保留 combobox 自由输入能力:用户可输入标题库之外的自定义标题
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import { AutoComplete } from "antd"
|
||||
import { DownOutlined } from "@ant-design/icons"
|
||||
import type { AutoCompleteProps } from "antd"
|
||||
|
||||
export interface TitleOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface TitleLibraryAutoCompleteProps {
|
||||
value: string
|
||||
onChange: (val: string) => void
|
||||
options: TitleOption[]
|
||||
placeholder?: string
|
||||
allowClear?: boolean
|
||||
maxLength?: number
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const TitleLibraryAutoComplete: React.FC<TitleLibraryAutoCompleteProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = "输入或从标题库选择",
|
||||
allowClear = true,
|
||||
maxLength = 50,
|
||||
style,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const hasTitles = options.length > 0
|
||||
|
||||
const filterOption: AutoCompleteProps["filterOption"] = (inputValue, option) => {
|
||||
const title = (option?.label || option?.value || "") as string
|
||||
return title.toLowerCase().includes((inputValue || "").toLowerCase())
|
||||
}
|
||||
|
||||
return (
|
||||
<AutoComplete
|
||||
value={value || undefined}
|
||||
onChange={(val) => onChange(val || "")}
|
||||
options={options}
|
||||
filterOption={filterOption}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onFocus={() => {
|
||||
// 标题库为空时不展开(避免弹出"暂无数据"空壳)
|
||||
if (hasTitles) setOpen(true)
|
||||
}}
|
||||
onSelect={() => setOpen(false)}
|
||||
suffixIcon={<DownOutlined style={{ color: "var(--text-secondary, #bbb)", fontSize: 12 }} />}
|
||||
placeholder={placeholder}
|
||||
allowClear={allowClear}
|
||||
maxLength={maxLength}
|
||||
style={{ width: "100%", ...style }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitleLibraryAutoComplete
|
||||
@@ -178,35 +178,3 @@
|
||||
border-color: var(--border-color);
|
||||
margin: var(--space-lg) 0;
|
||||
}
|
||||
|
||||
/* 微信账号绑定卡片 */
|
||||
.xx-settings-wechat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-lg);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.xx-settings-wechat-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-settings-wechat-info .xx-wechat-icon {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.xx-settings-wechat-info strong {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
.xx-settings-wechat-info p {
|
||||
margin: 2px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
@@ -1,110 +1,39 @@
|
||||
/**
|
||||
* 个人设置页面
|
||||
* - 个人资料(昵称)保存
|
||||
* - 微信账号绑定状态 / 绑定 / 解绑
|
||||
* P1-2: 添加 PageHead
|
||||
* P1-3: antd Form/Input/Button/Alert → 自定义 UI 组件
|
||||
*/
|
||||
import React, { useEffect, useRef, useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import React, { useState } from "react"
|
||||
import { Button, Input, Modal } from "@/components/ui"
|
||||
import { getCurrentUser, updateProfile, unbindWechat } from "@/api/auth"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import WechatQrModal from "@/components/auth/WechatQrModal"
|
||||
import "./ProfileSettings.css"
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
const queryClient = useQueryClient()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [displayName, setDisplayName] = useState(user?.display_name || "")
|
||||
const [wechatBindOpen, setWechatBindOpen] = useState(false)
|
||||
const bindTipShownRef = useRef(false)
|
||||
|
||||
// 拉取最新用户信息(微信绑定状态以后端为准)
|
||||
const { data: freshUser } = useQuery({
|
||||
queryKey: ["currentUser"],
|
||||
queryFn: getCurrentUser,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (freshUser) {
|
||||
setUser(freshUser)
|
||||
setDisplayName((prev) => prev || freshUser.display_name || "")
|
||||
}
|
||||
}, [freshUser, setUser])
|
||||
|
||||
// 绑定回调结果提示(?wechat_bind=success|failed)
|
||||
useEffect(() => {
|
||||
if (bindTipShownRef.current) return
|
||||
const result = searchParams.get("wechat_bind")
|
||||
if (!result) return
|
||||
bindTipShownRef.current = true
|
||||
if (result === "success") {
|
||||
message.success("微信绑定成功")
|
||||
} else if (result === "failed") {
|
||||
message.error("微信绑定失败,请重试")
|
||||
}
|
||||
searchParams.delete("wechat_bind")
|
||||
setSearchParams(searchParams, { replace: true })
|
||||
}, [searchParams, setSearchParams])
|
||||
|
||||
const wechatBound = user?.wechat_bound === true
|
||||
|
||||
const saveProfileMutation = useMutation({
|
||||
mutationFn: () => updateProfile({ display_name: displayName.trim() }),
|
||||
onSuccess: (updated) => {
|
||||
setUser(updated)
|
||||
message.success("资料已保存")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("保存失败,请重试")
|
||||
},
|
||||
})
|
||||
|
||||
// 弹窗扫码绑定成功:关闭弹窗,刷新用户信息并提示
|
||||
const handleBindSuccess = () => {
|
||||
setWechatBindOpen(false)
|
||||
queryClient.invalidateQueries({ queryKey: ["currentUser"] })
|
||||
message.success("微信绑定成功")
|
||||
}
|
||||
|
||||
const unbindMutation = useMutation({
|
||||
mutationFn: unbindWechat,
|
||||
onSuccess: () => {
|
||||
message.success("已解绑微信")
|
||||
queryClient.invalidateQueries({ queryKey: ["currentUser"] })
|
||||
// 本地立即更新,避免等待刷新
|
||||
if (user) {
|
||||
setUser({ ...user, wechat_bound: false, wechat_nickname: "" })
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
message.error("解绑失败,请重试")
|
||||
},
|
||||
})
|
||||
|
||||
const handleUnbind = () => {
|
||||
Modal.confirm({
|
||||
title: "解绑微信",
|
||||
content: "解绑后将无法使用微信登录该账号,确定要解绑吗?",
|
||||
okText: "确定解绑",
|
||||
cancelText: "取消",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => unbindMutation.mutateAsync(),
|
||||
const handleSave = () => {
|
||||
Modal.info({
|
||||
title: "提示",
|
||||
content: "个人资料修改接口暂未开放,保存功能即将上线。",
|
||||
})
|
||||
}
|
||||
|
||||
const displayNameDirty = displayName.trim() !== (user?.display_name || "")
|
||||
|
||||
return (
|
||||
<div className="xx-settings-page">
|
||||
<PageHead title="个人设置" description="管理您的账户信息" />
|
||||
|
||||
<div className="xx-settings-card">
|
||||
<h3>个人信息</h3>
|
||||
<div className="xx-settings-notice">
|
||||
<span className="xx-settings-notice-icon">ℹ️</span>
|
||||
<div>
|
||||
<strong>个人资料编辑暂未开放</strong>
|
||||
<p>当前仅展示登录用户信息,资料修改接口接入后再开放保存。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-settings-form">
|
||||
<div className="xx-settings-field">
|
||||
<label className="xx-settings-label">用户名</label>
|
||||
@@ -113,83 +42,25 @@ const Settings: React.FC = () => {
|
||||
|
||||
<div className="xx-settings-field">
|
||||
<label className="xx-settings-label">邮箱</label>
|
||||
<Input
|
||||
value={user?.email && !user.email.endsWith("@wechat.local") ? user.email : ""}
|
||||
disabled
|
||||
placeholder={user?.email?.endsWith("@wechat.local") ? "微信账号暂未绑定邮箱" : "邮箱"}
|
||||
/>
|
||||
<Input value={user?.email || ""} disabled placeholder="邮箱" />
|
||||
</div>
|
||||
|
||||
<div className="xx-settings-field">
|
||||
<label className="xx-settings-label">昵称</label>
|
||||
<label className="xx-settings-label">显示名称</label>
|
||||
<Input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="请输入昵称"
|
||||
maxLength={20}
|
||||
placeholder="请输入显示名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="xx-settings-field">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => saveProfileMutation.mutate()}
|
||||
loading={saveProfileMutation.isPending}
|
||||
disabled={!displayName.trim() || !displayNameDirty}
|
||||
>
|
||||
保存
|
||||
<Button buttonType="primary" buttonSize="md" onClick={handleSave} disabled>
|
||||
保存暂未开放
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-settings-card">
|
||||
<h3>微信账号</h3>
|
||||
<div className="xx-settings-wechat">
|
||||
<div className="xx-settings-wechat-info">
|
||||
<span className="xx-wechat-icon">💬</span>
|
||||
<div>
|
||||
{wechatBound ? (
|
||||
<>
|
||||
<strong>
|
||||
已绑定微信{user?.wechat_nickname ? `(${user.wechat_nickname})` : ""}
|
||||
</strong>
|
||||
<p>可使用微信扫码登录本账号</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>未绑定微信</strong>
|
||||
<p>绑定后可使用微信扫码快速登录</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-settings-wechat-actions">
|
||||
{wechatBound ? (
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="md"
|
||||
onClick={handleUnbind}
|
||||
loading={unbindMutation.isPending}
|
||||
>
|
||||
解绑
|
||||
</Button>
|
||||
) : (
|
||||
<Button buttonType="primary" buttonSize="md" onClick={() => setWechatBindOpen(true)}>
|
||||
绑定微信
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WechatQrModal
|
||||
open={wechatBindOpen}
|
||||
scene="bind"
|
||||
onClose={() => setWechatBindOpen(false)}
|
||||
onBindSuccess={handleBindSuccess}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,16 +6,10 @@ import { useAuthStore } from "@/store/authStore"
|
||||
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
|
||||
const profileCompleted = useAuthStore((state) => state.user?.profile_completed !== false)
|
||||
|
||||
if (!isAuthenticated || !hasAccessToken) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
// 微信新用户未完成昵称引导时,禁止进入主界面
|
||||
if (!profileCompleted) {
|
||||
return <Navigate to="/welcome/wechat" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Navigate, type RouteObject } from "react-router-dom"
|
||||
import MainLayout from "@/components/layout/MainLayout"
|
||||
import { ProtectedRoute } from "./ProtectedRoute"
|
||||
import { lazyRoute } from "./lazyRoute"
|
||||
|
||||
/**
|
||||
* 受保护的 /app 子路由
|
||||
@@ -14,118 +13,202 @@ const appChildren: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
path: "dashboard",
|
||||
lazy: lazyRoute(() => import("@/pages/dashboard/Dashboard")),
|
||||
lazy: () =>
|
||||
import("@/pages/dashboard/Dashboard").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "assets",
|
||||
lazy: lazyRoute(() => import("@/pages/assets/AssetLibrary")),
|
||||
lazy: () =>
|
||||
import("@/pages/assets/AssetLibrary").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "titles",
|
||||
lazy: lazyRoute(() => import("@/pages/titles/TitleLibrary")),
|
||||
lazy: () =>
|
||||
import("@/pages/titles/TitleLibrary").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "voices",
|
||||
lazy: lazyRoute(() => import("@/pages/voices/VoiceLibrary")),
|
||||
lazy: () =>
|
||||
import("@/pages/voices/VoiceLibrary").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "templates",
|
||||
lazy: lazyRoute(() => import("@/pages/templates/TemplateLibrary")),
|
||||
lazy: () =>
|
||||
import("@/pages/templates/TemplateLibrary").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "generate",
|
||||
lazy: lazyRoute(() => import("@/pages/generate/GeneratePage")),
|
||||
lazy: () =>
|
||||
import("@/pages/generate/GeneratePage").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "history",
|
||||
lazy: lazyRoute(() => import("@/pages/history/TaskHistory")),
|
||||
lazy: () =>
|
||||
import("@/pages/history/TaskHistory").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "products",
|
||||
lazy: lazyRoute(() => import("@/pages/products/ProductLibrary")),
|
||||
lazy: () =>
|
||||
import("@/pages/products/ProductLibrary").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "products/:id",
|
||||
lazy: lazyRoute(() => import("@/pages/products/ProductDetail")),
|
||||
lazy: () =>
|
||||
import("@/pages/products/ProductDetail").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "tasks",
|
||||
lazy: lazyRoute(() => import("@/pages/tasks/TaskCenter")),
|
||||
lazy: () =>
|
||||
import("@/pages/tasks/TaskCenter").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "editing-planner",
|
||||
lazy: lazyRoute(() => import("@/pages/editing-planner/EditingPlanner")),
|
||||
lazy: () =>
|
||||
import("@/pages/editing-planner/EditingPlanner").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "my-templates",
|
||||
lazy: lazyRoute(() => import("@/pages/my-templates/MyTemplates")),
|
||||
lazy: () =>
|
||||
import("@/pages/my-templates/MyTemplates").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "voice-clone",
|
||||
lazy: lazyRoute(() => import("@/pages/voice-clone/VoiceClone")),
|
||||
lazy: () =>
|
||||
import("@/pages/voice-clone/VoiceClone").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "voice-materials",
|
||||
lazy: lazyRoute(() => import("@/pages/voice-materials/VoiceMaterialLibrary")),
|
||||
lazy: () =>
|
||||
import("@/pages/voice-materials/VoiceMaterialLibrary").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "my-voices",
|
||||
lazy: lazyRoute(() => import("@/pages/my-voices/MyVoices")),
|
||||
lazy: () =>
|
||||
import("@/pages/my-voices/MyVoices").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "accounts",
|
||||
lazy: lazyRoute(() => import("@/pages/accounts/Accounts")),
|
||||
lazy: () =>
|
||||
import("@/pages/accounts/Accounts").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "duplication",
|
||||
lazy: lazyRoute(() => import("@/pages/duplication/DuplicationUpload")),
|
||||
lazy: () =>
|
||||
import("@/pages/duplication/DuplicationUpload").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "duplication/results",
|
||||
lazy: lazyRoute(() => import("@/pages/duplication/DuplicationResults")),
|
||||
lazy: () =>
|
||||
import("@/pages/duplication/DuplicationResults").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "duplication/:id",
|
||||
lazy: lazyRoute(() => import("@/pages/duplication/DuplicationDetail")),
|
||||
lazy: () =>
|
||||
import("@/pages/duplication/DuplicationDetail").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "subscription",
|
||||
lazy: lazyRoute(() => import("@/pages/subscription/Plans")),
|
||||
lazy: () =>
|
||||
import("@/pages/subscription/Plans").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "subscription/upgrade",
|
||||
lazy: lazyRoute(() => import("@/pages/subscription/UpgradeSubscription")),
|
||||
lazy: () =>
|
||||
import("@/pages/subscription/UpgradeSubscription").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "subscription/billing",
|
||||
lazy: lazyRoute(() => import("@/pages/subscription/Billing")),
|
||||
lazy: () =>
|
||||
import("@/pages/subscription/Billing").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "profile",
|
||||
lazy: lazyRoute(() => import("@/pages/profile/Settings")),
|
||||
lazy: () =>
|
||||
import("@/pages/profile/Settings").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "admin",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
|
||||
lazy: () =>
|
||||
import("@/pages/admin/AdminComingSoon").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "users",
|
||||
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
|
||||
lazy: () =>
|
||||
import("@/pages/admin/AdminComingSoon").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "analytics",
|
||||
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
|
||||
lazy: () =>
|
||||
import("@/pages/admin/AdminComingSoon").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "monitor",
|
||||
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
|
||||
lazy: () =>
|
||||
import("@/pages/admin/AdminComingSoon").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "logs",
|
||||
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
|
||||
lazy: () =>
|
||||
import("@/pages/admin/AdminComingSoon").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { LazyRouteFunction, RouteObject } from "react-router-dom"
|
||||
import { isChunkLoadError } from "@/utils/chunkLoadError"
|
||||
|
||||
/**
|
||||
* 给 React Router data router 的路由懒加载包一层自动重试:
|
||||
*
|
||||
* - 网络抖动 / 瞬态失败:自动重试最多 2 次(间隔 300ms / 800ms),用户无感恢复
|
||||
* - 发版后旧 chunk 404(chunk 文件名已不存在):重试也拿不到旧文件名,
|
||||
* 重试耗尽后抛出,由全局 ChunkErrorBoundary 捕获并引导整页刷新
|
||||
* (刷新后 index.html 是 no-cache 的,会拿到新 chunk 引用)
|
||||
*/
|
||||
const RETRY_DELAYS_MS = [300, 800]
|
||||
const RETRY_COUNT = RETRY_DELAYS_MS.length
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
export const lazyRoute = (
|
||||
factory: () => Promise<{ default: React.ComponentType }>,
|
||||
): LazyRouteFunction<RouteObject> => {
|
||||
return async () => {
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt <= RETRY_COUNT; attempt++) {
|
||||
try {
|
||||
const mod = await factory()
|
||||
if (!mod.default) {
|
||||
throw new Error("lazyRoute: 目标模块缺少 default 导出")
|
||||
}
|
||||
return { Component: mod.default }
|
||||
} catch (err) {
|
||||
lastError = err
|
||||
// 非 chunk 加载错误(代码 bug 等)立即抛出,不浪费重试
|
||||
if (!isChunkLoadError(err)) throw err
|
||||
if (attempt < RETRY_COUNT) {
|
||||
await sleep(RETRY_DELAYS_MS[attempt])
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import Register from "@/pages/auth/Register"
|
||||
import ForgotPassword from "@/pages/auth/ForgotPassword"
|
||||
import ResetPassword from "@/pages/auth/ResetPassword"
|
||||
import WechatCallback from "@/pages/auth/WechatCallback"
|
||||
import WechatOnboarding from "@/pages/auth/WechatOnboarding"
|
||||
import WechatBindCallback from "@/pages/auth/WechatBindCallback"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
|
||||
/** 首页路由组件:已登录跳 dashboard,未登录显示落地页 */
|
||||
@@ -47,12 +45,4 @@ export const publicRoutes: RouteObject[] = [
|
||||
path: "/auth/wechat/callback",
|
||||
element: <WechatCallback />,
|
||||
},
|
||||
{
|
||||
path: "/auth/wechat/bind/callback",
|
||||
element: <WechatBindCallback />,
|
||||
},
|
||||
{
|
||||
path: "/welcome/wechat",
|
||||
element: <WechatOnboarding />,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -13,12 +13,6 @@ interface User {
|
||||
display_name: string
|
||||
is_email_verified: boolean
|
||||
email_verified: boolean
|
||||
wechat_bound?: boolean
|
||||
wechat_nickname?: string
|
||||
avatar_url?: string
|
||||
phone?: string
|
||||
phone_verified?: boolean
|
||||
profile_completed?: boolean
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
|
||||
@@ -242,20 +242,6 @@ describe("assets API", () => {
|
||||
await expect(completeDirectUpload({ name: "test-item" })).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("请求体携带 file_size(后端同名兜底去重的大小校验依赖它)", async () => {
|
||||
await completeDirectUpload({
|
||||
project_id: "p-1",
|
||||
library_id: "l-1",
|
||||
storage_key: "uploads/k.mp4",
|
||||
file_size: 12345,
|
||||
} as never)
|
||||
const completeCalls = mockPost.mock.calls.filter(
|
||||
([u]: [string]) => u === "/upload/direct/complete",
|
||||
)
|
||||
expect(completeCalls).toHaveLength(1)
|
||||
expect(completeCalls[0][1]).toMatchObject({ file_size: 12345 })
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"))
|
||||
mockPost.mockRejectedValue(new Error("Network error"))
|
||||
@@ -273,114 +259,6 @@ describe("assets API", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("uploadAssetDirect skip_transfer 短路", () => {
|
||||
it("prepare 返回 skip_transfer=true → 直接返回 duplicated,不调 transfer/complete", async () => {
|
||||
mockPost.mockImplementation((url: string) => {
|
||||
if (url === "/upload/direct/prepare") {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
upload_url: "https://oss/x",
|
||||
method: "POST",
|
||||
storage_key: "uploads/skip/y.mp4",
|
||||
expires_at: "2099",
|
||||
fields: {},
|
||||
max_size_bytes: 1e9,
|
||||
asset_id: "existing-asset",
|
||||
skip_transfer: true,
|
||||
duplicated: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (url === "/upload/direct/complete") {
|
||||
throw new Error("complete 不应被调用")
|
||||
}
|
||||
throw new Error("unexpected url " + url)
|
||||
})
|
||||
const putSpy = vi.spyOn(globalThis, "XMLHttpRequest")
|
||||
const file = new File(["x"], "x.mp4", { type: "video/mp4" })
|
||||
const result = await uploadAssetDirect({ file, library_id: "lib-1" })
|
||||
expect(result.duplicated).toBe(true)
|
||||
expect(result.asset_id).toBe("existing-asset")
|
||||
// complete 未被调用(mockPost 只记录 prepare,complete 若调用会抛 "不应被调用")
|
||||
const completeCalls = mockPost.mock.calls.filter(
|
||||
([u]: [string]) => u === "/upload/direct/complete",
|
||||
)
|
||||
expect(completeCalls).toHaveLength(0)
|
||||
putSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("prepare 返回 skip_transfer=false → 走老流程(complete 被调用)", async () => {
|
||||
mockPost.mockImplementation((url: string) => {
|
||||
if (url === "/upload/direct/prepare") {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
upload_url: "https://oss/x",
|
||||
method: "POST",
|
||||
storage_key: "uploads/normal/y.mp4",
|
||||
expires_at: "2099",
|
||||
fields: {},
|
||||
max_size_bytes: 1e9,
|
||||
asset_id: "new-asset",
|
||||
},
|
||||
})
|
||||
}
|
||||
if (url === "/upload/direct/complete") {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
storage_key: "uploads/normal/y.mp4",
|
||||
ingest_job_id: "job-1",
|
||||
url: "https://oss/y.mp4",
|
||||
duplicated: false,
|
||||
asset_id: "new-asset",
|
||||
},
|
||||
})
|
||||
}
|
||||
throw new Error("unexpected url " + url)
|
||||
})
|
||||
// mock XMLHttpRequest:send 之后下一 tick 触发 onload 让 transfer 立即成功
|
||||
const origOpen = XMLHttpRequest.prototype.open
|
||||
const origSend = XMLHttpRequest.prototype.send
|
||||
const origSetReadyState = Object.getOwnPropertyDescriptor(
|
||||
XMLHttpRequest.prototype,
|
||||
"readyState",
|
||||
) as PropertyDescriptor | undefined
|
||||
const origStatus = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, "status")
|
||||
Object.defineProperty(XMLHttpRequest.prototype, "readyState", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 4,
|
||||
})
|
||||
Object.defineProperty(XMLHttpRequest.prototype, "status", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 200,
|
||||
})
|
||||
XMLHttpRequest.prototype.open = vi.fn() as unknown as typeof origOpen
|
||||
XMLHttpRequest.prototype.send = vi.fn(function (this: XMLHttpRequest) {
|
||||
// 下一 tick 触发 onload(模拟 XHR 异步完成)
|
||||
setTimeout(() => this.onload?.(new ProgressEvent("load")), 0)
|
||||
}) as unknown as typeof origSend
|
||||
const file = new File(["x"], "x.mp4", { type: "video/mp4" })
|
||||
const result = await uploadAssetDirect({ file, library_id: "lib-1" })
|
||||
expect(result.duplicated).toBeFalsy()
|
||||
expect(result.asset_id).toBe("new-asset")
|
||||
const completeCalls = mockPost.mock.calls.filter(
|
||||
([u]: [string]) => u === "/upload/direct/complete",
|
||||
)
|
||||
expect(completeCalls).toHaveLength(1)
|
||||
// complete 请求必须带上 file_size,否则后端同名兜底会误杀同名新视频
|
||||
expect(completeCalls[0][1]).toMatchObject({ file_size: file.size })
|
||||
XMLHttpRequest.prototype.open = origOpen
|
||||
XMLHttpRequest.prototype.send = origSend
|
||||
if (origSetReadyState) {
|
||||
Object.defineProperty(XMLHttpRequest.prototype, "readyState", origSetReadyState)
|
||||
}
|
||||
if (origStatus) {
|
||||
Object.defineProperty(XMLHttpRequest.prototype, "status", origStatus)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("getIngestJob", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getIngestJob("test-jobId")).resolves.not.toThrow()
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* 上传去重/幂等工具单测(Issue #1714)
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest"
|
||||
import {
|
||||
computeFileHash,
|
||||
findDuplicateInQueue,
|
||||
HASH_FULL_READ_LIMIT,
|
||||
HASH_SAMPLE_CHUNK,
|
||||
makeClientUploadId,
|
||||
makeFileFingerprint,
|
||||
} from "@/api/assets/uploadDedup"
|
||||
|
||||
const makeFile = (name: string, size = 100, lastModified = 1_700_000_000_000) =>
|
||||
new File([new Uint8Array(size)], name, { type: "video/mp4", lastModified })
|
||||
|
||||
describe("makeFileFingerprint", () => {
|
||||
it("同一文件(name+size+lastModified 相同)指纹一致", () => {
|
||||
const a = makeFile("a.mp4", 1000, 12345)
|
||||
const b = makeFile("a.mp4", 1000, 12345)
|
||||
expect(makeFileFingerprint(a)).toBe(makeFileFingerprint(b))
|
||||
})
|
||||
|
||||
it("文件名/大小/修改时间任一不同指纹即不同", () => {
|
||||
const base = makeFile("a.mp4", 1000, 100)
|
||||
expect(makeFileFingerprint(base)).not.toBe(makeFileFingerprint(makeFile("b.mp4", 1000, 100)))
|
||||
expect(makeFileFingerprint(base)).not.toBe(makeFileFingerprint(makeFile("a.mp4", 1001, 100)))
|
||||
expect(makeFileFingerprint(base)).not.toBe(makeFileFingerprint(makeFile("a.mp4", 1000, 101)))
|
||||
})
|
||||
})
|
||||
|
||||
describe("findDuplicateInQueue", () => {
|
||||
const queue = [
|
||||
{ fileKey: "k1", status: "preparing" },
|
||||
{ fileKey: "k2", status: "uploading" },
|
||||
{ fileKey: "k3", status: "ingesting" },
|
||||
{ fileKey: "k4", status: "done" },
|
||||
{ fileKey: "k5", status: "error" },
|
||||
]
|
||||
|
||||
it("在途状态(preparing/uploading/ingesting/done)命中重复", () => {
|
||||
expect(findDuplicateInQueue(queue, "k1")?.status).toBe("preparing")
|
||||
expect(findDuplicateInQueue(queue, "k2")?.status).toBe("uploading")
|
||||
expect(findDuplicateInQueue(queue, "k3")?.status).toBe("ingesting")
|
||||
expect(findDuplicateInQueue(queue, "k4")?.status).toBe("done")
|
||||
})
|
||||
|
||||
it("未命中返回 null", () => {
|
||||
expect(findDuplicateInQueue(queue, "missing")).toBeNull()
|
||||
})
|
||||
|
||||
it("排除 error 状态后,失败项不算重复(允许重新激活)", () => {
|
||||
expect(findDuplicateInQueue(queue, "k5", ["error"])).toBeNull()
|
||||
})
|
||||
|
||||
it("同时排除 done 后,已完成项也不算重复", () => {
|
||||
expect(findDuplicateInQueue(queue, "k4", ["error", "done"])).toBeNull()
|
||||
// 但在途的仍然命中
|
||||
expect(findDuplicateInQueue(queue, "k1", ["error", "done"])).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("makeClientUploadId", () => {
|
||||
it("生成带前缀且互不相同的幂等 token", () => {
|
||||
const ids = new Set(Array.from({ length: 20 }, () => makeClientUploadId()))
|
||||
expect(ids.size).toBe(20)
|
||||
for (const id of ids) expect(id.startsWith("up_")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("computeFileHash", () => {
|
||||
it("相同内容 hash 一致、不同内容 hash 不同", async () => {
|
||||
const f1 = makeFile("a.mp4", 4096)
|
||||
const f2 = makeFile("b.mp4", 4096)
|
||||
// 两个文件都是 0 填充,内容相同 → hash 一致
|
||||
expect(await computeFileHash(f1)).toBe(await computeFileHash(f2))
|
||||
|
||||
const f3 = new File([new Uint8Array(4096).fill(7)], "c.mp4", { type: "video/mp4" })
|
||||
expect(await computeFileHash(f1)).not.toBe(await computeFileHash(f3))
|
||||
})
|
||||
|
||||
it("返回 64 位十六进制(SHA-256,与后端 file_hash 长度一致)", async () => {
|
||||
const hash = await computeFileHash(makeFile("a.mp4", 1024))
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("computeFileHash 大文件抽样(>64MB)", () => {
|
||||
it("抽样路径正常返回 64 位 hex,且大小不同则 hash 不同", async () => {
|
||||
// mock 一个「声称」300MB 的 File:slice 返回小 buffer 即可,不真分配 300MB
|
||||
const makeBig = (declaredSize: number, head: number) => {
|
||||
const f = new File([new Uint8Array([head, 2, 3])], "big.mov", { type: "video/quicktime" })
|
||||
Object.defineProperty(f, "size", { value: declaredSize, configurable: true })
|
||||
// slice 仍按真实内容返回小片段(头尾片段内容由底层小 buffer 决定)
|
||||
return f
|
||||
}
|
||||
const h1 = await computeFileHash(makeBig(300 * 1024 * 1024, 1))
|
||||
const h2 = await computeFileHash(makeBig(301 * 1024 * 1024, 1))
|
||||
expect(h1).toMatch(/^[0-9a-f]{64}$/)
|
||||
// 声明大小不同 → 写入的 64 位 size 字段不同 → hash 必须不同(锁定 setBigUint64 路径)
|
||||
expect(h1).not.toBe(h2)
|
||||
})
|
||||
|
||||
it("≤64MB 走全量读取(slice 一次覆盖整个文件)", async () => {
|
||||
const f = new File([new Uint8Array(1024).fill(9)], "full.mp4", { type: "video/mp4" })
|
||||
Object.defineProperty(f, "size", { value: HASH_FULL_READ_LIMIT, configurable: true })
|
||||
const sliceSpy = vi.spyOn(f, "slice")
|
||||
await computeFileHash(f)
|
||||
// 全量路径:唯一一次 slice 为 (0, size)
|
||||
expect(sliceSpy).toHaveBeenCalledTimes(1)
|
||||
expect(sliceSpy).toHaveBeenCalledWith(0, HASH_FULL_READ_LIMIT)
|
||||
sliceSpy.mockRestore()
|
||||
})
|
||||
|
||||
it(">64MB 只读取头尾各 16MB 抽样,绝不整文件读入内存", async () => {
|
||||
const f = new File([new Uint8Array(1024).fill(9)], "big.mp4", { type: "video/mp4" })
|
||||
Object.defineProperty(f, "size", { value: HASH_FULL_READ_LIMIT + 1, configurable: true })
|
||||
const sliceSpy = vi.spyOn(f, "slice")
|
||||
await computeFileHash(f)
|
||||
// 抽样路径:两次 slice —— 头部 (0, 16MB) 与尾部 (size-16MB, size)
|
||||
expect(sliceSpy).toHaveBeenCalledTimes(2)
|
||||
expect(sliceSpy).toHaveBeenNthCalledWith(1, 0, HASH_SAMPLE_CHUNK)
|
||||
expect(sliceSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
HASH_FULL_READ_LIMIT + 1 - HASH_SAMPLE_CHUNK,
|
||||
HASH_FULL_READ_LIMIT + 1,
|
||||
)
|
||||
sliceSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
|
||||
describe("wxLogin 工具", () => {
|
||||
describe("parseWxAuthUrl", () => {
|
||||
it("从微信授权链接解析出 appid/redirect_uri/state(redirect_uri 解码)", async () => {
|
||||
const { parseWxAuthUrl } = await import("@/api/auth/wxLogin")
|
||||
const authUrl =
|
||||
"https://open.weixin.qq.com/connect/qrconnect?appid=wxb7ae80b48e53980d" +
|
||||
"&redirect_uri=https%3A%2F%2Fstaging.xiaoxiajianji.com%2Fauth%2Fwechat%2Fcallback" +
|
||||
"&response_type=code&scope=snsapi_login&state=abc123#wechat_redirect"
|
||||
const params = parseWxAuthUrl(authUrl)
|
||||
expect(params).not.toBeNull()
|
||||
expect(params?.appid).toBe("wxb7ae80b48e53980d")
|
||||
expect(params?.redirect_uri).toBe("https://staging.xiaoxiajianji.com/auth/wechat/callback")
|
||||
expect(params?.state).toBe("abc123")
|
||||
})
|
||||
|
||||
it("链接里缺 state 时回退使用 stateFallback", async () => {
|
||||
const { parseWxAuthUrl } = await import("@/api/auth/wxLogin")
|
||||
const authUrl =
|
||||
"https://open.weixin.qq.com/connect/qrconnect?appid=wx123" +
|
||||
"&redirect_uri=https%3A%2F%2Fexample.com%2Fcb"
|
||||
const params = parseWxAuthUrl(authUrl, "fallback-state")
|
||||
expect(params?.state).toBe("fallback-state")
|
||||
})
|
||||
|
||||
it("缺 appid 或 redirect_uri 时返回 null(调用方应回退整页跳转)", async () => {
|
||||
const { parseWxAuthUrl } = await import("@/api/auth/wxLogin")
|
||||
expect(parseWxAuthUrl("https://open.weixin.qq.com/connect/qrconnect?appid=wx123")).toBeNull()
|
||||
expect(parseWxAuthUrl("not a url")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadWxLoginScript", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
document.head.querySelectorAll("script[src*='wxLogin']").forEach((el) => el.remove())
|
||||
delete (window as unknown as { WxLogin?: unknown }).WxLogin
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("window.WxLogin 已存在时直接复用,不重复插入 script", async () => {
|
||||
const fakeCtor = vi.fn()
|
||||
;(window as unknown as { WxLogin: unknown }).WxLogin = fakeCtor
|
||||
const { loadWxLoginScript } = await import("@/api/auth/wxLogin")
|
||||
const ctor = await loadWxLoginScript()
|
||||
expect(ctor).toBe(fakeCtor)
|
||||
expect(document.head.querySelector("script[src*='wxLogin']")).toBeNull()
|
||||
})
|
||||
|
||||
it("脚本 onerror 时 reject(调用方据此回退整页跳转)", async () => {
|
||||
const { loadWxLoginScript } = await import("@/api/auth/wxLogin")
|
||||
const promise = loadWxLoginScript()
|
||||
const script = document.head.querySelector(
|
||||
"script[src*='wxLogin']",
|
||||
) as HTMLScriptElement | null
|
||||
expect(script).not.toBeNull()
|
||||
script?.dispatchEvent(new Event("error"))
|
||||
await expect(promise).rejects.toThrow(/加载失败/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
|
||||
import { render, screen, fireEvent } from "@testing-library/react"
|
||||
import { Button } from "antd"
|
||||
import { useState } from "react"
|
||||
import ChunkErrorBoundary from "@/components/common/ChunkErrorBoundary"
|
||||
import * as chunkUtils from "@/utils/chunkLoadError"
|
||||
|
||||
// reload 函数 mock 掉(jsdom 不支持真实 window.location.reload)
|
||||
vi.mock("@/utils/chunkLoadError", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/utils/chunkLoadError")>()
|
||||
return {
|
||||
...actual,
|
||||
reloadForChunkError: vi.fn(),
|
||||
goHomeRecover: vi.fn(),
|
||||
}
|
||||
})
|
||||
const { reloadForChunkError, goHomeRecover } = vi.mocked(chunkUtils)
|
||||
|
||||
/** 渲染时直接抛错的子组件 */
|
||||
const Boom: React.FC<{ error: Error }> = ({ error }) => {
|
||||
throw error
|
||||
}
|
||||
|
||||
/** 点击按钮后才抛 chunk 错误的子组件 */
|
||||
const ChunkBoomButton: React.FC = () => {
|
||||
const [boom, setBoom] = useState(false)
|
||||
if (boom) {
|
||||
throw new TypeError("Failed to fetch dynamically imported module: /assets/x.js")
|
||||
}
|
||||
return <Button onClick={() => setBoom(true)}>boom</Button>
|
||||
}
|
||||
|
||||
const renderBoundary = (ui: React.ReactNode) =>
|
||||
render(<ChunkErrorBoundary>{ui}</ChunkErrorBoundary>)
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
// error boundary 捕获后 React 会打 error log,静默掉
|
||||
vi.spyOn(console, "error").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
describe("ChunkErrorBoundary", () => {
|
||||
it("正常渲染 children", () => {
|
||||
renderBoundary(<div>hello-child</div>)
|
||||
expect(screen.getByText("hello-child")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("首次捕获 chunk 错误 → 自动刷新(reloadForChunkError)并显示自动刷新提示", () => {
|
||||
renderBoundary(<ChunkBoomButton />)
|
||||
fireEvent.click(screen.getByText("boom"))
|
||||
expect(reloadForChunkError).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByText(/正在自动刷新/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("已刷新过仍失败 → 不再自动刷新,显示手动兜底按钮", () => {
|
||||
// 模拟"本会话已经自动刷新过一次"
|
||||
sessionStorage.setItem("chunk_error_reloaded_at", String(Date.now()))
|
||||
renderBoundary(
|
||||
<Boom error={new TypeError("Failed to fetch dynamically imported module: /assets/y.js")} />,
|
||||
)
|
||||
expect(reloadForChunkError).not.toHaveBeenCalled()
|
||||
expect(screen.getByText("系统已更新")).toBeInTheDocument()
|
||||
// 点击兜底按钮 → goHomeRecover(跳首页,不刷新当前 URL)
|
||||
fireEvent.click(screen.getByText("刷新并返回首页"))
|
||||
expect(goHomeRecover).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("非 chunk 错误 → 显示通用错误页,不触发 chunk 自动刷新", () => {
|
||||
renderBoundary(<Boom error={new Error("普通业务报错")} />)
|
||||
expect(reloadForChunkError).not.toHaveBeenCalled()
|
||||
expect(screen.getByText("页面出现异常")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,165 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react"
|
||||
import WechatQrModal from "@/components/auth/WechatQrModal"
|
||||
|
||||
const { mockWxLoginCtor, mockGetAuthUrl, mockGetBindUrl, mockGetCurrentUser } = vi.hoisted(() => ({
|
||||
mockWxLoginCtor: vi.fn(),
|
||||
mockGetAuthUrl: vi.fn(),
|
||||
mockGetBindUrl: vi.fn(),
|
||||
mockGetCurrentUser: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
getWechatAuthUrl: (...args: unknown[]) => mockGetAuthUrl(...args),
|
||||
getWechatBindUrl: (...args: unknown[]) => mockGetBindUrl(...args),
|
||||
getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args),
|
||||
normalizeUser: (u: unknown) => u,
|
||||
}))
|
||||
|
||||
vi.mock("@/api/auth/wxLogin", () => ({
|
||||
loadWxLoginScript: vi.fn(async () => mockWxLoginCtor),
|
||||
parseWxAuthUrl: vi.fn(() => ({
|
||||
appid: "wxb7ae80b48e53980d",
|
||||
redirect_uri: "https://staging.xiaoxiajianji.com/auth/wechat/callback",
|
||||
state: "state-from-url",
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/auth/tokenRefresh", () => ({
|
||||
scheduleProactiveRefresh: vi.fn(),
|
||||
cancelProactiveRefresh: vi.fn(),
|
||||
}))
|
||||
|
||||
const { mockSetAuth, mockSetUser } = vi.hoisted(() => ({
|
||||
mockSetAuth: vi.fn(),
|
||||
mockSetUser: vi.fn(),
|
||||
}))
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: (selector: (s: unknown) => unknown) =>
|
||||
selector({ setAuth: mockSetAuth, setUser: mockSetUser }),
|
||||
}))
|
||||
|
||||
const AUTH_URL =
|
||||
"https://open.weixin.qq.com/connect/qrconnect?appid=wxb7ae80b48e53980d" +
|
||||
"&redirect_uri=https%3A%2F%2Fstaging.xiaoxiajianji.com%2Fauth%2Fwechat%2Fcallback&state=st123"
|
||||
|
||||
const postMessage = (data: Record<string, unknown>) =>
|
||||
window.dispatchEvent(new MessageEvent("message", { data, origin: window.location.origin }))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetAuthUrl.mockResolvedValue({ auth_url: AUTH_URL, state: "st123" })
|
||||
mockGetBindUrl.mockResolvedValue({ auth_url: AUTH_URL, state: "st123" })
|
||||
mockGetCurrentUser.mockResolvedValue({ id: 1, display_name: "测试用户" })
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe("WechatQrModal", () => {
|
||||
it("open=false 时不渲染弹窗内容", () => {
|
||||
render(<WechatQrModal open={false} scene="login" onClose={vi.fn()} />)
|
||||
expect(screen.queryByText("微信扫码登录")).toBeNull()
|
||||
})
|
||||
|
||||
it("登录场景:open 后请求授权链接、写入 state、用 WxLogin 渲染二维码", async () => {
|
||||
render(<WechatQrModal open scene="login" onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(mockGetAuthUrl).toHaveBeenCalledTimes(1))
|
||||
expect(localStorage.getItem("wechat_state")).toBe("st123")
|
||||
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
|
||||
expect(mockWxLoginCtor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
self_redirect: true,
|
||||
appid: "wxb7ae80b48e53980d",
|
||||
scope: "snsapi_login",
|
||||
state: "state-from-url",
|
||||
redirect_uri: "https://staging.xiaoxiajianji.com/auth/wechat/callback",
|
||||
}),
|
||||
)
|
||||
expect(screen.getByText(/请使用微信扫描二维码登录/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it("绑定场景:请求 bind/url 且写入 wechat_bind_state", async () => {
|
||||
render(<WechatQrModal open scene="bind" onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(mockGetBindUrl).toHaveBeenCalledTimes(1))
|
||||
expect(mockGetAuthUrl).not.toHaveBeenCalled()
|
||||
expect(localStorage.getItem("wechat_bind_state")).toBe("st123")
|
||||
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it("获取授权链接失败时弹窗内展示错误并提供刷新", async () => {
|
||||
mockGetAuthUrl.mockRejectedValueOnce({
|
||||
response: { status: 500, data: { detail: "微信服务内部错误" } },
|
||||
})
|
||||
render(<WechatQrModal open scene="login" onClose={vi.fn()} />)
|
||||
expect(await screen.findByText(/微信服务内部错误/)).toBeTruthy()
|
||||
expect(screen.getByText("刷新二维码")).toBeTruthy()
|
||||
// 点刷新后重新请求
|
||||
fireEvent.click(screen.getByText("刷新二维码"))
|
||||
await waitFor(() => expect(mockGetAuthUrl).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it("登录成功消息:同步登录态并回调 onLoginSuccess(needOnboarding)", async () => {
|
||||
const onSuccess = vi.fn()
|
||||
localStorage.setItem("access_token", "tok-123")
|
||||
render(<WechatQrModal open scene="login" onClose={vi.fn()} onLoginSuccess={onSuccess} />)
|
||||
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
|
||||
|
||||
postMessage({
|
||||
source: "xiaoxia-wechat-qr",
|
||||
scene: "login",
|
||||
success: true,
|
||||
payload: { needOnboarding: true },
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(true))
|
||||
expect(mockGetCurrentUser).toHaveBeenCalled()
|
||||
expect(mockSetAuth).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }), "tok-123", null)
|
||||
})
|
||||
|
||||
it("登录失败消息:弹窗内展示回调页透传的真实原因", async () => {
|
||||
render(<WechatQrModal open scene="login" onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
|
||||
|
||||
postMessage({
|
||||
source: "xiaoxia-wechat-qr",
|
||||
scene: "login",
|
||||
success: false,
|
||||
detail: "微信登录失败:state 已过期或已被使用",
|
||||
})
|
||||
|
||||
expect(await screen.findByText(/state 已过期或已被使用/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it("绑定成功消息:刷新用户并回调 onBindSuccess", async () => {
|
||||
const onBindSuccess = vi.fn()
|
||||
render(<WechatQrModal open scene="bind" onClose={vi.fn()} onBindSuccess={onBindSuccess} />)
|
||||
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
|
||||
|
||||
postMessage({ source: "xiaoxia-wechat-qr", scene: "bind", success: true })
|
||||
|
||||
await waitFor(() => expect(onBindSuccess).toHaveBeenCalledTimes(1))
|
||||
expect(mockSetUser).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("忽略跨源消息和其他场景的消息", async () => {
|
||||
const onSuccess = vi.fn()
|
||||
render(<WechatQrModal open scene="login" onClose={vi.fn()} onLoginSuccess={onSuccess} />)
|
||||
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
|
||||
|
||||
// 跨源
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: { source: "xiaoxia-wechat-qr", scene: "login", success: true },
|
||||
origin: "https://evil.example.com",
|
||||
}),
|
||||
)
|
||||
// 场景不符(bind 消息发给 login 弹窗)
|
||||
postMessage({ source: "xiaoxia-wechat-qr", scene: "bind", success: true })
|
||||
// 无协议标识
|
||||
postMessage({ foo: "bar" })
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
|
||||
// mock PageHead 简单mock
|
||||
vi.mock("@/components/layout/PageHead", () => ({
|
||||
default: ({ title, description }: { title: string; description?: string }) => (
|
||||
<div data-testid="page-head">
|
||||
@@ -12,142 +12,51 @@ vi.mock("@/components/layout/PageHead", () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
const mockSetUser = vi.fn()
|
||||
const mockInvalidate = vi.fn()
|
||||
let authState: Record<string, unknown> = {
|
||||
user: {
|
||||
id: "1",
|
||||
user_id: "1",
|
||||
username: "testuser",
|
||||
email: "test@example.com",
|
||||
display_name: "Test User",
|
||||
wechat_bound: false,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
setUser: mockSetUser,
|
||||
}
|
||||
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: (selector: (state: unknown) => unknown) => selector(authState),
|
||||
}))
|
||||
|
||||
const getCurrentUserMock = vi.fn(async () => authState.user as Record<string, unknown>)
|
||||
const updateProfileMock = vi.fn()
|
||||
const getWechatBindUrlMock = vi.fn(async () => ({
|
||||
auth_url: "https://wx.example/auth",
|
||||
state: "s1",
|
||||
}))
|
||||
const unbindWechatMock = vi.fn(async () => ({ success: true }))
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
getCurrentUser: () => getCurrentUserMock(),
|
||||
updateProfile: (d: unknown) => updateProfileMock(d),
|
||||
getWechatBindUrl: () => getWechatBindUrlMock(),
|
||||
unbindWechat: () => unbindWechatMock(),
|
||||
}))
|
||||
|
||||
vi.mock("antd", async () => {
|
||||
const actual = await vi.importActual("antd")
|
||||
return { ...actual, message: { success: vi.fn(), error: vi.fn() } }
|
||||
})
|
||||
|
||||
import Settings from "@/pages/profile/Settings"
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
|
||||
const renderPage = () =>
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<Settings />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
describe("Settings Page", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authState = {
|
||||
useAuthStore: (selector: (state: any) => any) =>
|
||||
selector({
|
||||
user: {
|
||||
id: "1",
|
||||
user_id: "1",
|
||||
username: "testuser",
|
||||
email: "test@example.com",
|
||||
display_name: "Test User",
|
||||
wechat_bound: false,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
setUser: mockSetUser,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
import Settings from "@/pages/profile/Settings"
|
||||
|
||||
describe("Settings Page", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Settings />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(screen.getByText("个人设置")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("渲染个人设置与用户信息", () => {
|
||||
renderPage()
|
||||
expect(screen.getByText("个人设置")).toBeTruthy()
|
||||
it("should display user info", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Settings />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(screen.getByDisplayValue("testuser")).toBeTruthy()
|
||||
expect(screen.getByDisplayValue("test@example.com")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("未绑定时显示绑定微信按钮,点击跳转微信授权", async () => {
|
||||
renderPage()
|
||||
expect(screen.getByText("未绑定微信")).toBeTruthy()
|
||||
const btn = screen.getByText("绑定微信")
|
||||
fireEvent.click(btn)
|
||||
await waitFor(() => {
|
||||
expect(getWechatBindUrlMock).toHaveBeenCalled()
|
||||
expect(localStorage.getItem("wechat_bind_state")).toBe("s1")
|
||||
})
|
||||
})
|
||||
|
||||
it("已绑定时显示状态与解绑按钮,确认后调解绑接口", async () => {
|
||||
authState.user = {
|
||||
...(authState.user as object),
|
||||
wechat_bound: true,
|
||||
wechat_nickname: "微信昵称",
|
||||
} as never
|
||||
renderPage()
|
||||
expect(screen.getByText(/已绑定微信/)).toBeTruthy()
|
||||
fireEvent.click(
|
||||
screen.getByText(
|
||||
(_, el) => el?.tagName === "BUTTON" && (el.textContent ?? "").replace(/\s/g, "") === "解绑",
|
||||
),
|
||||
it("should show save button is disabled", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Settings />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
// antd Modal.confirm 弹确认框(标题+内容均含"解绑微信",用 role=dialog 内的确认按钮)
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".ant-modal-confirm")).toBeTruthy()
|
||||
})
|
||||
fireEvent.click(
|
||||
screen.getByText(
|
||||
(_, el) => el?.tagName === "BUTTON" && (el.textContent ?? "").includes("确定解绑"),
|
||||
),
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(unbindWechatMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it("修改昵称后保存按钮可用,点击调用更新接口", async () => {
|
||||
renderPage()
|
||||
const saveBtn = screen.getByText(
|
||||
(_, el) => el?.tagName === "BUTTON" && (el.textContent ?? "").replace(/\s/g, "") === "保存",
|
||||
)
|
||||
expect(saveBtn.closest("button")?.disabled).toBe(true)
|
||||
fireEvent.change(screen.getByDisplayValue("Test User"), {
|
||||
target: { value: "新昵称" },
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(saveBtn.closest("button")?.disabled).toBe(false)
|
||||
})
|
||||
updateProfileMock.mockResolvedValueOnce({
|
||||
id: "1",
|
||||
display_name: "新昵称",
|
||||
wechat_bound: false,
|
||||
})
|
||||
fireEvent.click(saveBtn)
|
||||
await waitFor(() => {
|
||||
expect(updateProfileMock).toHaveBeenCalledWith({ display_name: "新昵称" })
|
||||
})
|
||||
const button = screen.getByText("保存暂未开放")
|
||||
expect(button).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,32 +26,22 @@ interface FakeHandle {
|
||||
fields: Record<string, string>
|
||||
max_size_bytes: number
|
||||
asset_id: string
|
||||
duplicated?: boolean
|
||||
skip_transfer?: boolean
|
||||
}
|
||||
transfer: ReturnType<typeof vi.fn>
|
||||
complete: ReturnType<typeof vi.fn>
|
||||
/** 手动结束传输(transfer 被调用后挂载);finish(true) 以失败结束 */
|
||||
finish: (fail?: boolean) => void
|
||||
/** complete 已被调用的次数 */
|
||||
completeCalls: { resolve: () => void; reject: (err: unknown) => void }[]
|
||||
}
|
||||
|
||||
let activeTransfers = 0
|
||||
let maxConcurrent = 0
|
||||
|
||||
/**
|
||||
* 创建一个假 handle:
|
||||
* - transfer 返回挂起的 promise,finish()/finish(true) 控制成败
|
||||
* - complete 每次调用返回独立的挂起 promise,由 completeCalls 记录控制,
|
||||
* 成功调 resolve(idx) / 失败调 reject(idx)(模拟超时)
|
||||
* 创建一个假 handle:transfer 返回挂起的 promise,
|
||||
* finish 槽位在 transfer executor 同步执行时挂载,测试中调用 finish() 控制成败
|
||||
*/
|
||||
const makeFakeHandle = (opts: {
|
||||
id: string
|
||||
duplicated?: boolean
|
||||
failTransfer?: boolean
|
||||
completeAuto?: boolean
|
||||
/** prepare 阶段就命中去重:prepare 响应 skip_transfer/duplicated=true */
|
||||
prepareDedup?: boolean
|
||||
}) => {
|
||||
const h: FakeHandle = {
|
||||
const makeFakeHandle = (opts: { id: string; duplicated?: boolean; failTransfer?: boolean }) => {
|
||||
const h = {
|
||||
prepared: {
|
||||
upload_url: "https://oss.example.com/u",
|
||||
method: "POST",
|
||||
@@ -60,43 +50,17 @@ const makeFakeHandle = (opts: {
|
||||
fields: {},
|
||||
max_size_bytes: 2_000_000_000,
|
||||
asset_id: opts.id,
|
||||
duplicated: opts.prepareDedup ? true : undefined,
|
||||
skip_transfer: opts.prepareDedup ? true : undefined,
|
||||
},
|
||||
transfer: vi.fn(),
|
||||
complete: vi.fn(),
|
||||
finish: () => {},
|
||||
completeCalls: [],
|
||||
complete: vi.fn().mockResolvedValue({
|
||||
storage_key: "uploads/x/y.mp4",
|
||||
ingest_job_id: opts.duplicated ? "" : "job-1",
|
||||
url: "https://oss.example.com/u",
|
||||
duplicated: opts.duplicated,
|
||||
asset_id: opts.id,
|
||||
}),
|
||||
finish: (() => {}) as (fail?: boolean) => void,
|
||||
}
|
||||
|
||||
h.complete.mockImplementation(
|
||||
() =>
|
||||
new Promise<{
|
||||
storage_key: string
|
||||
ingest_job_id: string
|
||||
url: string
|
||||
duplicated: boolean
|
||||
asset_id: string
|
||||
}>((resolve, reject) => {
|
||||
h.completeCalls.push({
|
||||
resolve: () =>
|
||||
resolve({
|
||||
storage_key: "uploads/x/y.mp4",
|
||||
ingest_job_id: opts.duplicated ? "" : `job-${opts.id}`,
|
||||
url: "https://oss.example.com/u",
|
||||
duplicated: !!opts.duplicated,
|
||||
asset_id: opts.id,
|
||||
}),
|
||||
reject,
|
||||
})
|
||||
// 默认立即成功,保持旧用例简单
|
||||
if (opts.completeAuto !== false) {
|
||||
const idx = h.completeCalls.length - 1
|
||||
Promise.resolve().then(() => h.completeCalls[idx]?.resolve())
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
h.transfer.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
@@ -114,24 +78,17 @@ const makeFakeHandle = (opts: {
|
||||
|
||||
type FakeHandleLike = ReturnType<typeof makeFakeHandle>
|
||||
|
||||
let activeTransfers = 0
|
||||
let maxConcurrent = 0
|
||||
|
||||
/** prepare mock:调用序号生成稳定 id,立即把 handle(含 finish 槽位)推入数组 */
|
||||
const installPrepareMock = (
|
||||
handles: FakeHandleLike[],
|
||||
optOverrides?: (id: string) => {
|
||||
duplicated?: boolean
|
||||
failTransfer?: boolean
|
||||
completeAuto?: boolean
|
||||
},
|
||||
optOverrides?: (id: string) => { duplicated?: boolean; failTransfer?: boolean },
|
||||
) => {
|
||||
let callNo = 0
|
||||
;(prepareDirectUploadHandle as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async () => {
|
||||
const id = `asset-${callNo++}`
|
||||
const overrides = optOverrides?.(id) ?? {}
|
||||
const h = makeFakeHandle({ id, completeAuto: true, ...overrides })
|
||||
const h = makeFakeHandle({ id, ...overrides })
|
||||
handles.push(h)
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
return h
|
||||
@@ -230,11 +187,6 @@ describe("useAssetUpload", () => {
|
||||
})
|
||||
await waitFor(() => expect(result.current.uploadItems[0].status).toBe("error"))
|
||||
|
||||
// 失败卡片记录失败阶段与完整错误原因(不再只显示"上传失败")
|
||||
const failed = result.current.uploadItems[0]
|
||||
expect(failed.failedStage).toBe("transfer")
|
||||
expect(failed.error).toContain("OSS boom")
|
||||
|
||||
// 重试:重新 prepare(handles[1] 成功)
|
||||
const tempId = result.current.uploadItems[0].tempId
|
||||
await act(async () => {
|
||||
@@ -271,142 +223,4 @@ describe("useAssetUpload", () => {
|
||||
expect(result.current.uploadItems[0].status).toBe("done")
|
||||
})
|
||||
})
|
||||
it("同一文件多次选择不重复入队(指纹去重)", async () => {
|
||||
const handles: FakeHandleLike[] = []
|
||||
installPrepareMock(handles)
|
||||
|
||||
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
// 同一文件(name+size+lastModified 完全一致)第一次入队
|
||||
const sameFile = mp4("same.mp4")
|
||||
await act(async () => {
|
||||
result.current.enqueueUploads([sameFile])
|
||||
})
|
||||
await waitFor(() => expect(handles.length).toBe(1))
|
||||
expect(result.current.uploadItems).toHaveLength(1)
|
||||
|
||||
// transfer 挂起期间,再次选择同一文件(模拟用户反复点选/拖拽)
|
||||
await act(async () => {
|
||||
result.current.enqueueUploads([sameFile])
|
||||
})
|
||||
await act(async () => {
|
||||
result.current.enqueueUploads([sameFile])
|
||||
})
|
||||
// 队列表只有 1 项、prepare 只有 1 次
|
||||
expect(result.current.uploadItems).toHaveLength(1)
|
||||
expect(handles.length).toBe(1)
|
||||
|
||||
// 完成后再次重复选择(已 done):仍然不新增
|
||||
await act(async () => {
|
||||
handles[0].finish()
|
||||
})
|
||||
await waitFor(() => expect(result.current.uploadItems[0].status).toBe("done"))
|
||||
await act(async () => {
|
||||
result.current.enqueueUploads([sameFile])
|
||||
})
|
||||
expect(result.current.uploadItems).toHaveLength(1)
|
||||
expect(handles.length).toBe(1)
|
||||
|
||||
// 不同文件正常入队
|
||||
await act(async () => {
|
||||
result.current.enqueueUploads([mp4("other.mp4")])
|
||||
})
|
||||
await waitFor(() => expect(handles.length).toBe(2))
|
||||
expect(result.current.uploadItems).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("complete 失败(超时)后重试:只重发 complete,不重新 prepare/直传", async () => {
|
||||
const handles: FakeHandleLike[] = []
|
||||
installPrepareMock(handles, () => ({ completeAuto: false }))
|
||||
|
||||
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
result.current.enqueueUploads([mp4("slow.mp4")])
|
||||
})
|
||||
await waitFor(() => expect(handles.length).toBe(1))
|
||||
await waitFor(() => expect(handles[0].transfer).toHaveBeenCalled())
|
||||
await act(async () => {
|
||||
handles[0].finish()
|
||||
})
|
||||
// complete 被调用但挂起
|
||||
await waitFor(() => expect(handles[0].complete).toHaveBeenCalledTimes(1))
|
||||
|
||||
// 模拟 complete 超时(后端记录可能已建成)
|
||||
await act(async () => {
|
||||
handles[0].completeCalls[0]?.reject(new Error("complete timeout (ECONNABORTED)"))
|
||||
})
|
||||
const tempId = result.current.uploadItems[0].tempId
|
||||
await waitFor(() => {
|
||||
const it = result.current.uploadItems.find((x) => x.tempId === tempId)
|
||||
expect(it?.status).toBe("error")
|
||||
expect(it?.failedStage).toBe("complete")
|
||||
// 卡片同时展示真实失败原因与"重试不会重新上传"提示
|
||||
expect(it?.error).toContain("complete timeout")
|
||||
expect(it?.error).toContain("不会重新上传文件")
|
||||
})
|
||||
|
||||
// 点重试:pump 复用 handle,只再调一次 complete(transfer/prepare 不重复)
|
||||
await act(async () => {
|
||||
result.current.retryUpload(tempId)
|
||||
})
|
||||
await waitFor(() => expect(handles[0].complete).toHaveBeenCalledTimes(2))
|
||||
expect(handles.length).toBe(1) // 没有重新 prepare
|
||||
expect(handles[0].transfer).toHaveBeenCalledTimes(1) // 没有重新直传
|
||||
|
||||
// 第二次 complete 成功
|
||||
await act(async () => {
|
||||
handles[0].completeCalls[1]?.resolve()
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(result.current.uploadItems.find((x) => x.tempId === tempId)?.status).toBe("done")
|
||||
})
|
||||
})
|
||||
|
||||
it("prepare 阶段失败:标记 prepare 阶段并保留后端错误明细", async () => {
|
||||
;(prepareDirectUploadHandle as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 500, data: { detail: "签名服务内部错误" } },
|
||||
message: "Request failed with status code 500",
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
result.current.enqueueUploads([mp4("prep-fail.mp4")])
|
||||
})
|
||||
await waitFor(() => expect(result.current.uploadItems[0]?.status).toBe("error"))
|
||||
const it = result.current.uploadItems[0]
|
||||
expect(it.failedStage).toBe("prepare")
|
||||
expect(it.error).toContain("签名服务内部错误")
|
||||
})
|
||||
it("prepare 返回 skip_transfer=true 时立即跳过 transfer+complete,标记 done+duplicated", async () => {
|
||||
const h = makeFakeHandle({ id: "a-skip", prepareDedup: true })
|
||||
;(prepareDirectUploadHandle as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async () => h,
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
result.current.enqueueUploads([mp4("skip-transfer.mp4")])
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.transfer).not.toHaveBeenCalled()
|
||||
expect(h.complete).not.toHaveBeenCalled()
|
||||
const it = result.current.uploadItems[0]
|
||||
expect(it?.status).toBe("done")
|
||||
expect(it?.duplicated).toBe(true)
|
||||
expect(it?.assetId).toBe("a-skip")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { render, screen, waitFor, cleanup } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import WechatBindCallback from "@/pages/auth/WechatBindCallback"
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
const mockSetUser = vi.fn()
|
||||
const mockParams = new URLSearchParams({ code: "bind_code", state: "bind_state" })
|
||||
const mockSearchParams = [mockParams] as const
|
||||
|
||||
const localStorageStore: Record<string, string> = {}
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation((key) => localStorageStore[key] || null)
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation((key, val) => {
|
||||
localStorageStore[key] = val
|
||||
})
|
||||
vi.spyOn(Storage.prototype, "removeItem").mockImplementation((key) => {
|
||||
delete localStorageStore[key]
|
||||
})
|
||||
|
||||
let bindError: unknown = null
|
||||
const mockBindResult = { user: { id: "u1", wechat_bound: true } }
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = await vi.importActual("react-router-dom")
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
useSearchParams: () => mockSearchParams,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
bindWechat: vi.fn(async () => {
|
||||
if (bindError) throw bindError
|
||||
return mockBindResult
|
||||
}),
|
||||
normalizeUser: (u: unknown) => u,
|
||||
}))
|
||||
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: (selector: (state: unknown) => unknown) => selector({ setUser: mockSetUser }),
|
||||
}))
|
||||
|
||||
// iframe 场景:默认非 iframe;用例可 mockReturnValue(true)
|
||||
const { mockIsInIframe, mockPostResult } = vi.hoisted(() => ({
|
||||
mockIsInIframe: vi.fn(() => false),
|
||||
mockPostResult: vi.fn(),
|
||||
}))
|
||||
vi.mock("@/components/auth/WechatQrModal/messages", () => ({
|
||||
isInIframe: () => mockIsInIframe(),
|
||||
postWechatQrResult: (...args: unknown[]) => mockPostResult(...args),
|
||||
}))
|
||||
|
||||
const renderPage = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<WechatBindCallback />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
describe("WechatBindCallback Page", () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsInIframe.mockReturnValue(false)
|
||||
bindError = null
|
||||
Array.from(mockParams.keys()).forEach((k) => mockParams.delete(k))
|
||||
mockParams.set("code", "bind_code")
|
||||
mockParams.set("state", "bind_state")
|
||||
localStorageStore.wechat_bind_state = "bind_state"
|
||||
})
|
||||
|
||||
it("绑定成功跳转设置页并携带 success 标记", async () => {
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/app/profile?wechat_bind=success", {
|
||||
replace: true,
|
||||
})
|
||||
})
|
||||
expect(mockSetUser).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("本地无 wechat_bind_state(微信内/跨浏览器)不再误杀,绑定正常完成", async () => {
|
||||
delete localStorageStore.wechat_bind_state
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/app/profile?wechat_bind=success", {
|
||||
replace: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("后端报错(微信已被其他账号绑定)时页面透传真实原因,不静默跳走", async () => {
|
||||
bindError = {
|
||||
isAxiosError: true,
|
||||
response: { status: 409, data: { detail: "该微信已绑定其他账号" } },
|
||||
message: "Request failed with status code 409",
|
||||
}
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/该微信已绑定其他账号/)).toBeTruthy()
|
||||
})
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("缺少 code/state 时提示无效回调", async () => {
|
||||
mockParams.delete("code")
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/无效的回调参数/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("iframe(弹窗内嵌二维码)场景", () => {
|
||||
it("绑定成功时 postMessage 通知父窗口,不做 navigate", async () => {
|
||||
mockIsInIframe.mockReturnValue(true)
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockPostResult).toHaveBeenCalledWith("bind", true)
|
||||
})
|
||||
expect(mockSetUser).toHaveBeenCalled()
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("绑定失败时把真实原因 postMessage 给父窗口", async () => {
|
||||
mockIsInIframe.mockReturnValue(true)
|
||||
bindError = {
|
||||
isAxiosError: true,
|
||||
response: { status: 409, data: { detail: "该微信已绑定其他账号" } },
|
||||
}
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockPostResult).toHaveBeenCalledWith("bind", false, {
|
||||
detail: expect.stringContaining("该微信已绑定其他账号"),
|
||||
})
|
||||
})
|
||||
expect(screen.queryByText(/返回设置/)).toBeNull()
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,223 +1,79 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { render, screen, waitFor, cleanup } from "@testing-library/react"
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import WechatCallback from "@/pages/auth/WechatCallback"
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
const mockSetAuth = vi.fn()
|
||||
|
||||
// useSearchParams 返回模块级稳定引用(数组元素同一 URLSearchParams 实例),
|
||||
// 避免每次 render 返回新数组/新实例导致 useEffect 依赖变化重跑
|
||||
const mockParams = new URLSearchParams({ code: "test_code", state: "test_state" })
|
||||
const mockSearchParams = [mockParams] as const
|
||||
const mockAuthState = { setAuth: mockSetAuth }
|
||||
|
||||
// 文件级 localStorage mock(避免每个用例重复 spy 导致链式污染)
|
||||
const localStorageStore: Record<string, string> = {}
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation((key) => localStorageStore[key] || null)
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation((key, val) => {
|
||||
localStorageStore[key] = val
|
||||
})
|
||||
vi.spyOn(Storage.prototype, "removeItem").mockImplementation((key) => {
|
||||
delete localStorageStore[key]
|
||||
})
|
||||
|
||||
let mockCallbackResult: Record<string, unknown> = {}
|
||||
let mockCurrentUser: Record<string, unknown> = {}
|
||||
let callbackError: unknown = null
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = await vi.importActual("react-router-dom")
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
useSearchParams: () => mockSearchParams,
|
||||
useNavigate: () => vi.fn(),
|
||||
useSearchParams: () => [new URLSearchParams({ code: "test_code", state: "test_state" })],
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
wechatCallback: vi.fn(async () => {
|
||||
if (callbackError) throw callbackError
|
||||
return mockCallbackResult
|
||||
}),
|
||||
getCurrentUser: vi.fn(async () => mockCurrentUser),
|
||||
wechatCallback: vi.fn(() => new Promise(() => {})), // pending promise,保持loading
|
||||
getCurrentUser: vi.fn(),
|
||||
normalizeUser: (u: unknown) => u,
|
||||
}))
|
||||
|
||||
vi.mock("@/api/auth/tokenRefresh", () => ({
|
||||
scheduleProactiveRefresh: vi.fn(),
|
||||
cancelProactiveRefresh: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: (selector: (state: unknown) => unknown) => selector({ setAuth: mockSetAuth }),
|
||||
useAuthStore: () => ({
|
||||
setAuth: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// iframe 场景:默认非 iframe;用例可 mockReturnValue(true)
|
||||
const { mockIsInIframe, mockPostResult } = vi.hoisted(() => ({
|
||||
mockIsInIframe: vi.fn(() => false),
|
||||
mockPostResult: vi.fn(),
|
||||
}))
|
||||
vi.mock("@/components/auth/WechatQrModal/messages", () => ({
|
||||
isInIframe: () => mockIsInIframe(),
|
||||
postWechatQrResult: (...args: unknown[]) => mockPostResult(...args),
|
||||
vi.mock("@/components/auth/BindContactModal", () => ({
|
||||
default: ({ open }: { open: boolean }) => (
|
||||
<div data-testid="bind-contact-modal" style={{ display: open ? "block" : "none" }}>
|
||||
BindContactModal
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const renderPage = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<WechatCallback />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
vi.mock("antd", async () => {
|
||||
const actual = await vi.importActual("antd")
|
||||
return {
|
||||
...actual,
|
||||
message: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe("WechatCallback Page", () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsInIframe.mockReturnValue(false)
|
||||
callbackError = null
|
||||
// 默认正常回调参数;用例可改写 mockParams 模拟 error 重定向
|
||||
Array.from(mockParams.keys()).forEach((k) => mockParams.delete(k))
|
||||
mockParams.set("code", "test_code")
|
||||
mockParams.set("state", "test_state")
|
||||
localStorageStore.wechat_state = "test_state"
|
||||
mockCallbackResult = {
|
||||
access_token: "at",
|
||||
refresh_token: "rt",
|
||||
is_new_user: false,
|
||||
// mock localStorage,设置wechat_state匹配,让校验通过
|
||||
const store: Record<string, string> = {
|
||||
wechat_state: "test_state",
|
||||
}
|
||||
mockCurrentUser = {
|
||||
id: "u1",
|
||||
display_name: "老用户",
|
||||
profile_completed: true,
|
||||
}
|
||||
})
|
||||
|
||||
it("老用户登录成功跳转首页/来源页", async () => {
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true })
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation((key) => store[key] || null)
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation((key, val) => {
|
||||
store[key] = val
|
||||
})
|
||||
expect(mockSetAuth).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("新用户(is_new_user)跳转昵称引导页", async () => {
|
||||
mockCallbackResult = { access_token: "at", refresh_token: "rt", is_new_user: true }
|
||||
mockCurrentUser = { id: "u2", display_name: "微信用户", profile_completed: false }
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/welcome/wechat", { replace: true })
|
||||
vi.spyOn(Storage.prototype, "removeItem").mockImplementation((key) => {
|
||||
delete store[key]
|
||||
})
|
||||
})
|
||||
|
||||
it("is_new_user=false 但 profile_completed=false(上次中断)也跳引导页", async () => {
|
||||
mockCallbackResult = { access_token: "at", refresh_token: "rt", is_new_user: false }
|
||||
mockCurrentUser = { id: "u3", display_name: "微信用户", profile_completed: false }
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/welcome/wechat", { replace: true })
|
||||
})
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<WechatCallback />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("本地无 wechat_state(微信内打开/跨浏览器场景)不再误杀,正常完成登录", async () => {
|
||||
delete localStorageStore.wechat_state
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true })
|
||||
})
|
||||
// state 已被清理
|
||||
expect(localStorageStore.wechat_state).toBeUndefined()
|
||||
})
|
||||
|
||||
it("后端返回 detail 错误时,页面透传真实原因(不再吞成通用提示)", async () => {
|
||||
callbackError = {
|
||||
isAxiosError: true,
|
||||
response: { status: 400, data: { detail: "微信授权码已过期,请重新扫码" } },
|
||||
message: "Request failed with status code 400",
|
||||
}
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/微信授权码已过期,请重新扫码/)).toBeTruthy()
|
||||
})
|
||||
expect(screen.queryByText(/^微信登录失败,请重试$/)).toBeNull()
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("微信重定向带 error(用户拒绝授权)时展示授权失败原因", async () => {
|
||||
for (const k of Array.from(mockParams.keys())) mockParams.delete(k)
|
||||
mockParams.set("error", "access_denied")
|
||||
mockParams.set("error_description", "The+user+denied+the+request")
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/微信授权失败/)).toBeTruthy()
|
||||
expect(screen.getByText(/access_denied/)).toBeTruthy()
|
||||
})
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("缺少 code/state 参数时提示无效回调", async () => {
|
||||
mockParams.delete("code")
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/无效的回调参数/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it("处理中显示 loading", () => {
|
||||
renderPage()
|
||||
expect(screen.getByText("微信登录中...")).toBeTruthy()
|
||||
})
|
||||
|
||||
describe("iframe(弹窗内嵌二维码)场景", () => {
|
||||
it("登录成功时 postMessage 通知父窗口(needOnboarding=false),不做 navigate", async () => {
|
||||
mockIsInIframe.mockReturnValue(true)
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockPostResult).toHaveBeenCalledWith("login", true, { needOnboarding: false })
|
||||
})
|
||||
expect(mockSetAuth).toHaveBeenCalled()
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("新用户成功时上报 needOnboarding=true", async () => {
|
||||
mockIsInIframe.mockReturnValue(true)
|
||||
mockCallbackResult = { access_token: "at", refresh_token: "rt", is_new_user: true }
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockPostResult).toHaveBeenCalledWith("login", true, { needOnboarding: true })
|
||||
})
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("后端报错时把真实原因 postMessage 给父窗口,页面不渲染错误/按钮", async () => {
|
||||
mockIsInIframe.mockReturnValue(true)
|
||||
callbackError = {
|
||||
isAxiosError: true,
|
||||
response: { status: 400, data: { detail: "state 已过期或已被使用" } },
|
||||
}
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockPostResult).toHaveBeenCalledWith("login", false, {
|
||||
detail: expect.stringContaining("state 已过期或已被使用"),
|
||||
})
|
||||
})
|
||||
expect(screen.queryByText(/返回登录/)).toBeNull()
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("微信重定向 error(拒绝授权)在 iframe 内也上报父窗口", async () => {
|
||||
mockIsInIframe.mockReturnValue(true)
|
||||
for (const k of Array.from(mockParams.keys())) mockParams.delete(k)
|
||||
mockParams.set("error", "access_denied")
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockPostResult).toHaveBeenCalledWith("login", false, {
|
||||
detail: expect.stringContaining("access_denied"),
|
||||
})
|
||||
})
|
||||
})
|
||||
it("should show loading state while processing", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<WechatCallback />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
// wechatCallback 返回 pending promise,所以应该显示 loading
|
||||
expect(screen.getByText("正在登录...")).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import WechatOnboarding from "@/pages/auth/WechatOnboarding"
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
const mockSetUser = vi.fn()
|
||||
let updateProfileMock = vi.fn()
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = await vi.importActual("react-router-dom")
|
||||
return { ...actual, useNavigate: () => mockNavigate }
|
||||
})
|
||||
|
||||
let authState: Record<string, unknown> = {}
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: (selector: (state: unknown) => unknown) => selector(authState),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
updateProfile: (data: { display_name: string }) => updateProfileMock(data),
|
||||
}))
|
||||
|
||||
vi.mock("antd", async () => {
|
||||
const actual = await vi.importActual("antd")
|
||||
return { ...actual, message: { success: vi.fn(), error: vi.fn() } }
|
||||
})
|
||||
|
||||
const renderPage = () =>
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<WechatOnboarding />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
describe("WechatOnboarding 昵称引导页", () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authState = {
|
||||
isAuthenticated: true,
|
||||
user: { id: "u1", display_name: "", profile_completed: false },
|
||||
setUser: mockSetUser,
|
||||
}
|
||||
localStorage.setItem("access_token", "at")
|
||||
updateProfileMock = vi.fn(async (data: { display_name: string }) => ({
|
||||
id: "u1",
|
||||
display_name: data.display_name,
|
||||
profile_completed: true,
|
||||
}))
|
||||
})
|
||||
|
||||
it("未登录时跳转登录页", () => {
|
||||
authState = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
setUser: mockSetUser,
|
||||
}
|
||||
localStorage.removeItem("access_token")
|
||||
renderPage()
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
// Navigate 组件渲染即生效;这里断言页面不含昵称表单
|
||||
expect(screen.queryByText("进入小虾智剪")).toBeNull()
|
||||
})
|
||||
|
||||
it("资料已完善的用户跳 dashboard", () => {
|
||||
authState = {
|
||||
isAuthenticated: true,
|
||||
user: { id: "u1", display_name: "已起名", profile_completed: true },
|
||||
setUser: mockSetUser,
|
||||
}
|
||||
renderPage()
|
||||
expect(screen.queryByText("进入小虾智剪")).toBeNull()
|
||||
})
|
||||
|
||||
it("昵称输入框不预填,必须用户自己输入", () => {
|
||||
renderPage()
|
||||
expect(screen.getByText("欢迎使用微信登录,请先设置您的昵称")).toBeTruthy()
|
||||
expect((screen.getByPlaceholderText("请输入您的昵称") as HTMLInputElement).value).toBe("")
|
||||
})
|
||||
|
||||
it("新用户可见昵称表单并能提交", async () => {
|
||||
renderPage()
|
||||
expect(screen.getByText("欢迎使用微信登录,请先设置您的昵称")).toBeTruthy()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入您的昵称"), {
|
||||
target: { value: "小虾用户" },
|
||||
})
|
||||
fireEvent.click(screen.getByText("进入小虾智剪"))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateProfileMock).toHaveBeenCalledWith({ display_name: "小虾用户" })
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(mockSetUser).toHaveBeenCalled()
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/app/dashboard", { replace: true })
|
||||
})
|
||||
})
|
||||
|
||||
it("昵称为空时不允许提交(表单校验)", async () => {
|
||||
renderPage()
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入您的昵称"), {
|
||||
target: { value: " " },
|
||||
})
|
||||
fireEvent.click(screen.getByText("进入小虾智剪"))
|
||||
// 等待表单校验
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(updateProfileMock).not.toHaveBeenCalled()
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
)
|
||||
})
|
||||
|
||||
it("连点提交按钮只触发一次请求(防重复提交)", async () => {
|
||||
// mutation 挂起不立即完成,模拟慢网络下连续双击
|
||||
let resolveSubmit: (v: unknown) => void = () => {}
|
||||
updateProfileMock = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSubmit = resolve
|
||||
}),
|
||||
)
|
||||
renderPage()
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入您的昵称"), {
|
||||
target: { value: "小虾用户" },
|
||||
})
|
||||
const btn = screen.getByText("进入小虾智剪")
|
||||
fireEvent.click(btn)
|
||||
// 第一次点击后立即再点(此时重渲染/loading 可能还没生效)
|
||||
fireEvent.click(btn)
|
||||
fireEvent.click(btn)
|
||||
await waitFor(() => {
|
||||
expect(updateProfileMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
// 释放挂起的 Promise,避免泄漏
|
||||
resolveSubmit({ id: "u1", display_name: "小虾用户", profile_completed: true })
|
||||
})
|
||||
|
||||
it("提交失败后守卫复位,允许再次提交", async () => {
|
||||
updateProfileMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 500, data: { detail: "服务内部错误" } },
|
||||
})
|
||||
.mockResolvedValueOnce({ id: "u1", display_name: "小虾用户", profile_completed: true })
|
||||
renderPage()
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入您的昵称"), {
|
||||
target: { value: "小虾用户" },
|
||||
})
|
||||
fireEvent.click(screen.getByText("进入小虾智剪"))
|
||||
await waitFor(() => {
|
||||
expect(updateProfileMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
// 失败后再点一次,应能重新提交
|
||||
fireEvent.click(screen.getByText("进入小虾智剪"))
|
||||
await waitFor(() => {
|
||||
expect(updateProfileMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
it("提交失败显示错误且不跳转", async () => {
|
||||
updateProfileMock = vi.fn(async () => {
|
||||
throw new Error("500")
|
||||
})
|
||||
renderPage()
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入您的昵称"), {
|
||||
target: { value: "小虾用户" },
|
||||
})
|
||||
fireEvent.click(screen.getByText("进入小虾智剪"))
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* TitleLibraryAutoComplete 单测(Issue #1737)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 聚焦空输入框 → 下拉立即展开,展示标题库全部标题(原生 AutoComplete 聚焦不展开,此为本工单核心修复)
|
||||
* - 输入关键词 → 下拉只显示匹配项
|
||||
* - 点击下拉项 → onChange 回填所选标题
|
||||
* - 自由输入自定义标题 → onChange 正常透传,不被下拉干扰
|
||||
* - 标题库为空 → 聚焦不展开(不出"暂无数据"空壳)
|
||||
* - 选中后下拉关闭
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest"
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import TitleLibraryAutoComplete from "@/pages/generate/components/title/TitleLibraryAutoComplete"
|
||||
|
||||
const OPTIONS = [
|
||||
{ label: "永康这家面馆绝了", value: "永康这家面馆绝了" },
|
||||
{ label: "永康美食探店vlog", value: "永康美食探店vlog" },
|
||||
{ label: "萌宠日常第一天", value: "萌宠日常第一天" },
|
||||
]
|
||||
|
||||
function renderBox(initialValue = "", opts = OPTIONS) {
|
||||
const onChange = vi.fn()
|
||||
const result = render(
|
||||
<TitleLibraryAutoComplete
|
||||
value={initialValue}
|
||||
onChange={onChange}
|
||||
options={opts}
|
||||
placeholder="输入或从标题库选择"
|
||||
/>,
|
||||
)
|
||||
return { onChange, ...result }
|
||||
}
|
||||
|
||||
/** 聚焦输入框(combobox role) */
|
||||
function focusInput() {
|
||||
const input = screen.getByRole("combobox") as HTMLInputElement
|
||||
fireEvent.focus(input)
|
||||
return input
|
||||
}
|
||||
|
||||
/** 取下拉中实际可见的选项(rc-virtual-list 渲染为 .ant-select-item-option;role=option 的 listbox 是 a11y 哨兵) */
|
||||
function getVisibleOptions(): HTMLElement[] {
|
||||
const dropdown = document.querySelector(".ant-select-dropdown:not(.ant-select-dropdown-hidden)")
|
||||
if (!dropdown) return []
|
||||
return Array.from(dropdown.querySelectorAll(".ant-select-item-option")) as HTMLElement[]
|
||||
}
|
||||
|
||||
describe("TitleLibraryAutoComplete (#1737)", () => {
|
||||
it("聚焦空输入框时下拉展开并展示标题库全部标题", async () => {
|
||||
renderBox()
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
|
||||
|
||||
focusInput()
|
||||
|
||||
await screen.findByRole("listbox")
|
||||
await waitFor(() => expect(getVisibleOptions()).toHaveLength(3))
|
||||
const options = getVisibleOptions()
|
||||
expect(options[0]).toHaveTextContent("永康这家面馆绝了")
|
||||
expect(options[2]).toHaveTextContent("萌宠日常第一天")
|
||||
})
|
||||
|
||||
it("输入关键词时下拉只显示匹配项", async () => {
|
||||
const user = userEvent.setup()
|
||||
renderBox()
|
||||
const input = screen.getByRole("combobox")
|
||||
await user.click(input)
|
||||
await screen.findByRole("listbox")
|
||||
|
||||
await user.type(input, "永康")
|
||||
await waitFor(() => expect(getVisibleOptions()).toHaveLength(2))
|
||||
const options = getVisibleOptions()
|
||||
expect(options.every((o) => o.textContent?.includes("永康"))).toBe(true)
|
||||
})
|
||||
|
||||
it("点击下拉项后 onChange 回填标题且下拉关闭", async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onChange } = renderBox()
|
||||
const input = screen.getByRole("combobox") as HTMLInputElement
|
||||
await user.click(input)
|
||||
await screen.findByRole("listbox")
|
||||
|
||||
await user.click(screen.getByText("萌宠日常第一天"))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith("萌宠日常第一天")
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it("自由输入自定义标题时 onChange 正常透传(不被下拉干扰)", async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onChange } = renderBox()
|
||||
const input = screen.getByRole("combobox")
|
||||
await user.click(input)
|
||||
|
||||
await user.type(input, "我自己编的标题XYZ")
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith("我自己编的标题XYZ")
|
||||
})
|
||||
// 输入无匹配关键词,下拉无 option 时不阻塞输入
|
||||
expect(input).toHaveValue("我自己编的标题XYZ")
|
||||
})
|
||||
|
||||
it("标题库为空时聚焦不展开下拉", async () => {
|
||||
renderBox("", [])
|
||||
focusInput()
|
||||
// 等一帧确认没有 listbox
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("渲染下拉箭头图标作为可选择提示", () => {
|
||||
const { container } = renderBox()
|
||||
// antd 后缀图标在 .ant-select-arrow 内
|
||||
expect(container.querySelector(".ant-select-arrow")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("有初始值时输入框正常展示", () => {
|
||||
renderBox("已有标题")
|
||||
expect(screen.getByRole("combobox")).toHaveValue("已有标题")
|
||||
})
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest"
|
||||
import { lazyRoute } from "@/router/lazyRoute"
|
||||
|
||||
const chunkErr = () => new TypeError("Failed to fetch dynamically imported module: /assets/x.js")
|
||||
|
||||
/** fake 模块 */
|
||||
const Comp = function Comp() {}
|
||||
const factoryOk = vi.fn(async () => ({ default: Comp }))
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("lazyRoute", () => {
|
||||
it("首次成功直接返回 Component", async () => {
|
||||
const result = await lazyRoute(factoryOk)()
|
||||
expect(result).toEqual({ Component: Comp })
|
||||
expect(factoryOk).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("chunk 失败重试:前两次失败、第三次成功 → 不抛出", async () => {
|
||||
const f = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(chunkErr())
|
||||
.mockRejectedValueOnce(chunkErr())
|
||||
.mockResolvedValueOnce({ default: Comp })
|
||||
|
||||
const result = await lazyRoute(f as never)()
|
||||
expect(result).toEqual({ Component: Comp })
|
||||
expect(f).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it("chunk 失败重试 2 次仍失败 → 抛出", async () => {
|
||||
const f = vi.fn().mockRejectedValue(chunkErr())
|
||||
await expect(lazyRoute(f as never)()).rejects.toThrow(/dynamically imported/)
|
||||
expect(f).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it("非 chunk 错误立即抛出,不重试", async () => {
|
||||
const f = vi.fn().mockRejectedValue(new Error("业务模块内部报错"))
|
||||
await expect(lazyRoute(f as never)()).rejects.toThrow("业务模块内部报错")
|
||||
expect(f).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
|
||||
import {
|
||||
getChunkReloadedAt,
|
||||
goHomeRecover,
|
||||
isChunkLoadError,
|
||||
reloadForChunkError,
|
||||
} from "@/utils/chunkLoadError"
|
||||
|
||||
describe("isChunkLoadError", () => {
|
||||
it("识别 Vite 动态 import 失败", () => {
|
||||
const err = new TypeError(
|
||||
"Failed to fetch dynamically imported module: https://x/assets/AssetLibrary-abc.js",
|
||||
)
|
||||
expect(isChunkLoadError(err)).toBe(true)
|
||||
})
|
||||
|
||||
it("识别 Webpack 风格 ChunkLoadError", () => {
|
||||
const err = new Error("Loading chunk 12 failed.")
|
||||
err.name = "ChunkLoadError"
|
||||
expect(isChunkLoadError(err)).toBe(true)
|
||||
})
|
||||
|
||||
it("识别字符串形式错误", () => {
|
||||
expect(isChunkLoadError("Error loading dynamically imported module")).toBe(true)
|
||||
})
|
||||
|
||||
it("普通错误不命中", () => {
|
||||
expect(isChunkLoadError(new Error("Cannot read properties of undefined"))).toBe(false)
|
||||
expect(isChunkLoadError(null)).toBe(false)
|
||||
expect(isChunkLoadError(undefined)).toBe(false)
|
||||
expect(isChunkLoadError({ status: 500 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("reload 标记", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
// jsdom 未实现真实导航,reload 仅打 "not implemented" 警告,静默掉
|
||||
vi.spyOn(console, "error").mockImplementation(() => {})
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it("无标记返回 null", () => {
|
||||
expect(getChunkReloadedAt()).toBeNull()
|
||||
})
|
||||
|
||||
it("reloadForChunkError 写入刷新标记", () => {
|
||||
expect(() => reloadForChunkError()).not.toThrow()
|
||||
expect(getChunkReloadedAt()).not.toBeNull()
|
||||
})
|
||||
|
||||
it("标记过期(>10min)返回 null", () => {
|
||||
sessionStorage.setItem("chunk_error_reloaded_at", String(Date.now() - 11 * 60 * 1000))
|
||||
expect(getChunkReloadedAt()).toBeNull()
|
||||
})
|
||||
|
||||
it("goHomeRecover 清掉标记", () => {
|
||||
reloadForChunkError()
|
||||
expect(getChunkReloadedAt()).not.toBeNull()
|
||||
expect(() => goHomeRecover()).not.toThrow()
|
||||
expect(sessionStorage.getItem("chunk_error_reloaded_at")).toBeNull()
|
||||
})
|
||||
|
||||
it("sessionStorage 抛异常(无痕模式)时降级不崩溃", () => {
|
||||
const spy = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("Storage disabled")
|
||||
})
|
||||
const setSpy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("Storage disabled")
|
||||
})
|
||||
expect(getChunkReloadedAt()).toBeNull()
|
||||
expect(() => reloadForChunkError()).not.toThrow()
|
||||
expect(() => goHomeRecover()).not.toThrow()
|
||||
spy.mockRestore()
|
||||
setSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* 发版后旧标签页懒加载 chunk 失效(白屏)的识别与恢复工具。
|
||||
*
|
||||
* 背景:页面 React Router 的 lazy 动态 import,发版后旧 chunk 文件名被删除,
|
||||
* 停留在旧标签页的用户点菜单时 import 404,抛出
|
||||
* "Failed to fetch dynamically imported module"(Vite)/ ChunkLoadError,
|
||||
* 不捕获就是整页白屏。
|
||||
*/
|
||||
|
||||
/** sessionStorage 标记:最近已经为 chunk 失效自动刷新过一次(带时间戳,10min 有效) */
|
||||
const RELOAD_FLAG_KEY = "chunk_error_reloaded_at"
|
||||
/** 标记有效期:超过后允许再次自动刷新,避免用户手动正常刷新后标记永久残留 */
|
||||
const RELOAD_FLAG_TTL_MS = 10 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Storage 在 Safari 无痕模式 / 禁用 Cookie 的浏览器 / 严格 iframe 策略下
|
||||
* 访问可能抛异常;此处统一容错,拿不到存储就降级为"无标记",绝不能让
|
||||
* 错误边界本身因读存储而崩溃。
|
||||
*/
|
||||
const safeStorage = {
|
||||
getItem: (key: string): string | null => {
|
||||
try {
|
||||
return sessionStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
setItem: (key: string, value: string): void => {
|
||||
try {
|
||||
sessionStorage.setItem(key, value)
|
||||
} catch {
|
||||
/* 存储不可用时静默降级:仅丢失"已刷新"标记,不影响恢复动作 */
|
||||
}
|
||||
},
|
||||
removeItem: (key: string): void => {
|
||||
try {
|
||||
sessionStorage.removeItem(key)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/** 判断错误是否为懒加载 chunk 加载失败(发版 404 / 网络中断 / 动态 import 失败) */
|
||||
export const isChunkLoadError = (error: unknown): boolean => {
|
||||
if (!error) return false
|
||||
// Vite: Failed to fetch dynamically imported module: /assets/xxx-yyy.js
|
||||
// Webpack: ChunkLoadError: Loading chunk xxx failed.
|
||||
const needle =
|
||||
error instanceof Error
|
||||
? `${error.name} ${error.message}`
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: ""
|
||||
return /failed to fetch dynamically imported module|chunkloaderror|loading chunk \d+ failed|error loading dynamically imported module|importing a module script failed/i.test(
|
||||
needle,
|
||||
)
|
||||
}
|
||||
|
||||
/** 读取上次自动刷新时间戳;过期或不存在返回 null */
|
||||
export const getChunkReloadedAt = (): number | null => {
|
||||
const raw = safeStorage.getItem(RELOAD_FLAG_KEY)
|
||||
if (!raw) return null
|
||||
const ts = Number(raw)
|
||||
if (!Number.isFinite(ts)) return null
|
||||
if (Date.now() - ts > RELOAD_FLAG_TTL_MS) return null
|
||||
return ts
|
||||
}
|
||||
|
||||
/** 标记"已为 chunk 失效自动刷新过",然后刷新页面 */
|
||||
export const reloadForChunkError = (): void => {
|
||||
safeStorage.setItem(RELOAD_FLAG_KEY, String(Date.now()))
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
/**
|
||||
* 硬恢复:清掉标记后回到首页(整页导航,不是当前 URL 刷新)。
|
||||
* - chunk 失效兜底:回到首页会拉取最新 index.html,彻底脱离旧 chunk 引用
|
||||
* - 非 chunk 的页面级崩溃:跳首页能绕开当前报错路由,避免"刷新-再崩"死循环
|
||||
*/
|
||||
export const goHomeRecover = (): void => {
|
||||
safeStorage.removeItem(RELOAD_FLAG_KEY)
|
||||
window.location.href = "/"
|
||||
}
|
||||
@@ -37,19 +37,14 @@ LONG_VIDEO_SEGMENT_SEC = 30 # 长视频每段秒数
|
||||
LONG_VIDEO_DURATION_THRESHOLD_SEC = 180 # 3 分钟阈值
|
||||
MIN_FRAMES_PER_SEGMENT = 2 # 长视频每段最少帧数
|
||||
|
||||
# ── 滑动窗口匹配常量(Issue #1702 二次校准) ─────────────────────
|
||||
# 阈值经 staging 真实数据两轮回归校准(worker 容器内离线实验):
|
||||
# 第一轮(2026-09-05):同源对 <=12 命中 4/11,异源最小距离 24 → 定 12;
|
||||
# 第二轮(2026-09-05,证据视频 B->A 仍漏检):扩大样本到该用户全部
|
||||
# 15 个真实成片(13 个异源候选)实测:
|
||||
# - 同源成片对(A 20s / B、C 各 11.75s,1s 密集采样):
|
||||
# B->A 中位数距离 14,<=16 命中 8/11=0.73;C->A 8/11=0.73
|
||||
# - 异源成片对(13 个真实视频):每帧全局最近邻最小距离 18,
|
||||
# <=16 命中帧数全部为 0(最近邻 18 仅个别帧,中位数 22~28)
|
||||
# 12 漏掉同源降重对(降重滤镜/字幕/画面扰动把距离从 ~8 推到 14~16);
|
||||
# 16 对同源命中 0.73+ 且与异源分布(最近邻 >=18)仍有 >=2bit 安全裕度,
|
||||
# 异源 <=16 命中 0 帧,无误报空间。
|
||||
PHASH_THRESHOLD = 16
|
||||
# ── 滑动窗口匹配常量(Issue #1702 重新校准) ─────────────────────
|
||||
# 阈值经 staging 真实数据回归校准(2026-09-05,worker 容器内离线实验):
|
||||
# - 同源成片对(20s/11s,各自 2-5% 随机边缘裁剪降重,1s 密集采样):
|
||||
# 全部帧对最小汉明距离 min=8,<=12 命中 10/31 帧(B->A 4/11)
|
||||
# - 异源成片对(4 个不同项目真实视频):最小距离 24,<=16 命中 0 帧
|
||||
# 8(#1658 旧值)会漏掉同源裁剪(自对照实验:同帧两次 2-5% 随机裁剪距离 4~10),
|
||||
# 12 能检出同源/局部复用且与异源分布(>=24)间隔 12bit,无误报空间。
|
||||
PHASH_THRESHOLD = 12
|
||||
SEGMENT_MATCH_THRESHOLD = PHASH_THRESHOLD # 片段匹配阈值与帧匹配统一(#1702:阈值常量统一来源)
|
||||
MIN_CONSECUTIVE_MATCHES = 5 # 连续匹配默认门槛;短视频自适应 min(5, max(2, 分片数//2))
|
||||
MAX_GAP = 2 # 允许的最大间隙帧数
|
||||
@@ -377,10 +372,9 @@ def find_duplicate_segments(
|
||||
1. 构建 query×target 全量汉明距离矩阵;每个 query chunk 保留所有
|
||||
距离 <= match_threshold 的候选 target 分片(与帧匹配判定同一阈值)。
|
||||
2. 时序一致贪心对齐:沿 query 时序推进,run 内优先选择与上一匹配帧
|
||||
目标序号连贯(|delta| <= neighbor_window+1,允许 ±1 邻接/时序偏移
|
||||
对齐——1s 密集采样下相邻帧 pHash 接近,最近邻在目标相邻帧间
|
||||
正/反向跳变均属正常,缓解场景切割切点、取帧错位、局部倒退)的
|
||||
候选;同距时偏好小索引(最早对齐位置)。
|
||||
目标序号连贯(0 <= delta <= neighbor_window+1,允许 ±1 邻接窗口 /
|
||||
时序偏移对齐,缓解场景切割导致的切点、取帧错位)的候选;同距时
|
||||
偏好大索引,避免重复 hash 塌缩到 target 首帧。
|
||||
3. 连贯匹配中允许 <= max_gap 帧间隙桥接;断裂后另起新 run——天然
|
||||
支持局部片段复用(复用片段可出现在任意时序位置,各成独立片段)。
|
||||
4. 连续匹配帧数 >= min_consecutive 的 run 报为重复片段。短视频自适应:
|
||||
@@ -393,7 +387,7 @@ def find_duplicate_segments(
|
||||
match_threshold: 汉明距离匹配阈值(统一常量 PHASH_THRESHOLD)
|
||||
min_consecutive: 最少连续匹配帧数;None 时按短视频自适应
|
||||
max_gap: 允许的最大间隙帧数
|
||||
neighbor_window: 时序对齐允许的目标分片序号邻接窗口(正/反向均允许)
|
||||
neighbor_window: 时序对齐允许的目标分片序号邻接窗口
|
||||
|
||||
Returns:
|
||||
DuplicateSegment 列表
|
||||
@@ -428,9 +422,8 @@ def find_duplicate_segments(
|
||||
min_consecutive = min(MIN_CONSECUTIVE_MATCHES, max(2, n // 2))
|
||||
|
||||
# Step 2: 时序一致贪心对齐。
|
||||
# run 内偏好与上一匹配帧目标序号连贯(|delta| <= neighbor_window+1,
|
||||
# 支持 ±1 邻接窗口/时序偏移对齐,正反向抖动均允许)的候选;
|
||||
# 无连贯候选时关闭旧 run。
|
||||
# run 内偏好与上一匹配帧目标序号连贯(0 <= delta <= neighbor_window+1,
|
||||
# 支持 ±1 邻接窗口/时序偏移对齐)的候选;无连贯候选时关闭旧 run。
|
||||
# 这天然支持局部片段复用:同一 query 视频中多个复用片段各自形成独立 run。
|
||||
frame_matches: list[tuple[bool, int, int]] = []
|
||||
runs: list[tuple[int, int]] = []
|
||||
@@ -451,7 +444,7 @@ def find_duplicate_segments(
|
||||
chosen = cand[0] if cand else None
|
||||
else:
|
||||
chosen = next(
|
||||
(c for c in cand if abs(c[0] - run_last_t) <= neighbor_window + 1),
|
||||
(c for c in cand if 0 <= c[0] - run_last_t <= neighbor_window + 1),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@@ -6,24 +6,6 @@ celery_app = Celery(settings.worker_name)
|
||||
celery_app.conf.broker_url = settings.broker_url
|
||||
celery_app.conf.result_backend = settings.result_backend
|
||||
celery_app.conf.broker_connection_retry_on_startup = True
|
||||
|
||||
# #1714 队列隔离:generation(高优,独占 worker)/ transcode(素材转码)/ celery(默认)
|
||||
from packages.shared.celery_queues import ( # noqa: E402
|
||||
GENERATION_WORKER_PREFETCH_MULTIPLIER,
|
||||
apply_queue_settings,
|
||||
)
|
||||
|
||||
apply_queue_settings(celery_app)
|
||||
# 长渲染任务预取 1,避免任务被预取占住导致调度不均
|
||||
celery_app.conf.worker_prefetch_multiplier = GENERATION_WORKER_PREFETCH_MULTIPLIER
|
||||
celery_app.conf.task_acks_late = True # worker 崩溃时未完成任务重回队列,由执行前守卫丢弃作废消息
|
||||
# worker 进程被 OOM/容器硬杀时拒绝 ack,消息留在队列由其他 worker 接手
|
||||
celery_app.conf.task_reject_on_worker_lost = True
|
||||
# Redis broker 消息可见性超时(#1714):acks_late 下,消息被预取后 visibility_timeout
|
||||
# 内未 ack 才会重投。长任务(ingest HEVC 转码 20-30 分钟、生成硬超时 11 分钟)
|
||||
# 必须远大于最长执行时间,否则正常任务会在执行中被误重投;4 小时覆盖最长转码 + 余量。
|
||||
celery_app.conf.broker_transport_options = {"visibility_timeout": 4 * 60 * 60}
|
||||
|
||||
celery_app.conf.imports = (
|
||||
"worker_app.tasks.health",
|
||||
"worker_app.tasks.ingest",
|
||||
@@ -40,25 +22,10 @@ celery_app.conf.imports = (
|
||||
)
|
||||
|
||||
# Celery Beat 定时任务调度
|
||||
# 注:worker 单实例内嵌 beat(entrypoint-worker.sh -B),定时任务不会重复执行
|
||||
celery_app.conf.beat_schedule = {
|
||||
# pending 任务超时清理:worker 停止消费后,卡 pending 的任务 15 分钟内释放限流名额
|
||||
"cleanup-stale-pending-tasks": {
|
||||
"task": "worker.cleanup_stale_pending_tasks",
|
||||
"schedule": 300.0, # 每 5 分钟(秒)
|
||||
"options": {"expires": 240}, # 4 分钟过期,避免堆积
|
||||
},
|
||||
# running 孤儿任务巡检:容器重启/进程被杀后卡 running 的任务,20 分钟无更新则判失败
|
||||
"cleanup-stale-running-tasks": {
|
||||
"task": "worker.cleanup_stale_running_tasks",
|
||||
"schedule": 300.0, # 每 5 分钟(秒)
|
||||
"options": {"expires": 240},
|
||||
},
|
||||
# 上传/转码链路孤儿巡检:worker 重启丢 prefetch 消息后,卡 pending/processing
|
||||
# 的 ingest_job + asset 占位超时标终态(#1714)。转码任务较长,10 分钟一轮
|
||||
"cleanup-stale-ingest-jobs": {
|
||||
"task": "worker.cleanup_stale_ingest_jobs",
|
||||
"schedule": 600.0, # 每 10 分钟(秒)
|
||||
"options": {"expires": 540},
|
||||
"options": {"expires": 300}, # 5 分钟过期,避免堆积
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,83 +7,11 @@ from worker_app.db import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 孤儿任务超时阈值:渲染任务超过此时间未更新则视为卡死
|
||||
ORPHAN_TASK_TIMEOUT_MINUTES = 10
|
||||
|
||||
def cleanup_stale_running_with_session(repo, timeout_minutes: int) -> int:
|
||||
"""清理超时未更新的 running GenerationTask(可注入 repo 的纯核心,便于单测)。
|
||||
|
||||
Returns:
|
||||
清理的任务数量
|
||||
"""
|
||||
return len(cleanup_stale_running_with_session_ids(repo, timeout_minutes))
|
||||
|
||||
|
||||
def cleanup_stale_running_with_session_ids(repo, timeout_minutes: int) -> list[tuple[str, str]]:
|
||||
"""同 cleanup_stale_running_with_session,返回 [(task_id, celery_task_id), ...]。"""
|
||||
fn = getattr(repo, "cleanup_stale_running_with_ids", None)
|
||||
if fn is not None:
|
||||
return fn(timeout_minutes)
|
||||
# 旧仓储无 _with_ids 方法:降级为计数,无法撤销消息(执行前状态守卫兜底)
|
||||
count = repo.cleanup_stale_running(timeout_minutes)
|
||||
return [("", "") for _ in range(count)]
|
||||
|
||||
|
||||
def cleanup_stale_pending_with_session(repo, timeout_minutes: int) -> int:
|
||||
"""清理超时 pending GenerationTask(可注入 repo 的纯核心,便于单测)。
|
||||
|
||||
Returns:
|
||||
清理的任务数量
|
||||
"""
|
||||
return len(cleanup_stale_pending_with_session_ids(repo, timeout_minutes))
|
||||
|
||||
|
||||
def cleanup_stale_pending_with_session_ids(repo, timeout_minutes: int) -> list[tuple[str, str]]:
|
||||
"""同 cleanup_stale_pending_with_session,返回 [(task_id, celery_task_id), ...]。"""
|
||||
fn = getattr(repo, "cleanup_stale_pending_with_ids", None)
|
||||
if fn is not None:
|
||||
return fn(timeout_minutes)
|
||||
count = repo.cleanup_stale_pending(timeout_minutes)
|
||||
return [("", "") for _ in range(count)]
|
||||
|
||||
|
||||
def _revoke_and_purge_stale_messages(items: list[tuple[str, str]]) -> int:
|
||||
"""把清理掉的任务对应的 Celery 消息撤销并从 Redis 队列清除(#1714)。
|
||||
|
||||
防止「DB 已标 failed,但队列消息还在 → 重投执行 → 非法状态转换 → 半成品」。
|
||||
失败不阻断清理流程(执行前状态守卫是第二道防线)。
|
||||
"""
|
||||
biz_ids = [tid for tid, _ in items if tid]
|
||||
celery_ids = [cid for _, cid in items if cid]
|
||||
if not biz_ids and not celery_ids:
|
||||
return 0
|
||||
try:
|
||||
from worker_app.celery_app import celery_app as app
|
||||
from worker_app.core.config import get_settings
|
||||
|
||||
from packages.shared.celery_orphan_guard import revoke_and_purge
|
||||
|
||||
broker_url = get_settings().broker_url
|
||||
return revoke_and_purge(
|
||||
app,
|
||||
broker_url,
|
||||
business_task_ids=biz_ids,
|
||||
celery_task_ids=celery_ids,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("撤销作废任务队列消息失败(执行前守卫仍会兜底): %s", e, exc_info=True)
|
||||
return 0
|
||||
|
||||
|
||||
# 孤儿任务超时阈值:running 任务超过此时间无进度更新则视为卡死。
|
||||
# 依据:worker.generate_video 硬超时 time_limit=11 分钟,正常任务不可能超过;
|
||||
# 20 分钟阈值覆盖硬超时 + 重试 + 余量,绝不误杀正常任务。
|
||||
ORPHAN_TASK_TIMEOUT_MINUTES = 20
|
||||
|
||||
# Pending 任务超时阈值:任务创建后超过此时间仍未开始执行则判死。
|
||||
# 注意区分 running 孤儿阈值(20 分钟):pending 是「排队等待」时间,
|
||||
# 队列积压(如 20+ 转码任务)时视频生成可能正常排队较久,阈值必须放宽,
|
||||
# 避免正常排队任务被误杀。队列隔离(#1714)后 generation 队列独占 worker,
|
||||
# 理论上排队极短;保留 45 分钟作为兜底,覆盖 worker 短暂停止消费的场景。
|
||||
PENDING_TASK_TIMEOUT_MINUTES = 45
|
||||
# Pending 任务超时阈值:pending 任务在队列中等待超过此时间则自动清理
|
||||
PENDING_TASK_TIMEOUT_MINUTES = 30
|
||||
|
||||
|
||||
def cleanup_orphan_tasks(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES) -> int: # pragma: no cover
|
||||
@@ -104,16 +32,11 @@ def cleanup_orphan_tasks(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES) ->
|
||||
|
||||
try:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
repo = SQLAlchemyGenerationTaskRepository(session)
|
||||
items = cleanup_stale_running_with_session_ids(repo, timeout_minutes)
|
||||
finally:
|
||||
session.close()
|
||||
count = len(items)
|
||||
repo = SQLAlchemyGenerationTaskRepository(session)
|
||||
count = repo.cleanup_stale_running(timeout_minutes)
|
||||
session.close()
|
||||
if count > 0:
|
||||
logger.warning("清理了 %d 个超时的孤儿 GenerationTask(超过 %d 分钟未更新)", count, timeout_minutes)
|
||||
purged = _revoke_and_purge_stale_messages(items)
|
||||
logger.info("孤儿任务对应队列消息撤销/清除完成: %d 条", purged)
|
||||
else:
|
||||
logger.info("无孤儿 GenerationTask 需要清理")
|
||||
return count
|
||||
@@ -147,9 +70,7 @@ def cleanup_stale_jobs(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES) -> in
|
||||
.all()
|
||||
)
|
||||
count = 0
|
||||
stale_items: list[tuple[str, str]] = []
|
||||
for model in stale_jobs:
|
||||
stale_items.append((model.id, getattr(model, "celery_task_id", "") or ""))
|
||||
model.status = JobStatus.FAILED.value
|
||||
model.error_message = f"任务执行中断(超过 {timeout_minutes} 分钟未更新)"
|
||||
count += 1
|
||||
@@ -159,8 +80,6 @@ def cleanup_stale_jobs(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES) -> in
|
||||
else:
|
||||
logger.info("无孤儿 Job 需要清理")
|
||||
session.close()
|
||||
if count > 0:
|
||||
_revoke_and_purge_generation(stale_items)
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.error("清理孤儿 Job 失败: %s", e, exc_info=True)
|
||||
@@ -186,12 +105,9 @@ def cleanup_stale_pending_tasks(timeout_minutes: int = PENDING_TASK_TIMEOUT_MINU
|
||||
session = SessionLocal()
|
||||
try:
|
||||
repo = SQLAlchemyGenerationTaskRepository(session)
|
||||
items = cleanup_stale_pending_with_session_ids(repo, timeout_minutes)
|
||||
count = len(items)
|
||||
count = repo.cleanup_stale_pending(timeout_minutes)
|
||||
if count > 0:
|
||||
logger.warning("清理了 %d 个超时的 pending GenerationTask(超过 %d 分钟未处理)", count, timeout_minutes)
|
||||
purged = _revoke_and_purge_stale_messages(items)
|
||||
logger.info("超时 pending 任务对应队列消息撤销/清除完成: %d 条", purged)
|
||||
else:
|
||||
logger.info("无超时 pending GenerationTask 需要清理")
|
||||
return count
|
||||
@@ -202,31 +118,6 @@ def cleanup_stale_pending_tasks(timeout_minutes: int = PENDING_TASK_TIMEOUT_MINU
|
||||
session.close()
|
||||
|
||||
|
||||
def _revoke_and_purge_generation(items: list[tuple[str, str]]) -> int:
|
||||
"""撤销 Job 表孤儿任务(TTS/配音等)的队列消息,队列覆盖全部已知队列。"""
|
||||
biz_ids = [tid for tid, _ in items if tid]
|
||||
celery_ids = [cid for _, cid in items if cid]
|
||||
if not biz_ids and not celery_ids:
|
||||
return 0
|
||||
try:
|
||||
from worker_app.celery_app import celery_app as app
|
||||
from worker_app.core.config import get_settings
|
||||
|
||||
from packages.shared.celery_orphan_guard import revoke_and_purge
|
||||
|
||||
broker_url = get_settings().broker_url
|
||||
return revoke_and_purge(
|
||||
app,
|
||||
broker_url,
|
||||
business_task_ids=biz_ids,
|
||||
celery_task_ids=celery_ids,
|
||||
queue_names=("generation", "transcode", "celery"),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("撤销 Job 队列消息失败: %s", e, exc_info=True)
|
||||
return 0
|
||||
|
||||
|
||||
def cleanup_all_stale_tasks(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES) -> dict: # pragma: no cover
|
||||
"""统一清理所有超时的孤儿任务。
|
||||
|
||||
@@ -257,32 +148,3 @@ def _on_worker_ready(sender, **kwargs): # pragma: no cover
|
||||
result = cleanup_all_stale_tasks()
|
||||
total = result["generation_tasks"] + result["jobs"]
|
||||
logger.info("Worker 启动清理完成,共清理 %d 个孤儿任务", total)
|
||||
|
||||
|
||||
@worker_ready.connect
|
||||
def _recover_stuck_ingest_jobs_on_ready(sender, **kwargs): # pragma: no cover
|
||||
"""Worker 启动完成后恢复卡死在 processing 的 ingest_job(#1714)。
|
||||
|
||||
容器重启/进程 OOM 导致 transcode 队列 unacked 消息未重投时,processing
|
||||
ingest_job 会永久卡死。启动时扫描 processing 超 10 分钟的 job,CAS 重置
|
||||
pending 并重新派单;Redis 锁保证同容器 generation/transcode 双 worker
|
||||
只有一个执行恢复。旧消息若后来重投,ingest_asset 执行前守卫会丢弃。
|
||||
"""
|
||||
try:
|
||||
from packages.application.ingest_orphan_cleanup import (
|
||||
make_redis_recovery_lock,
|
||||
recover_stuck_ingest_jobs_on_startup,
|
||||
)
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
recovered = recover_stuck_ingest_jobs_on_startup(
|
||||
session,
|
||||
lock_acquire=make_redis_recovery_lock(),
|
||||
stuck_minutes=10,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
logger.info("Worker 启动 ingest 恢复完成,共重新派单 %d 个卡死任务", recovered)
|
||||
except Exception as e: # noqa: BLE001 — 启动恢复失败不能阻断 worker 起服
|
||||
logger.error("启动 ingest 恢复扫描失败(beat 巡检仍会兜底标 failed): %s", e, exc_info=True)
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
"""定期清理任务 — Celery Beat 调度。
|
||||
|
||||
包含:
|
||||
- cleanup_stale_pending_tasks: 定期清理卡在 pending 超时的 generation_tasks(worker 停止消费时占位)
|
||||
- cleanup_stale_running_tasks: 定期清理卡在 running 超时的 generation_tasks(容器重启/进程被杀后的孤儿)
|
||||
- cleanup_stale_pending_tasks: 定期清理卡在 pending 超时的 generation_tasks
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
from worker_app.tasks._startup import (
|
||||
ORPHAN_TASK_TIMEOUT_MINUTES,
|
||||
PENDING_TASK_TIMEOUT_MINUTES,
|
||||
cleanup_orphan_tasks,
|
||||
cleanup_stale_jobs,
|
||||
cleanup_stale_pending_tasks,
|
||||
)
|
||||
|
||||
from packages.application.ingest_orphan_cleanup import (
|
||||
ASSET_ORPHAN_TIMEOUT_MINUTES,
|
||||
INGEST_PENDING_TIMEOUT_MINUTES,
|
||||
INGEST_PROCESSING_TIMEOUT_MINUTES,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -29,14 +19,12 @@ logger = logging.getLogger(__name__)
|
||||
def scheduled_cleanup_stale_pending(timeout_minutes: int = PENDING_TASK_TIMEOUT_MINUTES) -> dict:
|
||||
"""Celery Beat 调度的定期任务:清理超时的 pending 任务。
|
||||
|
||||
每 5 分钟执行一次(由 celery_app.py 的 beat_schedule 配置),
|
||||
每 10 分钟执行一次(由 celery_app.py 的 beat_schedule 配置),
|
||||
查找所有 status='pending' 且 created_at < NOW() - timeout_minutes
|
||||
的 generation_tasks,批量更新为 failed,释放限流名额;同时 revoke 并清除
|
||||
Redis 队列中对应的 Celery 消息,杜绝作废消息重投执行(#1714)。
|
||||
的 generation_tasks,批量更新为 failed。
|
||||
|
||||
Args:
|
||||
timeout_minutes: 超时时间(分钟),默认 45 分钟(pending 排队阈值放宽,
|
||||
与 running 孤儿 20 分钟区分,避免正常排队任务被误杀)
|
||||
timeout_minutes: 超时时间(分钟),默认 30 分钟
|
||||
|
||||
Returns:
|
||||
{"cleaned": int}
|
||||
@@ -45,83 +33,3 @@ def scheduled_cleanup_stale_pending(timeout_minutes: int = PENDING_TASK_TIMEOUT_
|
||||
if count > 0:
|
||||
logger.info("[Beat] 清理了 %d 个超时 pending 任务(超时阈值 %d 分钟)", count, timeout_minutes)
|
||||
return {"cleaned": count}
|
||||
|
||||
|
||||
@shared_task(name="worker.cleanup_stale_running_tasks")
|
||||
def scheduled_cleanup_stale_running(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES) -> dict:
|
||||
"""Celery Beat 调度的定期任务:清理超时的 running 孤儿任务。
|
||||
|
||||
每 5 分钟执行一次。worker_ready 信号只在 worker 启动时清一次,
|
||||
若 worker 没重启但任务卡死(上传挂起、进程 OOM 被内核杀掉等),
|
||||
任务会永久卡在 running 占位。此任务做持续兜底:
|
||||
查找 status='running' 且 updated_at < NOW() - timeout_minutes 的任务,
|
||||
标记为 failed(原因:容器重启/超时中断),同时清理 Job 表孤儿。
|
||||
|
||||
Args:
|
||||
timeout_minutes: 超时时间(分钟),默认 20 分钟
|
||||
(worker.generate_video 硬超时 11 分钟,正常任务不可能超过 20 分钟)
|
||||
|
||||
Returns:
|
||||
{"generation_tasks": int, "jobs": int}
|
||||
"""
|
||||
gen_count = cleanup_orphan_tasks(timeout_minutes)
|
||||
job_count = cleanup_stale_jobs(timeout_minutes)
|
||||
total = gen_count + job_count
|
||||
if total > 0:
|
||||
logger.warning(
|
||||
"[Beat] 清理孤儿任务: running GenerationTask=%d, Job=%d(超时阈值 %d 分钟)",
|
||||
gen_count,
|
||||
job_count,
|
||||
timeout_minutes,
|
||||
)
|
||||
return {"generation_tasks": gen_count, "jobs": job_count}
|
||||
|
||||
|
||||
@shared_task(name="worker.cleanup_stale_ingest_jobs")
|
||||
def scheduled_cleanup_stale_ingest_jobs(
|
||||
processing_timeout_minutes: int = INGEST_PROCESSING_TIMEOUT_MINUTES,
|
||||
pending_timeout_minutes: int = INGEST_PENDING_TIMEOUT_MINUTES,
|
||||
orphan_asset_timeout_minutes: int = ASSET_ORPHAN_TIMEOUT_MINUTES,
|
||||
) -> dict:
|
||||
"""Celery Beat 调度:清理上传/转码链路(IngestJob + Asset)孤儿记录。
|
||||
|
||||
每 10 分钟执行一次。worker 容器重启/进程 OOM 时,已 prefetch 的 transcode
|
||||
celery 消息会丢失(队列里也不存在),ingest_job 永久卡 pending/processing、
|
||||
asset 永久卡 processing/uploading,没有兜底永远不会恢复(#1714)。
|
||||
|
||||
- ingest_job processing > processing_timeout_minutes / pending > pending_timeout_minutes
|
||||
→ 标 failed;关联 asset 占位(processing/uploading)联动标 error
|
||||
- 无 ingest_job 关联、created_at > orphan_asset_timeout_minutes 的占位 asset
|
||||
→ 标 error
|
||||
- 作废 celery 消息 revoke + 物理清除(防重投,执行前守卫是第二道防线)
|
||||
"""
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
from packages.application.ingest_orphan_cleanup import (
|
||||
cleanup_orphan_processing_assets,
|
||||
cleanup_stale_ingest_jobs,
|
||||
revoke_stale_ingest_messages,
|
||||
)
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
job_items, asset_ids = cleanup_stale_ingest_jobs(
|
||||
session,
|
||||
processing_timeout_minutes=processing_timeout_minutes,
|
||||
pending_timeout_minutes=pending_timeout_minutes,
|
||||
)
|
||||
orphan_asset_ids = cleanup_orphan_processing_assets(session, timeout_minutes=orphan_asset_timeout_minutes)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
purged = revoke_stale_ingest_messages(job_items) if job_items else 0
|
||||
total_jobs = len(job_items)
|
||||
total_assets = len(set(asset_ids) | set(orphan_asset_ids))
|
||||
if total_jobs or total_assets:
|
||||
logger.warning(
|
||||
"[Beat] 清理 ingest 链路孤儿: stale_jobs=%d, assets→error=%d, 队列清除消息=%d",
|
||||
total_jobs,
|
||||
total_assets,
|
||||
purged,
|
||||
)
|
||||
return {"stale_jobs": total_jobs, "assets_to_error": total_assets, "purged_messages": purged}
|
||||
|
||||
@@ -22,8 +22,6 @@ from worker_app.celery_app import celery_app
|
||||
from worker_app.db import SessionLocal
|
||||
from worker_app.tasks.generation_plan_builder import build_error_info as _build_error_info
|
||||
|
||||
from packages.shared.celery_orphan_guard import TERMINAL_STATUS_VALUES
|
||||
|
||||
OUTPUT_WIDTH = 1280
|
||||
OUTPUT_HEIGHT = 720
|
||||
OUTPUT_FPS = 25.0
|
||||
@@ -660,34 +658,6 @@ def generate_video(self, task_id: str) -> dict:
|
||||
finally:
|
||||
_session.close()
|
||||
|
||||
# ── 0. 执行前状态守卫(#1714):任务已被超时清理/孤儿恢复标记为终态时,
|
||||
# 这是作废消息(worker 崩溃前未 ack 的旧消息重投/重复投递),直接丢弃,
|
||||
# 不进入渲染,杜绝 failed→running 非法转换后继续跑产出半成品。
|
||||
if gen_task is not None and gen_task.status.value in TERMINAL_STATUS_VALUES:
|
||||
logger.warning(
|
||||
"[task_id=%s] 任务状态已为 %s,丢弃作废消息,不执行渲染",
|
||||
task_id,
|
||||
gen_task.status.value,
|
||||
)
|
||||
return {
|
||||
"status": "discarded",
|
||||
"task_id": task_id,
|
||||
"reason": f"task already terminal: {gen_task.status.value}",
|
||||
}
|
||||
|
||||
# 标记任务为 running —— 必须成功:状态机非法转换(如 failed→running)说明
|
||||
# 任务已被作废,安全中止,禁止继续执行。
|
||||
if not _update_task_status(task_id, "mark_processing"):
|
||||
logger.error(
|
||||
"[task_id=%s] 标记 running 失败(任务可能已被作废/取消),安全中止,不执行渲染",
|
||||
task_id,
|
||||
)
|
||||
return {
|
||||
"status": "discarded",
|
||||
"task_id": task_id,
|
||||
"reason": "claim failed (invalid state transition)",
|
||||
}
|
||||
|
||||
# 记录接收任务日志
|
||||
if gen_task:
|
||||
gen_task.append_log(
|
||||
@@ -699,6 +669,8 @@ def generate_video(self, task_id: str) -> dict:
|
||||
)
|
||||
_flush_logs(task_id, gen_task)
|
||||
|
||||
# 标记任务为 running
|
||||
_update_task_status(task_id, "mark_processing")
|
||||
_update_task_progress(task_id, 10, "任务启动")
|
||||
|
||||
try:
|
||||
|
||||
@@ -359,54 +359,6 @@ def validate_transcode_output(
|
||||
return True
|
||||
|
||||
|
||||
def _original_key_from_storage_key(storage_key: str) -> str:
|
||||
"""从可能被 HEVC 转码改写的 storage_key 还原原始 key。
|
||||
|
||||
转码成功后 key 形如 uploads/<id>/IMG_2282_h264.MOV,
|
||||
占位 asset 以原始 key uploads/<id>/IMG_2282.MOV 创建。
|
||||
"""
|
||||
if not storage_key:
|
||||
return storage_key
|
||||
_p = Path(storage_key)
|
||||
if _p.stem.endswith("_h264"):
|
||||
return str(_p.parent / (_p.stem[: -len("_h264")] + _p.suffix))
|
||||
return storage_key
|
||||
|
||||
|
||||
def _resolve_placeholder_asset(asset_repo, job, original_storage_key):
|
||||
"""找到 complete 阶段创建的 PROCESSING 占位 asset(Issue #1714)。
|
||||
|
||||
HEVC 转码成功后 job.storage_key 会被改写为 *_h264 新 key,旧实现用新 key
|
||||
回查占位必然落空,进而兜底新建一条 READY 记录,导致原占位永久卡 processing。
|
||||
|
||||
查找优先级:
|
||||
1. job.asset_id(complete 派单时透传的占位 id,最可靠,不依赖 key);
|
||||
2. 原始 storage_key(占位记录以原始 key 创建);
|
||||
3. 当前 job.storage_key(未转码/降级场景与原始 key 相同)。
|
||||
|
||||
找不到返回 None(旧链路兼容,由调用方兜底新建并告警)。
|
||||
"""
|
||||
asset_id = getattr(job, "asset_id", "") or ""
|
||||
if asset_id:
|
||||
try:
|
||||
found = asset_repo.find_by_id(asset_id)
|
||||
if found is not None:
|
||||
return found
|
||||
except Exception as find_err:
|
||||
logger.warning("占位 asset 按 id 查询失败 asset_id=%s: %s", asset_id, find_err)
|
||||
for key in (original_storage_key, getattr(job, "storage_key", "")):
|
||||
if not key:
|
||||
continue
|
||||
try:
|
||||
found = asset_repo.find_by_storage_key(key)
|
||||
except Exception:
|
||||
logger.warning("find_by_storage_key not available, trying fallback lookup")
|
||||
found = None
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
@celery_app.task(name="worker.ingest_asset")
|
||||
def ingest_asset(job_id: str) -> dict:
|
||||
"""
|
||||
@@ -428,23 +380,6 @@ def ingest_asset(job_id: str) -> dict:
|
||||
if job is None:
|
||||
return {"status": "failed", "error": "job not found"}
|
||||
|
||||
# ── 执行前状态守卫(#1714):job 已终态(失败/完成)说明这是作废消息
|
||||
# (超时清理标记 failed 后旧消息重投、或重复投递),直接丢弃不执行,
|
||||
# 避免重复转码、重复回写。processing 是本任务自己第一次置位前的旧消息
|
||||
# 极少见,保守起见也丢弃(processing 的占位由恢复流程处理)。
|
||||
current_status = job.status.value if hasattr(job.status, "value") else str(job.status)
|
||||
if current_status in ("failed", "completed"):
|
||||
logger.warning(
|
||||
"[ingest job_id=%s] 任务状态已为 %s,丢弃作废消息,不执行转码",
|
||||
job_id,
|
||||
current_status,
|
||||
)
|
||||
return {"status": "discarded", "job_id": job_id, "reason": f"job already terminal: {current_status}"}
|
||||
|
||||
# 记录原始 storage_key:HEVC 转码成功后 job.storage_key 会改写为 *_h264,
|
||||
# 而 complete 阶段的占位 asset 始终以原始 key 创建,关联回写必须保留它。
|
||||
original_storage_key = job.storage_key
|
||||
|
||||
# Update job status to PROCESSING
|
||||
job.status = IngestJobStatus.PROCESSING
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
@@ -689,41 +624,22 @@ def ingest_asset(job_id: str) -> dict:
|
||||
error_reason,
|
||||
)
|
||||
|
||||
placeholder = _resolve_placeholder_asset(asset_repo, job, original_storage_key)
|
||||
if placeholder is not None:
|
||||
# 回写占位记录:标 ERROR(Issue #1714:禁止新建第二条导致占位孤儿)
|
||||
asset = placeholder
|
||||
asset.mime_type = mime_type
|
||||
asset.metadata = {"source": "upload", "ingest_error": error_reason}
|
||||
asset.file_size = int(metadata.get("size_bytes", 0))
|
||||
asset.duration = float(metadata.get("duration", 0)) or None
|
||||
asset.width = int(metadata.get("width", 0)) or None
|
||||
asset.height = int(metadata.get("height", 0)) or None
|
||||
codec_val = metadata.get("codec")
|
||||
if codec_val:
|
||||
asset.codec = str(codec_val)
|
||||
asset.status = AssetStatus.ERROR
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
asset_repo.update(asset)
|
||||
else:
|
||||
# 旧链路兜底:无占位记录(如历史 job 重跑)才新建
|
||||
logger.warning("无效素材且未找到占位记录,兜底新建 ERROR asset: job_id=%s", job_id)
|
||||
asset = Asset.create(
|
||||
project_id=job.project_id,
|
||||
library_id=job.library_id,
|
||||
name=filename,
|
||||
storage_key=job.storage_key,
|
||||
mime_type=mime_type,
|
||||
metadata={"source": "upload", "ingest_error": error_reason},
|
||||
file_size=int(metadata.get("size_bytes", 0)),
|
||||
duration=float(metadata.get("duration", 0)),
|
||||
width=int(metadata.get("width", 0)),
|
||||
height=int(metadata.get("height", 0)),
|
||||
codec=metadata.get("codec") or None,
|
||||
status=AssetStatus.ERROR,
|
||||
file_hash=job.file_hash,
|
||||
)
|
||||
asset_repo.create(asset)
|
||||
asset = Asset.create(
|
||||
project_id=job.project_id,
|
||||
library_id=job.library_id,
|
||||
name=filename,
|
||||
storage_key=job.storage_key,
|
||||
mime_type=mime_type,
|
||||
metadata={"source": "upload", "ingest_error": error_reason},
|
||||
file_size=int(metadata.get("size_bytes", 0)),
|
||||
duration=float(metadata.get("duration", 0)),
|
||||
width=int(metadata.get("width", 0)),
|
||||
height=int(metadata.get("height", 0)),
|
||||
codec=metadata.get("codec") or None,
|
||||
status=AssetStatus.ERROR,
|
||||
file_hash=job.file_hash,
|
||||
)
|
||||
asset_repo.create(asset)
|
||||
|
||||
# Update job status to FAILED
|
||||
job.status = IngestJobStatus.FAILED
|
||||
@@ -740,19 +656,16 @@ def ingest_asset(job_id: str) -> dict:
|
||||
"error": error_reason,
|
||||
}
|
||||
|
||||
# 查找 complete 阶段创建的占位 Asset 记录(Issue #1714)。
|
||||
# 必须用原始 storage_key / job.asset_id 关联——HEVC 转码后 job.storage_key
|
||||
# 已改写为 *_h264,用新 key 回查占位必然落空,旧实现因此兜底新建 READY 记录,
|
||||
# 导致原 PROCESSING 占位永久卡住(每个 HEVC 视频产生两条记录)。
|
||||
existing_asset = _resolve_placeholder_asset(asset_repo, job, original_storage_key)
|
||||
# 查找已存在的 Asset 记录(由 API 端在上传完成时立即创建为 PROCESSING 状态)
|
||||
existing_asset = None
|
||||
try:
|
||||
existing_asset = asset_repo.find_by_storage_key(job.storage_key)
|
||||
except Exception:
|
||||
logger.warning("find_by_storage_key not available, trying fallback lookup")
|
||||
|
||||
if existing_asset is None:
|
||||
# 兜底:仅当确实没有占位记录(旧版本 API / 历史 job 重跑)才新建。
|
||||
logger.warning(
|
||||
"No placeholder asset found for job_id=%s original_key=%s, creating new",
|
||||
job_id,
|
||||
original_storage_key,
|
||||
)
|
||||
# 兜底:如果 API 端没有预先创建 Asset(旧版本兼容),则创建新记录
|
||||
logger.info("No pre-created asset found for storage_key=%s, creating new", job.storage_key)
|
||||
metadata["source"] = "upload"
|
||||
asset = Asset.create(
|
||||
project_id=job.project_id,
|
||||
@@ -772,13 +685,8 @@ def ingest_asset(job_id: str) -> dict:
|
||||
)
|
||||
asset_repo.create(asset)
|
||||
else:
|
||||
# 更新占位记录:补充元数据、置 READY。转码成功时 storage_key 同步改写为
|
||||
# *_h264(播放/下载走转码产物),原始 key 记入 metadata 可溯源。
|
||||
# 更新已有的 Asset 记录,补充元数据并将状态改为 READY
|
||||
asset = existing_asset
|
||||
if job.storage_key != asset.storage_key:
|
||||
metadata["original_storage_key"] = asset.storage_key
|
||||
metadata["hevc_transcoded"] = True
|
||||
asset.storage_key = job.storage_key
|
||||
asset.mime_type = mime_type
|
||||
metadata["source"] = "upload"
|
||||
asset.metadata = metadata
|
||||
@@ -829,28 +737,9 @@ def ingest_asset(job_id: str) -> dict:
|
||||
job_repo.update(job)
|
||||
|
||||
# 将上传时创建的占位 Asset(PROCESSING/UPLOADING)标记为 ERROR,
|
||||
# 避免素材永远卡在中间状态。转码可能已把 job.storage_key 改写为
|
||||
# *_h264,需用 asset_id / 原始 key 多路径关联占位(Issue #1714)。
|
||||
# 避免素材永远卡在中间状态
|
||||
try:
|
||||
existing = None
|
||||
_asset_id = getattr(job, "asset_id", "") or ""
|
||||
if _asset_id:
|
||||
try:
|
||||
existing = asset_repo.find_by_id(_asset_id)
|
||||
except Exception:
|
||||
existing = None
|
||||
if existing is None:
|
||||
_candidate_keys = [
|
||||
_original_key_from_storage_key(job.storage_key),
|
||||
job.storage_key,
|
||||
]
|
||||
for _key in _candidate_keys:
|
||||
try:
|
||||
existing = asset_repo.find_by_storage_key(_key)
|
||||
except Exception:
|
||||
existing = None
|
||||
if existing is not None:
|
||||
break
|
||||
existing = asset_repo.find_by_storage_key(job.storage_key)
|
||||
if existing and existing.status in (
|
||||
AssetStatus.PROCESSING,
|
||||
AssetStatus.UPLOADING,
|
||||
|
||||
@@ -214,10 +214,3 @@ MEDIAKIT_TIMEOUT=60
|
||||
# ==================== 监控(可选)====================
|
||||
# Sentry DSN(取消注释并填入实际值以启用错误追踪)
|
||||
# SENTRY_DSN=${SENTRY_DSN}
|
||||
|
||||
|
||||
# ==================== 微信开放平台 OAuth(网页扫码登录)====================
|
||||
# 回调域名:xiaoxiajianji.com(微信开放平台已配置)
|
||||
WECHAT_OPEN_APP_ID=${WECHAT_APP_ID}
|
||||
WECHAT_OPEN_APP_SECRET=${WECHAT_APP_SECRET}
|
||||
WECHAT_OPEN_REDIRECT_URI=https://saas.xiaoxiajianji.com/auth/wechat/callback
|
||||
|
||||
@@ -231,10 +231,3 @@ DASHSCOPE_API_KEY=${DASHSCOPE_API_KEY}
|
||||
MEDIAKIT_API_KEY=${MEDIAKIT_API_KEY}
|
||||
MEDIAKIT_BASE_URL=https://mediakit.cn-beijing.volces.com/api/v1
|
||||
MEDIAKIT_TIMEOUT=60
|
||||
|
||||
|
||||
# ==================== 微信开放平台 OAuth(网页扫码登录)====================
|
||||
# 回调域名:xiaoxiajianji.com(微信开放平台已配置)
|
||||
WECHAT_OPEN_APP_ID=${WECHAT_APP_ID}
|
||||
WECHAT_OPEN_APP_SECRET=${WECHAT_APP_SECRET}
|
||||
WECHAT_OPEN_REDIRECT_URI=https://staging.xiaoxiajianji.com/auth/wechat/callback
|
||||
|
||||
@@ -14,10 +14,6 @@ server {
|
||||
# SPA routing - index.html 禁止缓存,确保每次获取最新版本
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache,
|
||||
# 每次校验 ETag/Last-Modified,保证发版后旧标签页重新加载拿到新 chunk 引用;
|
||||
# 带 hash 的静态资源由下方 ~* \.(js|css...) location 优先匹配,不受影响、保持 immutable
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
|
||||
# API proxy — Production 环境代理到 production API 容器
|
||||
|
||||
@@ -21,10 +21,6 @@ server {
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache,
|
||||
# 每次校验 ETag/Last-Modified,保证发版后旧标签页重新加载拿到新 chunk 引用;
|
||||
# 带 hash 的静态资源由下方 ~* \.(js|css...) location 优先匹配,不受影响、保持 immutable
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
|
||||
# API proxy — Staging 环境代理到 staging API 容器
|
||||
|
||||
@@ -115,8 +115,6 @@ services:
|
||||
APP_VERSION: ${APP_VERSION:-unknown}
|
||||
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-4}
|
||||
WORKER_MAX_TASKS_PER_CHILD: ${WORKER_MAX_TASKS_PER_CHILD:-100}
|
||||
# #1714 队列隔离:generation 队列独占 worker(默认并发 2),其余并发给转码
|
||||
GENERATION_CONCURRENCY: ${GENERATION_CONCURRENCY:-2}
|
||||
GENERATED_FILES_DIR: /app/generated
|
||||
GENERATED_FILES_URL_PREFIX: /generated-files
|
||||
PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-https://api.xiaoxiajianji.com}
|
||||
@@ -130,11 +128,11 @@ services:
|
||||
# 健康检查配置
|
||||
# 注:celery inspect ping 依赖 broker 连接,在容器内不可靠,改用进程检查
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep -f 'celery.*worker' | head -n1 >/dev/null 2>&1 || grep -q celery /proc/1/cmdline || exit 1"]
|
||||
test: ["CMD-SHELL", "grep -q celery /proc/1/cmdline || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
start_period: 30s
|
||||
|
||||
logging: *default-logging
|
||||
|
||||
@@ -142,8 +140,7 @@ services:
|
||||
# 资源限制建议(生产环境建议启用)
|
||||
# =========================================
|
||||
# 注意: Worker 需要处理视频,建议分配更多资源
|
||||
# #1714 队列隔离后容器内运行 generation + transcode 两个 worker 进程,
|
||||
# 总并发 = WORKER_CONCURRENCY(默认 4),4C8G 以上确保视频渲染不 OOM
|
||||
# 并发 4 时需要 4C8G 以上,确保视频渲染不 OOM
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
|
||||
@@ -146,7 +146,6 @@ docker run -d \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY="${WORKER_CONCURRENCY:-4}" \
|
||||
-e GENERATION_CONCURRENCY="${GENERATION_CONCURRENCY:-2}" \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
@@ -155,7 +154,7 @@ docker run -d \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"pgrep -f 'celery.*worker' >/dev/null 2>&1 || grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
|
||||
@@ -109,14 +109,13 @@ docker run -d \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY="${WORKER_CONCURRENCY:-4}" \
|
||||
-e GENERATION_CONCURRENCY="${GENERATION_CONCURRENCY:-2}" \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--label com.centurylinklabs.watchtower.enable=true \
|
||||
--health-cmd "sh -c \"pgrep -f 'celery.*worker' >/dev/null 2>&1 || grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
|
||||
@@ -1,64 +1,18 @@
|
||||
#!/bin/bash
|
||||
# Worker 启动脚本 — #1714 队列隔离
|
||||
#
|
||||
# 部署约束:worker 容器单实例(replicas=1),容器内启动两个 celery 进程:
|
||||
# 1. generation-worker:独占消费 generation 队列(用户视频生成,高优先级),
|
||||
# 内嵌 celery beat(-B),定时清理任务只在一个进程里跑,避免重复执行;
|
||||
# 2. transcode-worker:消费 transcode + celery 默认队列(素材转码/分类/查重/
|
||||
# 配音/下载等后台任务)。
|
||||
# 转码队列积压时,generation 队列仍有独立 worker 立即领取视频生成任务。
|
||||
#
|
||||
# 环境变量:
|
||||
# WORKER_CONCURRENCY 总并发槽参考(默认 4);生成 worker 并发默认 2,
|
||||
# 可用 GENERATION_CONCURRENCY 覆盖
|
||||
# GENERATION_CONCURRENCY generation worker 并发(默认 2)
|
||||
# TRANSCODE_CONCURRENCY transcode worker 并发(默认 = WORKER_CONCURRENCY - 2,最小 1)
|
||||
# WORKER_MAX_TASKS_PER_CHILD 每个子进程最大任务数(默认 100)
|
||||
# Worker 启动脚本 — 支持 WORKER_CONCURRENCY 环境变量
|
||||
# 未设置时默认 2(保持向后兼容)
|
||||
|
||||
set -e
|
||||
|
||||
CONCURRENCY="${WORKER_CONCURRENCY:-4}"
|
||||
MAX_TASKS="${WORKER_MAX_TASKS_PER_CHILD:-100}"
|
||||
CONCURRENCY="${WORKER_CONCURRENCY:-2}"
|
||||
|
||||
GEN_CONCURRENCY="${GENERATION_CONCURRENCY:-2}"
|
||||
if [ -z "$TRANSCODE_CONCURRENCY" ]; then
|
||||
TRANS_CONCURRENCY=$((CONCURRENCY - GEN_CONCURRENCY))
|
||||
if [ "$TRANS_CONCURRENCY" -lt 1 ]; then
|
||||
TRANS_CONCURRENCY=1
|
||||
fi
|
||||
else
|
||||
TRANS_CONCURRENCY="$TRANSCODE_CONCURRENCY"
|
||||
fi
|
||||
|
||||
echo "Starting generation worker (queue=generation, concurrency=$GEN_CONCURRENCY, beat embedded)"
|
||||
celery \
|
||||
# ⚠️ 部署约束:此 Worker 必须且只能运行单实例(replicas=1)
|
||||
# -B 标志嵌入 celery beat,beat 负责定期触发 pending 超时清理等定时任务
|
||||
# 多实例部署会导致每个 Worker 独立运行 Beat,造成定时任务重复执行
|
||||
# 若需横向扩展 Worker,必须将 Beat 拆分为独立服务(celery beat -A worker_app.celery_app)
|
||||
exec celery \
|
||||
-A worker_app.celery_app \
|
||||
worker \
|
||||
--loglevel=info \
|
||||
"-B" \
|
||||
-s /tmp/celerybeat-schedule \
|
||||
-Q generation \
|
||||
"--concurrency=${GEN_CONCURRENCY}" \
|
||||
"--max-tasks-per-child=${MAX_TASKS}" \
|
||||
-n generation@%h &
|
||||
GEN_PID=$!
|
||||
|
||||
echo "Starting transcode worker (queues=transcode,celery, concurrency=$TRANS_CONCURRENCY)"
|
||||
celery \
|
||||
-A worker_app.celery_app \
|
||||
worker \
|
||||
--loglevel=info \
|
||||
-Q transcode,celery \
|
||||
"--concurrency=${TRANS_CONCURRENCY}" \
|
||||
"--max-tasks-per-child=${MAX_TASKS}" \
|
||||
-n transcode@%h &
|
||||
TRANS_PID=$!
|
||||
|
||||
# 任一进程退出则终止另一个,让容器整体重启(restart: unless-stopped)
|
||||
trap 'echo "Shutting down workers..."; kill -TERM $GEN_PID $TRANS_PID 2>/dev/null || true' TERM INT
|
||||
|
||||
wait -n $GEN_PID $TRANS_PID
|
||||
EXIT_CODE=$?
|
||||
echo "One worker exited (code=$EXIT_CODE), stopping the other..."
|
||||
kill -TERM $GEN_PID $TRANS_PID 2>/dev/null || true
|
||||
exit $EXIT_CODE
|
||||
"--concurrency=${CONCURRENCY}"
|
||||
|
||||
@@ -16,10 +16,6 @@ server {
|
||||
# 注意:不能加 $uri/,否则 /assets 等与构建产物目录同名的路由会被当成目录访问,返回 403
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache,
|
||||
# 每次校验 ETag/Last-Modified,保证发版后旧标签页重新加载拿到新 chunk 引用;
|
||||
# 带 hash 的静态资源由下方 ~* \.(js|css...) location 优先匹配,不受影响、保持 immutable
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
|
||||
@@ -23,10 +23,6 @@ server {
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache,
|
||||
# 每次校验 ETag/Last-Modified,保证发版后旧标签页重新加载拿到新 chunk 引用;
|
||||
# 带 hash 的静态资源由下方 ~* \.(js|css...) location 优先匹配,不受影响、保持 immutable
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
|
||||
@@ -33,10 +33,6 @@ server {
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache,
|
||||
# 每次校验 ETag/Last-Modified,保证发版后旧标签页重新加载拿到新 chunk 引用;
|
||||
# 带 hash 的静态资源由下方 ~* \.(js|css...) location 优先匹配,不受影响、保持 immutable
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
|
||||
@@ -146,44 +146,3 @@ class InMemoryAssetRepository:
|
||||
if asset.library_id == library_id and asset.file_hash == file_hash:
|
||||
return asset
|
||||
return None
|
||||
|
||||
def find_by_library_and_client_upload_id(
|
||||
self,
|
||||
library_id: str,
|
||||
client_upload_id: str,
|
||||
) -> Asset | None:
|
||||
"""按素材库 + 客户端幂等 token 查找已有素材。"""
|
||||
if not client_upload_id:
|
||||
return None
|
||||
for asset in self._assets.values():
|
||||
if asset.library_id == library_id and getattr(asset, "client_upload_id", "") == client_upload_id:
|
||||
return asset
|
||||
return None
|
||||
|
||||
def find_recent_active_by_library_and_name(
|
||||
self,
|
||||
library_id: str,
|
||||
name: str,
|
||||
within_minutes: int = 30,
|
||||
file_size: int = 0,
|
||||
) -> Asset | None:
|
||||
"""兜底去重:同库 + 同文件名(+同大小)且近期活动状态的素材。"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
if not name:
|
||||
return None
|
||||
from packages.domain import AssetStatus
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=within_minutes)
|
||||
candidates = [
|
||||
a
|
||||
for a in self._assets.values()
|
||||
if a.library_id == library_id
|
||||
and a.name == name
|
||||
and a.status in (AssetStatus.UPLOADING, AssetStatus.PROCESSING)
|
||||
and a.created_at >= cutoff
|
||||
and (not file_size or file_size <= 0 or a.file_size == file_size)
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
return max(candidates, key=lambda a: a.created_at)
|
||||
|
||||
@@ -134,7 +134,6 @@ class SQLAlchemyAssetRepository:
|
||||
quality_score=asset.quality_score,
|
||||
uploaded_by_user_id=asset.uploaded_by_user_id or "system",
|
||||
file_hash=asset.file_hash or None,
|
||||
client_upload_id=asset.client_upload_id or None,
|
||||
created_at=asset.created_at,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -164,8 +163,6 @@ class SQLAlchemyAssetRepository:
|
||||
model.quality_score = asset.quality_score
|
||||
model.uploaded_by_user_id = asset.uploaded_by_user_id or model.uploaded_by_user_id
|
||||
model.file_hash = asset.file_hash or model.file_hash
|
||||
if getattr(model, "client_upload_id", None) is None and asset.client_upload_id:
|
||||
model.client_upload_id = asset.client_upload_id
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
self.session.flush()
|
||||
self._sync_asset_tags(asset.id, asset.tag_ids)
|
||||
@@ -391,7 +388,6 @@ class SQLAlchemyAssetRepository:
|
||||
quality_score=model.quality_score,
|
||||
uploaded_by_user_id=model.uploaded_by_user_id,
|
||||
file_hash=model.file_hash or "",
|
||||
client_upload_id=getattr(model, "client_upload_id", None) or "",
|
||||
metadata=metadata,
|
||||
tag_ids=tag_ids,
|
||||
created_at=model.created_at,
|
||||
@@ -456,58 +452,3 @@ class SQLAlchemyAssetRepository:
|
||||
if model is None:
|
||||
return None
|
||||
return self._to_domain(model)
|
||||
|
||||
def find_by_library_and_client_upload_id(
|
||||
self,
|
||||
library_id: str,
|
||||
client_upload_id: str,
|
||||
) -> Asset | None:
|
||||
"""按素材库 + 客户端幂等 token 查找已有素材(complete 幂等)。"""
|
||||
if not client_upload_id:
|
||||
return None
|
||||
model = (
|
||||
self.session.query(AssetModel)
|
||||
.filter(
|
||||
AssetModel.asset_library_id == library_id,
|
||||
AssetModel.client_upload_id == client_upload_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return None
|
||||
return self._to_domain(model)
|
||||
|
||||
def find_recent_active_by_library_and_name(
|
||||
self,
|
||||
library_id: str,
|
||||
name: str,
|
||||
within_minutes: int = 30,
|
||||
file_size: int = 0,
|
||||
) -> Asset | None:
|
||||
"""兜底去重:同库 + 同文件名(+同大小)且近期仍处活动状态(uploading/processing)的素材。
|
||||
|
||||
用于旧客户端未传 file_hash/client_upload_id 时,防止 complete 超时重试
|
||||
反复创建 PROCESSING 占位记录。只命中"活动中"的近期记录,READY 历史素材不拦。
|
||||
|
||||
严格模式(#1714 误杀修复):file_size 必须 > 0 且与记录大小严格一致;
|
||||
file_size=0(大小未知)时直接返回 None——宁可漏判(极端情况下多建一条
|
||||
占位)也不可仅凭同名 + processing 误杀内容全新的视频。
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
if not name:
|
||||
return None
|
||||
if not file_size or file_size <= 0:
|
||||
return None
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=within_minutes)
|
||||
query = self.session.query(AssetModel).filter(
|
||||
AssetModel.asset_library_id == library_id,
|
||||
AssetModel.name == name,
|
||||
AssetModel.status.in_([AssetStatus.UPLOADING.value, AssetStatus.PROCESSING.value]),
|
||||
AssetModel.created_at >= cutoff,
|
||||
AssetModel.file_size == file_size,
|
||||
)
|
||||
model = query.order_by(AssetModel.created_at.desc()).first()
|
||||
if model is None:
|
||||
return None
|
||||
return self._to_domain(model)
|
||||
|
||||
@@ -38,7 +38,6 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
|
||||
bgm_config=dict(getattr(model, "bgm_config", {}) or {}),
|
||||
is_preview=bool(getattr(model, "is_preview", False)),
|
||||
source_task_id=getattr(model, "source_task_id", "") or "",
|
||||
celery_task_id=getattr(model, "celery_task_id", "") or "",
|
||||
output_width=getattr(model, "output_width", 1280) or 1280,
|
||||
output_height=getattr(model, "output_height", 720) or 720,
|
||||
cover_url=getattr(model, "cover_url", "") or "",
|
||||
@@ -83,7 +82,6 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
bgm_config=task.bgm_config or {},
|
||||
is_preview=task.is_preview or False,
|
||||
source_task_id=task.source_task_id or "",
|
||||
celery_task_id=getattr(task, "celery_task_id", "") or "",
|
||||
output_width=task.output_width,
|
||||
output_height=task.output_height,
|
||||
cover_url=task.cover_url or "",
|
||||
@@ -140,52 +138,6 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
.count()
|
||||
)
|
||||
|
||||
def count_running_by_user(self, user_id: str) -> int:
|
||||
"""统计指定用户处于 running 状态的任务数(用于限流提示展示)。"""
|
||||
return (
|
||||
self.session.query(GenerationTaskModel)
|
||||
.filter(
|
||||
GenerationTaskModel.created_by_user_id == user_id,
|
||||
GenerationTaskModel.status == GenerationTaskStatus.RUNNING.value,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
def count_running_total(self) -> int:
|
||||
"""统计全局处于 running 状态的任务数(worker 实际在执行的任务数)。"""
|
||||
return (
|
||||
self.session.query(GenerationTaskModel)
|
||||
.filter(GenerationTaskModel.status == GenerationTaskStatus.RUNNING.value)
|
||||
.count()
|
||||
)
|
||||
|
||||
def estimate_avg_duration_seconds(self, limit: int = 20, default_seconds: float = 120.0) -> float:
|
||||
"""估算最近完成任务的平均耗时(秒),用于 429 限流提示的等待预估。
|
||||
|
||||
取最近 N 条 completed 任务的 (completed_at - started_at) 平均值;
|
||||
无足够历史数据时返回 default_seconds。
|
||||
用 Python 侧计算差值,避免 SQLite/PostgreSQL 方言差异。
|
||||
"""
|
||||
rows = (
|
||||
self.session.query(GenerationTaskModel.started_at, GenerationTaskModel.completed_at)
|
||||
.filter(
|
||||
GenerationTaskModel.status == GenerationTaskStatus.COMPLETED.value,
|
||||
GenerationTaskModel.started_at.isnot(None),
|
||||
GenerationTaskModel.completed_at.isnot(None),
|
||||
)
|
||||
.order_by(GenerationTaskModel.completed_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
durations = [
|
||||
(completed - started).total_seconds()
|
||||
for started, completed in rows
|
||||
if completed and started and (completed - started).total_seconds() > 0
|
||||
]
|
||||
if not durations:
|
||||
return default_seconds
|
||||
return sum(durations) / len(durations)
|
||||
|
||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]:
|
||||
models = (
|
||||
self.session.query(GenerationTaskModel)
|
||||
@@ -317,7 +269,6 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
if hasattr(model, "is_preview"):
|
||||
model.is_preview = task.is_preview or False
|
||||
model.source_task_id = task.source_task_id or ""
|
||||
model.celery_task_id = getattr(task, "celery_task_id", "") or model.celery_task_id or ""
|
||||
model.output_width = task.output_width
|
||||
model.output_height = task.output_height
|
||||
model.cover_url = task.cover_url or ""
|
||||
@@ -329,14 +280,12 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
def cleanup_stale_running(self, timeout_minutes: int = 10) -> int:
|
||||
"""清理超时未更新的 running 任务(孤儿任务)。
|
||||
|
||||
Returns:
|
||||
清理的任务数量(仅计数,保持旧签名兼容)
|
||||
"""
|
||||
items = self.cleanup_stale_running_with_ids(timeout_minutes)
|
||||
return len(items)
|
||||
将 status=running 且 updated_at 超过 timeout_minutes 分钟未更新的任务
|
||||
标记为 failed,error_message 标记为任务执行中断。
|
||||
|
||||
def cleanup_stale_running_with_ids(self, timeout_minutes: int = 10) -> list[tuple[str, str]]:
|
||||
"""同 cleanup_stale_running,但返回 [(task_id, celery_task_id), ...] 供撤销队列消息。"""
|
||||
Returns:
|
||||
清理的任务数量
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
|
||||
@@ -349,10 +298,8 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
.all()
|
||||
)
|
||||
if not models:
|
||||
return []
|
||||
result: list[tuple[str, str]] = []
|
||||
return 0
|
||||
for model in models:
|
||||
result.append((model.id, getattr(model, "celery_task_id", "") or ""))
|
||||
model.status = GenerationTaskStatus.FAILED.value
|
||||
model.error_message = "任务执行中断(worker重启/超时)"
|
||||
model.error_info = {
|
||||
@@ -362,43 +309,43 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
}
|
||||
model.completed_at = datetime.now(timezone.utc)
|
||||
self.session.commit()
|
||||
return result
|
||||
return len(models)
|
||||
|
||||
def cleanup_stale_pending(self, timeout_minutes: int = 30) -> int:
|
||||
"""清理超时的 pending 任务(未被 Worker 拉取的任务)。
|
||||
|
||||
Returns:
|
||||
清理的任务数量(仅计数,保持旧签名兼容)
|
||||
"""
|
||||
items = self.cleanup_stale_pending_with_ids(timeout_minutes)
|
||||
return len(items)
|
||||
全局任务队列有 pending 数量上限,长期卡在 pending 的任务会占满队列,
|
||||
导致新用户无法创建任务。将超时的 pending 任务标记为 failed。
|
||||
|
||||
def cleanup_stale_pending_with_ids(self, timeout_minutes: int = 30) -> list[tuple[str, str]]:
|
||||
"""同 cleanup_stale_pending,但返回 [(task_id, celery_task_id), ...] 供撤销队列消息。"""
|
||||
Args:
|
||||
timeout_minutes: 超时时间(分钟),默认 30 分钟
|
||||
|
||||
Returns:
|
||||
清理的任务数量
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
|
||||
models = (
|
||||
self.session.query(GenerationTaskModel)
|
||||
.filter(
|
||||
GenerationTaskModel.status == GenerationTaskStatus.PENDING.value,
|
||||
GenerationTaskModel.created_at < cutoff,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if not models:
|
||||
return []
|
||||
error_info = {
|
||||
"error_type": "PendingTimeout",
|
||||
"message": f"任务在 pending 状态停留超过 {timeout_minutes} 分钟,自动清理",
|
||||
"failed_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
result: list[tuple[str, str]] = []
|
||||
for model in models:
|
||||
result.append((model.id, getattr(model, "celery_task_id", "") or ""))
|
||||
model.status = GenerationTaskStatus.FAILED.value
|
||||
model.error_message = "pending timeout: auto cleanup"
|
||||
model.error_info = error_info
|
||||
model.completed_at = datetime.now(timezone.utc)
|
||||
count = (
|
||||
self.session.query(GenerationTaskModel)
|
||||
.filter(
|
||||
GenerationTaskModel.status == GenerationTaskStatus.PENDING.value,
|
||||
GenerationTaskModel.created_at < cutoff,
|
||||
)
|
||||
.update(
|
||||
{
|
||||
GenerationTaskModel.status: GenerationTaskStatus.FAILED.value,
|
||||
GenerationTaskModel.error_message: "pending timeout: auto cleanup",
|
||||
GenerationTaskModel.error_info: error_info,
|
||||
GenerationTaskModel.completed_at: datetime.now(timezone.utc),
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
self.session.commit()
|
||||
return result
|
||||
return count
|
||||
|
||||
@@ -18,8 +18,6 @@ class SQLAlchemyIngestJobRepository:
|
||||
error_message=job.error_message,
|
||||
result_asset_id=job.result_asset_id,
|
||||
file_hash=job.file_hash,
|
||||
asset_id=job.asset_id or "",
|
||||
celery_task_id=getattr(job, "celery_task_id", "") or "",
|
||||
created_at=job.created_at,
|
||||
updated_at=job.updated_at,
|
||||
)
|
||||
@@ -40,8 +38,6 @@ class SQLAlchemyIngestJobRepository:
|
||||
error_message=model.error_message,
|
||||
result_asset_id=model.result_asset_id,
|
||||
file_hash=model.file_hash or "",
|
||||
asset_id=getattr(model, "asset_id", "") or "",
|
||||
celery_task_id=getattr(model, "celery_task_id", "") or "",
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
@@ -58,12 +54,6 @@ class SQLAlchemyIngestJobRepository:
|
||||
model.error_message = job.error_message
|
||||
model.result_asset_id = job.result_asset_id
|
||||
model.file_hash = job.file_hash
|
||||
model.storage_key = job.storage_key
|
||||
if job.asset_id:
|
||||
model.asset_id = job.asset_id
|
||||
celery_tid = getattr(job, "celery_task_id", "")
|
||||
if celery_tid:
|
||||
model.celery_task_id = celery_tid
|
||||
model.updated_at = job.updated_at
|
||||
self.session.commit()
|
||||
return job
|
||||
|
||||
@@ -38,7 +38,6 @@ class UserModel(Base):
|
||||
phone = Column(String(32), nullable=True, unique=True, index=True)
|
||||
phone_verified = Column(Boolean, nullable=False, default=False)
|
||||
binding_completed_at = Column(DateTime, nullable=True)
|
||||
profile_completed = Column(Boolean, nullable=False, default=True, server_default="true")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
@@ -95,7 +94,6 @@ class AssetModel(Base):
|
||||
quality_score = Column(Float, nullable=True)
|
||||
uploaded_by_user_id = Column(String(36), nullable=False)
|
||||
file_hash = Column(String(64), nullable=True, index=True)
|
||||
client_upload_id = Column(String(64), nullable=True, index=True)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), index=True)
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
@@ -246,8 +244,6 @@ class IngestJobModel(Base):
|
||||
error_message = Column(Text, nullable=False, default="")
|
||||
result_asset_id = Column(String(36), nullable=False, default="")
|
||||
file_hash = Column(String(64), nullable=True, index=True)
|
||||
asset_id = Column(String(36), nullable=False, default="", index=True)
|
||||
celery_task_id = Column(String(64), nullable=False, default="", server_default="")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -300,7 +296,6 @@ class GenerationTaskModel(Base):
|
||||
resolution = Column(String(20), nullable=False, default="")
|
||||
is_preview = Column(Boolean, nullable=False, default=False, index=True)
|
||||
source_task_id = Column(String(32), nullable=False, default="", index=True)
|
||||
celery_task_id = Column(String(64), nullable=False, default="", server_default="")
|
||||
output_width = Column(Integer, nullable=False, default=1280)
|
||||
output_height = Column(Integer, nullable=False, default=720)
|
||||
cover_url = Column(String(1000), nullable=False, default="")
|
||||
|
||||
@@ -38,7 +38,6 @@ class SQLAlchemyUserRepository(UserRepository):
|
||||
model.phone = user.phone
|
||||
model.phone_verified = user.phone_verified
|
||||
model.binding_completed_at = user.binding_completed_at
|
||||
model.profile_completed = user.profile_completed
|
||||
model.created_at = user.created_at
|
||||
|
||||
self.session.commit()
|
||||
@@ -114,6 +113,5 @@ class SQLAlchemyUserRepository(UserRepository):
|
||||
phone=model.phone,
|
||||
phone_verified=model.phone_verified or False,
|
||||
binding_completed_at=model.binding_completed_at,
|
||||
profile_completed=model.profile_completed if model.profile_completed is not None else True,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
"""
|
||||
微信账号绑定/解绑 Use Case(已登录用户场景)
|
||||
|
||||
与 wechat_sync_use_case(登录/注册,系统级)不同:
|
||||
- bind:把微信 openid/unionid 绑定到【当前登录账号】,不创建新用户;
|
||||
微信身份若已绑定其他账号则冲突(409)。
|
||||
- unbind:解除当前账号的微信绑定;若账号没有其他登录方式(手机/邮箱/密码),
|
||||
解绑后将无法登录,因此拒绝解绑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from packages.domain.entities import User
|
||||
|
||||
|
||||
class WechatBindRequest:
|
||||
"""微信绑定请求"""
|
||||
|
||||
def __init__(self, user_id: str, openid: str, unionid: str = ""):
|
||||
self.user_id = user_id
|
||||
self.openid = (openid or "").strip()
|
||||
self.unionid = (unionid or "").strip()
|
||||
|
||||
|
||||
class WechatBindResult:
|
||||
"""微信绑定/解绑结果"""
|
||||
|
||||
def __init__(self, user: User):
|
||||
self.user = user
|
||||
|
||||
|
||||
class WechatBindUseCase:
|
||||
"""已登录用户绑定微信用例"""
|
||||
|
||||
def __init__(self, user_repository):
|
||||
self.user_repository = user_repository
|
||||
|
||||
def bind(self, request: WechatBindRequest) -> tuple[Optional[WechatBindResult], Optional[str], int]:
|
||||
"""
|
||||
绑定微信到当前登录账号。
|
||||
|
||||
Returns:
|
||||
(结果, 错误信息, http状态码) - 成功时错误信息为 None、状态码为 200;
|
||||
冲突返回 409,客户端/服务端错误返回 400/404。
|
||||
"""
|
||||
if not request.openid:
|
||||
return None, "缺少微信 openid", 400
|
||||
|
||||
user = self.user_repository.find_by_id(request.user_id)
|
||||
if user is None:
|
||||
return None, "当前用户不存在", 404
|
||||
|
||||
# 已绑定同一个微信:幂等成功
|
||||
if user.wechat_openid == request.openid:
|
||||
return WechatBindResult(user=user), None, 200
|
||||
|
||||
# 当前账号已绑定其他微信
|
||||
if user.wechat_openid:
|
||||
return None, "当前账号已绑定微信,请先解绑", 409
|
||||
|
||||
# openid 已被其他账号占用
|
||||
existing = self.user_repository.find_by_wechat_openid(request.openid)
|
||||
if existing is not None and existing.id != user.id:
|
||||
return None, "该微信已绑定其他账号,请先在原账号解绑", 409
|
||||
|
||||
# unionid 冲突:同主体微信已绑其他账号
|
||||
if request.unionid:
|
||||
existing_union = self.user_repository.find_by_wechat_unionid(request.unionid)
|
||||
if existing_union is not None and existing_union.id != user.id:
|
||||
return None, "该微信主体已绑定其他账号,请先在原账号解绑", 409
|
||||
|
||||
user.wechat_openid = request.openid
|
||||
if request.unionid and not user.wechat_unionid:
|
||||
user.wechat_unionid = request.unionid
|
||||
self.user_repository.save(user)
|
||||
|
||||
return WechatBindResult(user=user), None, 200
|
||||
|
||||
|
||||
class WechatUnbindUseCase:
|
||||
"""已登录用户解绑微信用例"""
|
||||
|
||||
def __init__(self, user_repository):
|
||||
self.user_repository = user_repository
|
||||
|
||||
def unbind(self, user_id: str) -> tuple[Optional[WechatBindResult], Optional[str], int]:
|
||||
"""
|
||||
解除当前账号的微信绑定。
|
||||
|
||||
解绑前置条件:账号必须还有其他登录方式(密码 / 已验证手机 / 真实邮箱),
|
||||
否则解绑后将永远无法登录。
|
||||
"""
|
||||
user = self.user_repository.find_by_id(user_id)
|
||||
if user is None:
|
||||
return None, "当前用户不存在", 404
|
||||
|
||||
if not user.wechat_openid:
|
||||
return None, "当前账号未绑定微信", 400
|
||||
|
||||
# 守卫:解绑后账号必须仍有可实际使用的登录方式。
|
||||
# 注意:微信注册用户带的是【随机密码】(用户不知道、无法用密码登录,
|
||||
# 且 @wechat.local 占位邮箱收不到重置邮件),故 password_hash 不作为兜底依据,
|
||||
# 口径与 /auth/me 的 binding_complete 一致。
|
||||
has_phone = bool(user.phone and user.phone_verified)
|
||||
has_real_email = bool(user.email and user.email_verified and "@wechat.local" not in user.email)
|
||||
if not (has_phone or has_real_email):
|
||||
return None, "账号需要至少一种其他登录方式(已验证手机或真实邮箱)后才能解绑微信", 400
|
||||
|
||||
user.wechat_openid = None
|
||||
user.wechat_unionid = None
|
||||
self.user_repository.save(user)
|
||||
|
||||
return WechatBindResult(user=user), None, 200
|
||||
@@ -20,7 +20,6 @@ import requests
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATE_TTL_SECONDS = 600 # state 有效期 10 分钟
|
||||
STATE_KEY_PREFIX = "wechat:state:" # Redis key 前缀(独立逻辑命名空间)
|
||||
|
||||
|
||||
class MemoryStateStore:
|
||||
@@ -54,80 +53,6 @@ class MemoryStateStore:
|
||||
del self._states[s]
|
||||
|
||||
|
||||
class RedisStateStore:
|
||||
"""Redis state 存储(多实例/容器重启安全)。
|
||||
|
||||
复用现有 Redis(celery broker 同实例),key 前缀 wechat:state:,
|
||||
TTL 10 分钟,SET NX EX + GETDEL 保证一次性消费。
|
||||
Redis 不可用时降级为内存存储,保证登录流程不中断(单节点场景)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis_url: str = "",
|
||||
ttl_seconds: int = STATE_TTL_SECONDS,
|
||||
key_prefix: str = STATE_KEY_PREFIX,
|
||||
client=None,
|
||||
):
|
||||
self._ttl = ttl_seconds
|
||||
self._prefix = key_prefix
|
||||
self._fallback = MemoryStateStore(ttl_seconds=ttl_seconds)
|
||||
self._redis = None
|
||||
if client is not None:
|
||||
# 测试/显式注入
|
||||
self._redis = client
|
||||
return
|
||||
try:
|
||||
import redis
|
||||
|
||||
self._redis = redis.Redis.from_url(redis_url, decode_responses=True)
|
||||
self._redis.ping()
|
||||
logger.info(
|
||||
"微信 state 存储使用 Redis: %s db=%s",
|
||||
self._redis.connection_pool.connection_kwargs.get("host"),
|
||||
self._redis.connection_pool.connection_kwargs.get("db"),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — Redis 不可用降级内存,登录流程不中断
|
||||
logger.warning("微信 state Redis 不可用,降级为内存存储: %s", e)
|
||||
self._redis = None
|
||||
|
||||
def _key(self, state: str) -> str:
|
||||
return f"{self._prefix}{state}"
|
||||
|
||||
def put(self, state: str) -> None:
|
||||
if self._redis is None:
|
||||
self._fallback.put(state)
|
||||
return
|
||||
try:
|
||||
# SET key 1 NX EX ttl:不存在才写入,自带过期
|
||||
self._redis.set(self._key(state), "1", nx=True, ex=self._ttl)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("微信 state 写入 Redis 失败,降级内存: %s", e)
|
||||
self._fallback.put(state)
|
||||
|
||||
# Lua:原子读取并删除(单线程执行),兼容所有 Redis 版本(GETDEL 需 6.2+)
|
||||
_CONSUME_LUA = """
|
||||
local v = redis.call('GET', KEYS[1])
|
||||
if v then redis.call('DEL', KEYS[1]) end
|
||||
return v
|
||||
"""
|
||||
|
||||
def verify_and_consume(self, state: str) -> bool:
|
||||
if self._redis is None:
|
||||
return self._fallback.verify_and_consume(state)
|
||||
try:
|
||||
try:
|
||||
val = self._redis.eval(self._CONSUME_LUA, 1, self._key(state))
|
||||
except Exception: # noqa: BLE001 — eval 不可用时退化 GET+DELETE
|
||||
val = self._redis.get(self._key(state))
|
||||
if val is not None:
|
||||
self._redis.delete(self._key(state))
|
||||
return val is not None
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("微信 state 校验 Redis 失败,降级内存: %s", e)
|
||||
return self._fallback.verify_and_consume(state)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WechatUserInfo:
|
||||
"""微信用户信息"""
|
||||
@@ -233,8 +158,6 @@ class WechatOAuthService:
|
||||
"grant_type": "authorization_code",
|
||||
}
|
||||
token_resp = requests.get(token_url, params=token_params, timeout=10)
|
||||
# 微信响应头不带 charset,requests 默认按 ISO-8859-1 解码会导致中文乱码
|
||||
token_resp.encoding = "utf-8"
|
||||
token_data = token_resp.json()
|
||||
|
||||
if "errcode" in token_data and token_data["errcode"] != 0:
|
||||
@@ -253,8 +176,6 @@ class WechatOAuthService:
|
||||
"lang": "zh_CN",
|
||||
}
|
||||
user_resp = requests.get(user_url, params=user_params, timeout=10)
|
||||
# 同上:显式 UTF-8 解码,保证中文昵称/unionid 等不乱码
|
||||
user_resp.encoding = "utf-8"
|
||||
user_data = user_resp.json()
|
||||
|
||||
if "errcode" in user_data and user_data["errcode"] != 0:
|
||||
@@ -279,29 +200,7 @@ class WechatOAuthService:
|
||||
return None, "微信登录处理失败"
|
||||
|
||||
|
||||
# 模块级单例:state 存储必须跨请求共享,否则 /wechat/url 生成的 state
|
||||
# 与 /wechat/callback 校验时不在同一个 MemoryStateStore,回调必然 400。
|
||||
# 多实例部署时应替换为 Redis state store(单容器多 worker 也需如此)。
|
||||
_oauth_service_singleton: WechatOAuthService | None = None
|
||||
|
||||
|
||||
def _build_default_state_store():
|
||||
"""默认 state 存储:优先 Redis(多实例/重启安全),不可用由 store 内部降级内存。"""
|
||||
redis_url = ""
|
||||
try:
|
||||
from app.config import get_settings
|
||||
|
||||
redis_url = get_settings().CELERY_BROKER_URL or get_settings().REDIS_URL
|
||||
except Exception: # noqa: BLE001 — API 配置不可用时退回环境变量
|
||||
redis_url = os.environ.get("CELERY_BROKER_URL", "") or os.environ.get("REDIS_URL", "")
|
||||
if redis_url:
|
||||
return RedisStateStore(redis_url)
|
||||
return MemoryStateStore()
|
||||
|
||||
|
||||
def get_wechat_oauth_service() -> WechatOAuthService:
|
||||
"""获取微信 OAuth 服务单例(state store 跨请求共享)"""
|
||||
global _oauth_service_singleton
|
||||
if _oauth_service_singleton is None:
|
||||
_oauth_service_singleton = WechatOAuthService(state_store=_build_default_state_store())
|
||||
return _oauth_service_singleton
|
||||
"""获取微信 OAuth 服务单例"""
|
||||
# TODO: 可替换为 Redis state store
|
||||
return WechatOAuthService()
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
微信同步登录/注册 Use Case
|
||||
|
||||
供 BFF 层调用的系统级接口:
|
||||
- 优先按 unionid 识别用户(跨应用/跨端识别同一微信用户)
|
||||
- 再按 openid 识别(同一应用内)
|
||||
- openid 命中老账号但 unionid 缺失时补写 unionid(开放平台绑定前的存量账号自动关联)
|
||||
- 都未命中则创建新用户
|
||||
- 根据 openid 查找用户,找到则登录返回 token
|
||||
- 没找到则创建新用户并返回 token
|
||||
- 支持 unionid 跨应用关联
|
||||
"""
|
||||
|
||||
@@ -84,10 +82,10 @@ class WechatSyncResponse:
|
||||
|
||||
|
||||
class WechatSyncUseCase:
|
||||
"""微信登录/注册同步用例
|
||||
"""微信同步登录/注册用例
|
||||
|
||||
系统级接口,由 BFF 通过 API Key 调用。
|
||||
职责:根据 unionid/openid 查找或创建用户,返回 SaaS token。
|
||||
职责:根据 openid 查找或创建用户,返回 SaaS token。
|
||||
"""
|
||||
|
||||
def __init__(self, user_repository, session_store=None, jwt_secret_key: str | None = None):
|
||||
@@ -107,54 +105,24 @@ class WechatSyncUseCase:
|
||||
return None, "openid is required"
|
||||
|
||||
is_new_user = False
|
||||
user = None
|
||||
openid_user = None
|
||||
unionid_user = None
|
||||
|
||||
# 1. 先按 unionid 查找(跨应用识别同一微信用户,优先级最高)
|
||||
if request.unionid:
|
||||
unionid_user = self.user_repository.find_by_wechat_unionid(request.unionid)
|
||||
# 1. 按 openid 查找用户
|
||||
user = self.user_repository.find_by_wechat_openid(request.openid)
|
||||
|
||||
# 2. 再按 openid 查找(同一应用内)
|
||||
openid_user = self.user_repository.find_by_wechat_openid(request.openid)
|
||||
# 2. 如果 openid 没找到,尝试 unionid
|
||||
if not user and request.unionid:
|
||||
user = self.user_repository.find_by_wechat_unionid(request.unionid)
|
||||
if user:
|
||||
# 找到用户但 openid 为空,绑定一下当前 openid
|
||||
user.wechat_openid = request.openid
|
||||
self.user_repository.save(user)
|
||||
|
||||
if unionid_user and openid_user:
|
||||
# 3a. 两边都命中
|
||||
if unionid_user.id == openid_user.id:
|
||||
# 同一个用户,直接登录
|
||||
user = unionid_user
|
||||
else:
|
||||
# unionid 与 openid 分属两个不同账号:数据异常,拒绝写入,
|
||||
# 交由人工/数据修复合并,避免账号被错误串联
|
||||
return None, ("wechat account conflict: unionid and openid bound to " "different users")
|
||||
elif unionid_user:
|
||||
# 3b. unionid 命中(跨端老用户),当前 openid 未绑定过:
|
||||
# 确认 openid 没有落在其他账号上后,把新 openid 绑到该用户
|
||||
if openid_user is not None and openid_user.id != unionid_user.id:
|
||||
return None, ("wechat account conflict: openid bound to another user")
|
||||
if unionid_user.wechat_openid != request.openid:
|
||||
unionid_user.wechat_openid = request.openid
|
||||
self.user_repository.save(unionid_user)
|
||||
user = unionid_user
|
||||
elif openid_user:
|
||||
# 3c. 仅 openid 命中(开放平台绑定前创建的存量账号):
|
||||
# 本次请求带了 unionid 且该账号还没有 unionid 时补写
|
||||
if request.unionid and not openid_user.wechat_unionid:
|
||||
# 去重:确认该 unionid 没有关联到其他用户
|
||||
conflict = self.user_repository.find_by_wechat_unionid(request.unionid)
|
||||
if conflict is not None and conflict.id != openid_user.id:
|
||||
return None, ("wechat account conflict: unionid already bound to " "another user")
|
||||
openid_user.wechat_unionid = request.unionid
|
||||
self.user_repository.save(openid_user)
|
||||
user = openid_user
|
||||
else:
|
||||
# 4. 都没找到,创建新用户
|
||||
# 额外兜底:若 unionid 已被其他账号占用(理论上上面已查过),
|
||||
# 不创建带冲突 unionid 的新账号
|
||||
# 3. 都没找到则创建新用户
|
||||
if not user:
|
||||
user = self._create_wechat_user(request)
|
||||
is_new_user = True
|
||||
|
||||
# 5. 创建 session 并生成 token
|
||||
# 4. 创建 session 并生成 token
|
||||
session_id = secrets.token_urlsafe(16)
|
||||
refresh_token = secrets.token_urlsafe(32)
|
||||
|
||||
@@ -232,8 +200,6 @@ class WechatSyncUseCase:
|
||||
email_verified=True, # 微信登录视为已验证
|
||||
wechat_openid=request.openid,
|
||||
wechat_unionid=request.unionid or None,
|
||||
# 微信新建用户首次登录需引导设置昵称
|
||||
profile_completed=False,
|
||||
)
|
||||
|
||||
self.user_repository.save(user)
|
||||
|
||||
@@ -12,8 +12,6 @@ class SubmitIngestJobCommand:
|
||||
library_id: str
|
||||
storage_key: str
|
||||
file_hash: str = ""
|
||||
asset_id: str = ""
|
||||
celery_task_id: str = ""
|
||||
|
||||
|
||||
class SubmitIngestJobUseCase:
|
||||
@@ -26,7 +24,5 @@ class SubmitIngestJobUseCase:
|
||||
library_id=command.library_id,
|
||||
storage_key=command.storage_key,
|
||||
file_hash=command.file_hash,
|
||||
asset_id=command.asset_id,
|
||||
celery_task_id=command.celery_task_id,
|
||||
)
|
||||
return self.ingest_job_repository.create(job)
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
"""上传/转码链路(IngestJob + Asset)孤儿清理核心逻辑。
|
||||
|
||||
#1714:generation 链路有 cleanup_stale_running/pending 兜底,但上传链路
|
||||
(ingest_jobs + assets)没有。worker 容器重启/进程 OOM 时,已 prefetch 的
|
||||
celery 消息会丢失(transcode 队列 worker_prefetch_multiplier=1,消息预取后
|
||||
宕机即丢失,Redis 队列里也不再存在),导致:
|
||||
|
||||
- ingest_jobs.status 永久卡 pending/processing
|
||||
- assets.status 永久卡 processing/uploading(complete 阶段预建的占位)
|
||||
|
||||
本模块提供纯核心(session 注入,便于单测):超时阈值内无更新的记录
|
||||
批量标终态(job→failed、asset→error),并返回 (job_id, celery_task_id)
|
||||
列表供调用方 revoke + purge 残留队列消息。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ingest_job PROCESSING 超时阈值:ingest 任务包含下载 + ffprobe + HEVC 转码
|
||||
# (1GB 视频约 10-20 分钟)+ 回传 OSS,正常任务可能跑 20-30 分钟;
|
||||
# 60 分钟阈值覆盖大文件转码 + 抖动,绝不误杀正常任务。
|
||||
INGEST_PROCESSING_TIMEOUT_MINUTES = 60
|
||||
|
||||
# ingest_job PENDING 超时阈值:transcode 队列 concurrency=1,队列积压时
|
||||
# 正常排队可能较久;90 分钟覆盖 worker 短暂停消费 + 排队。
|
||||
INGEST_PENDING_TIMEOUT_MINUTES = 90
|
||||
|
||||
# Asset 占位超时阈值:无关联 ingest_job 的孤儿占位(complete 预建后派单失败等),
|
||||
# 阈值放宽到 120 分钟,避免与 ingest_job 生命周期错杀。
|
||||
ASSET_ORPHAN_TIMEOUT_MINUTES = 120
|
||||
|
||||
_TERMINAL_JOB_STATUSES = ("failed", "completed")
|
||||
_TERMINAL_ASSET_STATUSES = ("ready", "error", "deleted")
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def cleanup_stale_ingest_jobs(
|
||||
session: Any,
|
||||
*,
|
||||
processing_timeout_minutes: int = INGEST_PROCESSING_TIMEOUT_MINUTES,
|
||||
pending_timeout_minutes: int = INGEST_PENDING_TIMEOUT_MINUTES,
|
||||
commit: bool = True,
|
||||
) -> tuple[list[tuple[str, str]], list[str]]:
|
||||
"""清理超时卡 pending/processing 的 ingest_jobs,并联动关联 asset。
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy session(或提供 query/commit 的鸭子类型)
|
||||
processing_timeout_minutes: processing 状态超时阈值
|
||||
pending_timeout_minutes: pending 状态超时阈值
|
||||
commit: 是否提交事务
|
||||
|
||||
Returns:
|
||||
(job_items, asset_ids)
|
||||
- job_items: [(job_id, celery_task_id), ...] 供 revoke/purge
|
||||
- asset_ids: 被联动标记为 error 的 asset id 列表
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel, IngestJobModel
|
||||
|
||||
now = _now()
|
||||
processing_cutoff = now - timedelta(minutes=processing_timeout_minutes)
|
||||
pending_cutoff = now - timedelta(minutes=pending_timeout_minutes)
|
||||
|
||||
stale_jobs = (
|
||||
session.query(IngestJobModel)
|
||||
.filter(
|
||||
IngestJobModel.status.in_(["pending", "processing"]),
|
||||
(
|
||||
(IngestJobModel.status == "processing") & (IngestJobModel.updated_at < processing_cutoff)
|
||||
| (IngestJobModel.status == "pending") & (IngestJobModel.created_at < pending_cutoff)
|
||||
),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
job_items: list[tuple[str, str]] = []
|
||||
asset_ids: list[str] = []
|
||||
stale_asset_models: list[Any] = []
|
||||
for job_model in stale_jobs:
|
||||
ref_time = job_model.updated_at or job_model.created_at
|
||||
if ref_time.tzinfo is None: # SQLite 读回 naive datetime 的防御
|
||||
ref_time = ref_time.replace(tzinfo=timezone.utc)
|
||||
stale_minutes = int((now - ref_time).total_seconds() // 60)
|
||||
job_model.status = "failed"
|
||||
job_model.error_message = (
|
||||
f"转码任务执行中断(超过超时阈值未更新,疑似 worker 重启/进程退出,已卡死 {stale_minutes} 分钟)"
|
||||
)
|
||||
job_model.updated_at = now
|
||||
job_items.append((job_model.id, getattr(job_model, "celery_task_id", "") or ""))
|
||||
if job_model.asset_id:
|
||||
asset_ids.append(job_model.asset_id)
|
||||
|
||||
if asset_ids:
|
||||
stale_asset_models = (
|
||||
session.query(AssetModel)
|
||||
.filter(
|
||||
AssetModel.id.in_(asset_ids),
|
||||
AssetModel.status.in_(["processing", "uploading"]),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for asset_model in stale_asset_models:
|
||||
asset_model.status = "error"
|
||||
asset_model.updated_at = now
|
||||
|
||||
if commit and (job_items or stale_asset_models):
|
||||
session.commit()
|
||||
|
||||
if job_items:
|
||||
logger.warning(
|
||||
"[ingest-cleanup] 清理 %d 个超时 ingest_job(processing>%dm / pending>%dm),联动 %d 个 asset 标 error",
|
||||
len(job_items),
|
||||
processing_timeout_minutes,
|
||||
pending_timeout_minutes,
|
||||
len(stale_asset_models),
|
||||
)
|
||||
return job_items, [a.id for a in stale_asset_models]
|
||||
|
||||
|
||||
def cleanup_orphan_processing_assets(
|
||||
session: Any,
|
||||
*,
|
||||
timeout_minutes: int = ASSET_ORPHAN_TIMEOUT_MINUTES,
|
||||
commit: bool = True,
|
||||
) -> list[str]:
|
||||
"""清理无 ingest_job 关联、超时卡 processing/uploading 的孤儿 asset 占位。
|
||||
|
||||
complete 阶段预建 asset 后若派单失败(或 direct 上传 complete 后
|
||||
未触发 ingest),占位会永久卡住。这类 asset 没有对应 ingest_job,
|
||||
只能按 created_at 超时兜底标 error。
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel, IngestJobModel
|
||||
|
||||
cutoff = _now() - timedelta(minutes=timeout_minutes)
|
||||
orphan_assets = (
|
||||
session.query(AssetModel)
|
||||
.outerjoin(IngestJobModel, IngestJobModel.asset_id == AssetModel.id)
|
||||
.filter(
|
||||
AssetModel.status.in_(["processing", "uploading"]),
|
||||
AssetModel.created_at < cutoff,
|
||||
IngestJobModel.id.is_(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for asset_model in orphan_assets:
|
||||
asset_model.status = "error"
|
||||
asset_model.updated_at = _now()
|
||||
if commit and orphan_assets:
|
||||
session.commit()
|
||||
logger.warning("[ingest-cleanup] 清理 %d 个无 job 关联的超时孤儿 asset 占位", len(orphan_assets))
|
||||
return [a.id for a in orphan_assets]
|
||||
|
||||
|
||||
def revoke_stale_ingest_messages(
|
||||
job_items: list[tuple[str, str]],
|
||||
*,
|
||||
celery_app_factory: Callable[[], Any] | None = None,
|
||||
broker_url_factory: Callable[[], str] | None = None,
|
||||
) -> int:
|
||||
"""revoke + 物理清理 ingest 作废消息(transcode/celery 队列)。
|
||||
|
||||
消息可能已在 worker 宕机时丢失(队列里查不到),那也无害;
|
||||
若消息还在(极端重复投递),物理清除防止重投执行。
|
||||
失败不阻断清理(ingest_asset 的执行前状态守卫是第二道防线)。
|
||||
"""
|
||||
biz_ids = [jid for jid, _ in job_items if jid]
|
||||
celery_ids = [cid for _, cid in job_items if cid]
|
||||
if not biz_ids and not celery_ids:
|
||||
return 0
|
||||
try:
|
||||
from packages.shared.celery_orphan_guard import revoke_and_purge
|
||||
|
||||
app = celery_app_factory() if celery_app_factory else None
|
||||
broker_url = broker_url_factory() if broker_url_factory else ""
|
||||
if app is None or not broker_url:
|
||||
from worker_app.celery_app import celery_app as _app
|
||||
from worker_app.core.config import get_settings
|
||||
|
||||
app = _app
|
||||
broker_url = get_settings().broker_url
|
||||
return revoke_and_purge(
|
||||
app,
|
||||
broker_url,
|
||||
business_task_ids=biz_ids,
|
||||
celery_task_ids=celery_ids,
|
||||
queue_names=("transcode", "celery"),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("撤销作废 ingest 队列消息失败(执行前守卫仍会兜底): %s", e, exc_info=True)
|
||||
return 0
|
||||
|
||||
|
||||
# ── worker 启动恢复(#1714)──────────────────────────────────────────────
|
||||
#
|
||||
# task_acks_late=True 下,worker 崩溃/容器重启时未 ack 的消息理论上会在
|
||||
# visibility_timeout 到期后重新投递;但 prefork 进程异常、部署窗口跨
|
||||
# visibility 配置边界等场景仍可能留下卡在 processing 的 ingest_job
|
||||
# (staging 实证:03:16 派单、03:45 置 processing 后 worker 重启,
|
||||
# unacked 消息未重投,任务永久卡死)。启动时做一次显式恢复扫描兜底。
|
||||
#
|
||||
# 恢复策略:processing 超过 stuck_minutes(默认 10 分钟,部署中跨进程
|
||||
# 交接的正常窗口 < 10 分钟,不会误抢别的 worker 正在执行的任务)的 job,
|
||||
# CAS 重置为 pending 并重新 send_task;旧消息若后来重投,ingest_asset
|
||||
# 的执行前守卫会把状态不匹配的旧 celery 消息丢弃。
|
||||
|
||||
|
||||
def recover_stuck_ingest_jobs_on_startup(
|
||||
session: Any,
|
||||
*,
|
||||
send_task: Callable[..., Any] | None = None,
|
||||
update_celery_task_id: Callable[[str, str], None] | None = None,
|
||||
lock_acquire: Callable[[], bool] | None = None,
|
||||
stuck_minutes: int = 10,
|
||||
commit: bool = True,
|
||||
) -> int:
|
||||
"""worker 启动时把卡在 processing 超时的 ingest_job 重新派单。
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy session
|
||||
send_task: celery send_task 可调用(注入便于测试);不传则用 worker celery_app
|
||||
update_celery_task_id: 回写新 celery task id 的回调(job_id, new_task_id)
|
||||
lock_acquire: 分布式锁获取回调(多 worker 进程同时启动时只允许一个恢复);
|
||||
返回 False 表示未抢到锁,本次跳过
|
||||
stuck_minutes: processing 超过该分钟数视为卡死
|
||||
|
||||
Returns:
|
||||
重新派单的 job 数
|
||||
"""
|
||||
if lock_acquire is not None and not lock_acquire():
|
||||
logger.info("[ingest-recover] 未抢到恢复锁,跳过(另一进程正在恢复)")
|
||||
return 0
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import IngestJobModel
|
||||
|
||||
cutoff = _now() - timedelta(minutes=stuck_minutes)
|
||||
stuck_jobs = (
|
||||
session.query(IngestJobModel)
|
||||
.filter(IngestJobModel.status == "processing", IngestJobModel.updated_at < cutoff)
|
||||
.order_by(IngestJobModel.updated_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
if not stuck_jobs:
|
||||
logger.info("[ingest-recover] 无卡死 processing ingest_job 需要恢复")
|
||||
return 0
|
||||
|
||||
if send_task is None:
|
||||
from worker_app.celery_app import celery_app as _app
|
||||
|
||||
send_task = _app.send_task
|
||||
|
||||
recovered = 0
|
||||
for job_model in stuck_jobs:
|
||||
# CAS:只有仍是 processing 才重置(并发/旧消息已回写终态时不碰)
|
||||
updated = (
|
||||
session.query(IngestJobModel)
|
||||
.filter(IngestJobModel.id == job_model.id, IngestJobModel.status == "processing")
|
||||
.update({"status": "pending", "error_message": "", "updated_at": _now()})
|
||||
)
|
||||
if not updated:
|
||||
continue
|
||||
try:
|
||||
result = send_task("worker.ingest_asset", args=[job_model.id])
|
||||
new_task_id = getattr(result, "id", "") or ""
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("[ingest-recover] 重新派单失败 job_id=%s: %s", job_model.id, e)
|
||||
continue
|
||||
if new_task_id:
|
||||
job_model.celery_task_id = new_task_id
|
||||
if update_celery_task_id is not None:
|
||||
update_celery_task_id(job_model.id, new_task_id)
|
||||
logger.warning(
|
||||
"[ingest-recover] 卡死 ingest_job %s 已重置 pending 并重新派单 (new celery task=%s)",
|
||||
job_model.id,
|
||||
new_task_id,
|
||||
)
|
||||
recovered += 1
|
||||
|
||||
if commit and recovered:
|
||||
session.commit()
|
||||
logger.warning("[ingest-recover] 启动恢复完成,共重新派单 %d 个卡死 ingest_job", recovered)
|
||||
return recovered
|
||||
|
||||
|
||||
def make_redis_recovery_lock(lock_key: str = "ingest:recover:startup", ttl_seconds: int = 300):
|
||||
"""构造基于 Redis SET NX 的恢复锁工厂(多 worker 进程互斥)。
|
||||
|
||||
返回一个无参 callable,调用时尝试抢锁:抢到返回 True,未抢到返回 False。
|
||||
Redis 不可用时不阻断启动恢复(返回 True,恢复逻辑自身有 CAS 幂等保护)。
|
||||
"""
|
||||
|
||||
def _acquire() -> bool:
|
||||
try:
|
||||
import redis as redis_lib
|
||||
from worker_app.core.config import get_settings
|
||||
|
||||
client = redis_lib.Redis.from_url(get_settings().broker_url)
|
||||
return bool(client.set(lock_key, "1", nx=True, ex=ttl_seconds))
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("[ingest-recover] Redis 锁不可用,降级为无锁执行(CAS 兜底): %s", e)
|
||||
return True
|
||||
|
||||
return _acquire
|
||||
@@ -57,8 +57,6 @@ class User:
|
||||
phone: str | None = None
|
||||
phone_verified: bool = False
|
||||
binding_completed_at: datetime | None = None
|
||||
# 资料是否已完善(微信新用户首次设置昵称后置 True;邮箱注册默认 True)
|
||||
profile_completed: bool = True
|
||||
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -176,7 +174,6 @@ class Asset:
|
||||
quality_score: float | None = None
|
||||
uploaded_by_user_id: str = ""
|
||||
file_hash: str = ""
|
||||
client_upload_id: str = ""
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
tag_ids: list[str] = field(default_factory=list)
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
@@ -211,7 +208,6 @@ class Asset:
|
||||
quality_score: float | None = None,
|
||||
uploaded_by_user_id: str = "",
|
||||
file_hash: str = "",
|
||||
client_upload_id: str = "",
|
||||
) -> "Asset":
|
||||
clean_name = name.strip()
|
||||
if not clean_name:
|
||||
@@ -239,7 +235,6 @@ class Asset:
|
||||
quality_score=quality_score,
|
||||
uploaded_by_user_id=uploaded_by_user_id.strip(),
|
||||
file_hash=file_hash.strip(),
|
||||
client_upload_id=client_upload_id.strip(),
|
||||
metadata=metadata or {},
|
||||
tag_ids=[],
|
||||
)
|
||||
@@ -271,8 +266,6 @@ class IngestJob:
|
||||
error_message: str = ""
|
||||
result_asset_id: str = ""
|
||||
file_hash: str = ""
|
||||
asset_id: str = ""
|
||||
celery_task_id: str = ""
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -283,8 +276,6 @@ class IngestJob:
|
||||
library_id: str,
|
||||
storage_key: str,
|
||||
file_hash: str = "",
|
||||
asset_id: str = "",
|
||||
celery_task_id: str = "",
|
||||
) -> "IngestJob":
|
||||
if not project_id.strip():
|
||||
raise ValueError("project_id 不能为空")
|
||||
@@ -298,6 +289,4 @@ class IngestJob:
|
||||
library_id=library_id.strip(),
|
||||
storage_key=storage_key.strip(),
|
||||
file_hash=file_hash.strip(),
|
||||
asset_id=asset_id.strip(),
|
||||
celery_task_id=celery_task_id.strip(),
|
||||
)
|
||||
|
||||
@@ -117,7 +117,6 @@ class GenerationTask:
|
||||
bgm_config: dict = field(default_factory=dict)
|
||||
is_preview: bool = False
|
||||
source_task_id: str = ""
|
||||
celery_task_id: str = ""
|
||||
output_width: int = 1280
|
||||
output_height: int = 720
|
||||
cover_url: str = ""
|
||||
|
||||
@@ -125,23 +125,3 @@ class AssetRepository(ABC):
|
||||
) -> Asset | None:
|
||||
"""按素材库 + 文件哈希查找已有素材(去重检测)。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def find_by_library_and_client_upload_id(
|
||||
self,
|
||||
library_id: str,
|
||||
client_upload_id: str,
|
||||
) -> Asset | None:
|
||||
"""按素材库 + 客户端幂等 token 查找已有素材(complete 幂等)。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def find_recent_active_by_library_and_name(
|
||||
self,
|
||||
library_id: str,
|
||||
name: str,
|
||||
within_minutes: int = 30,
|
||||
file_size: int = 0,
|
||||
) -> Asset | None:
|
||||
"""兜底去重:同库 + 同文件名(+同大小)且近期仍在 uploading/processing 的素材。"""
|
||||
pass
|
||||
|
||||
@@ -20,12 +20,6 @@ class GenerationTaskRepository(Protocol):
|
||||
|
||||
def count_pending_total(self) -> int: ...
|
||||
|
||||
def count_running_by_user(self, user_id: str) -> int: ...
|
||||
|
||||
def count_running_total(self) -> int: ...
|
||||
|
||||
def estimate_avg_duration_seconds(self, limit: int = 20, default_seconds: float = 120.0) -> float: ...
|
||||
|
||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]: ...
|
||||
|
||||
def list_by_source_edit_plan(self, plan_id: str) -> list[GenerationTask]: ...
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
"""孤儿任务消息撤销与执行前状态守卫(API / Worker 共享)。
|
||||
|
||||
#1714 / #1710 缺陷修复:超时清理/孤儿恢复把 DB 任务标记为 failed/cancelled
|
||||
后,Redis 队列里对应的 Celery 消息仍然存在;worker 重启或重新拉取时该消息
|
||||
被再次执行,状态机抛「非法状态转换: failed → running」,旧实现打印 ERROR 后
|
||||
继续跑,最终产出半成品。
|
||||
|
||||
防御两道:
|
||||
1. 清理任务标 failed 时,调用 revoke_and_purge() 撤销(celery revoke 广播,
|
||||
通知在线 worker 丢弃)并直接扫描 Redis 队列移除消息体(worker 下线期间
|
||||
队列中的消息 revoke 广播收不到,必须物理移除);
|
||||
2. 任务真正开始业务逻辑前,调用 ensure_task_claimable() 校验 DB 状态,
|
||||
非 pending 的消息直接丢弃(抛 StaleTaskDiscarded,task 捕获后安全返回,
|
||||
不进入渲染/转码,不产出半成品)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StaleTaskDiscarded(Exception):
|
||||
"""任务消息已作废(DB 中任务已是终态),应安全中止、丢弃消息。"""
|
||||
|
||||
def __init__(self, task_id: str, status: str):
|
||||
self.task_id = task_id
|
||||
self.status = status
|
||||
super().__init__(f"任务 {task_id} 已是终态 {status},丢弃重复/作废消息")
|
||||
|
||||
|
||||
# 终态状态值集合:处于这些状态的任务消息一律不执行
|
||||
TERMINAL_STATUS_VALUES = frozenset({"failed", "cancelled", "completed"})
|
||||
|
||||
|
||||
def ensure_task_claimable(
|
||||
task_id: str,
|
||||
get_status: Callable[[str], str | None],
|
||||
*,
|
||||
task_label: str = "任务",
|
||||
) -> str:
|
||||
"""执行前守卫:任务必须处于可领取状态(pending)。
|
||||
|
||||
Args:
|
||||
task_id: 业务任务 ID
|
||||
get_status: 回调,返回 DB 中任务当前状态字符串;返回 None 表示任务不存在
|
||||
task_label: 日志用任务类型名
|
||||
|
||||
Returns:
|
||||
当前状态字符串(pending);任务不存在时返回空串(由调用方处理 not found)
|
||||
|
||||
Raises:
|
||||
StaleTaskDiscarded: 任务已是终态(failed/cancelled/completed),消息必须丢弃
|
||||
"""
|
||||
status = get_status(task_id)
|
||||
if status is None:
|
||||
return ""
|
||||
if status in TERMINAL_STATUS_VALUES:
|
||||
logger.warning("[%s] task_id=%s 状态已为 %s,消息作废,丢弃不执行", task_label, task_id, status)
|
||||
raise StaleTaskDiscarded(task_id, status)
|
||||
return status
|
||||
|
||||
|
||||
def _extract_business_ids(raw: bytes) -> tuple[str | None, str | None]:
|
||||
"""从 Redis 中的 Celery 消息提取 (celery 消息 ID, 业务任务 ID)。
|
||||
|
||||
Redis transport 存储格式为 JSON 信封:
|
||||
{"body": base64(json), "headers": {"id": <celery id>, "task": <name>, ...}, ...}
|
||||
body 解码后 Celery task 协议为 [args, kwargs, embed];
|
||||
generate_video / ingest_asset 均以 args=[业务任务ID] 投递。
|
||||
|
||||
无法解析时返回 (None, None)(保守保留该消息,绝不误删)。
|
||||
"""
|
||||
try:
|
||||
envelope = json.loads(raw)
|
||||
celery_id = None
|
||||
headers = envelope.get("headers") or {}
|
||||
if isinstance(headers, dict):
|
||||
celery_id = headers.get("id")
|
||||
body = envelope.get("body")
|
||||
if not body:
|
||||
return celery_id, None
|
||||
decoded = base64.b64decode(body)
|
||||
payload = json.loads(decoded)
|
||||
# 两种 body 形态:
|
||||
# 1. 标准 Celery task 消息:[args, kwargs, embed] 三元组 → 业务 ID 在 payload[0][0]
|
||||
# 2. 裸 producer 发布:body 即 args 数组 ["biz-id"] → 业务 ID 在 payload[0]
|
||||
args = None
|
||||
if isinstance(payload, dict):
|
||||
args = payload.get("args")
|
||||
elif isinstance(payload, (list, tuple)) and payload:
|
||||
first = payload[0]
|
||||
if isinstance(first, (list, tuple)):
|
||||
args = first # 三元组:[args, kwargs, embed]
|
||||
else:
|
||||
args = payload # body 本身就是 args
|
||||
if isinstance(args, (list, tuple)) and args and args[0] is not None:
|
||||
return celery_id, str(args[0])
|
||||
return celery_id, None
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
def purge_stale_messages_from_queues(
|
||||
broker_url: str,
|
||||
queue_names: Iterable[str],
|
||||
business_task_ids: Iterable[str] = (),
|
||||
celery_task_ids: Iterable[str] = (),
|
||||
) -> int:
|
||||
"""扫描 Redis 队列,移除作废任务的待消费消息。
|
||||
|
||||
同时按业务任务 ID(消息 args[0])和 celery 消息 ID(headers.id)匹配,
|
||||
任一命中即移除。未命中或无法解析的消息原样保留(保持相对顺序)。
|
||||
|
||||
Returns:
|
||||
实际移除的消息条数
|
||||
"""
|
||||
biz_ids = {bid for bid in business_task_ids if bid}
|
||||
msg_ids = {mid for mid in celery_task_ids if mid}
|
||||
if not biz_ids and not msg_ids:
|
||||
return 0
|
||||
|
||||
try:
|
||||
import redis
|
||||
except ImportError:
|
||||
logger.warning("redis-py 不可用,跳过队列消息清理")
|
||||
return 0
|
||||
|
||||
try:
|
||||
client = redis.Redis.from_url(broker_url)
|
||||
client.ping()
|
||||
except Exception as e:
|
||||
logger.warning("连接 Redis 清理作废消息失败: %s", e)
|
||||
return 0
|
||||
|
||||
removed_total = 0
|
||||
try:
|
||||
for queue in queue_names:
|
||||
removed_total += _purge_one_queue(client, queue, biz_ids, msg_ids)
|
||||
finally:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
if removed_total:
|
||||
logger.info(
|
||||
"从 Redis 队列移除 %d 条作废消息(biz=%s, celery=%s)",
|
||||
removed_total,
|
||||
sorted(biz_ids),
|
||||
sorted(msg_ids),
|
||||
)
|
||||
return removed_total
|
||||
|
||||
|
||||
def _purge_one_queue(client: Any, queue_name: str, biz_ids: set[str], msg_ids: set[str]) -> int:
|
||||
try:
|
||||
raw_messages = client.lrange(queue_name, 0, -1)
|
||||
except Exception as e:
|
||||
logger.warning("读取队列 %s 失败: %s", queue_name, e)
|
||||
return 0
|
||||
if not raw_messages:
|
||||
return 0
|
||||
|
||||
keep: list[bytes] = []
|
||||
removed = 0
|
||||
for raw in raw_messages:
|
||||
celery_id, biz_id = _extract_business_ids(raw)
|
||||
hit = (biz_id is not None and biz_id in biz_ids) or (celery_id is not None and celery_id in msg_ids)
|
||||
if hit:
|
||||
removed += 1
|
||||
continue
|
||||
keep.append(raw)
|
||||
|
||||
if removed:
|
||||
try:
|
||||
pipe = client.pipeline()
|
||||
pipe.delete(queue_name)
|
||||
if keep:
|
||||
pipe.rpush(queue_name, *keep)
|
||||
pipe.execute()
|
||||
except Exception as e:
|
||||
logger.warning("重写队列 %s 失败: %s", queue_name, e)
|
||||
return 0
|
||||
return removed
|
||||
|
||||
|
||||
def revoke_and_purge(
|
||||
celery_app: Any,
|
||||
broker_url: str,
|
||||
business_task_ids: Iterable[str] = (),
|
||||
celery_task_ids: Iterable[str] = (),
|
||||
*,
|
||||
queue_names: Iterable[str] = ("generation", "transcode", "celery"),
|
||||
) -> int:
|
||||
"""撤销作废任务:revoke 广播(在线 worker)+ 物理清理 Redis 队列消息。
|
||||
|
||||
Args:
|
||||
celery_app: Celery app 实例(worker 端 worker_app.celery_app.celery_app)
|
||||
broker_url: Redis broker URL
|
||||
business_task_ids: 业务任务 ID(generation_tasks.id / ingest_jobs.id)
|
||||
celery_task_ids: 入队时记录的 celery 消息 ID
|
||||
queue_names: 需要扫描清理的队列名
|
||||
|
||||
Returns:
|
||||
从队列中实际移除的消息条数
|
||||
"""
|
||||
for tid in celery_task_ids:
|
||||
if not tid:
|
||||
continue
|
||||
try:
|
||||
celery_app.control.revoke(tid)
|
||||
except Exception as e:
|
||||
logger.warning("revoke celery 消息 %s 失败: %s", tid, e)
|
||||
|
||||
return purge_stale_messages_from_queues(
|
||||
broker_url, queue_names, business_task_ids=business_task_ids, celery_task_ids=celery_task_ids
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user