security: 全面生产安全加固与部署修复
后端安全: - DEBUG 默认 True → False - 彻底移除 AUTH_BYPASS 认证绕过 - 验证码不再明文打印到日志 - 上传接口增加大小限制(500MB/20MB/100MB)与魔数校验 - python-jose → PyJWT, 更新 requirements.lock/uv.lock - Bandit 恢复关键规则(B104/B301/B305/B314/B324/B603/B607) - 修复 5 处 try_except_pass, 15 处加 nosec 注释 - 启用 Bandit pre-commit 钩子 前端安全: - 配置完整 CSP 策略 - 收紧 Capabilities(fs:allow-read-file → $RESOURCE/**) - 移除硬编码 devToken - 清理前端 TODO(美家卡智影命名统一) 部署修复: - docker-compose.prod 增加 alembic 迁移步骤 - api + scheduler 增加 Redis 心跳健康检查 - Nginx 添加安全响应头 - Nginx client_max_body_size 100M → 500M - .env.example 补充 UPLOAD_MAX_* 配置与安全注释 其他: - /voice/upload 合并到 /upload/audio - Rust 上传增加文件大小检查 - 清理 Rust 19 处 println! + 前端 21 处 console.info - 修复 VideoCompose.tsx toast 未导入(已有bug)
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
# === 基础配置 ===
|
||||
APP_NAME=美家卡智影 API
|
||||
APP_VERSION=0.1.0
|
||||
# ⚠️ 生产环境必须设为 false
|
||||
DEBUG=true
|
||||
ENV=development
|
||||
HOST=0.0.0.0
|
||||
@@ -37,7 +38,6 @@ REDIS_DB=0
|
||||
SECRET_KEY=dev-secret-key-change-me
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
AUTH_BYPASS=false
|
||||
ALGORITHM=HS256
|
||||
|
||||
# === CORS 配置 ===
|
||||
@@ -73,6 +73,11 @@ WXPAY_APPID=your-wx-appid
|
||||
WXPAY_API_KEY=your-api-key
|
||||
WXPAY_NOTIFY_URL=https://your-domain.com/api/v1/points/recharge/notify
|
||||
|
||||
# === 文件上传限制(字节)===
|
||||
UPLOAD_MAX_VIDEO_SIZE=524288000 # 500MB
|
||||
UPLOAD_MAX_IMAGE_SIZE=20971520 # 20MB
|
||||
UPLOAD_MAX_AUDIO_SIZE=104857600 # 100MB
|
||||
|
||||
# === B2M 短信平台 ===
|
||||
# ⚠️ P0 必填 — 未配置时 SMS 降级为仅打印日志(开发可用,测试/生产不可用)
|
||||
SMS_APP_ID=your-sms-app-id
|
||||
@@ -81,4 +86,5 @@ SMS_BASE_URL=https://bjksmtn.b2m.cn/inter/sendSingleSMS
|
||||
# SMS_EXTENDED_CODE= # 扩展码(选填)
|
||||
|
||||
# === 日志配置 ===
|
||||
# 生产环境建议 INFO
|
||||
LOG_LEVEL=DEBUG
|
||||
|
||||
@@ -17,21 +17,20 @@ repos:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
|
||||
# TODO: 修复历史遗留类型错误后重新启用
|
||||
# 类型检查(暂时禁用)
|
||||
# 类型检查(暂时禁用:326 个历史遗留类型错误待修复)
|
||||
# - repo: https://github.com/pre-commit/mirrors-mypy
|
||||
# rev: v1.14.0
|
||||
# hooks:
|
||||
# - id: mypy
|
||||
# additional_dependencies: [types-PyYAML]
|
||||
|
||||
# 安全扫描(暂时禁用)
|
||||
# - repo: https://github.com/PyCQA/bandit
|
||||
# rev: 1.8.0
|
||||
# hooks:
|
||||
# - id: bandit
|
||||
# args: ["-c", "pyproject.toml"]
|
||||
# additional_dependencies: ["bandit[toml]"]
|
||||
# 安全扫描
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.8.0
|
||||
hooks:
|
||||
- id: bandit
|
||||
args: ["-c", "pyproject.toml"]
|
||||
additional_dependencies: ["bandit[toml]"]
|
||||
|
||||
# 依赖锁定文件同步检查
|
||||
- repo: local
|
||||
|
||||
@@ -67,8 +67,8 @@ class _PlatformInstance:
|
||||
response_time=adapter_result.data.get("response_time_ms", 0) if adapter_result.data else 0,
|
||||
last_error=adapter_result.error_message,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"平台 {self.provider_id} 健康检查失败: {e}")
|
||||
return ModelHealth(
|
||||
id=model_name or self.provider_id,
|
||||
name=self.provider_id,
|
||||
|
||||
@@ -154,8 +154,8 @@ def load_system_prompt(category: str, subcategory: str) -> str:
|
||||
if not prompt_files:
|
||||
return ""
|
||||
|
||||
# 随机取一个
|
||||
chosen = random.choice(prompt_files)
|
||||
# 随机取一个提示词模板(非安全场景)
|
||||
chosen = random.choice(prompt_files) # nosec: B311
|
||||
return chosen.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
|
||||
@@ -31,30 +31,7 @@ async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""
|
||||
获取当前登录用户
|
||||
|
||||
从 Authorization Header 中提取 JWT Access Token 并验证。
|
||||
|
||||
【测试环境特殊处理】
|
||||
当 settings.AUTH_BYPASS = True 时,直接短路返回测试用户,跳过一切 token 校验。
|
||||
AUTH_BYPASS 独立于 DEBUG,专门用于测试环境绕过认证。
|
||||
"""
|
||||
# ========== 测试环境:直接短路返回测试用户 ==========
|
||||
if settings.AUTH_BYPASS:
|
||||
result = await db.execute(select(User).limit(1))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# 数据库为空时自动创建测试用户,确保始终有用户可返回
|
||||
if user is None:
|
||||
user = await user_crud.get_or_create_by_mobile(
|
||||
db,
|
||||
mobile="13800138000",
|
||||
nickname="测试用户",
|
||||
)
|
||||
return user
|
||||
|
||||
# ========== 生产环境:正常 JWT 认证 ==========
|
||||
"""从 Authorization Header 中提取 JWT Access Token 并验证。"""
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
||||
@@ -13,12 +13,110 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import get_settings
|
||||
from app.schemas.common import ApiResponse, success_response
|
||||
from app.services.qiniu_service import get_qiniu_service
|
||||
|
||||
router = APIRouter(prefix="/upload", tags=["Upload"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def _validate_file_magic(content: bytes, expected_content_type: str) -> bool:
|
||||
"""通过文件头魔数校验文件真实类型,防止 MIME 伪造攻击。"""
|
||||
if len(content) < 12:
|
||||
return False
|
||||
|
||||
# 拒绝常见危险文件头
|
||||
dangerous_signatures = [
|
||||
(b"MZ", "Windows 可执行文件"), # .exe, .dll
|
||||
(b"#!", "Shell 脚本"), # bash, python, etc
|
||||
(b"PK\x03\x04", "ZIP 压缩包"), # .zip, .jar, .docx
|
||||
(b"<?xml", "XML 文件"),
|
||||
(b"<html", "HTML 文件"),
|
||||
(b"<!DO", "HTML 文档"),
|
||||
(b"%PDF", "PDF 文件"),
|
||||
]
|
||||
for sig, _ in dangerous_signatures:
|
||||
if content.startswith(sig):
|
||||
return False
|
||||
if b"<script" in content[:512].lower():
|
||||
return False
|
||||
|
||||
main_type = expected_content_type.split("/")[0]
|
||||
|
||||
# 图片校验
|
||||
if main_type == "image":
|
||||
if content.startswith(b"\xff\xd8\xff"):
|
||||
return expected_content_type in ("image/jpeg", "image/jpg")
|
||||
if content.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return expected_content_type == "image/png"
|
||||
if content.startswith(b"GIF89a") or content.startswith(b"GIF87a"):
|
||||
return expected_content_type == "image/gif"
|
||||
if content.startswith(b"RIFF") and content[8:12] == b"WEBP":
|
||||
return expected_content_type == "image/webp"
|
||||
return False
|
||||
|
||||
# 视频校验
|
||||
if main_type == "video":
|
||||
# MP4 / MOV / M4V 等 ISO Base Media File Format
|
||||
if content[4:8] == b"ftyp":
|
||||
brand = content[8:12]
|
||||
if brand in (b"qt ", b"qtw "):
|
||||
return expected_content_type in ("video/quicktime",)
|
||||
# mp4, isom, avc1, mp41, mp42 等
|
||||
return expected_content_type in (
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
)
|
||||
if content.startswith(b"RIFF") and content[8:12] == b"AVI ":
|
||||
return expected_content_type == "video/x-msvideo"
|
||||
if content.startswith(b"\x1aE\xdf\xa3"):
|
||||
return expected_content_type == "video/webm"
|
||||
return False
|
||||
|
||||
# 音频校验
|
||||
if main_type == "audio":
|
||||
if content[:3] == b"ID3" or content[:2] in (
|
||||
b"\xff\xfb",
|
||||
b"\xff\xf3",
|
||||
b"\xff\xf2",
|
||||
):
|
||||
return expected_content_type in ("audio/mpeg", "audio/mp3")
|
||||
if content.startswith(b"RIFF") and content[8:12] == b"WAVE":
|
||||
return expected_content_type in ("audio/wav", "audio/x-wav")
|
||||
if content.startswith(b"fLaC"):
|
||||
return expected_content_type == "audio/flac"
|
||||
if content.startswith(b"OggS"):
|
||||
return expected_content_type == "audio/ogg"
|
||||
# AAC / M4A(也是 ftyp 格式)
|
||||
if content[4:8] == b"ftyp":
|
||||
brand = content[8:12]
|
||||
if brand in (b"M4A ", b"m4a ", b"mp42", b"isom", b"M4P "):
|
||||
return expected_content_type in (
|
||||
"audio/mp4",
|
||||
"audio/aac",
|
||||
"audio/m4a",
|
||||
)
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _check_upload_file(content: bytes, max_size: int, content_type: str, type_label: str) -> None:
|
||||
"""统一校验文件大小和魔数,失败时直接抛 HTTPException。"""
|
||||
if len(content) > max_size:
|
||||
max_mb = max_size // 1024 // 1024
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"{type_label}文件大小不能超过 {max_mb}MB",
|
||||
)
|
||||
if not _validate_file_magic(content, content_type):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{type_label}文件内容与实际格式不符,可能存在安全风险",
|
||||
)
|
||||
|
||||
|
||||
class UploadResponse(BaseModel):
|
||||
@@ -71,6 +169,14 @@ async def upload_video(
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="文件内容为空")
|
||||
|
||||
# 校验大小和魔数
|
||||
_check_upload_file(
|
||||
content,
|
||||
settings.UPLOAD_MAX_VIDEO_SIZE,
|
||||
content_type,
|
||||
"视频",
|
||||
)
|
||||
|
||||
# 生成唯一文件名
|
||||
ext = Path(file.filename or "video.mp4").suffix or ".mp4"
|
||||
unique_name = f"{uuid.uuid4().hex[:16]}{ext}"
|
||||
@@ -150,6 +256,14 @@ async def upload_image(
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="文件内容为空")
|
||||
|
||||
# 校验大小和魔数
|
||||
_check_upload_file(
|
||||
content,
|
||||
settings.UPLOAD_MAX_IMAGE_SIZE,
|
||||
content_type,
|
||||
"图片",
|
||||
)
|
||||
|
||||
ext = Path(file.filename or "image.jpg").suffix or ".jpg"
|
||||
unique_name = f"{uuid.uuid4().hex[:16]}{ext}"
|
||||
|
||||
@@ -233,6 +347,14 @@ async def upload_audio(
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="文件内容为空")
|
||||
|
||||
# 校验大小和魔数
|
||||
_check_upload_file(
|
||||
content,
|
||||
settings.UPLOAD_MAX_AUDIO_SIZE,
|
||||
content_type,
|
||||
"音频",
|
||||
)
|
||||
|
||||
ext = Path(file.filename or "audio.mp3").suffix or ".mp3"
|
||||
unique_name = f"{uuid.uuid4().hex[:16]}{ext}"
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -95,13 +95,6 @@ class VoiceCloneTaskResponse(BaseModel):
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
class VoiceUploadResponse(BaseModel):
|
||||
"""音频上传响应"""
|
||||
|
||||
url: str = Field(..., description="七牛云访问 URL")
|
||||
key: str = Field(..., description="存储 Key")
|
||||
|
||||
|
||||
class VoiceInfo(BaseModel):
|
||||
"""音色信息"""
|
||||
|
||||
@@ -116,77 +109,6 @@ class VoiceInfo(BaseModel):
|
||||
# ========== API 路由 ==========
|
||||
|
||||
|
||||
@router.post("/upload", response_model=ApiResponse[VoiceUploadResponse])
|
||||
async def upload_voice_file(
|
||||
file: UploadFile = File(...),
|
||||
file_type: str = Form(default="audio", description="文件类型: audio | video"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
上传音频/视频文件到七牛云
|
||||
|
||||
接收音频(mp3/wav)或视频(mp4/mov)文件,上传至七牛云 media bucket,
|
||||
返回公开访问 URL。
|
||||
"""
|
||||
try:
|
||||
file_type = file_type.lower().strip()
|
||||
if file_type not in ("audio", "video"):
|
||||
raise HTTPException(status_code=400, detail="file_type 必须是 audio 或 video")
|
||||
|
||||
# 根据类型校验 MIME
|
||||
if file_type == "audio":
|
||||
allowed_types = {"audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", "audio/mp4"}
|
||||
max_size = 20 * 1024 * 1024 # 20MB
|
||||
prefix = "meijiaka-zy/voice"
|
||||
type_label = "音频"
|
||||
else:
|
||||
allowed_types = {"video/mp4", "video/quicktime"}
|
||||
max_size = 200 * 1024 * 1024 # 200MB
|
||||
prefix = "meijiaka-zy/voice_clone"
|
||||
type_label = "视频"
|
||||
|
||||
content_type = file.content_type or "application/octet-stream"
|
||||
if content_type not in allowed_types:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"不支持的{type_label}格式: {content_type},仅支持 {', '.join(allowed_types)}",
|
||||
)
|
||||
|
||||
# 读取文件内容
|
||||
content = await file.read()
|
||||
if len(content) > max_size:
|
||||
raise HTTPException(status_code=400, detail=f"{type_label}文件大小不能超过 {max_size // 1024 // 1024}MB")
|
||||
|
||||
# 生成存储 key
|
||||
ext = content_type.split("/")[-1].replace("quicktime", "mov").replace("mpeg", "mp3")
|
||||
key = f"{prefix}/{uuid.uuid4().hex}.{ext}"
|
||||
|
||||
# 上传到七牛云
|
||||
from app.services.qiniu_service import get_qiniu_service
|
||||
qiniu = get_qiniu_service()
|
||||
from io import BytesIO
|
||||
|
||||
await qiniu.upload_stream_async(
|
||||
stream=BytesIO(content),
|
||||
key=key,
|
||||
mime_type=content_type,
|
||||
)
|
||||
|
||||
# 获取公开 URL(media bucket 使用 video_domain)
|
||||
url = qiniu.get_file_url(qiniu.video_domain, key)
|
||||
|
||||
return success_response(
|
||||
data=VoiceUploadResponse(url=url, key=key),
|
||||
message="上传成功",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Voice] 上传失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"上传失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/voices", response_model=ApiResponse[list[VoiceInfo]])
|
||||
async def list_voices(
|
||||
current_user: User = Depends(get_current_user),
|
||||
|
||||
@@ -25,13 +25,13 @@ class Settings(BaseSettings):
|
||||
# 应用基础配置
|
||||
APP_NAME: str = Field(default="美家卡智影 API", description="应用名称")
|
||||
APP_VERSION: str = Field(default="0.1.0", description="应用版本")
|
||||
DEBUG: bool = Field(default=True, description="调试模式")
|
||||
DEBUG: bool = Field(default=False, description="调试模式")
|
||||
ENV: Literal["development", "staging", "production"] = Field(
|
||||
default="development", description="运行环境"
|
||||
)
|
||||
|
||||
# 服务器配置
|
||||
HOST: str = Field(default="0.0.0.0", description="监听地址")
|
||||
HOST: str = Field(default="0.0.0.0", description="监听地址") # nosec: B104
|
||||
PORT: int = Field(default=8000, description="监听端口")
|
||||
APP_BASE_URL: str = Field(
|
||||
default="",
|
||||
@@ -84,10 +84,6 @@ class Settings(BaseSettings):
|
||||
default=30,
|
||||
description="Refresh Token 过期时间(天)",
|
||||
)
|
||||
AUTH_BYPASS: bool = Field(
|
||||
default=False,
|
||||
description="测试环境认证绕过(独立于 DEBUG)",
|
||||
)
|
||||
ALGORITHM: str = Field(default="HS256", description="JWT 算法")
|
||||
|
||||
# CORS 配置
|
||||
@@ -141,6 +137,17 @@ class Settings(BaseSettings):
|
||||
|
||||
|
||||
|
||||
# 文件上传限制(字节)
|
||||
UPLOAD_MAX_VIDEO_SIZE: int = Field(
|
||||
default=500 * 1024 * 1024, description="视频最大上传大小(字节)"
|
||||
)
|
||||
UPLOAD_MAX_IMAGE_SIZE: int = Field(
|
||||
default=20 * 1024 * 1024, description="图片最大上传大小(字节)"
|
||||
)
|
||||
UPLOAD_MAX_AUDIO_SIZE: int = Field(
|
||||
default=100 * 1024 * 1024, description="音频最大上传大小(字节)"
|
||||
)
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field(
|
||||
default="DEBUG",
|
||||
|
||||
@@ -13,7 +13,8 @@ import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from jose import JWTError, jwt
|
||||
import jwt
|
||||
from jwt import PyJWTError
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
@@ -88,7 +89,7 @@ def verify_token(token: str) -> dict[str, Any] | None:
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
return payload
|
||||
except JWTError:
|
||||
except PyJWTError:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ class AsyncEngine:
|
||||
finally:
|
||||
elapsed = asyncio.get_event_loop().time() - tick_start
|
||||
logger.debug(f"Tick completed in {elapsed:.2f}s")
|
||||
# 写入心跳,供 healthcheck 检查
|
||||
await self.redis.set("scheduler:heartbeat", str(asyncio.get_event_loop().time()), ex=60)
|
||||
|
||||
async def _safe_tick(
|
||||
self, name: str, handler: AsyncHandler, tasks: list[Any]
|
||||
|
||||
@@ -46,8 +46,8 @@ def _get_script_max_slots() -> int:
|
||||
if platform:
|
||||
# LLM 推理是慢请求,max_slots 不应超过服务器承载能力
|
||||
return min(int(platform.rate_limit_qps), 10)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"读取脚本平台 rate_limit 配置失败: {e}")
|
||||
return 10
|
||||
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ def _get_subtitle_max_slots() -> int:
|
||||
platform = loader.get_platform("volcengine_caption")
|
||||
if platform:
|
||||
return int(platform.rate_limit_qps)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"读取字幕平台 rate_limit 配置失败: {e}")
|
||||
return 5
|
||||
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ def _get_video_max_slots() -> int:
|
||||
return int(platform.methods["lip_sync"].rate_limit_qps)
|
||||
if platform:
|
||||
return int(platform.rate_limit_qps)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"读取视频平台 rate_limit 配置失败: {e}")
|
||||
return 5
|
||||
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ async def send_sms_code(mobile: str) -> str:
|
||||
raise ValueError(f"该手机号今日验证码发送次数已达上限({SMS_DAILY_LIMIT}次)")
|
||||
|
||||
# 生成验证码
|
||||
code = "".join([str(random.randint(0, 9)) for _ in range(SMS_CODE_LENGTH)])
|
||||
code = "".join([str(random.randint(0, 9)) for _ in range(SMS_CODE_LENGTH)]) # nosec: B311
|
||||
|
||||
# 存入 Redis,设置过期时间
|
||||
await redis.setex(key, SMS_CODE_EXPIRE_MINUTES * 60, code)
|
||||
@@ -143,14 +143,13 @@ async def send_sms_code(mobile: str) -> str:
|
||||
expire_minutes=SMS_CODE_EXPIRE_MINUTES,
|
||||
)
|
||||
else:
|
||||
# 配置不完整,降级为日志打印(开发环境)
|
||||
# 配置不完整,记录警告但不打印验证码
|
||||
logger.warning(
|
||||
f"[SMS] B2M 短信配置不完整,验证码仅打印到日志: "
|
||||
f"手机号={mobile}, 验证码={code}"
|
||||
f"[SMS] B2M 短信配置不完整,验证码未发送: 手机号={mobile}"
|
||||
)
|
||||
except SMSError as e:
|
||||
logger.error(f"[SMS] 短信发送失败: {e}")
|
||||
# 短信发送失败不影响验证码生成(用户可以通过其他方式获取,如日志)
|
||||
# 短信发送失败不影响验证码生成
|
||||
except Exception as e:
|
||||
logger.exception(f"[SMS] 短信发送异常: {e}")
|
||||
|
||||
|
||||
@@ -152,13 +152,13 @@ def match_material(
|
||||
# 2. 排除已使用的,从中随机选
|
||||
unused = [m for m in matching if m["url"] not in exclude_urls]
|
||||
if unused:
|
||||
return random.choice(unused)
|
||||
return random.choice(unused) # nosec: B311
|
||||
|
||||
# 3. 严格模式下不允许返回已排除的素材
|
||||
if strict:
|
||||
return None
|
||||
|
||||
# 4. 非严格模式:全部用完则允许重复
|
||||
return random.choice(matching)
|
||||
return random.choice(matching) # nosec: B311
|
||||
|
||||
return None
|
||||
|
||||
@@ -163,7 +163,7 @@ class QiniuService:
|
||||
"""计算文件的 MD5 哈希"""
|
||||
import hashlib
|
||||
|
||||
md5 = hashlib.md5()
|
||||
md5 = hashlib.md5() # nosec: B324 — 七牛云 ETag 协议强制使用 MD5
|
||||
with open(local_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
md5.update(chunk)
|
||||
|
||||
@@ -98,7 +98,7 @@ class B2MSMSService:
|
||||
# ECB 模式
|
||||
cipher = Cipher(
|
||||
algorithms.AES(self._secret_key_bytes),
|
||||
modes.ECB(),
|
||||
modes.ECB(), # nosec: B305 — B2M 短信平台协议强制使用 ECB 模式
|
||||
)
|
||||
encryptor = cipher.encryptor()
|
||||
return encryptor.update(padded_data) + encryptor.finalize()
|
||||
@@ -110,7 +110,7 @@ class B2MSMSService:
|
||||
|
||||
cipher = Cipher(
|
||||
algorithms.AES(self._secret_key_bytes),
|
||||
modes.ECB(),
|
||||
modes.ECB(), # nosec: B305 — B2M 短信平台协议强制使用 ECB 模式
|
||||
)
|
||||
decryptor = cipher.decryptor()
|
||||
padded_data = decryptor.update(ciphertext) + decryptor.finalize()
|
||||
|
||||
@@ -92,8 +92,8 @@ class WechatPayService:
|
||||
sign_str = "&".join(f"{k}={v}" for k, v in sorted_items)
|
||||
# 拼接 key
|
||||
sign_str += f"&key={self.api_key}"
|
||||
# MD5 大写
|
||||
return hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper()
|
||||
# MD5 大写(微信支付 APIv2 签名算法强制使用 MD5)
|
||||
return hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper() # nosec: B324
|
||||
|
||||
def verify_notify(self, notify_data: dict[str, Any]) -> bool:
|
||||
"""
|
||||
@@ -125,9 +125,9 @@ class WechatPayService:
|
||||
@staticmethod
|
||||
def _xml_to_dict(xml_str: str) -> dict[str, Any]:
|
||||
"""将微信 XML 转为字典"""
|
||||
import xml.etree.ElementTree as ET
|
||||
import xml.etree.ElementTree as ET # nosec: B405
|
||||
|
||||
root = ET.fromstring(xml_str)
|
||||
root = ET.fromstring(xml_str) # nosec: B314
|
||||
result = {}
|
||||
for child in root:
|
||||
result[child.tag] = child.text or ""
|
||||
|
||||
@@ -33,6 +33,7 @@ services:
|
||||
volumes:
|
||||
# 仅持久化日志到宿主机,其他数据走对象存储
|
||||
- /opt/meijiaka-zy/logs:/root/Documents/Meijiaka-zy/logs
|
||||
command: alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
ports:
|
||||
- "8000:8000"
|
||||
restart: unless-stopped
|
||||
@@ -41,7 +42,7 @@ services:
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
start_period: 30s
|
||||
|
||||
scheduler:
|
||||
build:
|
||||
@@ -63,6 +64,12 @@ services:
|
||||
- /opt/meijiaka-zy/logs:/root/Documents/Meijiaka-zy/logs
|
||||
command: python -m app.scheduler.main
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import asyncio, time; from app.core.redis_client import get_redis_client; r=get_redis_client(); t=asyncio.run(r.get('scheduler:heartbeat')); t=float(t) if t else 0; assert t>0 and time.time()-t<30, 'scheduler heartbeat stale'\""]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -86,8 +86,8 @@ services:
|
||||
- meijiaka-zy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import asyncio; from app.scheduler.engine import AsyncEngine; print('scheduler ok')\""]
|
||||
interval: 60s
|
||||
test: ["CMD-SHELL", "python -c \"import asyncio, time; from app.core.redis_client import get_redis_client; r=get_redis_client(); t=asyncio.run(r.get('scheduler:heartbeat')); t=float(t) if t else 0; assert t>0 and time.time()-t<30, 'scheduler heartbeat stale'\""]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
@@ -53,6 +53,11 @@ server {
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
|
||||
client_max_body_size 100M;
|
||||
client_max_body_size 500M;
|
||||
|
||||
# 安全响应头
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ dependencies = [
|
||||
"qiniu~=7.13.0",
|
||||
|
||||
# 工具
|
||||
"python-jose[cryptography]~=3.4.0",
|
||||
"pyjwt~=2.10.0",
|
||||
|
||||
"pyyaml~=6.0.2",
|
||||
"orjson>=3.11.0", # 安全修复:修复 CVE-2025-XXXX
|
||||
@@ -127,7 +127,8 @@ asyncio_mode = "auto"
|
||||
|
||||
[tool.bandit]
|
||||
exclude_dirs = ["tests", "scripts"]
|
||||
skips = ["B101", "B104", "B105", "B106", "B107", "B301", "B403", "B404", "B603", "B607"]
|
||||
# B101: assert 使用 — 非安全问题,纯代码规范,跳过
|
||||
skips = ["B101"]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
|
||||
@@ -40,13 +40,9 @@ charset-normalizer==3.4.7
|
||||
click==8.3.3
|
||||
# via uvicorn
|
||||
cryptography==48.0.0
|
||||
# via
|
||||
# python-jose
|
||||
# volcengine-python-sdk
|
||||
# via volcengine-python-sdk
|
||||
distro==1.9.0
|
||||
# via openai
|
||||
ecdsa==0.19.2
|
||||
# via python-jose
|
||||
fastapi==0.136.1
|
||||
# via meijiaka-ai-api (pyproject.toml)
|
||||
frozenlist==1.8.0
|
||||
@@ -98,10 +94,6 @@ propcache==0.4.1
|
||||
# yarl
|
||||
psycopg2-binary==2.9.12
|
||||
# via meijiaka-ai-api (pyproject.toml)
|
||||
pyasn1==0.4.8
|
||||
# via
|
||||
# python-jose
|
||||
# rsa
|
||||
pycparser==3.0
|
||||
# via cffi
|
||||
pydantic==2.9.2
|
||||
@@ -115,14 +107,14 @@ pydantic-core==2.23.4
|
||||
# via pydantic
|
||||
pydantic-settings==2.6.1
|
||||
# via meijiaka-ai-api (pyproject.toml)
|
||||
pyjwt==2.10.1
|
||||
# via meijiaka-ai-api (pyproject.toml)
|
||||
python-dateutil==2.9.0.post0
|
||||
# via volcengine-python-sdk
|
||||
python-dotenv==1.2.2
|
||||
# via
|
||||
# pydantic-settings
|
||||
# uvicorn
|
||||
python-jose==3.4.0
|
||||
# via meijiaka-ai-api (pyproject.toml)
|
||||
python-multipart==0.0.27
|
||||
# via meijiaka-ai-api (pyproject.toml)
|
||||
pyyaml==6.0.3
|
||||
@@ -135,11 +127,8 @@ redis==5.2.1
|
||||
# via meijiaka-ai-api (pyproject.toml)
|
||||
requests==2.33.1
|
||||
# via qiniu
|
||||
rsa==4.9.1
|
||||
# via python-jose
|
||||
six==1.17.0
|
||||
# via
|
||||
# ecdsa
|
||||
# python-dateutil
|
||||
# volcengine-python-sdk
|
||||
sniffio==1.3.1
|
||||
|
||||
Generated
+22
-54
@@ -557,18 +557,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.136.1"
|
||||
@@ -965,13 +953,14 @@ dependencies = [
|
||||
{ name = "bcrypt" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "mutagen" },
|
||||
{ name = "openai" },
|
||||
{ name = "orjson" },
|
||||
{ name = "passlib", extra = ["bcrypt"] },
|
||||
{ name = "psycopg2-binary" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-jose", extra = ["cryptography"] },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "qiniu" },
|
||||
@@ -1005,6 +994,7 @@ requires-dist = [
|
||||
{ name = "black", marker = "extra == 'dev'", specifier = "~=24.10.0" },
|
||||
{ name = "fastapi", specifier = ">=0.136.1" },
|
||||
{ name = "httpx", specifier = "~=0.28.0" },
|
||||
{ name = "mutagen", specifier = "~=1.47.0" },
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = "~=1.14.0" },
|
||||
{ name = "openai", specifier = "~=1.58.0" },
|
||||
{ name = "orjson", specifier = ">=3.11.0" },
|
||||
@@ -1014,10 +1004,10 @@ requires-dist = [
|
||||
{ name = "psycopg2-binary", specifier = "~=2.9.10" },
|
||||
{ name = "pydantic", specifier = "~=2.9.0" },
|
||||
{ name = "pydantic-settings", specifier = "~=2.6.0" },
|
||||
{ name = "pyjwt", specifier = "~=2.10.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = "~=8.3.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "~=0.24.0" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = "~=6.0.0" },
|
||||
{ name = "python-jose", extras = ["cryptography"], specifier = "~=3.4.0" },
|
||||
{ name = "python-multipart", specifier = "~=0.0.20" },
|
||||
{ name = "pyyaml", specifier = "~=6.0.2" },
|
||||
{ name = "qiniu", specifier = "~=7.13.0" },
|
||||
@@ -1146,6 +1136,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mutagen"
|
||||
version = "1.47.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/e6/64bc71b74eef4b68e61eb921dcf72dabd9e4ec4af1e11891bbd312ccbb77/mutagen-1.47.0.tar.gz", hash = "sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99", size = 1274186, upload-time = "2023-09-03T16:33:33.411Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/7a/620f945b96be1f6ee357d211d5bf74ab1b7fe72a9f1525aafbfe3aee6875/mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719", size = 194391, upload-time = "2023-09-03T16:33:29.955Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.14.1"
|
||||
@@ -1480,15 +1479,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/30/f2/3483060562245668bb07193b65277f0ea619cabf530deb351911eb0453eb/py_serializable-1.1.2-py3-none-any.whl", hash = "sha256:801be61b0a1ba64c3861f7c624f1de5cfbbabf8b458acc9cdda91e8f7e5effa1", size = 22786, upload-time = "2024-10-01T15:55:42.498Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.4.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/db/fffec68299e6d7bad3d504147f9094830b704527a7fc098b721d38cc7fa7/pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", size = 146820, upload-time = "2019-11-16T17:27:38.772Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/1e/a94a8d635fa3ce4cfc7f506003548d0a2447ae76fd5ca53932970fe3053f/pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", size = 77145, upload-time = "2019-11-16T17:27:11.07Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
@@ -1557,6 +1547,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
version = "3.3.2"
|
||||
@@ -1640,25 +1639,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-jose"
|
||||
version = "3.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ecdsa" },
|
||||
{ name = "pyasn1" },
|
||||
{ name = "rsa" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/a0/c49687cf40cb6128ea4e0559855aff92cd5ebd1a60a31c08526818c0e51e/python-jose-3.4.0.tar.gz", hash = "sha256:9a9a40f418ced8ecaf7e3b28d69887ceaa76adad3bcaa6dae0d9e596fec1d680", size = 92145, upload-time = "2025-02-18T17:26:41.985Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/b0/2586ea6b6fd57a994ece0b56418cbe93fff0efb85e2c9eb6b0caf24a4e37/python_jose-3.4.0-py2.py3-none-any.whl", hash = "sha256:9c9f616819652d109bd889ecd1e15e9a162b9b94d682534c9c2146092945b78f", size = 34616, upload-time = "2025-02-18T17:26:40.826Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
cryptography = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.26"
|
||||
@@ -1753,18 +1733,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "4.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.8.6"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Vite 开发环境变量
|
||||
# 复制此文件为 .env 并填写实际值
|
||||
#
|
||||
# 前端连接的 Python API 地址
|
||||
# 本地开发: http://localhost:8000
|
||||
# 测试环境: https://dev.tapi.meijiaka.cn/api/v1
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
@@ -5,8 +5,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- TODO: 临时方案命名 -->
|
||||
<title>美家卡 智影</title>
|
||||
<title>美家卡智影</title>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'DouyinSans';
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
{
|
||||
"identifier": "opener:allow-open-path",
|
||||
"allow": [
|
||||
{ "path": "$DOCUMENT/Meijiaka-zy/**" },
|
||||
{ "path": "$DOCUMENT/**" },
|
||||
{ "path": "$APPLOCALDATA/**" },
|
||||
{ "path": "$APPDATA/**" },
|
||||
{ "path": "$APPCONFIG/**" },
|
||||
{ "path": "$RESOURCE/**" },
|
||||
{ "path": "/**" }
|
||||
]
|
||||
},
|
||||
@@ -25,13 +25,7 @@
|
||||
"identifier": "fs:allow-read-file",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$DOCUMENT/Meijiaka/**"
|
||||
},
|
||||
{
|
||||
"path": "$DOCUMENT/**"
|
||||
},
|
||||
{
|
||||
"path": "/**"
|
||||
"path": "$RESOURCE/**"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -39,7 +33,16 @@
|
||||
"identifier": "fs:allow-exists",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$DOCUMENT/**"
|
||||
"path": "$APPLOCALDATA/**"
|
||||
},
|
||||
{
|
||||
"path": "$APPDATA/**"
|
||||
},
|
||||
{
|
||||
"path": "$APPCONFIG/**"
|
||||
},
|
||||
{
|
||||
"path": "$RESOURCE/**"
|
||||
},
|
||||
{
|
||||
"path": "/**"
|
||||
|
||||
@@ -355,6 +355,14 @@ pub async fn upload_file_to_backend(
|
||||
mime_type: &str,
|
||||
read_error_prefix: &str,
|
||||
) -> ApiResponse<UploadResult> {
|
||||
// 文件大小限制(字节)
|
||||
let max_size: u64 = match endpoint {
|
||||
"/upload/video" => 500 * 1024 * 1024, // 500MB
|
||||
"/upload/image" => 20 * 1024 * 1024, // 20MB
|
||||
"/upload/audio" => 100 * 1024 * 1024, // 100MB
|
||||
_ => 100 * 1024 * 1024, // 默认 100MB
|
||||
};
|
||||
|
||||
let file_bytes = match std::fs::read(local_path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
@@ -366,6 +374,19 @@ pub async fn upload_file_to_backend(
|
||||
}
|
||||
};
|
||||
|
||||
let file_size = file_bytes.len() as u64;
|
||||
if file_size > max_size {
|
||||
return ApiResponse {
|
||||
code: 413,
|
||||
message: format!(
|
||||
"文件大小 {}MB 超过限制 {}MB",
|
||||
file_size / 1024 / 1024,
|
||||
max_size / 1024 / 1024
|
||||
),
|
||||
data: None,
|
||||
};
|
||||
}
|
||||
|
||||
let filename = std::path::Path::new(local_path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
|
||||
@@ -93,7 +93,6 @@ pub async fn run_ffmpeg(app: &AppHandle, args: Vec<String>) -> Result<String, St
|
||||
let log = String::from_utf8_lossy(&line);
|
||||
stderr_output.push_str(&log);
|
||||
stderr_output.push('\n');
|
||||
println!("FFmpeg Log: {}", log);
|
||||
|
||||
// 尝试解析进度: time=00:00:05.12
|
||||
if let Some(pos) = log.find("time=") {
|
||||
@@ -285,9 +284,7 @@ fn get_fonts_dir(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
// 先尝试从资源目录获取(生产模式)
|
||||
if let Ok(resource_path) = app.path().resource_dir() {
|
||||
let fonts_path = resource_path.join("fonts");
|
||||
println!("[get_fonts_dir] Checking resource path: {:?}", fonts_path);
|
||||
if fonts_path.exists() {
|
||||
println!("[get_fonts_dir] Found fonts at: {:?}", fonts_path);
|
||||
return Ok(fonts_path);
|
||||
}
|
||||
}
|
||||
@@ -298,27 +295,21 @@ fn get_fonts_dir(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
|
||||
// cwd 通常是 src-tauri 目录,所以直接找 fonts/
|
||||
let dev_fonts_path = cwd.join("fonts");
|
||||
println!("[get_fonts_dir] Checking dev cwd path: {:?}", dev_fonts_path);
|
||||
if dev_fonts_path.exists() {
|
||||
println!("[get_fonts_dir] Found fonts at: {:?}", dev_fonts_path);
|
||||
return Ok(dev_fonts_path);
|
||||
}
|
||||
|
||||
// 如果还是找不到,尝试往上一级找(支持项目根目录结构)
|
||||
if let Some(parent) = cwd.parent() {
|
||||
let dev_fonts_path_alt = parent.join("src-tauri/fonts");
|
||||
println!("[get_fonts_dir] Checking dev parent path: {:?}", dev_fonts_path_alt);
|
||||
if dev_fonts_path_alt.exists() {
|
||||
println!("[get_fonts_dir] Found fonts at: {:?}", dev_fonts_path_alt);
|
||||
return Ok(dev_fonts_path_alt);
|
||||
}
|
||||
|
||||
// 再往上一级(如果 cwd 是 src-tauri/src)
|
||||
if let Some(grandparent) = parent.parent() {
|
||||
let dev_fonts_path_grand = grandparent.join("tauri-app/src-tauri/fonts");
|
||||
println!("[get_fonts_dir] Checking grandparent path: {:?}", dev_fonts_path_grand);
|
||||
if dev_fonts_path_grand.exists() {
|
||||
println!("[get_fonts_dir] Found fonts at: {:?}", dev_fonts_path_grand);
|
||||
return Ok(dev_fonts_path_grand);
|
||||
}
|
||||
}
|
||||
@@ -413,7 +404,6 @@ pub async fn burn_ass_subtitle(
|
||||
];
|
||||
|
||||
let _ = app.emit("ffmpeg-phase-start", PhaseInfo { step: 1, total: 2 });
|
||||
println!("[burn_ass_subtitle] Step 1: overlay image");
|
||||
if let Err(e) = run_ffmpeg(app, overlay_args).await {
|
||||
let _ = std::fs::remove_file(&temp_output);
|
||||
return Err(format!("Overlay 图片失败: {}", e));
|
||||
@@ -432,7 +422,6 @@ pub async fn burn_ass_subtitle(
|
||||
];
|
||||
|
||||
let _ = app.emit("ffmpeg-phase-start", PhaseInfo { step: 2, total: 2 });
|
||||
println!("[burn_ass_subtitle] Step 2: burn subtitle");
|
||||
let result = run_ffmpeg(app, burn_args).await;
|
||||
let _ = std::fs::remove_file(&temp_output);
|
||||
return result.map(|_| ());
|
||||
@@ -441,7 +430,6 @@ pub async fn burn_ass_subtitle(
|
||||
// 无 overlay,走原有单步逻辑(带 fontsdir 回退)
|
||||
let filter = ass_filter.clone();
|
||||
let _ = app.emit("ffmpeg-phase-start", PhaseInfo { step: 1, total: 1 });
|
||||
println!("[burn_ass_subtitle] Trying with fontsdir: {}", filter);
|
||||
|
||||
let args = vec![
|
||||
"-i".to_string(), safe_video.clone(),
|
||||
@@ -456,14 +444,12 @@ pub async fn burn_ass_subtitle(
|
||||
|
||||
match run_ffmpeg(app, args).await {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) => {
|
||||
println!("[burn_ass_subtitle] With fontsdir failed: {}, retrying without fontsdir...", e);
|
||||
Err(_e) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:不带 fontsdir
|
||||
let filter = format!("ass='{}'", ass_path_escaped);
|
||||
println!("[burn_ass_subtitle] Trying without fontsdir: {}", filter);
|
||||
|
||||
let args = vec![
|
||||
"-i".to_string(), safe_video,
|
||||
|
||||
@@ -46,9 +46,7 @@ pub struct ApiResponse<T> {
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let api_base_url = crate::storage::config::get_api_base_url_sync();
|
||||
println!("DEBUG: Starting Tauri app with Superpowers...");
|
||||
println!("DEBUG: Python API base URL: {}", api_base_url);
|
||||
let _api_base_url = crate::storage::config::get_api_base_url_sync();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null,
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' https: data: blob: asset:; media-src 'self' https: blob: asset:; connect-src 'self' https: ws://localhost:*;",
|
||||
"capabilities": [
|
||||
"default"
|
||||
],
|
||||
|
||||
@@ -80,13 +80,6 @@ async function loadAuthToken(): Promise<string | null> {
|
||||
console.error('[client] 加载认证 token 失败:', e);
|
||||
}
|
||||
|
||||
// 浏览器开发环境兜底:使用测试 token
|
||||
if (!isTauri() && import.meta.env.DEV) {
|
||||
const devToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMGVlYmM5OS05YzBiLTRlZjgtYmI2ZC02YmI5YmQzODBhMTEiLCJtb2JpbGUiOiIxMzgwMDEzODAwMCIsIm5pY2tuYW1lIjoiXHU2ZDRiXHU4YmQ1XHU3NTI4XHU2MjM3IiwiZXhwIjoxNzc2ODY3MDM0fQ._fl5HeX8YHaSJlWTb8s7lfHd2mLDzDZAP1NvvTQ_GpY';
|
||||
cachedAuth = { accessToken: devToken };
|
||||
return devToken;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ const isTauri = (): boolean => {
|
||||
// 安全调用 Tauri 命令
|
||||
const safeInvoke = async <T>(cmd: string, args?: Record<string, unknown>): Promise<T | null> => {
|
||||
const tauriAvailable = isTauri();
|
||||
console.info(`[localStorage] Command: ${cmd}, Tauri available: ${tauriAvailable}`);
|
||||
|
||||
if (!tauriAvailable) {
|
||||
console.warn(`[localStorage] Tauri not available, command: ${cmd} skipped`);
|
||||
@@ -34,9 +33,7 @@ const safeInvoke = async <T>(cmd: string, args?: Record<string, unknown>): Promi
|
||||
|
||||
// Tauri 环境:真实调用
|
||||
try {
|
||||
console.info(`[localStorage] Invoking ${cmd} with args:`, args);
|
||||
const result = await invoke<T>(cmd, args);
|
||||
console.info(`[localStorage] ${cmd} result:`, result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error(`[localStorage] invoke ${cmd} failed:`, e);
|
||||
|
||||
@@ -99,8 +99,7 @@ export async function synthesizeTTS(request: TTSSynthesizeRequest): Promise<TTSR
|
||||
export async function uploadAudio(file: File): Promise<string> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('file_type', 'audio');
|
||||
const result = await client.postForm<{ url: string; key: string }>('/voice/upload', formData);
|
||||
const result = await client.postForm<{ url: string; key: string }>('/upload/audio', formData);
|
||||
return result.url;
|
||||
}
|
||||
|
||||
|
||||
@@ -88,11 +88,10 @@ export default function Sidebar({ currentPath, onNavigate }: SidebarProps) {
|
||||
<div className="sidebar-logo">
|
||||
<img
|
||||
src="/assets/logo.png"
|
||||
alt="美家卡 智影" // TODO: 临时方案命名
|
||||
alt="美家卡智影"
|
||||
style={{ width: 28, height: 24, objectFit: 'contain' }}
|
||||
/>
|
||||
{/* TODO: 临时方案命名 */}
|
||||
<span className="sidebar-title">美家卡 智影</span>
|
||||
<span className="sidebar-title">美家卡智影</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -88,11 +88,10 @@ export default function Login() {
|
||||
<div className="login-logo">
|
||||
<img
|
||||
src="/assets/logo.png"
|
||||
alt="美家卡 智影" // TODO: 临时方案命名
|
||||
alt="美家卡智影"
|
||||
style={{ width: 42, height: 36, objectFit: 'contain' }}
|
||||
/>
|
||||
{/* TODO: 临时方案命名 */}
|
||||
<span className="login-logo-text">美家卡 智影</span>
|
||||
<span className="login-logo-text">美家卡智影</span>
|
||||
</div>
|
||||
<p className="login-subtitle">AI 驱动的智能视频创作平台</p>
|
||||
</div>
|
||||
|
||||
@@ -157,7 +157,6 @@ export default function CoverDesign() {
|
||||
if (res.ok) {
|
||||
data = await res.json();
|
||||
if (isValidBgConfig(data)) {
|
||||
console.info('[CoverDesign] 使用远程背景图配置');
|
||||
} else {
|
||||
console.warn('[CoverDesign] 远程配置格式校验失败,尝试本地 fallback');
|
||||
data = null;
|
||||
@@ -173,7 +172,6 @@ export default function CoverDesign() {
|
||||
if (res.ok) {
|
||||
data = await res.json();
|
||||
if (isValidBgConfig(data)) {
|
||||
console.info('[CoverDesign] 使用本地背景图配置');
|
||||
} else {
|
||||
console.error('[CoverDesign] 本地配置格式校验失败');
|
||||
data = null;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useProjectStore, saveMetaToLocalFile } from '../../store';
|
||||
import { useProgressStore } from '../../store/progressStore';
|
||||
|
||||
import { useLocalVideo } from '../../hooks/useLocalVideo';
|
||||
import { toast } from '../../store/uiStore';
|
||||
|
||||
import './VideoCreation.css';
|
||||
import './VideoGeneration.css';
|
||||
|
||||
@@ -604,7 +604,6 @@ export default function VideoGeneration() {
|
||||
|
||||
// 1c. 上传切割后的视频到七牛云
|
||||
const clipUrl = await uploadVideoFile(clipPath);
|
||||
console.info(`[VideoGeneration] Segment ${shot.id} 视频上传完成:`, clipUrl);
|
||||
|
||||
// 1d. 提交视频生成任务(仅当该分镜有 clipAudioUrl 时)
|
||||
if (!shot.clipAudioUrl) {
|
||||
@@ -617,7 +616,6 @@ export default function VideoGeneration() {
|
||||
audioUrl: shot.clipAudioUrl,
|
||||
duration: shot.actualDuration,
|
||||
});
|
||||
console.info(`[VideoGeneration] Segment ${shot.id} 视频生成任务提交成功:`, lipSyncRes.taskId);
|
||||
|
||||
// 1e. 保存 taskId 到 segment
|
||||
updateSegment(shot.id, { lipSyncTaskId: lipSyncRes.taskId, lipSyncState: 'processing' });
|
||||
@@ -691,7 +689,6 @@ export default function VideoGeneration() {
|
||||
lipSyncVideoUrl: status.videoUrl,
|
||||
lipSyncVideoPath: pathRes.data,
|
||||
});
|
||||
console.info(`[VideoGeneration] Segment ${shot.id} 生成视频下载完成:`, pathRes.data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,7 +738,6 @@ export default function VideoGeneration() {
|
||||
|
||||
clipPaths.push(clipPathRes.data);
|
||||
updateSegment(shot.id, { clipVideoPath: clipPathRes.data });
|
||||
console.info(`[VideoGeneration] Empty shot ${shot.id} 片段生成完成:`, clipPathRes.data);
|
||||
} else {
|
||||
progress.update(`正在处理分镜片段 (${i + 1}/${currentSegmentsForCompose.length})...`);
|
||||
const segDuration = shot.actualDuration!;
|
||||
@@ -779,7 +775,6 @@ export default function VideoGeneration() {
|
||||
|
||||
clipPaths.push(clipPathRes.data);
|
||||
updateSegment(shot.id, { clipVideoPath: clipPathRes.data });
|
||||
console.info(`[VideoGeneration] Segment ${shot.id} 片段生成完成:`, clipPathRes.data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -803,7 +798,6 @@ export default function VideoGeneration() {
|
||||
|
||||
const concatResult = await concatVideoClips(projectId, clipPaths);
|
||||
const finalVideoPath = concatResult.outputPath;
|
||||
console.info('[VideoGeneration] 视频拼接完成:', finalVideoPath);
|
||||
|
||||
// 清理中间产物:删除 lipSyncVideoPath 和 clipVideoPath 对应的文件
|
||||
const segmentsToClean = useProjectStore.getState().segments;
|
||||
@@ -814,7 +808,6 @@ export default function VideoGeneration() {
|
||||
projectId,
|
||||
filePath: shot.lipSyncVideoPath,
|
||||
});
|
||||
console.info(`[VideoGeneration] 已删除生成视频: ${shot.lipSyncVideoPath}`);
|
||||
} catch (e) {
|
||||
console.warn(`[VideoGeneration] 删除生成视频失败: ${shot.lipSyncVideoPath}`, e);
|
||||
}
|
||||
@@ -825,7 +818,6 @@ export default function VideoGeneration() {
|
||||
projectId,
|
||||
filePath: shot.clipVideoPath,
|
||||
});
|
||||
console.info(`[VideoGeneration] 已删除片段视频: ${shot.clipVideoPath}`);
|
||||
} catch (e) {
|
||||
console.warn(`[VideoGeneration] 删除片段视频失败: ${shot.clipVideoPath}`, e);
|
||||
}
|
||||
|
||||
@@ -94,7 +94,6 @@ function VideoCreationContent() {
|
||||
if (meta.exportedAt !== undefined) {updates.exportedAt = meta.exportedAt;}
|
||||
|
||||
useProjectStore.setState(updates);
|
||||
console.info('[VideoCreation] 已从 meta.json 恢复:', Object.keys(updates));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[VideoCreation] 加载 meta.json 失败:', e);
|
||||
|
||||
@@ -287,7 +287,6 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
|
||||
} else {
|
||||
localStorage.removeItem(AUTH_LOCAL_STORAGE_KEY);
|
||||
}
|
||||
console.info('[Auth] 已退出登录,清除认证状态');
|
||||
} catch (e) {
|
||||
console.error('[Auth] 清除认证状态失败:', e);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,6 @@ export async function saveMetaToLocalFile(overrides: MetaOverrides = {}) {
|
||||
const meta = buildProjectMeta(projectId, existing, overrides);
|
||||
|
||||
await localProjectApi.saveMeta(projectId, meta);
|
||||
console.info('[ProjectStore] Meta saved, fields:', Object.keys(overrides).join(', '));
|
||||
} catch (err) {
|
||||
console.error('[ProjectStore] Save meta failed:', err);
|
||||
}
|
||||
@@ -133,7 +132,6 @@ export const useProjectStore = create<ProjectStore>()(
|
||||
|
||||
updateSegment: (id, data) =>
|
||||
set(state => {
|
||||
console.info('[ProjectStore] updateSegment called:', { id, data, segmentsCount: state.segments.length });
|
||||
const shotIndex = state.segments.findIndex(s => s.id === id);
|
||||
if (shotIndex !== -1) {
|
||||
// 直接替换整个对象,避免 Object.assign 与 Immer 代理的问题
|
||||
@@ -143,7 +141,6 @@ export const useProjectStore = create<ProjectStore>()(
|
||||
};
|
||||
state.segments[shotIndex] = updatedShot as ScriptShot;
|
||||
state.updatedAt = Date.now();
|
||||
console.info('[ProjectStore] Shot updated:', updatedShot);
|
||||
} else {
|
||||
console.warn('[ProjectStore] Shot not found:', id);
|
||||
}
|
||||
@@ -262,7 +259,6 @@ export async function initProjectStore(projectId?: string): Promise<void> {
|
||||
currentProjectId = id;
|
||||
setCurrentProjectId(id);
|
||||
|
||||
console.info('[ProjectStore] Initializing project:', id);
|
||||
|
||||
const { meta: rawMeta, segments } = await localProjectApi.loadProject(id);
|
||||
|
||||
@@ -276,7 +272,6 @@ export async function initProjectStore(projectId?: string): Promise<void> {
|
||||
...(meta || {}),
|
||||
_hasHydrated: true,
|
||||
});
|
||||
console.info('[ProjectStore] Loaded from local file:', id);
|
||||
} else {
|
||||
// 新项目
|
||||
useProjectStore.setState({
|
||||
@@ -284,7 +279,6 @@ export async function initProjectStore(projectId?: string): Promise<void> {
|
||||
segments: [],
|
||||
_hasHydrated: true,
|
||||
});
|
||||
console.info('[ProjectStore] New project created:', id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user