From 95e55293c6f6a9d9cf7c14cb590ce440a5d2923e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=B1=BC=E5=BC=80=E5=8F=91?= Date: Sun, 10 May 2026 23:31:34 +0800 Subject: [PATCH] =?UTF-8?q?security:=20=E5=85=A8=E9=9D=A2=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E5=AE=89=E5=85=A8=E5=8A=A0=E5=9B=BA=E4=B8=8E=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端安全: - 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) --- python-api/.env.example | 8 +- python-api/.pre-commit-config.yaml | 17 ++- python-api/app/ai/model_router.py | 4 +- python-api/app/ai/prompts/loader.py | 4 +- python-api/app/api/deps.py | 25 +--- python-api/app/api/v1/upload.py | 122 ++++++++++++++++++ python-api/app/api/v1/voice.py | 80 +----------- python-api/app/config.py | 19 ++- python-api/app/core/security.py | 5 +- python-api/app/scheduler/engine.py | 2 + .../app/scheduler/handlers/script_handler.py | 4 +- .../scheduler/handlers/subtitle_handler.py | 4 +- .../app/scheduler/handlers/video_handler.py | 4 +- python-api/app/services/auth_service.py | 9 +- python-api/app/services/material_service.py | 4 +- python-api/app/services/qiniu_service.py | 2 +- python-api/app/services/sms_service.py | 4 +- python-api/app/services/wxpay_service.py | 8 +- python-api/docker-compose.prod.yml | 9 +- python-api/docker-compose.test.yml | 4 +- python-api/nginx/meijiaka-zy.conf | 7 +- python-api/pyproject.toml | 5 +- python-api/requirements.lock | 17 +-- python-api/uv.lock | 76 ++++------- tauri-app/.env.example | 7 + tauri-app/index.html | 3 +- tauri-app/src-tauri/capabilities/default.json | 23 ++-- .../src-tauri/src/commands/video_compose.rs | 21 +++ tauri-app/src-tauri/src/ffmpeg_cmd.rs | 16 +-- tauri-app/src-tauri/src/lib.rs | 4 +- tauri-app/src-tauri/tauri.conf.json | 2 +- tauri-app/src/api/client.ts | 7 - tauri-app/src/api/modules/localStorage.ts | 3 - tauri-app/src/api/modules/voice.ts | 3 +- tauri-app/src/components/Layout/Sidebar.tsx | 5 +- tauri-app/src/pages/Login/Login.tsx | 5 +- .../src/pages/VideoCreation/CoverDesign.tsx | 2 - .../src/pages/VideoCreation/VideoCompose.tsx | 1 + .../pages/VideoCreation/VideoGeneration.tsx | 8 -- tauri-app/src/pages/VideoCreation/index.tsx | 1 - tauri-app/src/store/authStore.ts | 1 - tauri-app/src/store/projectStore.ts | 6 - 42 files changed, 275 insertions(+), 286 deletions(-) create mode 100644 tauri-app/.env.example diff --git a/python-api/.env.example b/python-api/.env.example index 1012bb4..b497076 100644 --- a/python-api/.env.example +++ b/python-api/.env.example @@ -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 diff --git a/python-api/.pre-commit-config.yaml b/python-api/.pre-commit-config.yaml index e86e12c..f54e288 100644 --- a/python-api/.pre-commit-config.yaml +++ b/python-api/.pre-commit-config.yaml @@ -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 diff --git a/python-api/app/ai/model_router.py b/python-api/app/ai/model_router.py index a4a97a4..756425e 100644 --- a/python-api/app/ai/model_router.py +++ b/python-api/app/ai/model_router.py @@ -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, diff --git a/python-api/app/ai/prompts/loader.py b/python-api/app/ai/prompts/loader.py index 60c0c45..a279fad 100644 --- a/python-api/app/ai/prompts/loader.py +++ b/python-api/app/ai/prompts/loader.py @@ -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") diff --git a/python-api/app/api/deps.py b/python-api/app/api/deps.py index fe3d814..563b65b 100644 --- a/python-api/app/api/deps.py +++ b/python-api/app/api/deps.py @@ -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, diff --git a/python-api/app/api/v1/upload.py b/python-api/app/api/v1/upload.py index 2778133..27ce277 100644 --- a/python-api/app/api/v1/upload.py +++ b/python-api/app/api/v1/upload.py @@ -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" 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}" diff --git a/python-api/app/api/v1/voice.py b/python-api/app/api/v1/voice.py index 81e6b40..0f32ac2 100644 --- a/python-api/app/api/v1/voice.py +++ b/python-api/app/api/v1/voice.py @@ -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), diff --git a/python-api/app/config.py b/python-api/app/config.py index 70844f7..f3d3819 100644 --- a/python-api/app/config.py +++ b/python-api/app/config.py @@ -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", diff --git a/python-api/app/core/security.py b/python-api/app/core/security.py index 55d62d5..fe0aed2 100644 --- a/python-api/app/core/security.py +++ b/python-api/app/core/security.py @@ -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 diff --git a/python-api/app/scheduler/engine.py b/python-api/app/scheduler/engine.py index 3327d13..bd6307f 100644 --- a/python-api/app/scheduler/engine.py +++ b/python-api/app/scheduler/engine.py @@ -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] diff --git a/python-api/app/scheduler/handlers/script_handler.py b/python-api/app/scheduler/handlers/script_handler.py index be9fcce..e4aec3c 100644 --- a/python-api/app/scheduler/handlers/script_handler.py +++ b/python-api/app/scheduler/handlers/script_handler.py @@ -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 diff --git a/python-api/app/scheduler/handlers/subtitle_handler.py b/python-api/app/scheduler/handlers/subtitle_handler.py index 802736b..510026b 100644 --- a/python-api/app/scheduler/handlers/subtitle_handler.py +++ b/python-api/app/scheduler/handlers/subtitle_handler.py @@ -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 diff --git a/python-api/app/scheduler/handlers/video_handler.py b/python-api/app/scheduler/handlers/video_handler.py index 5fab27c..7fb9b12 100644 --- a/python-api/app/scheduler/handlers/video_handler.py +++ b/python-api/app/scheduler/handlers/video_handler.py @@ -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 diff --git a/python-api/app/services/auth_service.py b/python-api/app/services/auth_service.py index 6c4ea83..79e8b0a 100644 --- a/python-api/app/services/auth_service.py +++ b/python-api/app/services/auth_service.py @@ -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}") diff --git a/python-api/app/services/material_service.py b/python-api/app/services/material_service.py index f04960a..4aa3f84 100644 --- a/python-api/app/services/material_service.py +++ b/python-api/app/services/material_service.py @@ -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 diff --git a/python-api/app/services/qiniu_service.py b/python-api/app/services/qiniu_service.py index 62378e0..2e6c68c 100644 --- a/python-api/app/services/qiniu_service.py +++ b/python-api/app/services/qiniu_service.py @@ -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) diff --git a/python-api/app/services/sms_service.py b/python-api/app/services/sms_service.py index 4b37b13..d84a0a3 100644 --- a/python-api/app/services/sms_service.py +++ b/python-api/app/services/sms_service.py @@ -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() diff --git a/python-api/app/services/wxpay_service.py b/python-api/app/services/wxpay_service.py index 9bfddb8..1f2cc11 100644 --- a/python-api/app/services/wxpay_service.py +++ b/python-api/app/services/wxpay_service.py @@ -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 "" diff --git a/python-api/docker-compose.prod.yml b/python-api/docker-compose.prod.yml index 500824c..4648344 100644 --- a/python-api/docker-compose.prod.yml +++ b/python-api/docker-compose.prod.yml @@ -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 diff --git a/python-api/docker-compose.test.yml b/python-api/docker-compose.test.yml index 3ac459b..38af852 100644 --- a/python-api/docker-compose.test.yml +++ b/python-api/docker-compose.test.yml @@ -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 diff --git a/python-api/nginx/meijiaka-zy.conf b/python-api/nginx/meijiaka-zy.conf index b4fcafa..0c6c977 100644 --- a/python-api/nginx/meijiaka-zy.conf +++ b/python-api/nginx/meijiaka-zy.conf @@ -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; } } diff --git a/python-api/pyproject.toml b/python-api/pyproject.toml index 0dd9027..44eb696 100644 --- a/python-api/pyproject.toml +++ b/python-api/pyproject.toml @@ -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*"] diff --git a/python-api/requirements.lock b/python-api/requirements.lock index 20005dc..217c020 100644 --- a/python-api/requirements.lock +++ b/python-api/requirements.lock @@ -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 diff --git a/python-api/uv.lock b/python-api/uv.lock index c6e227b..ad7f980 100644 --- a/python-api/uv.lock +++ b/python-api/uv.lock @@ -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" diff --git a/tauri-app/.env.example b/tauri-app/.env.example new file mode 100644 index 0000000..5cc6e1c --- /dev/null +++ b/tauri-app/.env.example @@ -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 diff --git a/tauri-app/index.html b/tauri-app/index.html index 14a966d..e68a3bf 100644 --- a/tauri-app/index.html +++ b/tauri-app/index.html @@ -5,8 +5,7 @@ - - 美家卡 智影 + 美家卡智影