145 Commits

Author SHA1 Message Date
小鱼开发 603650cfb3 bump version to 1.6.6 2026-05-27 15:38:50 +08:00
小鱼开发 15dc5df12c chore(log): 日志只记录错误信息
- uvicorn access_log 设为 False,关闭 HTTP 访问日志
- .env.example 中 LOG_LEVEL 默认改为 ERROR
2026-05-27 15:35:51 +08:00
小鱼开发 4659f4536e fix(ui): BGM 清除按钮文案改为取消 2026-05-27 15:29:23 +08:00
小鱼开发 784c4faa55 fix(ui): 视频合成页 BGM 选择后支持清除 2026-05-27 15:16:11 +08:00
小鱼开发 5b804e9d79 fix(ui): 统一声音复刻时长提示为 10秒~5分钟 2026-05-27 14:24:06 +08:00
小鱼开发 00f0088c2a fix(prompts): 将'评论区扣/抠'改为'评论区回复',避免台词中出现'扣'字 2026-05-27 14:20:27 +08:00
小鱼开发 4a295e6e0d fix(vidu): 回调签名头大小写不敏感匹配
dict(request.headers) 的 key 为小写(x-hmac-signature),代码用大写(X-HMAC-SIGNATURE)获取导致全为None。建立小写查找表统一处理。
2026-05-27 09:40:45 +08:00
小鱼开发 63e0ffeaea fix(vidu): 回调签名使用 APP_BASE_URL 构建 URL,添加调试日志
- vidu_callback 改用 APP_BASE_URL 构建 callback_url,避免 Nginx 代理导致 scheme 不一致
- verify_signature 增加详细调试日志,打印 signing_string 和签名对比
2026-05-27 09:34:59 +08:00
小鱼开发 2797583d81 fix(points): 登录后调用 fetchBalance 加载积分余额,修复显示为0的问题 2026-05-27 09:24:39 +08:00
小鱼开发 10fc4092b2 bump version to 1.6.5 2026-05-26 23:37:05 +08:00
小鱼开发 cc2e3f639c fix(tauri): 修复 updater 重启失败和 IPC CSP 报错
- capabilities: 添加 process:allow-restart,解决更新后无法自动重启
- CSP: connect-src 增加 ipc://localhost/*,匹配带路径的 IPC 请求
2026-05-26 23:35:16 +08:00
小鱼开发 6c64189c70 fix(update): check_update 兼容同平台多包,优先返回 updater 包
- scalar_one_or_none() 在 release_id+platform+architecture 多行时抛异常
- 改为查询全部后取第一个,按 signature desc 排序优先 updater 包
2026-05-26 23:28:09 +08:00
小鱼开发 d84a4e9d65 fix(db): 放宽 release_package 唯一约束,支持同平台多文件(dmg + app.tar.gz)
- 唯一约束从 (release_id, platform, architecture) 改为包含 filename
- 新增 Alembic 迁移 7d855b38fe83
2026-05-26 22:57:20 +08:00
小鱼开发 7f522f5b83 feat(release): 发版脚本支持扫描 dmg 安装包
- 新增 .dmg 文件扫描逻辑,给 macOS 新用户首次安装使用
- dmg 无签名文件,signature 设为空字符串
2026-05-26 22:52:10 +08:00
小鱼开发 d2220ac176 fix(bump-version): 脚本自动提交后再打 tag,避免 tag 落在旧 commit 上 2026-05-26 20:00:03 +08:00
小鱼开发 790cf3a7fb bump version to 1.6.4 2026-05-26 19:54:02 +08:00
小鱼开发 943358bafc bump version to 1.6.3 2026-05-26 19:21:23 +08:00
小鱼开发 9ca07ff571 fix(cover): 封面主副标题位置固定化
- 主标题固定 top=200,副标题固定 top=380,不再根据封面形象高度和文字行数动态计算
- 清理未使用的 avatarTop、hasAvatar、mainTitleHeight、subtitleHeight 变量
- 补全 renderCover useCallback 依赖数组(增加 loadAvatarImage)
2026-05-26 18:54:42 +08:00
小鱼开发 9df8572512 Merge branch 'master' of http://git2.haodian.cn/xiaoyu/meijiaka-zy 2026-05-26 18:31:17 +08:00
meijiaka-dev 7b53abf37b fix(video-preview): 统一本地/网络视频预览,修复首次加载黑屏与 loading 状态
- Rust: transcode_for_preview 支持网络视频下载缓存,统一走转码流程
- Rust: rename 后 sync_all 文件数据+目录项,避免 WebKit 首次读取不完整
- Rust: 视频缓存上限从 500MB 调至 2GB
- 前端: handlePreview 统一处理本地/网络视频,不再直接设网络 URL
- 前端: 修复 previewVideoUrl 为 null 时 loading 动画不显示的问题
- 前端: 去掉 video preload=metadata,加 ref + onCanPlay 兜底播放
- 工程: .gitignore 忽略 sidecar binaries,修复 engine.rs unused warning
2026-05-26 18:29:34 +08:00
小鱼开发 cf3ea8d619 fix(cover): 修复 Windows 上封面主副标题位置跑到底部的问题
- 当封面形象加载失败或未选择时,回退到模板固定位置,避免文字堆在画布底部
- 增加封面形象加载失败的 console.warn 日志
- 修正 FONT_FAMILY 字体名 DouyinSans -> DouyinSansBold
2026-05-26 17:59:57 +08:00
小鱼开发 af734eb6ca fix: 应用启动时预加载积分规则,修复按钮显示默认值问题 2026-05-26 17:35:32 +08:00
小鱼开发 2b35a9ced0 feat: 封面人物形象 + 素材匹配优化 + Windows 预览修复
- 新增 cover_avatar 积分类型和弹窗支持
- Modal 组件支持 maxHeight 属性
- 素材匹配增加 loading 状态(匹配中...)
- 修复 Windows 视频预览:统一 handlePreview、preload=metadata、修复 Rust UNC 路径
- 修复进度条倒退问题
- 更新运营脚本
- 新增 Windows 11 开发环境搭建文档
2026-05-26 15:40:21 +08:00
小鱼开发 993d6e0c78 chore(release): bump version to 1.6.2 2026-05-26 10:25:17 +08:00
小鱼开发 e35b0f0bbb fix(rust): Windows 路径验证失败(canonicalize UNC 前缀不匹配)
在 Windows 上 std::fs::canonicalize() 会返回 \?\ 前缀的 UNC 路径
(如 \?\C:\Users\...),但 get_app_data_dir() 返回普通路径格式
(C:\Users\...)。PathBuf::starts_with() 做组件级比较时两者前缀
类型不同导致返回 false,所有本地文件操作都被错误拒绝。

修复:对允许目录也做 canonicalize(),使两边格式一致后再比较。

影响文件:
- ffmpeg_cmd.rs: validate_safe_path()
- commands/product.rs: delete_local_product, rename_local_product,
  export_product
2026-05-26 10:24:37 +08:00
小鱼开发 8cddaec70e chore(release): 优化发布脚本并统一表名
- publish_release.py: 自动加载.env,macOS Universal拆分为x86_64+aarch64,七牛云目录按平台区分
- 重命名表 mjk_release_packages -> mjk_app_release_packages,同步约束名
- 更新相关文档
2026-05-26 10:13:52 +08:00
小鱼开发 915339d42a release: bump version to 1.6.1
Frontend fixes:
- fix(VideoCompose): clear step dirty flag after compose success
- refactor(MyWorks): play product videos directly without transcode cache
- feat(CoverDesign): swap main/subtitle positions in cover preview
- fix(SubtitleBurning): charge points after burn success instead of before
- fix(VoiceSynthesis/VideoGeneration/SubtitleBurning/CoverDesign): mark downstream steps dirty on re-generation
- fix(MyWorks): bind video event listeners after async videoUrl load
- fix(CoverDesign): revoke Blob URLs on upload/unmount to prevent memory leak
2026-05-25 22:35:35 +08:00
小鱼开发 33265df299 style(settings): 关于区块版本号垂直布局排版 2026-05-25 01:41:56 +08:00
小鱼开发 4af42c157e fix: BGM 混音链路修复——URL 先下载到本地缓存再混音
- fix: 删除 BGM 预览硬编码开发者路径,改为使用 url 字段
- fix: BGM 混音前检测是否为 URL,先下载到 bgm_cache 本地缓存
- fix: Rust mix_bgm_to_video 恢复 validate_safe_path 校验,拒绝 URL
- feat: 新增 bgm_cache 目录及自动清理策略(30天/200MB上限)
- feat: Settings 缓存清理扩展为媒体缓存(video + BGM 统一清理)
- chore: BGM url 字段改为后端必填,同步 schema/model/seed/迁移
2026-05-25 00:54:17 +08:00
小鱼开发 818fe7cc03 release: v1.6.0
- fix: finalVideoDuration 遗漏导致切换项目时残留旧值且无法保存
- feat: BGM 云端化(129 首上传七牛云,数据库保存 URL)
- feat: BGM 弹窗改为分类+列表+试听交互
- feat: 步骤页面 UI 统一(按钮规范、预览状态、去掉图标)
- feat: CoverDesign 视觉重构(绿色边框、角标、hover 遮罩)
- fix: 消除积分预检硬编码(CoverDesign/SubtitleBurning/VideoCompose)
- fix: success 提示积分去掉硬编码
- fix: BGM/封面形象持久化到 meta.json
2026-05-24 22:16:46 +08:00
小鱼开发 c6e3e6dd25 fix: success 提示中的积分消耗去掉硬编码
CoverDesign: 2 → coverDesignPoints
SubtitleBurning: 2 → subtitleBurnPoints
VideoCompose: 5 → composePoints
ScriptCreation: 5 → scriptPoints
2026-05-24 21:42:08 +08:00
小鱼开发 daba6dcc14 fix: 消除积分预检硬编码,统一使用 getRule() 动态读取
CoverDesign: 预检 COVER_POINTS=2 → getRule('cover_design')?.points || 2
SubtitleBurning: 预检 SUBTITLE_POINTS=2 → getRule('subtitle_burn')?.points || 2
VideoCompose: 预检 COMPOSE_POINTS=5 → getRule('compose')?.points || 5

确保预检口径与扣费口径一致,避免后端规则调整后出现余额误判
2026-05-24 21:29:04 +08:00
小鱼开发 6a2302401f fix: 修复 BGM 弹窗和封面持久化的多个 bug
VideoCompose:
- 修复 Audio 对象泄漏:弹窗关闭/组件卸载时清理 onended 回调
- 修复 bgmMusicId=0 被误判为未选择(falsy 判断改为 !== undefined/null)
- 修复 Slider 进度条不随值变化:动态传入 --slider-percent CSS 变量
- 修复试听无 url 时无法播放:回退到 filePath 拼接本地路径
- 去掉 webkitRelativePath 错误回退
- 弹窗默认分类:打开时根据当前选中 BGM 的 category 自动切换标签
- 本地上传 BGM 弹窗内显示当前选中提示条(含清除按钮)

CoverDesign:
- 自动保存去掉 trim(),避免输入时吃掉末尾空格
- 缩小自动保存依赖范围,只监听 backgroundImage/avatarImage

VideoGeneration.css:
- 本地上传提示条样式
2026-05-24 20:43:54 +08:00
小鱼开发 b76252b0ac fix: BGM/封面形象持久化 + 滑块颜色
- localStorage.saveMeta 的 orderedMeta 补充 bgmMusicId/bgMusicTitle/bgmMusicPath/bgmVolume,修复 BGM 数据无法写入 meta.json
- CoverDesign 新增 useEffect:背景图/形象变化时自动防抖保存 coverConfig
- VideoCompose BGM 音量滑块添加 slider-input 类,使用主题绿色替代浏览器默认蓝色
2026-05-24 20:34:52 +08:00
小鱼开发 bb84cb5604 style: BGM 分类标签圆角改为中等(去掉椭圆形) 2026-05-24 20:24:28 +08:00
小鱼开发 184ab8bce3 feat: BGM 弹窗改为分类+列表+试听交互
- 顶部分类标签栏(全部/知识科普/案例展示/促销活动/家居生活/智能家居)
- 列表布局:每项含试听按钮、标题、时长、选中标记
- 试听功能:点击圆形按钮播放/暂停,播放中按钮呼吸动画
- 选中 BGM 后关闭弹窗,本地上传入口移至底部
- 弹窗关闭时自动停止音频播放
2026-05-24 20:22:57 +08:00
小鱼开发 2c9e0f0015 feat: add BGM seed data and seed script for deployment
- bgm_seed_data.json: 129 首 BGM 元数据(含七牛云 URL)
- seed_bgm.py: 部署环境初始化脚本,容器内直接运行
2026-05-24 20:09:50 +08:00
小鱼开发 06ec0ee202 feat: BGM 云端化 + 步骤页面 UI 统一重构
后端:
- 新增 BGM 数据库模型、Schema、CRUD、API 路由
- BgmMusic 增加 url 字段存储七牛云地址
- Alembic 迁移: 创建 BGM 表 + 添加 url 字段
- import_bgm.py 导入时自动上传七牛云 (meijiaka-zy/bgm/...)

前端:
- VideoCompose BGM 选择改为卡片弹窗 (系统BGM + 本地上传)
- 去掉 BGM 硬编码本地路径, 直接使用云端 URL
- CoverDesign 视觉重构: 绿色边框卡片、角标、hover 遮罩
- CoverDesign 去掉预选背景, 默认空白需手动选择
- 所有步骤按钮规范统一: 左=重新生成(主色), 右=导出/预览(次色)
- 预览按钮状态统一: 文字变为'视频预览中...', 保持 btn-secondary
- 去掉所有步骤按钮的 svg/emoji 图标

Rust:
- mix_bgm_to_video 支持临时文件保护 (输入输出同路径时自动中转)
- FFmpeg BGM 混合使用 aloop 循环 + amix 滤镜
2026-05-24 15:39:54 +08:00
小鱼开发 616649c872 feat(cover-avatar): 前置余额检查 + 积分不足弹窗(与声音复刻一致) 2026-05-23 11:13:17 +08:00
小鱼开发 fae2a77734 feat(ui): 封面形象余额不足时不弹 toast;声音复刻弹窗增加积分消耗提示 2026-05-23 11:08:47 +08:00
小鱼开发 53371aabcd feat(image): 封面形象抠图增加积分消耗(每次 10 积分)
- config/points-config.yaml: 添加 cover_avatar: 10 固定积分
- point_service.py: _CATEGORY_MAP 添加 cover_avatar → 封面形象
- image.py: remove_background 接口前置余额检查 + 后置扣费
- CoverAvatarLibrary.tsx: 上传弹窗显示积分提示,余额不足友好提示
2026-05-23 10:59:47 +08:00
小鱼开发 9589d7c78a fix(cover-avatar): 修复卡片操作按钮误触 + 列表滚动 2026-05-23 10:19:01 +08:00
小鱼开发 bf51d8b423 style(cover-avatar): 列表改为 avatar-card 卡片样式(hover 操作按钮 + 图片缩放 + 4 列网格) 2026-05-23 10:16:28 +08:00
小鱼开发 db34090d5d feat(image): 人物描边宽度从 10px 调整为 20px 2026-05-23 10:10:21 +08:00
小鱼开发 d18e705a99 feat(image): 抠图增加人物白色描边(need_contour + contour_color + contour_size + need_crop_background)
- provider: 增加 need_contour/contour_color/contour_size/need_crop_background 参数
- service: 默认 scene=human,human/product 场景自动启用白色描边 + 裁剪背景
- adapter: 透传新参数到 provider
- API: scene 默认值改为 human
- 前端: removeBackground 默认 scene 改为 human
2026-05-23 10:04:34 +08:00
小鱼开发 6011225eec fix(image): 抠图结果下载后转存七牛云,解决前端 CORS 跨域加载失败 2026-05-23 09:50:40 +08:00
小鱼开发 222c468681 fix(mediakit): 兼容火山引擎抠图API的两种响应格式(code/data 和 success/result) 2026-05-23 09:44:27 +08:00
小鱼开发 430aea4aa8 fix(image): 增强抠图失败时的诊断日志,记录原始响应内容 2026-05-23 09:39:38 +08:00
小鱼开发 df6915191a chore(deploy): 恢复 deploy-test.sh 可执行权限 2026-05-23 09:35:15 +08:00
小鱼开发 9733a7f311 fix(progress-modal): 图标匹配改为互斥条件,避免标题同时命中多个关键词时重复渲染 2026-05-23 09:34:38 +08:00
小鱼开发 29f74f7afc chore(deploy): 让 deploy-test.sh 在 Git 中保持可执行权限 2026-05-23 09:26:15 +08:00
小鱼开发 8a5f0ace34 fix(update): 204 响应不通过 HTTPException 抛出,避免 Content-Length 校验失败 2026-05-23 09:24:53 +08:00
小鱼开发 f01f2c366a feat(cover-avatar): 封面形象功能
后端:
- 新增 POST /upload/image 图片上传(七牛云 image bucket)
- 新增 POST /image/remove-background AI 抠图(火山引擎 MediaKit)
- 提取 file_validation.py 共享模块

Rust:
- 新增 cover_avatar.rs 存储层(cover_avatars.json + 图片本地存储)
- 新增 4 个 IPC 命令:load/save/delete/save_image

前端:
- 新增 CoverAvatarLibrary 页面(内容管理 → 封面形象)
- 新增 coverAvatar API 模块和 coverAvatarStore
- 封面设计集成:背景图/封面形象弹窗选择 + Fabric.js 叠加
- 优化左侧布局:视觉素材横向卡片(9:16)+ 文案配置分组
2026-05-22 18:38:18 +08:00
小鱼开发 c55c256dc7 style(settings): reduce section title size (22px -> 18px) 2026-05-22 15:38:07 +08:00
小鱼开发 a7c81c14eb refactor(sidebar): remove lightning icon, adjust avatar/text layout
- Remove lightning bolt SVG from balance display
- Increase avatar size (32px -> 36px)
- Tighten nickname/balance spacing in user-info
- Adjust user-name font weight (500 -> 600) and size (var(--font-sm) -> 14px)
- Reduce sidebar-user justify-content: center -> default (flex-start)
2026-05-22 15:33:10 +08:00
小鱼开发 7f43795b2e refactor(sidebar): move balance below nickname 2026-05-22 15:28:43 +08:00
小鱼开发 9870a8cbc8 refactor(app-header): move back button to right side 2026-05-22 15:25:16 +08:00
小鱼开发 538cb1a367 refactor(sidebar): merge balance and user into unified card 2026-05-22 15:22:50 +08:00
小鱼开发 a50c61bbb5 refactor(pricing): remove explanation section from pricing modal 2026-05-22 15:17:20 +08:00
小鱼开发 19a166a873 fix(pricing): line break after period for video rule detail 2026-05-22 15:14:29 +08:00
小鱼开发 1cb1c0b387 refactor(profile): remove logout button
Profile page no longer shows logout button — logout is accessible via Sidebar dropdown menu only.
2026-05-22 15:10:22 +08:00
小鱼开发 1a0679049e refactor(profile): restore recent transactions table
Replace menu list (使用明细 + 设置) with recent transactions table:
- Add back recentTx state and loading state
- Fetch last 5 transactions in loadData
- Display table with type/amount/description/time columns
- Add '查看全部' link to usage-detail page
- Remove unused icon components (FileTextIcon, SettingsIcon, ChevronRightIcon)
2026-05-22 15:02:11 +08:00
小鱼开发 91774f52ee refactor(Phase 3): merge SystemUpdate + AboutUs into Settings page
- New Settings page combines: system update check, version info, auth/copyright
- Remove standalone AboutUs.tsx and SystemUpdate.tsx
- Route: 'about-us' + 'system-update' → 'settings'
- Profile menu: merge '系统设置' + '关于我们' into single '设置' entry
- Sidebar dropdown: add '设置' back to user menu (besides '我的账户')
2026-05-22 14:48:29 +08:00
小鱼开发 34d6f671fe refactor(Phase 2): extract components + AppHeader
New components:
- PricingModal: standalone product pricing modal with lazy rule loading
- PointsCard: reusable points balance + today consumed + action buttons
- AppHeader: unified page header with title, back button, and right actions

Profile.tsx:
- Use PricingModal + PointsCard (380 lines -> ~200 lines)
- Remove embedded pricing table logic (~80 lines)
- Remove inline points display logic (~40 lines)

Pages updated with AppHeader:
- Profile: '我的账户' (no back button)
- UsageDetail: '积分明细' + back button
- SystemUpdate: '系统更新' + back button
- AboutUs: '关于我们' + back button

CSS:
- Add app-header, app-header-left, app-header-title, app-header-right classes
- Remove margin-bottom from page-back-btn (handled by app-header)
2026-05-22 12:45:41 +08:00
小鱼开发 5386a1dbf4 refactor: redesign Profile page, fix navigation & interaction logic
Profile page:
- Add page title '我的账户' in settings-section h2
- Restructure points section: horizontal action buttons (充值/明细/定价)
- Remove '产品定价' from menu list (already in points section)
- Remove '更多' heading

Interaction fixes:
- Profile logout now uses onNavigate('logout') → App.tsx ConfirmModal
- Sidebar dropdown: only '我的账户' + '退出登录' (others go through Profile)
- Add back button to usage-detail / system-update / about-us → navigate to profile
- content-management click: only expand/collapse, no auto-navigate to first child

CSS:
- Add --bg-secondary / --bg-tertiary to variables.css
- Add page-back-btn CSS class
- Convert profile-points-actions to horizontal row layout
2026-05-22 11:27:41 +08:00
小鱼开发 abf03712a5 refactor(profile): tighten spacing, simplify logout button
- Reduce points card padding (20px→16px vertical)
- Reduce points section padding (24px→20px vertical)
- Reduce section spacing (24px→16px)
- Section title margin (12px→8px)
- Logout button: remove card background/border, keep text-only with error hover
2026-05-22 11:13:10 +08:00
小鱼开发 31fec11c44 refactor(profile): zero inline styles
- Add profile-card-flat, profile-edit-wrap, profile-section-spaced,
  profile-pricing-loading, profile-pricing-detail-list CSS classes
- Profile.tsx: remove all remaining inline style attributes
2026-05-22 11:05:45 +08:00
小鱼开发 0a52195490 refactor(profile): extract CSS classes, fix undefined vars, remove inline styles
- variables.css: add --bg-secondary and --bg-tertiary (used but undefined)
- ContentManagement.css: add 30+ Profile CSS classes following design system
- Profile.tsx: rewrite with className, remove all inline styles and emoji
  - pricing modal tags use semantic colors via CSS classes
  - logout hover uses var(--error) instead of hardcoded #e74c3c
  - menu items use CSS hover instead of onMouseEnter/Leave handlers
2026-05-22 11:04:01 +08:00
小鱼开发 aebc9f6bcc refactor: Phase 1 Profile/Settings UX refactoring
- Sidebar: Remove '系统设置' from navItems, add balance badge + user dropdown
  menu in footer (我的账户/使用明细/系统设置/关于我们/退出登录)
- Profile: Remove inline recent transactions table (UsageDetail page exists),
  simplify to info + points + menu entries. Add inline pricing modal.
- GenerationControls: Show current balance alongside point cost in button text
- Points config: Adjust subtitle_burn/cover_design to 5 pts, recharge validity
2026-05-22 10:50:48 +08:00
小鱼开发 574874c856 feat: 视频生成使用系统素材时每个额外收取 2 积分 2026-05-22 09:40:23 +08:00
小鱼开发 497e65d86d fix: 视频生成积分扣费不允许欠费 2026-05-21 22:28:41 +08:00
小鱼开发 372b36becc feat: 音量滑块范围改为 1-5(对应传参 0-4) 2026-05-21 22:18:03 +08:00
小鱼开发 582068b599 fix: 匹配素材成功后自动切换镜头并加载播放 2026-05-21 21:42:04 +08:00
小鱼开发 1448cd54ab feat: 匹配素材成功后自动加载播放预览 2026-05-21 21:32:14 +08:00
小鱼开发 59237f1098 style: 匹配素材按钮移到上传素材旁边 2026-05-21 21:29:33 +08:00
小鱼开发 d6fe43b7c3 feat: 匹配素材改为针对单个镜头匹配
- 新增 matchSingleMaterial 函数,调用单条匹配 API
- 点击'匹配素材'只匹配当前镜头,不影响其他镜头
2026-05-21 21:24:57 +08:00
小鱼开发 e52513f452 refactor: 匹配素材与换一个合并为同个按钮的不同状态 2026-05-21 21:22:16 +08:00
小鱼开发 4123b66ab9 fix: 添加 Tauri window.show/set_focus 权限
- 修复文件选择对话框触发的权限拒绝错误
- core:window:allow-show, core:window:allow-set-focus
2026-05-21 21:19:29 +08:00
小鱼开发 54fc6b2638 feat: 空镜素材自动匹配改为手动匹配
- 移除页面加载时的自动批量匹配逻辑
- 每个未匹配空镜卡片新增'匹配素材'按钮
- 点击后触发批量匹配,已匹配后显示'换一个'按钮
2026-05-21 21:19:25 +08:00
小鱼开发 2cece72abe feat: 用户白名单免验证码登录
- Settings 新增 SMS_CODE_WHITELIST 配置(逗号分隔手机号)
- login_with_sms 中白名单手机号跳过验证码校验
- 方便内部测试和演示账号使用
2026-05-21 16:32:09 +08:00
小鱼开发 44ec2dceb7 feat: ffprobe 快速检测 H.264/yuv420p 视频,跳过不必要的预览转码
- 应用生成的成品视频已是 H.264/yuv420p,无需重复转码
- 超时后显式 kill ffprobe 子进程,避免僵尸进程
- 分辨率上限判断:4K 素材仍转码为 540p 代理保证预览流畅
2026-05-21 16:32:02 +08:00
小鱼开发 6def12995e style: 视频预览加载遮罩增加'加载中...'文案 2026-05-21 16:06:40 +08:00
小鱼开发 ec3b2b87ed feat: 视频缓存自动清理
- 应用启动时后台清理 video_cache 目录
- 删除超过 30 天未修改的缓存文件
- 总容量超 500MB 时,按修改时间删最旧文件直到 300MB
- 不阻塞首屏加载
2026-05-21 16:00:55 +08:00
小鱼开发 59bfadcb99 fix: FAT32 文件系统修改时间读取失败导致转码报错;更新 useLocalVideo 注释 2026-05-21 15:58:05 +08:00
小鱼开发 666842ce2b feat: 视频预览自动转码为浏览器兼容格式
- Rust: 新增 transcode_for_preview,用 FFmpeg 将任意视频转码为
  H.264 Baseline + YUV420p 540p,确保跨平台预览兼容
- Rust: 缓存按(路径hash + 文件大小 + 修改时间)管理,避免重复转码
- 前端: 新增 getPreviewVideoUrl 工具,统一替换视频预览的 URL 获取逻辑
- 根本性解决 Windows WebView2 视频黑屏问题,同时提升预览性能
2026-05-21 15:52:30 +08:00
小鱼开发 5250381579 fix: Windows 视频预览黑屏 — 禁用 D3D11 硬件视频解码
Chromium 在 Windows 上的 D3D11 视频解码器与部分显卡驱动/视频编码
不兼容,导致视频画面黑屏但音频正常。回退到软件解码解决此问题。
2026-05-21 15:26:12 +08:00
小鱼开发 c4a9c9c2eb style: 启动加载动画颜色从 #1a9e8a 改为 var(--primary) 绿色 2026-05-21 15:20:57 +08:00
小鱼开发 0e876384d6 ci: 构建产物自动上传到 GitHub Release,artifacts 保留 3 天 2026-05-21 15:12:38 +08:00
小鱼开发 81145fb9d0 fix: ffprobe duration 解析增加 format 回退,兼容 MPEG-TS 等格式 2026-05-21 14:34:08 +08:00
小鱼开发 a913c6e3da chore: 更新版本号至 1.5.19 2026-05-21 14:26:04 +08:00
小鱼开发 2720dacc1d fix: Windows 视频分辨率 0x0 问题 — 改用 Rust 层 ffprobe 读取元数据
- 新增 ffmpeg_cmd::get_video_metadata,通过 ffprobe sidecar 读取视频信息
- 新增 Tauri Command get_video_metadata_cmd 供前端调用
- 前端 videoValidation.ts 不再依赖 HTML5 <video> 标签,改为调用 Rust ffprobe
- 解决 macOS 与 Windows 浏览器视频解码器差异导致的元数据读取不一致问题
2026-05-21 14:23:44 +08:00
小鱼开发 3c4c765f2a ci: macOS 构建使用 gh CLI 下载私有仓库 sidecar,解决认证问题 2026-05-21 11:14:32 +08:00
小鱼开发 2be938d0a3 ci: 修复 macOS 构建 sidecar 下载 URL,使用动态仓库名 2026-05-21 11:07:56 +08:00
小鱼开发 71bad49710 ci: 恢复 GitHub Actions macOS 构建 2026-05-21 10:55:43 +08:00
小鱼开发 30396543ee chore: 删除未使用的 minisign 密钥,更新签名文档
- 从 git 仓库移除 minisign.key.pub(未被任何配置引用)
- 本地删除 minisign.key 私钥
- 更新 windows-signing.md:将密钥文件名修正为实际使用的 .tauri-signing-key.pub
2026-05-21 10:50:34 +08:00
小鱼开发 ec428ba1c8 chore: 删除重复的 tauri.key.pub(内容与 .tauri-signing-key.pub 完全相同) 2026-05-21 10:48:02 +08:00
小鱼开发 f8ee7c61b9 chore: 清理测试密钥文件,防止敏感信息泄露
- 从 git 仓库移除已提交的测试公钥(cargo-test.key.pub、npm-test.key.pub)
- 本地删除测试密钥文件(cargo-test.key、npm-test.key)
- 更新 .gitignore 排除所有测试相关密钥
2026-05-21 10:46:41 +08:00
小鱼开发 d7fa20a890 feat: 样式系统重构、图标更新、FFmpeg 模块调整及配置更新
- 更新 .gitignore 排除私钥和 IDE 配置
- 重构前端样式系统(新增 reset.css/animations.css/components/)
- 更新应用图标资源(多种尺寸)
- 调整 FFmpeg 命令模块
- 更新部署脚本和图标生成脚本
- 新增数据库迁移脚本
- 添加签名公钥文件
2026-05-21 10:45:04 +08:00
小鱼开发 4fc8ee58cb fix: Windows 下 convertFileSrc 使用 http://asset.localhost,CSP 需显式放行 2026-05-21 10:40:19 +08:00
小鱼开发 3ce29d5333 fix(updater): 使用带密码的签名密钥对,修复 CI 签名失败
Tauri CLI 2.x 生成无密码密钥存在已知 bug(tauri-apps/tauri#14829)。
按主流方案改为使用带密码的密钥对:
- 重新生成带密码的 updater 签名密钥
- 同步更新公钥到 tauri.conf.json 和 tauri.key.pub
- CI workflow 增加 TAURI_SIGNING_PRIVATE_KEY_PASSWORD 环境变量
2026-05-21 07:50:10 +08:00
小鱼开发 c42500d256 chore(deps): 升级 @tauri-apps/cli 到 2.11.2
修复 Tauri CLI 2.10.0 中 updater 签名无法识别空密码密钥的问题。
tauri-cli 2.11.2 在 CI 环境下会自动将未设置的密码视为空字符串。
2026-05-21 07:31:29 +08:00
小鱼开发 1dd934e0a2 fix(updater): 修复 Tauri 签名密钥对格式问题
Tauri CLI 2.x 的 tauri signer generate --ci 存在已知 bug:
生成的无密码私钥中 KDF byte 被错误设为非零值,导致签名阶段
报错 incorrect updater private key password。

通过 Python 脚本手动将 KDF byte 修正为 0x00,并同步更新公钥。

参考: tauri-apps/tauri#14829
2026-05-20 23:13:23 +08:00
小鱼开发 2a4a9511d6 fix: 重新生成 updater 密钥对,修复运行模式切换,启用 updater 产物生成 2026-05-20 22:19:39 +08:00
小鱼开发 20cca6e631 fix(tauri): add F12/Ctrl+Shift+I shortcut and shift+right-click for DevTools
- Add open_devtools IPC command
- Frontend keydown listener for F12 / Ctrl+Shift+I
- Allow contextmenu when Shift is held (for Inspect element)
- Auto open_devtools after window.show() with 1s delay
2026-05-20 15:20:44 +08:00
小鱼开发 501c5e8221 fix(tauri): ensure devtools opens after window is visible
- window.show() before open_devtools() since visible=false in config
- Add 1s delay in spawned thread for WebView init completion
2026-05-20 15:13:00 +08:00
小鱼开发 9f3ea6dece fix(ci): add missing sidecar download step for Windows build
- Windows job was missing Download sidecar binaries step, causing
  'ffmpeg-x86_64-pc-windows-msvc.exe doesn't exist' build failure
- Remove duplicate sidecar download step from disabled macOS job
2026-05-20 12:34:16 +08:00
小鱼开发 837fbc997d fix: 移除未使用的 React 导入,修复 TS6133 编译错误 2026-05-20 12:23:37 +08:00
小鱼开发 b6311bec9d fix(ci): 改回 gh release download,当前仓库已有 sidecar release 2026-05-20 12:09:52 +08:00
小鱼开发 41e495f0f0 fix(ci): sidecar 下载改回当前仓库 release 2026-05-20 11:52:38 +08:00
小鱼开发 b98df5a1a4 fix(ci): 用 curl 直接下载 sidecar,绕过 gh CLI 跨仓库权限限制 2026-05-20 11:23:04 +08:00
小鱼开发 98c14582d4 temp: 禁用 updater 签名,绕过私钥缺失问题 2026-05-20 11:17:05 +08:00
小鱼开发 f7b57d9fd8 temp: 固定 sidecar 仓库 + 禁用 macOS 构建 2026-05-20 11:11:51 +08:00
小鱼开发 1d7a45618a temp: 启用 Windows DevTools + 清理无用文件 + 修复积分计算 2026-05-20 10:55:43 +08:00
小鱼开发 0abc032682 fix: Windows icons use square fill, macOS keeps rounded
- Windows .ico and SquareLogo: content fills 100% canvas, no transparency
- macOS .icns and PNG: keep 80.5% rounded rectangle
- Fixes white/grey square background and blur on Windows
2026-05-20 10:07:10 +08:00
小鱼开发 2d7e1473a9 fix: eliminate white screen on startup
- Main window starts hidden (visible: false), shown after frontend ready
- Remove React.StrictMode to reduce initial render overhead
- Add loading spinner during app initialization
- Use Promise.all + requestIdleCallback to optimize startup timing
2026-05-20 09:47:59 +08:00
小鱼开发 8794901bfa chore: Windows installer use per-machine mode for multi-user 2026-05-20 09:39:30 +08:00
小鱼开发 68b7954e0d chore: configure updater signing for new repo
- Generate new minisign keypair for updater signing
- Update pubkey in tauri.conf.json
- Restore createUpdaterArtifacts: true
- Restore TAURI_SIGNING_PRIVATE_KEY env in workflow
2026-05-20 09:33:16 +08:00
小鱼开发 bb6cd37282 fix: disable updater signing for new repo build 2026-05-20 00:49:50 +08:00
小鱼开发 5aeb1d9e3c fix: sidecar download uses current repo instead of fun0 2026-05-20 00:31:15 +08:00
小鱼开发 966cdfc08a trigger: refresh workflow index 2026-05-20 00:22:32 +08:00
小鱼开发 331e9ccc23 chore: 重新生成应用图标
- 使用新的绿色M logo(白底+内容图案作为整体)
- 图标内容占画布80.5%,四周留透明边距(参考腾讯视频)
- 恢复脚本为正确的圆角裁剪逻辑,去掉错误的trim和overscale
- 移除Android/iOS图标生成(桌面端项目不需要)
2026-05-19 23:58:16 +08:00
小鱼开发 4cbbb8d2b3 refactor(icons): 重构图标生成,统一圆角白底风格;添加 updater bundle 配置
- 重写 generate-icons.py,macOS/Windows 统一使用圆角白底风格
- 白色圆角背景占图标 70%,logo 占背景 60%
- 清理未使用的平台图标(Android/iOS/Square 系列)
- tauri.conf.json 添加 createUpdaterArtifacts 支持自动更新签名
2026-05-19 18:30:59 +08:00
小鱼开发 7e5c7ee349 fix: 修复打包后视频生成失败 + 弹窗Toast重复
- ffmpeg_cmd: 添加 universal-apple-darwin sidecar 回退查找,解决CI构建后找不到FFmpeg的问题
- useVideoGeneration: 去掉 catch 块中的 toast.error,避免弹窗和Toast同时出现
2026-05-19 16:30:36 +08:00
小鱼开发 32d86061e7 chore: DMG背景图改为白色 2026-05-19 15:35:50 +08:00
小鱼开发 9ddcb2347d ci: 构建流程优化 - test环境固定/平台选择/版本号自动更新/缓存
- VITE_API_BASE_URL 固定为 dev.tapi.meijiaka.cn(test环境)
- 添加 platform 选择(all/macos/windows),支持单独构建
- 添加版本号自动更新(tauri.conf.json + Cargo.toml)
- 添加 Rust + Node 构建缓存,节省CI额度
- 修复 ViduAdapter parse_callback 运算符优先级bug
- 修复 ViduProvider tts_sync 日志前缀误写
- VoiceSynthesis 空状态UI优化
2026-05-19 15:17:36 +08:00
小鱼开发 66db8a0788 ci: 恢复 DMG 背景图方案,生成含 Gatekeeper 指引的背景图,移除 README 方案 2026-05-19 14:14:26 +08:00
小鱼开发 53476d3e4a ci: 改用 TAURI_SIGNING_PRIVATE_KEY_PATH 环境变量传私钥文件路径,避免 clap 参数冲突 2026-05-19 13:56:41 +08:00
小鱼开发 f36e8d3742 ci: 使用 env -u 清除环境变量,避免 -f 参数冲突 2026-05-19 13:51:12 +08:00
小鱼开发 c3c5ff442d ci: unset TAURI_SIGNING_PRIVATE_KEY 避免与 -f 参数冲突 2026-05-19 12:50:55 +08:00
小鱼开发 ce754f7004 ci: 私钥写入文件前 strip 掉前后空白,修复首字节换行符问题 2026-05-19 12:49:48 +08:00
小鱼开发 00409fd9a8 ci: 修复 DMG 重新签名,用 python3 写入私钥文件避免 heredoc 缩进问题 2026-05-19 12:41:48 +08:00
小鱼开发 0292a7e1de ci: 修复 DMG 重新签名,使用 heredoc 将私钥写入临时文件 2026-05-19 12:23:20 +08:00
小鱼开发 e6bbf0308a ci: 修复 DMG 重新签名步骤,通过环境变量自动读取私钥 2026-05-19 11:59:03 +08:00
小鱼开发 dd3864db1f fix: CORS 配置添加 Windows Tauri 生产模式 origin http://tauri.localhost 2026-05-19 11:36:53 +08:00
小鱼开发 09ea37bae1 fix: Windows 图标支持多分辨率 RGBA 圆角,修复 ICO 只有 16x16 单帧的问题 2026-05-19 11:35:39 +08:00
小鱼开发 c6fd452e87 chore: 移除 DMG 背景图,添加 README.txt 到 DMG 根目录 2026-05-19 11:30:52 +08:00
小鱼开发 a1636e6b5d fix: use TIFF format for DMG background (light + dark mode) 2026-05-19 11:14:24 +08:00
小鱼开发 09aa1ca45a feat: DMG background with larger fonts and app design system 2026-05-19 10:45:02 +08:00
小鱼开发 fc92370993 feat: update DMG background - green brand title, simplified tip text 2026-05-19 10:20:34 +08:00
小鱼开发 6431666e7d feat: add DMG background with app design system and Gatekeeper guide 2026-05-19 10:15:35 +08:00
小鱼开发 92359e98f8 docs: update DMG background design spec with app design system 2026-05-19 10:05:12 +08:00
小鱼开发 88f913b511 revert: remove auto-generated DMG background, pending design 2026-05-19 09:50:56 +08:00
小鱼开发 e100494c6a feat: add DMG background with Gatekeeper installation guide 2026-05-19 09:46:30 +08:00
小鱼开发 236055b75f ci: remove .app from macOS artifacts, only upload .dmg + .sig 2026-05-19 09:39:07 +08:00
小鱼开发 fe778b66e3 ci: include .sig files in release artifacts 2026-05-19 09:37:50 +08:00
224 changed files with 11587 additions and 3080 deletions
+95 -10
View File
@@ -10,19 +10,23 @@ on:
description: '版本号 (例如 1.5.16)'
required: true
type: string
environment:
description: '构建环境'
platform:
description: '构建平台'
required: true
type: choice
options:
- test
- prod
default: test
- all
- macos
- windows
default: all
jobs:
build-macos:
name: Build macOS (Universal)
if: ${{ github.event_name == 'push' || inputs.platform == 'all' || inputs.platform == 'macos' }}
runs-on: macos-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -37,10 +41,25 @@ jobs:
with:
targets: x86_64-apple-darwin,aarch64-apple-darwin
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
tauri-app/src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('tauri-app/src-tauri/Cargo.lock') }}
- name: Cache Node dependencies
uses: actions/cache@v4
with:
path: tauri-app/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('tauri-app/package-lock.json') }}
- name: Download sidecar binaries
run: |
mkdir -p tauri-app/src-tauri/binaries
gh release download v0.0.0-sidecar --repo fun0/meijiaka-zy --pattern "sidecar-binaries.tar.gz" --dir /tmp
gh release download v0.0.0-sidecar --repo ${{ github.repository }} --pattern "sidecar-binaries.tar.gz" --dir /tmp
tar xzf /tmp/sidecar-binaries.tar.gz -C tauri-app/src-tauri/binaries/
chmod +x tauri-app/src-tauri/binaries/ffmpeg-* tauri-app/src-tauri/binaries/ffprobe-*
# Create universal binary for macOS universal-apple-darwin target
@@ -62,11 +81,22 @@ jobs:
working-directory: tauri-app
run: npm ci
- name: Update version
run: |
if [ -n "${{ inputs.version }}" ]; then
VERSION="${{ inputs.version }}"
else
VERSION="${GITHUB_REF_NAME#v}"
fi
perl -pi -e "s/\"version\"\s*:\s*\"[^\"]*\"/\"version\": \"$VERSION\"/" tauri-app/src-tauri/tauri.conf.json
perl -pi -e "s/^version = \"[^\"]*\"/version = \"$VERSION\"/" tauri-app/src-tauri/Cargo.toml
echo "Version updated to: $VERSION"
- name: Build macOS Universal
working-directory: tauri-app
run: npm run tauri -- build --target universal-apple-darwin
env:
VITE_API_BASE_URL: ${{ inputs.environment == 'prod' && 'https://tapi.meijiaka.cn/api/v1' || 'https://dev.tapi.meijiaka.cn/api/v1' }}
VITE_API_BASE_URL: https://dev.tapi.meijiaka.cn/api/v1
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
@@ -76,11 +106,25 @@ jobs:
name: macos-universal
path: |
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz.sig
retention-days: 3
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
files: |
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz.sig
build-windows:
name: Build Windows (x64)
if: ${{ github.event_name == 'push' || inputs.platform == 'all' || inputs.platform == 'windows' }}
runs-on: windows-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -95,12 +139,28 @@ jobs:
with:
targets: x86_64-pc-windows-msvc
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
tauri-app/src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('tauri-app/src-tauri/Cargo.lock') }}
- name: Cache Node dependencies
uses: actions/cache@v4
with:
path: tauri-app/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('tauri-app/package-lock.json') }}
- name: Download sidecar binaries
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path tauri-app/src-tauri/binaries
gh release download v0.0.0-sidecar --repo fun0/meijiaka-zy --pattern "sidecar-binaries.tar.gz" --dir $env:TEMP
gh release download v0.0.0-sidecar --repo ${{ github.repository }} --pattern "sidecar-binaries.tar.gz" --dir $env:TEMP
tar xzf "$env:TEMP\sidecar-binaries.tar.gz" -C tauri-app/src-tauri/binaries/
Get-ChildItem tauri-app/src-tauri/binaries/
env:
GH_TOKEN: ${{ github.token }}
@@ -108,12 +168,24 @@ jobs:
working-directory: tauri-app
run: npm ci
- name: Update version
shell: pwsh
run: |
$version = if ("${{ inputs.version }}") { "${{ inputs.version }}" } else { $env:GITHUB_REF_NAME -replace '^v', '' }
$content = Get-Content tauri-app/src-tauri/tauri.conf.json -Raw
$content = $content -replace '"version"\s*:\s*"[^"]*"', "`"version`": `"$version`""
Set-Content tauri-app/src-tauri/tauri.conf.json -Value $content
$cargo = Get-Content tauri-app/src-tauri/Cargo.toml -Raw
$cargo = $cargo -replace '^version = "[^"]*"', "version = `"$version`""
Set-Content tauri-app/src-tauri/Cargo.toml -Value $cargo
Write-Host "Version updated to: $version"
- name: Build Windows x64
working-directory: tauri-app
shell: pwsh
run: npm run tauri -- build --target x86_64-pc-windows-msvc
env:
VITE_API_BASE_URL: ${{ inputs.environment == 'prod' && 'https://tapi.meijiaka.cn/api/v1' || 'https://dev.tapi.meijiaka.cn/api/v1' }}
VITE_API_BASE_URL: https://dev.tapi.meijiaka.cn/api/v1
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
@@ -123,3 +195,16 @@ jobs:
name: windows-x64
path: |
tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
retention-days: 3
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
files: |
tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
+7
View File
@@ -12,6 +12,8 @@ __pycache__/
# IDE
.vscode/
.idea/
.cursor/
.claude/
# Logs
*.log
@@ -22,5 +24,10 @@ __pycache__/
test_kick.sh
.playwright-mcp/
*.seed_materials_cache.json
.qiniu_pythonsdk_hostscache.json
tauri-app/src-tauri/binaries/*
.tauri-signing-key
*.key
*test*.key*
.atomcode/
mixkit_bgm/
+7 -3
View File
@@ -80,8 +80,9 @@ build-frontend-macos:
paths:
# DMG 安装包 (推荐用户下载)
- tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg
# .app bundle (供进一步分发或公证使用)
- tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app
# Updater 专用包 + 签名
- tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz
- tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz.sig
expire_in: "${ARTIFACT_EXPIRE_DAYS} days"
timeout: 45 minutes
retry:
@@ -114,8 +115,11 @@ build-frontend-windows:
artifacts:
name: "meijiaka-windows-${CI_COMMIT_REF_NAME}-${CI_COMMIT_SHORT_SHA}"
paths:
# NSIS 安装包 (推荐用户下载)
# Updater 专用包 + 签名
- tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
- tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
# NSIS 安装包 (推荐用户下载)
- tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
# MSI 安装包 (企业部署场景)
- tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
expire_in: "${ARTIFACT_EXPIRE_DAYS} days"
+1 -1
View File
@@ -9,7 +9,7 @@
**美家卡智影**是一款面向桌面端的 AI 视频创作应用,采用"Python 后端 API + Tauri 桌面前端"的混合架构。
- **产品标识**: `cn.meijiaka.ai-video` / `cn.meijiaka.ai-zy`
- **版本**: `1.5.18`
- **版本**: `1.6.6`
- **核心功能**: AI 脚本生成、AI 配音合成(TTS)、声音复刻、视频生成(Vidu)、视频字幕生成、压制成片(FFmpeg)、项目本地持久化
### 技术栈总览
-245
View File
@@ -1,245 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
**美家卡智影** - AI 视频创作桌面应用,采用 Python FastAPI 后端 + Tauri (Rust + React) 前端的混合架构。
**核心功能**: AI 脚本生成、TTS 语音合成、声音克隆、对口型 (Vidu)、字幕生成、视频合成、项目本地持久化
## 项目结构
```
.
├── python-api/ # Python 后端 API (FastAPI)
├── tauri-app/ # Tauri 桌面前端 (Rust + React 19)
├── docs/ # 架构文档
└── AGENTS.md # AI Agent 专用详细文档
```
### 后端 (python-api/)
```
python-api/
├── app/
│ ├── api/v1/ # API 路由 (按领域拆分: auth, script, voice, vidu, caption, tasks, upload, materials, system)
│ ├── core/ # 核心工具 (配置、异常、Redis、健康检查)
│ ├── db/ # 数据库配置 (SQLAlchemy 2.0 async)
│ ├── models/ # ORM 模型 (BaseModel: UUID 主键 + 时间戳)
│ ├── schemas/ # Pydantic Schema (请求/响应校验)
│ ├── services/ # 业务逻辑层
│ ├── scheduler/ # Async Engine 异步任务调度
│ ├── ai/ # AI 模型封装 (providers, adapters, gateways)
│ └── platform_gateway.py # 第三方平台统一调用网关
├── config/ # 运行时配置 (platform-config.yaml, materials.json)
├── alembic/ # 数据库迁移
└── Makefile # 开发命令
```
### 前端 (tauri-app/)
```
tauri-app/
├── src/
│ ├── api/
│ │ ├── client.ts # 智能路由客户端 (HTTP/IPC 自动选择, camelCase ↔ snake_case)
│ │ ├── modules/ # 按领域拆分的 API 模块
│ │ └── generated/ # OpenAPI 生成类型
│ ├── components/ # 可复用组件 (PascalCase 文件夹)
│ ├── pages/ # 页面组件 (PascalCase 文件夹)
│ ├── store/ # Zustand 状态管理
│ ├── hooks/ # 自定义 Hooks
│ └── styles/ # CSS 变量与全局样式
├── src-tauri/src/
│ ├── commands/ # Tauri IPC 命令 (按领域拆分)
│ ├── storage/ # 本地存储引擎
│ ├── ffmpeg_cmd.rs # FFmpeg 命令封装
│ └── lib.rs # Tauri Builder、Command 定义
└── package.json
```
## 混合通信架构
1. **纯数据 API** (脚本、TTS、字幕、视频生成、任务查询等) → 前端通过 **HTTP 直连 Python 后端**
- 使用 `tauri-app/src/api/client.ts``client.get/post/put/delete`
- 异步任务统一走 `POST /tasks/{task_type}` + 轮询 `/tasks/{task_id}`
2. **本地系统能力** (FFmpeg 合成、文件系统、项目持久化) → 走 **Tauri IPC → Rust 层**
- 使用 `invoke()` 调用 `src-tauri/src/commands/` 中的 `#[tauri::command]`
- 命令参数用 `snake_case`,前端用 `camelCase`,通过 `#[serde(rename_all = "camelCase")]` 转换
**新增纯数据 API 时**: 只需在 `tauri-app/src/api/modules/` 使用 `client` 调用,无需修改 Rust 代码。
**新增本地能力时**: 在 `src-tauri/src/commands/` 添加 `#[tauri::command]`,并在 `lib.rs``invoke_handler` 中注册。
## 常用命令
### 后端 (cd python-api)
```bash
# 安装依赖
make dev
# 启动服务 (需两个终端)
make run # FastAPI 开发服务器 (8000)
make scheduler # Async Engine 调度器
# 代码质量
make lint # ruff + mypy
make format # black + ruff --fix
make lint-semantic # 语义层禁词检查 (防止供应商术语泄漏)
make ci # format-check + lint + lint-semantic + test + security
# 测试
make test # pytest
make test-cov # 覆盖率报告
# 安全扫描
make security # bandit + pip-audit
# Docker
make docker-run # 启动 api + scheduler (共享外部 db/redis)
make docker-rebuild # 强制重建
make docker-logs # 查看日志
# 数据库迁移
alembic revision --autogenerate -m "描述"
alembic upgrade head
```
### 前端 (cd tauri-app)
```bash
# 开发
npm run dev # Vite 前端服务器 (1420)
npm run tauri dev # Tauri 完整开发模式
# 构建
npm run build # tsc + vite build
npm run tauri build # Tauri 打包
# 测试
npm run test # Vitest
npm run test:ui # Vitest UI
npm run test:coverage # 覆盖率
# 代码质量
npm run lint # ESLint
npm run lint:fix # ESLint --fix
npm run format # Prettier
npm run stylelint # Stylelint
# API 类型生成
npm run gen:api # 从 openapi.json 生成 TypeScript 类型
```
## 代码规范
### 命名约定
- **组件/页面文件夹**: PascalCase (`VideoCreation/`, `ErrorBoundary/`)
- **Store/Hooks/API 文件**: camelCase (`authStore.ts`, `useProjectData.ts`)
- **Python 模块/函数**: snake_case (`script_handler.py`, `get_settings()`)
- **Python 类**: PascalCase (`AsyncEngine`, `BaseModel`)
- **常量**: UPPER_SNAKE_CASE (`PYTHON_API_BASE_URL`)
### 注释语言
- **统一使用中文注释**
- 关键架构决策需用多行注释说明
### Python 代码质量
- **格式化**: black (line-length: 100)
- **检查**: ruff (E, F, I, N, W, UP, B, C4, SIM)
- **类型检查**: mypy (新代码 `app.schemas.*`, `app.crud.*`, `app.scheduler.handlers.*` 严格模式)
- **依赖管理**: uv (`requirements.lock` 必须与 `pyproject.toml` 同步)
- **安全**: bandit + pip-audit
### TypeScript 配置
- `strict: true` 已开启
- `jsx: "react-jsx"`,无需手动引入 React
- 路径别名 `@/``./src`
### 语义层防护 (Makefile `lint-semantic`)
- API 层禁止 `element_id` 作为字段名 (应使用 `provider_element_id`)
- Scheduler 层统一使用 `task` 命名 (`TaskRegistry`, `task_id`, `task:` Redis key 前缀),禁止混用 `job`
## 关键架构组件
### Async Engine (异步任务调度)
- **位置**: `python-api/app/scheduler/`
- **核心组件**:
- `AsyncEngine`: 主调度器,驱动 Tick 循环
- `TaskRegistry`: Redis 任务注册表 (running/pending/finished)
- `SlotManager`: 并发槽位管理 (按 task_type 限制并发数)
- `handlers/`: 各领域处理器 (`script`, `tts`, `subtitle`, `video`)
- **启动**: `make scheduler``python -m app.scheduler.main`
- **Tick 间隔**: 5 秒 (可配置)
### Platform Gateway (第三方平台统一调用)
- **位置**: `python-api/app/platform_gateway.py`
- **功能**: 统一调用所有第三方平台 (Vidu、火山方舟、火山字幕)
- **方法**:
- `call_sync()`: 同步调用
- `submit_task()`: 异步任务提交
- `query_task()`: 任务状态查询
- `handle_webhook()`: 回调处理 (含签名验证 + nonce 防重放)
### Model Router (AI 模型路由)
- **位置**: `python-api/app/ai/model_router.py`
- **功能**: 从 YAML 配置文件加载平台/模型配置,支持模型自动选择
- **配置文件**: `python-api/config/platform-config.yaml`
### 状态管理 (前端)
- **Zustand + Immer**: 不可变更新
- **projectStore**: 自定义 `persist` 存储,通过 Tauri IPC 持久化到本地文件系统 (`app_config_dir/current_project.json`)
- **其他 Store**: 使用 `localStorage` 持久化
## 环境变量 (后端)
关键配置见 `python-api/.env.example`:
| 变量 | 说明 |
|------|------|
| `DATABASE_URL` | PostgreSQL 连接字符串 |
| `REDIS_HOST` / `REDIS_PORT` / `REDIS_DB` | Redis 连接信息 |
| `SECRET_KEY` | JWT 签名密钥 (生产环境必须设置) |
| `CORS_ORIGINS` | 允许的跨域来源 |
| `VOLCENGINE_API_KEY` | 火山方舟 API Key |
| `VIDU_API_KEY` | Vidu API Key |
| `QINIU_ACCESS_KEY` / `QINIU_SECRET_KEY` | 七牛云存储密钥 |
| `APP_BASE_URL` | 应用公网地址 (第三方回调用) |
## 部署
### 测试环境 (GitLab CI)
- 触发条件: master 分支
- 流程: 拉取代码 → 构建 api + scheduler → 启动服务 → 健康检查 (`/health`)
- 清理: 删除 7 天前的旧镜像
### 生产环境
```bash
cd python-api
docker compose -f docker-compose.prod.yml up -d --build
```
## 数据库
- **ORM**: SQLAlchemy 2.0 (asyncpg 驱动)
- **迁移**: Alembic
- **基础模型**: `app.models.base.BaseModel` (UUID 主键、`created_at``updated_at`)
## 安全注意事项
- JWT `SECRET_KEY` 生产环境必须设置强随机字符串
- 依赖安全: `aiohttp>=3.13.4``orjson>=3.11.0` 为强制最低版本
- 输入验证: 所有 API 入参通过 Pydantic Schema 校验
- 数据库: 使用参数化查询 (SQLAlchemy ORM)
## 开发注意事项
1. 修改 `pyproject.toml` 后必须运行 `make update-lock` 更新 `requirements.lock`
2. 新增组件遵循 PascalCase 文件夹约定
3. 语义层禁词检查 (`make lint-semantic`) 必须通过
4. Tauri 配置变更后需重新 `tauri dev`
5. `projectStore``partialize` 字段决定哪些状态被保存到本地文件
6. 前端测试在 `src/__tests__/setup.ts` 中已全局 mock localStorage 和 Tauri API
+1 -1
View File
@@ -1 +1 @@
1.5.18
1.6.6
-181
View File
@@ -1,181 +0,0 @@
# Alembic 迁移诊断报告
## 迁移历史概览
```
<base>
509aa8b53d81 initial_schema(创建 6 张初始表)
ccf61ff6f4bb remove_frozen_and_refunded(删除 3 个废弃字段)
95eb1a1c0af9 add_duration_to_point_transactionduration 字段)
8aa48b89a07d add_category_to_point_transactioncategory 字段)
69274ce979a5 add_broll_material_tables(⚠️ 问题脚本,593 行)
e02c96e264d9 add_cover_backgrounds_table(封面背景表)
d0a7c5a375c6 add_app_update_tables(更新系统表)
7a412121e69a rename_mjk_to_mjk_brollbroll 表重命名) ← head
```
依赖链:**线性,无 branch/merge**head 为 `7a412121e69a`
---
## 🔴 严重问题
### 1. 69274ce979a5 脚本职责混乱、极其臃肿
**脚本名**`add_broll_material_tables`
**实际内容**
- 创建 3 张新表(`mjk_categories`/`mjk_tags`/`mjk_materials`):约 60 行
- 给已有 6 张表的所有字段加 `comment`:约 530 行
**问题**
- 脚本体积膨胀到 **593 行**90% 内容和脚本名称无关
- 新环境执行迁移时,会触发几十个 `ALTER COLUMN` 操作,耗时显著增加
- 脚本难以维护,任何已有表字段的 comment 调整都需要修改这个脚本
- "加 comment" 和 "创建新表" 是完全独立的操作,不应该混在一起
**正确做法** hindsight ):
- 脚本 A:创建 broll 表(~60 行)
- 脚本 B:给已有字段补 comment~530 行)
- 或在 `initial_schema` 中直接带上 comment,避免后续回头补
---
### 2. "创建 → 加 comment → 重命名" 链式低效
| 步骤 | 迁移脚本 | 操作 |
|------|---------|------|
| 1 | 509aa8b53d81 | 创建 `mjk_categories`(无 comment |
| 2 | 69274ce979a5 | 给 `mjk_categories` 字段加 comment |
| 3 | 7a412121e69a | 重命名 `mjk_categories``mjk_broll_categories` |
**问题**:同一张表经历了"创建 → 补 comment → 重命名"三次操作。正确的做法是在创建时就用最终表名并带上 comment。
---
## 🟡 中等问题
### 3. downgrade 顺序错误
**d0a7c5a375c6**(更新系统表):
```python
# 当前(逻辑顺序错误)
op.drop_table("release_packages") # 1. 先删子表
op.drop_index("ix_app_releases_version", table_name="app_releases") # 2. 再删索引
op.drop_table("app_releases") # 3. 最后删父表
```
虽然 `drop_table` 会级联删除索引不会报错,但正确的逻辑顺序应该是:
```python
op.drop_table("release_packages")
op.drop_index("ix_app_releases_version", table_name="app_releases")
op.drop_table("app_releases")
```
> 注:当前顺序实际不会导致错误,只是不够规范。
### 4. 表名前缀不一致
| 表 | 前缀 | 一致性 |
|----|------|--------|
| `mjk_users` | `mjk_` | ✅ |
| `mjk_point_transactions` | `mjk_` | ✅ |
| `mjk_broll_categories` | `mjk_` | ✅ |
| `mjk_cover_backgrounds` | `mjk_` | ✅ |
| `app_releases` | `app_` | ❌ 缺少 `mjk_` 前缀 |
| `release_packages` | 无前缀 | ❌ 缺少 `mjk_` 前缀 |
### 5. 时间戳与顺序不一致
- `d0a7c5a375c6` Create Date`2026-05-11 09:30:00`
- `e02c96e264d9` Create Date`2026-05-11 20:00:00`
依赖链:`e02c96e264d9 → d0a7c5a375c6`,但时间戳显示 `e02c96e264d9``d0a7c5a375c6` 晚(20:00 vs 09:30)。
不影响功能,只是记录不规范。
---
## 🟢 正常部分
### 模型与迁移一致性 ✅
| 模型 | 表名 | 字段 | 状态 |
|------|------|------|------|
| `UserPoint` | `mjk_user_points` | 无 `frozen`/`total_refunded` | ✅ 与 `ccf61ff6f4bb` 一致 |
| `PointBatch` | `mjk_point_batches` | 无 `frozen` | ✅ 与 `ccf61ff6f4bb` 一致 |
| `PointTransaction` | `mjk_point_transactions` | 有 `duration` + `category` | ✅ 与 `95eb1a1c0af9`/`8aa48b89a07d` 一致 |
| `BrollCategory` | `mjk_broll_categories` | - | ✅ 与 `7a412121e69a` 重命名后一致 |
| `BrollMaterial` | `mjk_broll_materials` | - | ✅ 与 `7a412121e69a` 重命名后一致 |
| `BrollTag` | `mjk_broll_tags` | - | ✅ 与 `7a412121e69a` 重命名后一致 |
| `CoverBackground` | `mjk_cover_backgrounds` | - | ✅ 与 `e02c96e264d9` 一致 |
| `AppRelease`/`ReleasePackage` | `app_releases`/`release_packages` | - | ✅ 与 `d0a7c5a375c6` 一致 |
### env.py 模型导入 ✅
导入了全部 12 个模型类,无遗漏。
---
## 修复建议
### 方案 A:维持现状(推荐,已投产)
**理由**
- 生产环境数据库已有数据,不能直接修改或删除已有迁移脚本
- 当前迁移链虽然"丑",但功能正确,执行无误
**行动**
- 保留现有迁移,以后的新迁移保持**单一职责**(一个脚本只做一件事)
- 不再在迁移脚本里大量添加 `comment`(初始 schema 没带的就保持原样)
### 方案 B:Squash 迁移(适合新环境 / 大版本重构时)
将所有历史迁移合并为一张干净的 `initial_schema`
```bash
# 1. 备份当前 alembic 目录
mv alembic/versions alembic/versions_backup
# 2. 在空数据库上生成一张完整的初始迁移
alembic revision --autogenerate -m "squash: initial schema"
# 3. 验证新迁移包含所有表和字段
# 4. 删除备份
rm -rf alembic/versions_backup
```
**风险**
- 生产环境已有 `alembic_version` 记录,不能直接切换
- 需要协调所有环境(开发/测试/生产)同时切换
### 方案 C:拆分 69274ce979a5(仅做记录,不建议执行)
`69274ce979a5` 拆分为两个脚本:
1. `create_broll_tables`:创建 `mjk_broll_categories`/`mjk_broll_materials`/`mjk_broll_tags`
2. `add_column_comments`:给已有字段加 comment
**不可行原因**:已有迁移的 revision ID 已写入数据库 `alembic_version` 表,拆分会导致已执行环境的历史记录和脚本不匹配。
---
## 结论
| 问题 | 严重程度 | 是否可修复 | 建议 |
|------|---------|-----------|------|
| 69274ce979a5 职责混乱 | 🔴 高 | ❌ 已投产,不动 | 以后新迁移保持单一职责 |
| 创建→comment→重命名链 | 🔴 高 | ❌ 已投产,不动 | 以后在创建时直接用最终表名 |
| downgrade 顺序 | 🟡 中 | ⚠️ 不建议改 | 当前不会报错,保持现状 |
| 表名前缀不一致 | 🟡 中 | ❌ 已投产,不动 | 接受现状 |
| 时间戳不一致 | 🟢 低 | ❌ 无影响 | 忽略 |
**最终建议**:维持现状,不修改已有迁移脚本。以后新迁移遵循"单一职责原则"。
+7 -7
View File
@@ -71,7 +71,7 @@ INSERT INTO app_releases (version, release_date, notes, mandatory) VALUES
('0.2.0', '2026-04-20 10:00:00', '新增:批量导出功能\n优化:性能提升 30%', FALSE);
```
#### 2.1.2 release_packages - 平台包信息
#### 2.1.2 mjk_app_release_packages - 平台包信息
| 字段 | 类型 | 说明 | 约束 |
|------|------|------|------|
@@ -97,7 +97,7 @@ INSERT INTO app_releases (version, release_date, notes, mandatory) VALUES
**示例数据**:
```sql
INSERT INTO release_packages (release_id, platform, architecture, filename, file_url, file_size, file_hash) VALUES
INSERT INTO mjk_app_release_packages (release_id, platform, architecture, filename, file_url, file_size, file_hash) VALUES
(2, 'darwin', 'x86_64', 'meijiaka_0.1.1_darwin_x86_64.dmg',
'https://cdn.meijiaka.com/releases/meijiaka_0.1.1_darwin_x86_64.dmg',
102400000, 'sha256:abc123...'),
@@ -129,7 +129,7 @@ INSERT INTO release_packages (release_id, platform, architecture, filename, file
CREATE INDEX idx_releases_release_date ON app_releases(release_date DESC);
-- 平台包复合索引
CREATE INDEX idx_packages_platform_arch ON release_packages(platform, architecture);
CREATE INDEX idx_packages_platform_arch ON mjk_app_release_packages(platform, architecture);
-- 下载统计
CREATE INDEX idx_downloads_release_id ON update_downloads(release_id);
@@ -151,7 +151,7 @@ CREATE TABLE IF NOT EXISTS app_releases (
);
-- 创建平台包信息表
CREATE TABLE IF NOT EXISTS release_packages (
CREATE TABLE IF NOT EXISTS mjk_app_release_packages (
id SERIAL PRIMARY KEY,
release_id INTEGER NOT NULL REFERENCES app_releases(id) ON DELETE CASCADE,
platform VARCHAR(20) NOT NULL,
@@ -177,8 +177,8 @@ CREATE TABLE IF NOT EXISTS update_downloads (
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_releases_version ON app_releases(version);
CREATE INDEX IF NOT EXISTS idx_releases_release_date ON app_releases(release_date DESC);
CREATE INDEX IF NOT EXISTS idx_packages_platform_arch ON release_packages(platform, architecture);
CREATE INDEX IF NOT EXISTS idx_packages_release_id ON release_packages(release_id);
CREATE INDEX IF NOT EXISTS idx_packages_platform_arch ON mjk_app_release_packages(platform, architecture);
CREATE INDEX IF NOT EXISTS idx_packages_release_id ON mjk_app_release_packages(release_id);
CREATE INDEX IF NOT EXISTS idx_downloads_release_id ON update_downloads(release_id);
CREATE INDEX IF NOT EXISTS idx_downloads_download_at ON update_downloads(download_at);
@@ -240,7 +240,7 @@ class AppRelease(Base):
class ReleasePackage(Base):
"""平台包信息"""
__tablename__ = "release_packages"
__tablename__ = "mjk_app_release_packages"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
release_id: Mapped[int] = mapped_column(
+227
View File
@@ -0,0 +1,227 @@
# DMG 背景图设计规范
> 本规范与美家卡智影桌面应用视觉体系保持一致。
---
## 一、画布规格
| 项目 | 规格 |
|------|------|
| 尺寸 | **660 × 400 px** |
| 格式 | PNG(不透明) |
| 分辨率 | 72 DPI |
| 色彩模式 | sRGB |
---
## 二、视觉风格
### 2.1 设计语言
与主应用保持一致:
- **卡片式布局** — 圆角卡片承载信息
- **轻微阴影** — 营造层级感
- **绿色主色调** — 品牌识别色 `#36b26a`
- **圆角设计** — 大圆角(12px)为主,小圆角(8px)为辅
- **毛玻璃质感** — 侧边栏/浮层使用半透明模糊效果
### 2.2 色彩规范(引用应用 CSS 变量)
| 用途 | 色值 | CSS 变量 |
|------|------|----------|
| 背景底色 | `#f9fafb` | `--bg-main` |
| 卡片背景 | `#ffffff` | `--bg-card` |
| 品牌主色 | `#36b26a` | `--primary` |
| 品牌辅色 | `#18a08a` | `--secondary` |
| 文字主色 | `#111827` | `--text-primary` |
| 文字次色 | `#6b7280` | `--text-secondary` |
| 文字三级 | `#9ca3af` | `--text-tertiary` |
| 边框颜色 | `#e5e7eb` | `--border-color` |
| 成功/提示 | `#10b981` | `--success` |
| 警告 | `#f59e0b` | `--warning` |
| 错误 | `#ef4444` | `--error` |
| 信息 | `#3b82f6` | `--info` |
### 2.3 字体规范
- **字体家族**`'Inter', -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif`
- **字重**:标题 600SemiBold),正文 400Regular
| 层级 | 字号 | 字重 | 用途 |
|------|------|------|------|
| 品牌标题 | 28 px | 600 | 顶部 "美家卡智影" |
| 引导文字 | 15 px | 500 | 拖拽指引 |
| 提示标题 | 14 px | 600 | 卡片内小标题 |
| 提示正文 | 13 px | 400 | 卡片内说明文字 |
| 版本号 | 11 px | 400 | 底部版本信息 |
### 2.4 阴影规范
| 用途 | 阴影值 |
|------|--------|
| 卡片阴影 | `0 1px 3px rgb(0 0 0 / 5%)` |
| 浮层阴影 | `0 4px 12px rgb(0 0 0 / 6%)` |
| 强调阴影 | `0 4px 12px rgb(54 178 106 / 30%)` |
### 2.5 圆角规范
| 用途 | 圆角 |
|------|------|
| 大卡片 | 12 px (`--radius-lg`) |
| 小元素 | 8 px (`--radius-md`) |
| 按钮/标签 | 6 px (`--radius-sm`) |
---
## 三、布局规范
### 3.1 安全区域
Tauri 会在背景图上**自动叠加**以下元素,背景图需为其预留空间:
| 元素 | 尺寸(约) | 位置(660×400 画布) |
|------|-----------|---------------------|
| `.app` 图标 | 100 × 100 px | 左侧中心 (180, 170) |
| `Applications` 文件夹 | 100 × 100 px | 右侧中心 (480, 170) |
> ⚠️ 左右两侧 120~140 px 宽度区域避免放置重要信息,留给系统图标。
### 3.2 信息层级(从上至下)
```
┌─────────────────────────────────────────┐
│ │
│ [品牌 Logo + 应用名称] │ ← 顶部居中
│ │
│ │
│ [.app] [Applications] │ ← 系统图标区域
│ 图标 文件夹 │
│ │
│ ← 拖拽箭头 / 视觉引导线 → │ ← 中部
│ │
│ ┌─────────────────────────────┐ │
│ │ [提示图标] 首次安装提示 │ │ ← 提示卡片
│ │ 说明文字... │ │
│ └─────────────────────────────┘ │
│ │
│ v1.5.18 │ ← 底部版本号
│ │
└─────────────────────────────────────────┘
```
---
## 四、内容规范
### 4.1 顶部品牌区
**内容:**
- 应用 Logo(绿色 M 图标,约 48×48 px)
- 应用名称:"美家卡智影"
**样式:**
- Logo 与文字水平排列,间距 12 px
- 文字颜色:`#111827` (`--text-primary`)
- 文字字号:28 px,字重 600
- 整体居中于顶部,距上边距约 40 px
### 4.2 中部拖拽区
**内容:**
- 拖拽箭头或虚线引导线:从 `.app` 图标指向 `Applications` 文件夹
- 引导文字:"将应用拖拽到 Applications 文件夹"
**样式:**
- 箭头颜色:`#36b26a` (`--primary`) 或 `#9ca3af` (`--text-tertiary`)
- 引导文字颜色:`#6b7280` (`--text-secondary`)
- 引导文字字号:15 px,字重 500
### 4.3 底部提示卡片(核心区域)
由于应用未注册 Apple 开发者账号,macOS Gatekeeper 会拦截首次打开。必须通过醒目的提示卡片告知用户解决方法。
**卡片样式:**
- 背景:`#ffffff` (`--bg-card`)
- 边框:1 px solid `#e5e7eb` (`--border-color`)
- 圆角:12 px (`--radius-lg`)
- 阴影:`0 1px 3px rgb(0 0 0 / 5%)` (`--shadow-card`)
- 内边距:左右 24 px,上下 16 px
- 最大宽度:约 440 px,水平居中
**卡片内容:**
```
[绿色圆点图标] 首次安装提示
─────────────────────────────────
由于未注册 Apple 开发者,首次打开时请:
方法 1:右键点击应用图标 → 选择「打开」
方法 2:系统设置 → 隐私与安全性 → 仍要打开
```
**文字样式:**
- 提示标题:14 px600,颜色 `#36b26a` (`--primary`)
- 说明正文:13 px400,颜色 `#6b7280` (`--text-secondary`)
- 行高:1.6
**提示图标:**
- 使用绿色圆点(8 px)或 Info 图标(16 px
- 颜色:`#36b26a` (`--primary`)
### 4.4 底部版本号(可选)
**内容:** "v1.5.18"
**样式:**
- 字号:11 px (`--font-xs`)
- 颜色:`#9ca3af` (`--text-tertiary`)
- 位置:底部居中,距下边距约 16 px
---
## 五、设计禁忌
| ❌ 禁止 | ✅ 推荐 |
|---------|---------|
| 使用鲜艳刺眼的背景色(大红、亮黄) | 使用浅灰 `#f9fafb` 或纯白 `#fff` |
| 文字过小(< 11 px)导致可读性差 | 最小字号 11 px,正文 13 px |
| 左右两侧放置重要信息(被系统图标遮挡) | 左右两侧 120 px 留白 |
| 使用纯黑 `#000` 文字 | 使用深灰 `#111827` |
| 阴影过重(如 0 10px 30px) | 使用轻微阴影 `0 4px 12px rgb(0 0 0 / 6%)` |
| 圆角过小(2-4 px)或直角 | 使用 8-12 px 大圆角 |
| 使用多种字体混排 | 统一使用 Inter / PingFang SC |
---
## 六、交付物
| 文件 | 说明 |
|------|------|
| `dmg-background.png` | 660 × 400 px72 DPIsRGBPNG 格式 |
| `dmg-background@2x.png` | 1320 × 800 pxRetina 屏高清版本,可选) |
| 源文件 | PSD / Sketch / Figma 源文件 |
**放置路径:** `tauri-app/src-tauri/dmg-background.png`
---
## 七、参考案例
### 7.1 飞书 (Lark)
- 浅灰背景 `#f5f5f7`
- 中央大 Logo
- 两侧图标 + 拖拽箭头
- 底部小字提示
### 7.2 微信
- 纯白背景
- 简洁的拖拽指引
- 无 Gatekeeper 提示(已签名)
### 7.3 推荐风格
参考 **Apple 官方设计风格** + **应用自身绿色品牌色**
- 背景:`#f9fafb`
- 卡片:纯白 + 浅灰边框 + 轻微阴影
- 强调:绿色 `#36b26a`
- 整体简洁、专业、无多余装饰
+756
View File
@@ -0,0 +1,756 @@
# 前端系统兼容性审查报告 v2(业务场景驱动)
> 审查范围:`tauri-app/src` 全部源码 + `tauri-app/src-tauri/src` Rust 层命令
> 审查方法:按用户真实使用路径和数据流转分析,非通用技术罗列
> 审查日期:2026-05-21
> 当前版本:v1.6.0
---
## 一、综述
本次审查以**用户真实操作流程**为主线,从数据持久化、媒体处理、第三方服务、版本升级、跨设备迁移五个业务维度展开,共发现 **14 项与业务直接相关的兼容性问题**
**核心结论**
1. **BGM 云端化改造存在链路缺口**:前端存储了 URL,但混音时直接传给 FFmpeg,未做本地缓存,网络波动或 URL 过期会导致合成失败或产生"无声成片"。
2. **积分消费存在 TOCTOU 竞态**:预检通过→合成完成→扣费失败之间有时间窗口,可能导致用户白嫖或重复扣费。
3. **项目数据完全不可迁移**:所有本地路径为绝对路径,无导出/导入功能,换设备后项目报废。
4. **磁盘满等大文件场景缺乏保护**:合成成果可能直接丢失,大视频上传/下载全量读内存。
5. **多处"静默失败"**:保存项目、分段配音、BGM 混音等关键环节出错时不提示用户,导致用户以为成功实际数据残缺。
---
## 二、🔴 严重问题(影响功能、数据或资金)
### 1. BGM 云端化后混音链路断裂——"无声成片"与合成失败
**业务场景**
1. 用户在 BGM 弹窗中选择一首云端 BGM(七牛云 URL)
2. 保存项目(`bgmMusicPath` 写入 `meta.json`,值为 `https://media.liche.cn/.../xxx.mp3`
3. 几天后点击「合成视频」,FFmpeg 混音时直接拉取该 URL
4. 网络波动或 URL 签名过期 → FFmpeg HTTP 输入超时 → 混音失败
**实际代码路径**
```typescript
// VideoCompose.tsx:265-276
if (bgmMusicPath) {
const mixRes = await invoke('mix_bgm_to_video', {
args: {
videoPath: result,
bgmPath: bgmFullPath, // <-- 这里是七牛云 URL,不是本地路径
outputPath: result,
bgmVolume: (bgmVolume ?? 0.25),
},
});
if (mixRes.code !== 200) {
console.warn('BGM 混合失败,使用无 BGM 版本:', mixRes.message);
}
}
```
```rust
// ffmpeg_cmd.rs:509-546
pub async fn mix_bgm_to_video(...) {
let safe_video = validate_safe_path(video_path)?; // 只校验视频路径
let safe_bgm = bgm_path.to_string(); // <-- 直接透传 URL,无校验
run_ffmpeg(app, vec!["-i", safe_video, "-i", safe_bgm, ...])
}
```
**用户实际看到**
- 界面提示「压制成片完成」✅
- 播放视频发现**没有背景音乐** ❌
- 用户以为是自己操作问题,反复合成浪费积分和时间
**影响评估**
- 功能完整性受损:选了 BGM 却出无声视频
- 积分浪费:每次合成消耗积分,但产出不符合预期
- 用户信任度下降:无法解释为什么有时有 BGM 有时没有
**修复建议**
`VideoCompose.tsx` `handleStart` 中,混音前确保 BGM 为本地文件:
```typescript
let finalBgmPath = bgmMusicPath;
if (bgmMusicPath?.startsWith('http')) {
// 下载到本地缓存目录
const cacheDir = await invoke<string>('get_bgm_cache_dir');
const cachedPath = `${cacheDir}/bgm_${bgmMusicId}.mp3`;
const exists = await invoke<boolean>('file_exists', { path: cachedPath });
if (!exists) {
useProgressStore.getState().update('正在下载背景音乐...');
await videoComposeApi.downloadFile({ url: bgmMusicPath, outputPath: cachedPath });
}
finalBgmPath = cachedPath;
}
// 然后传给 mix_bgm_to_video
```
Rust 侧 `mix_bgm_to_video` 应恢复 `validate_safe_path` 校验,拒绝 URL
```rust
let safe_bgm = validate_safe_path(bgm_path)?; // 强制要求本地路径
```
---
### 2. 积分消费 TOCTOU 竞态——合成完成扣费失败导致"白嫖"或需重来
**业务场景**
1. 用户点击「合成视频」,预检余额充足(如 50 积分,需扣 5 分)
2. 视频合成耗时 3-5 分钟
3. 期间用户在手机端或其他场景消费了积分,余额降至 3 分
4. 合成完成后调用 `consumePoints`,返回 402 "积分不足"
5. 前端回滚 `finalVideoPath` 状态,但**不删除已生成的视频文件**
**实际代码**
```typescript
// VideoCompose.tsx:287-309
const composePoints = usePointStore.getState().getRule('compose')?.points || 5;
try {
await pointsApi.consumePoints({
points: composePoints,
sourceType: 'compose',
sourceId: `compose_${useAuthStore.getState().user?.id || 'unknown'}_${Date.now()}`,
description: '压制成片',
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setResultPath('');
setFinalVideoPath(undefined); // 回滚前端状态
setExportedAt(undefined);
// ❌ 没有删除 products/ 目录下已生成的视频文件
if (msg.includes('402') || msg.includes('积分不足')) {
setShowPointsModal(true);
return;
}
}
```
**用户实际看到**
- 提示「积分不足」弹出充值弹窗
-`~/Library/Application Support/cn.meijiaka.ai-zy/projects/xxx/products/` 下已经有一份完整的 `.mp4`
- 用户可以通过 Finder 直接找到并播放该文件,**实际已白嫖成功**
- 或者用户充值后再次点击合成,重复消耗时间
**影响评估**
- 资金损失风险:用户可在不扣积分的情况下拿到成品
- 用户体验差:明明看到"合成完成"的进度条走到 100%,最后说积分不够
- 运营数据失真:成品文件存在但系统无消费记录
**修复建议(方案二选一)**
**方案 A:积分预占/冻结机制(推荐)**
后端新增 "预占积分" API,合成前预占 5 积分,合成完成后确认扣费,失败则释放。消除时间窗口。
**方案 B:扣费前置 + 失败清理**
若无法改后端,至少做到失败时清理文件:
```typescript
} catch (err) {
// 回滚状态
setResultPath('');
setFinalVideoPath(undefined);
// 清理已生成的文件
if (outputPath) {
await invoke('delete_project_file', { projectId, filePath: outputPath })
.catch(() => {}); // 清理失败不阻断错误提示
}
// ... 原有错误处理
}
```
---
### 3. 项目数据绝对路径依赖——换设备后项目完全报废
**业务场景**
1. 用户 Mac A 上创建项目,生成视频,一切正常
2. 用户将 `~/Library/Application Support/cn.meijiaka.ai-zy/projects/` 文件夹复制到 Mac B(或 Time Machine 恢复)
3. 在 Mac B 上打开应用,项目列表显示正常
4. 点击项目进入编辑,视频预览空白、配音无法播放、封面无法加载
**根本原因**
`meta.json``segments.json` 中所有本地文件路径均为**绝对路径**:
```json
{
"avatarMaterialPath": "/Users/alice/Library/Application Support/cn.meijiaka.ai-zy/projects/proj_xxx/assets/voice.mp3",
"burnedVideoPath": "/Users/alice/Library/Application Support/.../burned_xxx.mp4",
"coverPath": "/Users/alice/Library/Application Support/.../cover.png"
}
```
Mac B 上用户名为 `bob`,上述路径全部指向不存在的目录。
**当前代码无修复机制**
- `loadMeta` 直接返回磁盘数据,无路径校验或重映射
- `getLocalFileUrl` 调用 Rust `validate_media_path`,校验通过后会返回 `asset://` URL,但文件不存在时直接抛错
- `useLocalVideo` 抛错后显示空白,无降级提示
**用户实际看到**
- 项目标题、主题等文字信息正常
- 视频预览区域空白或转圈
- 配音试听按钮点击无反应
- 用户以为数据损坏,恐慌
**影响评估**
- 用户换机/重装系统后所有本地项目无法继续编辑
- 与「桌面端本地持久化」的核心卖点相矛盾
- Time Machine 备份恢复后项目数据"假死"
**修复建议**
**短期(最小改动)**:加载项目时检测路径有效性,无效时给出明确提示:
```typescript
// initProjectStore 中
const validatedMeta = await validateProjectPaths(meta);
if (validatedMeta.brokenPaths.length > 0) {
toast.warn(`项目 ${validatedMeta.brokenPaths.join(', ')} 关联的文件已丢失,可能因迁移设备或清理磁盘导致`);
}
```
**长期**
1. 持久化时存储**相对路径**(相对于项目目录)
2. 加载时解析为绝对路径:
```typescript
function resolveProjectPath(projectId: string, relativePath: string): string {
return `${APP_LOCAL_DATA_DIR}/projects/${projectId}/${relativePath}`;
}
```
3. 新增「项目包导出/导入」功能:将 `meta.json` + `segments.json` + `assets/` + `videos/` 打包为 `.zip`
---
### 4. 磁盘空间不足时合成成果直接丢失
**业务场景**
1. 用户 Mac 剩余空间 2GB
2. 合成一个 1.5GB 的视频,临时文件 + 输出文件刚好占满磁盘
3. FFmpeg 合成成功,但 `std::fs::copy` 移动最终文件时因磁盘满失败
4. 临时文件被清理,用户一无所获
**实际代码**
```rust
// video_processing.rs:93
std::fs::rename(&final_output, output_path)
.or_else(|_| {
std::fs::copy(&final_output, output_path)
.and_then(|_| std::fs::remove_file(&final_output))
})
```
`rename` 跨卷时失败,`copy` 在磁盘满时失败,临时文件在 `Drop` 或后续清理中被删除。
**用户实际看到**
- 进度条走到 100%,显示「正在保存...」
- 突然报错「移动最终视频失败」
- 数分钟的等待 + 积分消耗,结果什么都没有
**影响评估**
- 极端挫败感:用户最高预期时刻("马上完成了")直接失败
- 积分和时间双重浪费
**修复建议**
1. 合成前检查磁盘空间:
```rust
// 在 handleStart 调用前
let required_space = estimate_output_size(video_paths) * 2; // 输出 + 临时文件
let available = fs2::available_space(&output_parent)?;
if available < required_space {
return Err("磁盘空间不足,需要至少 {} GB 可用空间".into());
}
```
2. `copy` 失败时保留临时文件,给用户手动恢复的机会:
```rust
if let Err(e) = std::fs::copy(&final_output, output_path) {
return Err(format!("保存失败(磁盘可能已满)。临时文件保留在: {},错误: {}",
final_output.display(), e));
}
```
---
### 5. 大文件上传/下载全量读内存——低配机器 OOM
**业务场景**
1. 用户生成了一段 10 分钟 1080p 视频,文件大小 500MB
2. 点击「上传」或系统自动上传到七牛云/后端
3. Rust 侧 `std::fs::read(local_path)` 将 500MB 全量读入内存
4. 再复制到 reqwest multipart body,峰值内存占用 >1GB
5. 8GB 内存的 MacBook Air 可能触发系统 OOM,应用被杀死
**实际代码**
```rust
// Rust 侧 upload_video_file / upload_audio_file
let file_bytes = match std::fs::read(local_path) { ... };
let form = reqwest::multipart::Form::new()
.part("file", reqwest::multipart::Part::bytes(file_bytes) ...);
```
```rust
// Rust 侧 download_file
let client = reqwest::Client::new(); // 默认无超时
let bytes = match response.bytes().await { ... }; // 全量入内存
std::fs::write(&safe_output, &bytes);
```
**用户实际看到**
- 上传/下载大文件时应用突然消失(被系统杀死)
- 或进度条卡住很久,没有任何反馈
- 重启后需要重新开始整个流程
**影响评估**
- 长视频用户(核心目标用户群)完全无法使用
- 应用稳定性差,低配置设备体验极差
**修复建议**
上传改用流式:
```rust
use tokio::fs::File;
use tokio::io::AsyncReadExt;
let file = File::open(local_path).await?;
let stream = tokio_util::codec::FramedRead::new(file, tokio_util::codec::BytesCodec::new());
let body = reqwest::Body::wrap_stream(stream);
let part = reqwest::multipart::Part::stream(body)
.file_name(filename)
.mime_str("video/mp4")?;
```
下载改用边下边写 + 超时:
```rust
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.connect_timeout(std::time::Duration::from_secs(30))
.build()?;
let mut response = client.get(url).send().await?;
let mut file = tokio::fs::File::create(&safe_output).await?;
while let Some(chunk) = response.chunk().await? {
tokio::io::copy(&mut chunk.as_ref(), &mut file).await?;
}
```
---
## 三、🟡 中等问题(影响体验或存在数据风险)
### 6. 保存项目失败用户完全不知情——数据丢失风险
**业务场景**
1. 用户在 CoverDesign 页面调整标题,触发自动保存
2. 磁盘已满(或文件被其他程序锁定)
3. `saveMetaToLocalFile` 抛出 IO 错误
4. 错误被 `.catch` 捕获后只 `console.error`**没有任何 UI 提示**
5. 用户继续编辑,关闭应用
6. 重新打开后发现之前的修改全部丢失
**实际代码**
```typescript
// localStorage.ts 中的 safeInvoke 错误处理
try {
const result = await invoke<T>(cmd, args);
return result;
} catch (error) {
console.error(`Tauri IPC 调用失败 [${cmd}]:`, error);
throw error; // 抛给上层
}
// saveMetaToLocalFile 调用链
metaSavePromise = metaSavePromise.then(task).catch(err => {
console.error('保存项目元数据失败:', err);
throw err; // 继续抛出,但无人处理
});
```
**用户实际看到**
- 没有任何错误提示
- 下次打开项目时数据回到旧状态
- 用户以为是应用 bug,不信任自动保存功能
**修复建议**
`saveMetaToLocalFile` 的 catch 中增加用户可见提示:
```typescript
metaSavePromise = metaSavePromise.then(task).catch(err => {
console.error('保存项目元数据失败:', err);
const message = err instanceof Error ? err.message : String(err);
if (message.includes('磁盘') || message.includes('space') || message.includes('No space')) {
toast.error('项目保存失败:磁盘空间不足,请清理后重试');
} else {
toast.error('项目保存失败,请检查文件权限或重启应用');
}
throw err;
});
```
---
### 7. 配音分段失败静默继续——"部分缺失"的配音
**业务场景**
1. 用户生成 10 段配音,每段对应一个分镜
2. 第 3 段 `extractAudioSegment``uploadAudioFile` 失败(网络抖动、文件被占用)
3. 错误被 catch 后只 `console.error`,循环继续
4. 最终提示「配音合成完成」
5. 用户导出视频后发现第 3 分镜没有声音
**实际代码**
```typescript
// VoiceSynthesis.tsx:243-245(近似逻辑)
for (const segment of segments) {
try {
await extractAudioSegment(...);
await uploadAudioFile(...);
} catch (err) {
console.error('分段处理失败:', err); // ❌ 静默吞掉
// 循环继续...
}
}
toast.success('配音合成完成');
```
**用户实际看到**
- 提示「配音合成完成」✅
- 导出视频后发现部分片段无声 ❌
- 无法定位是哪一段出了问题
**修复建议**
```typescript
const failedSegments: string[] = [];
for (const segment of segments) {
try {
await extractAudioSegment(...);
await uploadAudioFile(...);
} catch (err) {
console.error('分段处理失败:', err);
failedSegments.push(segment.id);
// 继续处理其他段,但记录失败
}
}
if (failedSegments.length > 0) {
toast.warn(`配音合成部分完成,第 ${failedSegments.join(', ')} 段处理失败,请检查网络后重试`);
} else {
toast.success('配音合成完成');
}
```
---
### 8. 轮询任务状态遇到网络闪断直接失败——长任务前功尽弃
**业务场景**
1. 用户提交 Vidu 视频生成任务,进入轮询等待
2. 轮询 3 分钟后,用户 WiFi 短暂断开 5 秒
3. `getTaskStatus` 抛出网络错误
4. `while` 循环无内部 try-catch,整个函数抛出异常
5. 前端提示「视频生成失败」
6. 实际上后端任务仍在执行,用户需重新提交并再次等待
**实际代码**
```typescript
// useVideoGeneration.ts / ScriptCreation.tsx 等处的轮询逻辑
while (status === 'pending' || status === 'running') {
await new Promise(resolve => setTimeout(resolve, pollInterval));
const resp = await taskApi.getTaskStatus(taskId); // ❌ 无 try-catch
status = resp.status;
}
```
**用户实际看到**
- 等待数分钟后突然报错「失败」
- 重新提交后又需等待同样长的时间
- 后端实际上可能已经完成了任务,但前端放弃了
**修复建议**
```typescript
let consecutiveErrors = 0;
const MAX_CONSECUTIVE_ERRORS = 3;
while (status === 'pending' || status === 'running') {
await new Promise(resolve => setTimeout(resolve, pollInterval));
try {
const resp = await taskApi.getTaskStatus(taskId);
status = resp.status;
consecutiveErrors = 0;
} catch (err) {
consecutiveErrors++;
console.warn(`轮询失败 (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}):`, err);
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
throw new Error('网络异常,视频状态获取失败,请稍后重试');
}
// 继续轮询,给用户一个恢复的机会
}
}
```
---
### 9. 余额获取失败 = 误判为余额不足——有积分却被阻止
**业务场景**
1. 用户打开应用,网络较差
2. `fetchBalance` 调用失败(`console.error` 后静默)
3. `balance` 保持默认值 `0`
4. 用户点击「合成视频」
5. 预检:`currentBalance < requiredPoints``0 < 5`**阻止**
6. 用户明明有积分,却无法使用
**实际代码**
```typescript
// pointStore.ts
fetchBalance: async () => {
try {
const data = await pointsApi.getBalance();
set({ balance: data.balance, rules: data.rules });
} catch (e) {
console.error('获取积分余额失败:', e); // ❌ 静默失败
// balance 保持旧值或 0
}
},
```
**用户实际看到**
- 点击合成按钮后弹出「积分不足」充值弹窗
- 用户去「我的」页面查看,发现余额显示为 0 或旧值
- 刷新页面后余额恢复正常
**修复建议**
```typescript
fetchBalance: async () => {
try {
const data = await pointsApi.getBalance();
set({ balance: data.balance, rules: data.rules, balanceError: null });
} catch (e) {
console.error('获取积分余额失败:', e);
set({ balanceError: '获取余额失败,请检查网络' });
// balance 保持旧值,不要变成 0
}
},
// 预检时
if (balanceError) {
// 无法确认余额,允许操作但提示风险
toast.warn('余额获取失败,将尝试扣费,若余额不足会提示充值');
return true;
}
```
---
### 10. 多应用实例并发修改导致数据覆盖
**业务场景**
1. 用户双击应用图标,意外打开两个窗口(或命令行启动第二个实例)
2. 实例 A 在 CoverDesign 修改标题为「现代简约风」
3. 实例 B 在 ScriptCreation 修改主题为「奶油风」
4. 两个实例同时点击保存
5. 实例 A 的保存覆盖了实例 B 的修改(或反之)
**根因分析**
`saveMetaToLocalFile` 使用 Read-Modify-Write 模式:
1.`meta.json`
2. 内存合并
3.`meta.json`(带文件锁,保证单写)
但文件锁只保护"写"操作,两个实例可以同时读取同一个文件,然后各自基于旧版本修改并写入,导致后写入的覆盖前者。
**用户实际看到**
- 在一个窗口里明明保存了修改
- 切到另一个窗口再切回来,发现修改消失了
- 用户以为是应用不稳定
**修复建议**
1. **应用层单实例锁**:启动时检查是否已有实例在运行
```rust
// main.rs
let single = single_instance::SingleInstance::new("cn.meijiaka.ai-zy").unwrap();
if !single.is_single() {
// 已有实例,唤起旧实例窗口并退出
return;
}
```
2. **或文件锁扩展为读写锁**:读取时也加共享锁,防止并发读-改-写
---
### 11. BGM 预览硬编码开发者路径——正式包无法预览系统 BGM
**业务场景**
1. 用户安装正式版应用
2. 进入 BGM 选择弹窗
3. 点击任意系统 BGM 的试听按钮
4. 无声音,或报错
**实际代码**
```typescript
// VideoCompose.tsx:113
const audioSrc = item.url || (item.filePath ? `/Users/0fun/work/meijiaka-zy/mixkit_bgm/${item.filePath}` : '');
```
`item.url` 为空且 `item.filePath` 存在时,构造的路径是开发者本机绝对路径 `/Users/0fun/...`,正式包用户机器上不存在此目录。
**影响评估**
- 虽然云端化后 `item.url` 应始终有值,但如果 API 返回异常或旧数据残留,会回退到硬编码路径
- 开发环境测试时「正常」的功能,正式包上直接失效
**修复建议**
直接移除硬编码回退,若 `item.url` 为空则禁用试听:
```typescript
const audioSrc = item.url;
if (!audioSrc) {
toast.warn('该音乐暂无可用的试听链接');
return;
}
```
---
### 12. 封面 Fabric.js 跨域加载失败无用户提示
**业务场景**
1. 用户选择一张网络图片作为封面背景
2. 该图片服务器未配置 CORS 头
3. `image.crossOrigin = 'anonymous'` 加载失败
4. `useCoverFabric.ts` 中 catch 静默吞掉错误
5. Canvas 上背景为空白,用户不知道为什么
**实际代码**
```typescript
// useCoverFabric.ts:192-196
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = (e) => reject(e);
image.src = imagePath;
// ...
} catch {
// no-op: 背景图加载失败已在内部处理
}
```
**用户实际看到**
- 选了背景图,但 Canvas 预览为纯色背景
- 不知道是因为图片跨域、链接失效还是其他原因
**修复建议**
在 catch 中区分错误类型并提示:
```typescript
} catch (err) {
console.error('封面背景加载失败:', err);
if (imagePath.startsWith('http')) {
toast.error('封面图片加载失败,可能是跨域限制或链接失效,请尝试本地上传');
} else {
toast.error('封面图片加载失败,文件可能已被移动或删除');
}
}
```
---
## 四、🟢 低风险/建议(5 项)
### 13. 自动更新后无数据迁移逻辑
**业务场景**
1. v1.6.0 用户自动更新到 v1.7.0
2. v1.7.0 新增了某个必填字段(如 `videoCodec`
3. 旧项目加载后该字段为 `undefined`
4. 如果新功能直接读取此字段不做防御,可能崩溃
**现状**
- `migrateMeta` 只处理了 `v0 → v1`(添加 `version` 字段)
- 注释预留了 `v1 → v2` 的扩展点,但无实际实现
- Tauri updater 安装后只是重启应用,不触发任何数据迁移
**建议**
在应用启动时(`bootstrap``App.tsx` useEffect)增加一次性的全局迁移检查:
```typescript
async function runGlobalMigrations() {
const appVersion = await getVersion();
const lastMigratedVersion = localStorage.getItem('last_migrated_version');
if (lastMigratedVersion === appVersion) return;
// 遍历所有本地项目,执行迁移
const projects = await localProjectApi.listProjects();
for (const project of projects) {
const meta = await localProjectApi.loadMeta(project.id);
if (meta) {
const migrated = migrateMeta(meta); // 扩展此函数
await localProjectApi.saveMeta(project.id, migrated);
}
}
localStorage.setItem('last_migrated_version', appVersion);
}
```
---
### 14. 旧字段删除无运行时降级处理
**现状**
Git 历史中有多个字段被删除/重命名:
- `subtitlePreset``captionPreset`
- `dubbingVoiceId``selectedVoiceId`
- `selectedHumanId` / `selectedElementId` 被移除
- `caption``mainTitle`CoverDesign 中有 fallback
**当前行为**
旧项目加载后,旧字段保留在 `meta.json` 中但被忽略,对应功能降级为默认状态。对用户来说,打开旧项目后发现某些设置"复位"了,但不明白为什么。
**建议**
`migrateMeta` 中增加字段映射:
```typescript
function migrateMeta(raw: Record<string, unknown>): Partial<ProjectMeta> {
// v0 → v1
if ((raw.version as number) < 1) {
raw.version = 1;
}
// 字段重命名映射
if (raw.subtitlePreset && !raw.captionPreset) {
raw.captionPreset = raw.subtitlePreset;
delete raw.subtitlePreset;
}
if (raw.dubbingVoiceId && !raw.selectedVoiceId) {
raw.selectedVoiceId = raw.dubbingVoiceId;
delete raw.dubbingVoiceId;
}
return raw as Partial<ProjectMeta>;
}
```
---
## 五、按业务维度汇总
| 业务维度 | 问题编号 | 核心风险 | 用户感知 |
|----------|----------|----------|----------|
| **BGM/音频** | 1, 11 | 合成无声、预览失效 | "为什么选了音乐却没有声音" |
| **积分/资金** | 2, 9 | 白嫖可能、误判余额不足 | "明明有积分却说不让用" |
| **数据持久化** | 3, 6, 10, 13, 14 | 换机报废、保存失败无感知、多实例覆盖 | "修改保存后怎么没了" |
| **视频合成** | 4, 5, 7, 8 | 磁盘满丢失、OOM、分段缺失、长任务闪断 | "等了5分钟结果什么都没有" |
| **封面/视觉** | 12 | 跨域图片加载失败无提示 | "选了图片但封面是空的" |
---
## 六、修复优先级(按业务影响排序)
### P0(立即修复,影响核心功能或资金)
1. **#1 BGM 混音链路缺口**:混音前下载 URL 到本地缓存
2. **#2 积分 TOCTOU**:扣费失败时清理已生成文件,或推动后端预占机制
3. **#5 大文件 OOM**:上传/下载改用流式传输
### P1(本轮迭代修复,影响体验)
4. **#4 磁盘满保护**:合成前检查空间,`copy` 失败保留临时文件
5. **#6 保存失败无提示**`saveMetaToLocalFile` 错误 toast 提示
6. **#7 分段配音失败静默**:记录失败段并提示用户
7. **#8 轮询闪断**:增加网络错误容忍和重试
8. **#9 余额误判**:余额获取失败时不阻断用户
### P2(后续排期,架构改进)
9. **#3 项目跨设备迁移**:路径相对化 + 导出/导入功能
10. **#10 多实例并发**:应用层单实例锁
11. **#11 BGM 预览硬编码**:移除开发者路径
12. **#12 封面跨域提示**:增加错误提示
13. **#13 自动更新迁移**:全局迁移框架
14. **#14 旧字段映射**`migrateMeta` 扩展
+676
View File
@@ -0,0 +1,676 @@
# 前端系统兼容性审查报告
> 审查范围:`tauri-app/src` 全部源码 + `tauri-app/src-tauri/src` Rust 层命令
> 审查维度:跨平台(macOS/Windows)、Tauri API、媒体/音频、CSS、网络、文件系统
> 审查日期:2026-05-21
---
## 一、综述
本次审查共发现 **28 项兼容性问题**,其中:
| 级别 | 数量 | 说明 |
|------|------|------|
| 🔴 严重 | 6 | 可能导致功能失效、安全漏洞或数据损坏 |
| 🟡 中等 | 11 | 潜在风险,特定场景下会触发问题 |
| 🟢 低风险 | 11 | 建议优化,影响较小或仅存在于边缘场景 |
**关键结论**
1. **Windows 路径处理是最大隐患**:多处 Rust 代码对 Windows 路径的反斜杠、大小写、UNC 前缀处理不完善,可能导致 FFmpeg 调用失败或安全检查被绕过。
2. **前端内存泄漏已确认 1 处**`CoverDesign.tsx``URL.createObjectURL` 未释放。
3. **Asset Protocol 过度授权**`tauri.conf.json``"scope": "/**"` 允许 WebView 读取整个文件系统。
4. **CSS/Web API 兼容性良好**:项目运行在 Tauri 封装的 WebViewEdge/WebKit)中,现代 CSS 特性和 Web API 支持度较高,未发现严重兼容性问题。
---
## 二、🔴 严重问题(6 项)
### 1. `URL.createObjectURL` 内存泄漏 — 背景图上传
**位置**`tauri-app/src/pages/VideoCreation/CoverDesign.tsx:181`
```typescript
const url = URL.createObjectURL(file);
setConfig(prev => ({ ...prev, backgroundImage: url }));
```
**问题**:本地上传背景图时创建 Blob URL,但**从未调用 `URL.revokeObjectURL(url)`**。用户多次上传不同背景图时,旧的 Blob URL 会一直占用内存,直到页面刷新。
**影响**:内存泄漏,长时间使用后可能导致应用卡顿或崩溃。
**修复建议**
```typescript
const prevUrl = config.backgroundImage;
const url = URL.createObjectURL(file);
setConfig(prev => ({ ...prev, backgroundImage: url }));
// 释放旧的 Blob URL
if (prevUrl?.startsWith('blob:')) {
URL.revokeObjectURL(prevUrl);
}
// 组件卸载时也要清理
useEffect(() => {
return () => {
if (config.backgroundImage?.startsWith('blob:')) {
URL.revokeObjectURL(config.backgroundImage);
}
};
}, []);
```
---
### 2. Windows 敏感路径检查大小写不敏感问题
**位置**`tauri-app/src-tauri/src/commands/file.rs:47`
```rust
let windows_denied = vec![
r"c:\windows\",
r"c:\program files\",
r"c:\program files (x86)\",
r"c:\users\all users\",
];
```
**问题**Windows 文件系统(NTFS)是**大小写保留但大小写不敏感**的。用户传入 `C:\Windows\``C:\WINDOWS\` 会**完全绕过**上述安全检查。
**影响**:攻击者可通过大小写变体访问系统敏感目录。
**修复建议**
```rust
let path_lower = path.to_lowercase();
let windows_denied = vec![
r"c:\windows\",
r"c:\program files\",
r"c:\program files (x86)\",
r"c:\users\all users\",
];
for denied in &windows_denied {
if path_lower.starts_with(denied) {
return Err(...);
}
}
```
---
### 3. Asset Protocol 范围过度授权
**位置**`tauri-app/src-tauri/tauri.conf.json`
```json
"assetProtocol": {
"enable": true,
"scope": ["$APPLOCALDATA/**", "$APPDATA/**", "$APPCONFIG/**", "/**"]
}
```
**问题**`/**` 允许 WebView 通过 `asset://` 协议读取**整个文件系统的任何文件**。这意味着前端 JavaScript 可以构造 URL 访问用户的任何本地文件(如 `asset:///etc/passwd``asset://C:/Users/xxx/Documents/`)。
**影响**:严重安全漏洞。即使需要配合路径遍历,也极大扩大了攻击面。
**修复建议**:移除 `/**`,仅保留应用数据目录:
```json
"assetProtocol": {
"enable": true,
"scope": ["$APPLOCALDATA/**", "$APPDATA/**", "$APPCONFIG/**"]
}
```
> 注:如果确有需要访问用户选择的文件,应通过 Tauri Dialog API 让用户主动选择,而非开放全局文件系统。
---
### 4. `escape_ffmpeg_path` 不支持 Windows 路径格式
**位置**`tauri-app/src-tauri/src/ffmpeg_cmd.rs:23`
```rust
fn escape_ffmpeg_path(path: &str) -> String {
path.replace("'", "'\\''")
}
```
**问题**:该函数仅转义单引号,但**不处理 Windows 反斜杠 `\` 和盘符冒号 `:`**。在 FFmpeg 的 `ass='{}':fontsdir='{}'` filter 语法或 concat demuxer 的 `file 'path'` 格式中,Windows 路径如 `C:\Users\name\video.mp4` 中的反斜杠可能被 FFmpeg 解析为转义序列。
**影响**:Windows 用户在字幕压制、字体加载、视频合成时,FFmpeg 可能因路径解析错误而失败。
**修复建议**
```rust
fn escape_ffmpeg_path(path: &str) -> String {
// 1. 统一使用正斜杠(FFmpeg 支持跨平台路径分隔符)
let normalized = path.replace('\\', "/");
// 2. 转义单引号(用于 FFmpeg filter 语法中的引号包裹)
normalized.replace("'", "'\\''")
}
```
> 注意:Windows 上 `C:/Users/...` 这种正斜杠路径 FFmpeg 完全支持,这是最简单的跨平台方案。
---
### 5. `canonicalize()` 在 Windows 上返回 UNC 路径导致下游问题
**位置**:多处使用 `std::fs::canonicalize`
- `tauri-app/src-tauri/src/commands/product.rs:198,258,345`
- `tauri-app/src-tauri/src/commands/project.rs:124,140`
- `tauri-app/src-tauri/src/ffmpeg_cmd.rs:46,792`
**问题**:在 Windows 上,`std::fs::canonicalize()` 返回 UNC 路径格式 `\\?\C:\Users\...`。这种路径格式:
1. **FFmpeg 某些版本不支持**,可能导致命令执行失败
2. **与 `starts_with` 比较时行为异常**,如果比较路径不是 UNC 格式
3. **序列化到 JSON 传给前端时**,前端可能无法正确理解这种路径
**影响**:Windows 上的文件校验、路径比较、FFmpeg 调用可能全部受影响。
**修复建议**:封装一个跨平台的 `normalize_path` 函数,替代 `canonicalize`
```rust
use std::path::{Path, PathBuf};
fn normalize_path(path: &Path) -> PathBuf {
// 使用 dunce::simplified() 消除 UNC 前缀,同时保持路径有效性
dunce::simplified(path).to_path_buf()
}
```
> 需要添加 `dune` crate 依赖,这是 Rust 社区处理 UNC 路径的标准方案。
---
### 6. `atob()` 解析 JWT 存在 base64url 兼容性问题
**位置**`tauri-app/src/api/client.ts:116`
```typescript
const payload = JSON.parse(atob(token.split('.')[1]));
```
**问题**JWT 使用 **base64url** 编码(将 `+``-``/``_`,去掉 padding `=`),而 `atob()` 是标准 **base64** 解码器。如果 JWT payload 中包含 `-``_` 或需要 padding 的字符,`atob()` 会抛出 `DOMException`
**当前影响有限**:因为 `exp` 字段通常是纯数字时间戳,但理论上如果用户 ID 或其他 claim 包含这些字符就会失败。
**修复建议**
```typescript
function base64UrlDecode(str: string): string {
// base64url → base64
let padding = '';
const padLen = 4 - (str.length % 4);
if (padLen !== 4) {
padding = '='.repeat(padLen);
}
const base64 = str.replace(/-/g, '+').replace(/_/g, '/') + padding;
return atob(base64);
}
// 使用
const payload = JSON.parse(base64UrlDecode(token.split('.')[1]));
```
---
## 三、🟡 中等问题(11 项)
### 7. `crossOrigin = 'anonymous'` 跨域图片污染 Canvas
**位置**
- `tauri-app/src/hooks/useCoverFabric.ts:192,235`
```typescript
image.crossOrigin = 'anonymous';
image.src = imagePath;
```
**问题**:当加载远程 HTTP(S) 图片时,如果服务器未配置 `Access-Control-Allow-Origin` 响应头,Canvas 会被**污染(tainted**。被污染的 Canvas 调用 `toDataURL()` 会抛出 `SecurityError: The canvas has been tainted by cross-origin data`
当前代码用 try-catch 静默吞掉了错误,用户会看到空白封面,但不知道原因。
**修复建议**:捕获错误并向用户提示:
```typescript
try {
// ...加载图片...
} catch (err) {
console.error('封面图片加载失败:', err);
toast.error('封面图片加载失败,可能是跨域限制或图片链接失效');
}
```
---
### 8. `requestAnimationFrame` + Video 字幕同步在后台标签页节流
**位置**`tauri-app/src/hooks/useCanvasSubtitleRenderer.ts:158-168`
```typescript
const onFrame = () => {
drawFrame();
if (!video.paused) {
rafRef.current = requestAnimationFrame(onFrame);
}
};
```
**问题**:当应用窗口不在前台或标签页在后台时,浏览器会**节流 `requestAnimationFrame`**(通常降到 1fps 或完全暂停)。这会导致 Canvas 字幕与视频画面不同步。
**影响**:用户切出应用再切回时,字幕可能短暂错位。
**修复建议**:使用 `video.requestVideoFrameCallback()`(如果支持)作为更精确的同步机制,或在 `visibilitychange` 事件触发时强制重绘:
```typescript
useEffect(() => {
const onVisibilityChange = () => {
if (!document.hidden) drawFrame();
};
document.addEventListener('visibilitychange', onVisibilityChange);
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
}, [drawFrame]);
```
---
### 9. `video` 控制条高度硬编码导致字幕定位偏移
**位置**`tauri-app/src/hooks/useCanvasSubtitleRenderer.ts:86`
```typescript
const VIDEO_CONTROLS_HEIGHT = 40;
```
**问题**`<video controls>` 的控制条高度在不同浏览器/OS 上不同(macOS Safari 约 30pxWindows 约 40-50px,全屏模式约 0px)。硬编码 40px 会导致字幕在预览时的垂直位置与压制输出不完全一致。
**修复建议**:在视频元数据加载后动态计算:
```typescript
const video = videoRef.current;
if (video) {
const rect = video.getBoundingClientRect();
const videoRect = video.videoWidth / video.videoHeight;
// 实际视频画面高度 = 容器宽度 / 宽高比
const actualVideoHeight = rect.width / videoRect;
const controlsHeight = rect.height - actualVideoHeight;
}
```
---
### 10. `audio.duration` 可能返回 `NaN`/`Infinity` 未处理
**位置**
- `tauri-app/src/pages/VideoCreation/VoiceSynthesis.tsx:360-367`
- `tauri-app/src/pages/VideoGeneration/hooks/useVideoGeneration.ts:371-378`
- `tauri-app/src/pages/ContentManagement/VoiceMaterialLibrary.tsx:163-178`
```typescript
audio.onloadedmetadata = () => {
clearTimeout(timeoutId);
resolve(audio.duration);
};
```
**问题**:如果音频文件损坏、格式不支持或元数据缺失,`audio.duration` 可能返回 `NaN``Infinity`。直接 resolve 这个值会导致下游计算错误。
**修复建议**
```typescript
audio.onloadedmetadata = () => {
clearTimeout(timeoutId);
if (!isFinite(audio.duration) || audio.duration <= 0) {
reject(new Error('音频时长无效,文件可能损坏或格式不支持'));
} else {
resolve(audio.duration);
}
};
```
---
### 11. `rename` 在 Windows 上目标已存在时失败
**位置**
- `tauri-app/src-tauri/src/video_processing.rs:93`
- `tauri-app/src-tauri/src/commands/video_compose.rs:222`
**问题**`std::fs::rename()` 在 Windows 上**如果目标文件已存在会直接失败**(Unix 是原子替换)。代码虽然有 copy 回退,但逻辑可能留下残留文件。
**影响**:Windows 上如果输出路径已存在(如用户重复合成),操作可能失败或留下临时文件。
**修复建议**:在 `rename` 前先删除目标文件(如果存在):
```rust
if output_path.exists() {
std::fs::remove_file(&output_path)?;
}
std::fs::rename(&temp_output, &output_path)?;
```
---
### 12. `to_str().unwrap()` 在非 UTF-8 路径上会 panic
**位置**`tauri-app/src-tauri/src/commands/video_compose.rs:80`
```rust
concat_videos_copy(&app, list_path.to_str().unwrap(), ...)
```
**问题**Windows 允许非 UTF-8 编码的文件路径(历史 OEM code page 文件)。`to_str()` 返回 `None``unwrap()` 会**直接 panic**。
**影响**:极少数 Windows 用户(使用中文 Windows 95/XP 时代遗留文件系统编码)可能导致应用崩溃。
**修复建议**:使用 `to_string_lossy()``as_os_str()` 传递路径:
```rust
// 如果需要传给 FFmpeg,使用 to_string_lossy()
let path_str = list_path.to_string_lossy();
```
---
### 13. `get_fonts_dir` 开发模式路径探测在 Windows 上可能失效
**位置**`tauri-app/src-tauri/src/ffmpeg_cmd.rs:298-328`
```rust
cwd.join("fonts"),
parent.join("src-tauri/fonts"),
grandparent.join("tauri-app/src-tauri/fonts"),
```
**问题**:开发模式下的字体目录探测使用 `/` 路径拼接。虽然 `PathBuf::join` 会处理分隔符,但如果开发时的**当前工作目录**与预期不同(如从 IDE 以不同路径启动),探测会失败。
**影响**:开发环境下 Windows 开发者可能遇到字体加载失败。
**修复建议**:添加环境变量覆盖或更健壮的探测逻辑:
```rust
// 优先从环境变量读取
if let Ok(font_dir) = std::env::var("MEIJIAKA_FONTS_DIR") {
let p = PathBuf::from(font_dir);
if p.exists() { return Some(p); }
}
```
---
### 14. `concat` demuxer 列表中的 Windows 反斜杠问题
**位置**`tauri-app/src-tauri/src/commands/video_compose.rs:65`
```rust
format!("file '{}'\n", ffmpeg_cmd::escape_ffmpeg_path(path))
```
**问题**FFmpeg concat demuxer 的列表文件格式中,`file 'path'` 语法在 Windows 上如果路径包含反斜杠,反斜杠可能被 FFmpeg 解释为转义字符。
**影响**:与问题 #4 类似,Windows 路径导致 FFmpeg 解析错误。
**修复建议**:在写入 concat 列表前统一将路径中的 `\` 替换为 `/`
```rust
let normalized = path.replace('\\', "/");
format!("file '{}'\n", normalized)
```
---
### 15. `load_app_config` 失败时无降级配置
**位置**`tauri-app/src/main.tsx:10-16`
```typescript
try {
const config = await loadAppConfig();
appEnvironment = config.environment;
} catch {
// 加载失败时默认为生产模式
}
```
**问题**:如果 `load_app_config`(Tauri IPC 调用)失败,应用降级为生产模式。这是合理的,但**生产模式会禁用右键菜单和 F12 DevTools**。开发者在调试时如果 IPC 调用失败,会突然失去所有调试能力,且不知道原因。
**修复建议**:在降级时输出警告日志:
```typescript
} catch (e) {
console.warn('[bootstrap] 加载应用配置失败,降级为生产模式:', e);
appEnvironment = 'production';
}
```
---
### 16. `window.location.reload()` 在 Tauri 中行为不确定
**位置**`tauri-app/src/pages/Settings/Settings.tsx:178`
```typescript
setTimeout(() => { window.location.reload(); }, 500);
```
**问题**Tauri 应用中的 `window.location.reload()` 行为与浏览器不同。在某些 Tauri 版本中可能导致:
- 白屏而非正常刷新
- WebView 进程崩溃
- 状态丢失但窗口不重新加载
**修复建议**:使用 Tauri 的 `relaunch()` 命令重启整个应用,或重新挂载 React 根组件:
```typescript
import { relaunch } from '@tauri-apps/plugin-process';
// 重启应用
await relaunch();
```
---
### 17. `Math.random()` 用于缓存清除参数(安全性)
**位置**`tauri-app/src/api/client.ts:334`
```typescript
const cacheBuster = `_t=${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
```
**问题**`Math.random()` 不是加密安全的随机数生成器。虽然这里只是用于缓存清除,但如果未来用于其他安全相关场景会有风险。
**修复建议**:使用 `crypto.randomUUID()``crypto.getRandomValues()`
```typescript
const cacheBuster = `_t=${Date.now()}_${crypto.randomUUID().slice(0, 8)}`;
```
---
## 四、🟢 低风险/建议(11 项)
### 18. `backdrop-filter` 无标准前缀回退
**位置**`tauri-app/src/pages/VideoCreation/CoverDesign.css:444-445`
```css
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
```
**评估**:当前代码同时有标准和 WebKit 前缀版本,在 Tauri WebViewEdge/WebKit)中支持良好。Firefox 不支持,但项目不面向 Firefox。**无需修复**。
---
### 19. `::-webkit-scrollbar` 在 Firefox 中无效
**位置**:多处(`global.css``CoverDesign.css` 等)
**评估**:项目运行在 Tauri WebView 中(基于系统浏览器引擎),不是 Firefox。在 Windows 上基于 WebView2Edge),macOS 上基于 WKWebViewSafari),均支持 WebKit 滚动条样式。**无需修复**。
---
### 20. `aspect-ratio` 在旧版 Safari 中可能不支持
**位置**:多处使用 `aspect-ratio: 9 / 16`
**评估**macOS 12+ 的 Safari 支持 `aspect-ratio`。如果目标用户可能使用较旧的 macOS 版本,可能需要 `padding-top: 177.77%` 回退。但鉴于这是 Tauri 桌面应用,可以控制最低系统版本。**建议确认 `tauri.conf.json``macOS.minimumSystemVersion` 是否要求 12.0+**。
---
### 21. `requestIdleCallback` 缺失回退不完整
**位置**`tauri-app/src/main.tsx:75-79`
```typescript
if ('requestIdleCallback' in window) {
requestIdleCallback(showWindow, { timeout: 500 });
} else {
setTimeout(showWindow, 100);
}
```
**评估**Tauri WebView2Edge)和 WKWebViewSafari)均支持 `requestIdleCallback`。回退逻辑也已实现。**无需修复**。
---
### 22. `navigator.userAgent` 已被冻结
**位置**
- `tauri-app/src/store/authStore.ts:254`
- `tauri-app/src/api/client.ts:259`
**评估**:虽然现代浏览器正在限制 `navigator.userAgent`,但 Tauri WebView 不受此限制。且当前用法仅为日志和登录信息上报,不影响功能。**无需修复**。
---
### 23. `document.fonts.check()` 参数格式兼容性
**位置**`tauri-app/src/utils/canvasSubtitleDrawer.ts:209`
```typescript
if (document.fonts.check(`bold 16px ${fontName}`)) {
```
**评估**`document.fonts.check()` 的参数格式在不同浏览器中实现有细微差异,但 Tauri WebView2/WKWebView 均支持此用法。**风险极低**。
---
### 24. `Date.now()` 连续调用可能冲突
**位置**:多处使用 `Date.now()` 生成文件名
**评估**:仅在极快速连续调用时(<1ms)可能冲突。当前场景下不太可能。**风险极低**。
---
### 25. `autoPlay` 视频可能被浏览器阻止
**位置**:多处 `<video autoPlay>`
**评估**:桌面应用中的 WebView 通常不受浏览器自动播放策略限制。但如果用户操作系统设置了辅助功能限制,仍可能被阻止。**建议添加 `muted` 属性作为后备**(如果需要自动播放且带声音)。
---
### 26. `file.path` 是非标准 Chromium 属性
**位置**`tauri-app/src/pages/VideoCreation/CoverDesign.tsx:178`
```typescript
const path = (file as any).path || (file as any).webkitRelativePath || '';
```
**评估**`File.path` 是 Chromium 的私有属性,在标准浏览器(Firefox)中不存在。但由于项目运行在 TauriChromium/WebView2)中,这**当前是可行的**。但如果未来需要支持 Web 端部署,需要改用 Tauri Dialog API 获取路径。**建议添加注释说明此依赖**。
---
### 27. `storage/engine.rs` 无 Windows 文件权限设置
**位置**`tauri-app/src-tauri/src/storage/engine.rs:161`
```rust
#[cfg(unix)]
fn set_restrictive_permissions(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(path, perms)?;
Ok(())
}
```
**评估**Unix 上设置了 0o600 权限,Windows 上跳过。Windows 上文件默认对用户可读可写,其他用户也可读(取决于 ACL)。虽然这不是严重安全问题(应用数据存储在用户目录),但**建议在 Windows 上设置等效的 ACL 限制**。
**修复建议**
```rust
#[cfg(windows)]
fn set_restrictive_permissions(path: &Path) -> Result<()> {
// 使用 windows crate 或 fs_extra 设置 ACL
// 简化为仅当前用户可读写
// 这是可选优化,优先级低
Ok(())
}
```
---
### 28. `Slider.css` 和 `CoverDesign.css` 中 `appearance` 重复声明
**位置**
- `tauri-app/src/components/Slider/Slider.css:32-33`
- `tauri-app/src/pages/VideoCreation/CoverDesign.css:79-80`
```css
appearance: none;
appearance: none;
```
**评估**:纯代码质量问题,不影响兼容性。**建议移除重复行**。
---
## 五、按维度汇总表
| 维度 | 严重 | 中等 | 低风险 | 主要文件 |
|------|------|------|--------|----------|
| **内存/资源管理** | 1 (#1) | 1 (#10) | 1 (#24) | CoverDesign.tsx, VoiceSynthesis.tsx |
| **Windows 路径** | 2 (#4, #5) | 3 (#11, #12, #14) | 1 (#27) | ffmpeg_cmd.rs, file.rs, product.rs |
| **安全检查** | 2 (#2, #3) | 0 | 0 | file.rs, tauri.conf.json |
| **Canvas/媒体** | 0 | 3 (#7, #8, #9) | 2 (#20, #25) | useCoverFabric.ts, useCanvasSubtitleRenderer.ts |
| **网络/API** | 1 (#6) | 1 (#17) | 2 (#22, #23) | client.ts |
| **Tauri 原生** | 0 | 1 (#16) | 1 (#26) | Settings.tsx, CoverDesign.tsx |
| **CSS** | 0 | 0 | 3 (#18, #19, #28) | CoverDesign.css, global.css |
| **启动/配置** | 0 | 1 (#15) | 1 (#21) | main.tsx |
| **字体加载** | 0 | 1 (#13) | 0 | ffmpeg_cmd.rs |
---
## 六、修复优先级建议
### 立即修复(影响功能/安全)
1. **#3 Asset Protocol 过度授权** — 安全漏洞,一行配置修改
2. **#2 Windows 敏感路径大小写** — 安全检查被绕过
3. **#1 URL.createObjectURL 泄漏** — 内存泄漏,用户可见
4. **#4 escape_ffmpeg_path Windows 支持** — Windows 功能失效
5. **#5 canonicalize() UNC 路径** — Windows 文件操作异常
### 本轮迭代修复(影响体验)
6. **#6 atob() base64url 兼容性** — Token 解析潜在失败
7. **#7 crossOrigin 图片污染提示** — 用户友好性
8. **#8 RAF 后台节流** — 字幕同步
9. **#10 audio.duration NaN 处理** — 音频处理健壮性
10. **#9 控制条高度硬编码** — 预览准确性
11. **#11 Windows rename 已存在** — 文件操作健壮性
### 后续排期(优化/边缘场景)
12-28. 其余低风险项
---
## 七、特别说明:Tauri 环境 vs 浏览器环境的兼容性差异
本项目同时支持两种运行模式:
| 特性 | Tauri 桌面模式 | 浏览器模式(开发调试用) |
|------|---------------|------------------------|
| `invoke()` | ✅ Tauri IPC | ❌ 会 catch 失败 |
| `convertFileSrc()` | ✅ `asset://` | ❌ 会 catch 失败 |
| `localStorage` | ✅ 可用 | ✅ 可用 |
| File 系统 API | ✅ Tauri 插件 | ❌ 不可用 |
| `__TAURI_INTERNALS__` | ✅ 存在 | ❌ 不存在 |
**当前代码对浏览器模式有降级处理**`isTauri()` 检查 + catch 错误),这是好的实践。但以下功能在浏览器模式下完全不可用,需要评估是否影响开发调试:
- 本地视频预览(依赖 `asset://` + FFmpeg 转码)
- 文件保存/导出(依赖 Tauri Dialog
- 项目本地持久化(依赖 Tauri IPC)
- 自动更新(依赖 Tauri Updater
**建议**:在 `README` 或开发文档中明确列出浏览器模式的功能限制,避免开发者困惑。
+336
View File
@@ -0,0 +1,336 @@
# Mixkit 免版权音乐清单(装修行业口播短视频)
> 来源: Mixkit.co(免版税、无需署名、可商用)
> 下载时间: 2026-05-23
> 总计: 129 首 / 616 MB
---
## 分类说明
| 分类 | 适用场景 | 数量 |
|------|----------|------|
| **知识科普** | 装修避坑、材料选择、流程科普 | 66 首 |
| **案例展示** | 完工验收、前后对比、实景展示 | 49 首 |
| **促销活动** | 开业促销、团购活动、限时优惠 | 49 首 |
| **家居生活** | 软装搭配、生活 vlog、温馨家庭 | 54 首 |
| **智能家居** | 全屋智能、现代设计、灯光系统 | 48 首 |
---
## 一、知识科普(专业可信,不抢戏)
| ID | 音乐名 | 艺术家 |
|----|--------|--------|
| 22 | Piano Reflections | Ahjay Stelino |
| 105 | See Line Funk | Alejandro Magaña |
| 113 | House Fest | Alejandro Magaña (A. M.) |
| 114 | Kodama Night Town | Alejandro Magaña (A. M.) |
| 1167 | Close Up | Michael Ramir C. |
| 124 | Techno Fest Vibes | Alejandro Magaña (A. M.) |
| 127 | Valley Sunset | Alejandro Magaña (A. M.) |
| 132 | Hazy After Hours | Alejandro Magaña (A. M.) |
| 134 | Deep Techno Ambience | Alejandro Magaña (A. M.) |
| 138 | Forest Treasure | Alejandro Magaña (A. M.) |
| 139 | Spirit in the Woods | Alejandro Magaña (A. M.) |
| 147 | Spirit in the Woods 2 | Alejandro Magaña (A. M.) |
| 160 | Minimal Emotion | Alejandro Magaña (A. M.) |
| 162 | Minimal Techno 01 | Alejandro Magaña (A. M.) |
| 168 | Staring at the Night Sky | Alejandro Magaña (A. M.) |
| 169 | Zanarkand Forest | Alejandro Magaña (A. M.) |
| 175 | Digital Clouds | Alejandro Magaña (A. M.) |
| 184 | Vastness | Andrew Ev |
| 251 | Ambient | Arulo |
| 292 | Relax Beat | Arulo |
| 324 | Smooth Meditation | Arulo |
| 340 | Nap Time | Arulo |
| 371 | Cat Walk | Arulo |
| 416 | Young Trizzy | Arulo |
| 441 | Meditation | Arulo |
| 443 | Serene View | Arulo |
| 470 | Golden Storm | Diego Nava |
| 471 | Rising Forest | Diego Nava |
| 480 | Curiosity | Diego Nava |
| 493 | Beautiful Dream | Diego Nava |
| 568 | Focus on Yourself | Eugenio Mininni |
| 584 | Rest Now | Eugenio Mininni |
| 593 | Opalescent | Eugenio Mininni |
| 594 | River Flow | Eugenio Mininni |
| 616 | What it Takes | Eugenio Mininni |
| 617 | Wind Leaves | Eugenio Mininni |
| 620 | B.O.R.N | Eugenio Mininni |
| 623 | Deep Urban | Eugenio Mininni |
| 628 | Summer Dream | Eugenio Mininni |
| 629 | Skeeomaver Sound | Eugenio Mininni |
| 633 | Xanthos | Eugenio Mininni |
| 652 | Soul Jazz | Francisco Alvear |
| 664 | Pop One | Francisco Alvear |
| 695 | Pop 05 | Grigoriy Nuzhny |
| 700 | Pop 03 | Grigoriy Nuzhny |
| 713 | Classical 6 | Jonny S. |
| 720 | New Bass 01 | Lily J |
| 726 | Uplifting Bass | Lily J |
| 729 | Pop Track 03 | Lily J |
| 738 | Hip Hop 02 | Lily J |
| 744 | House 02 | Lily J |
| 749 | Relaxation 05 | Lily J |
| 759 | Romantic 05 | Lily J |
| 770 | Autofahren | Mauro Urbina |
| 779 | Oh | Michael Ramir C. |
| 801 | Happy Home | Michael Ramir C. |
| 802 | Here Comes The Train | Michael Ramir C. |
| 804 | I Love You Grandma | Michael Ramir C. |
| 813 | Magical moment | Michael Ramir C. |
| 816 | Please | Michael Ramir C. |
| 821 | At the Playhouse | Michael Ramir C. |
| 832 | I'm Going Home | Michael Ramir C. |
| 834 | It's Love | Michael Ramir C. |
| 837 | Life is a Dream | Michael Ramir C. |
| 839 | Tears of Joy | Michael Ramir C. |
| 840 | That's the Way of Life | Michael Ramir C. |
| 847 | It's April | Michael Ramir C. |
| 852 | Music and Life | Michael Ramir C. |
| 856 | Salty and Sweet | Michael Ramir C. |
| 872 | Gimme that Groove! | Michael Ramir C. |
| 897 | A Very Happy Christmas | Michael Ramir C. |
| 953 | Feel Alive | Michael Ramir C. |
| 963 | Just Keep Walking | Michael Ramir C. |
| 970 | Night Sky Hip Hop | Michael Ramir C. |
| 993 | Finding Myself | Michael Ramir C. |
| 1000 | I Can Hear Your Heartbeat | Michael Ramir C. |
| 1001 | I Do! | Michael Ramir C. |
| 1052 | Baby Yohan | Michael Ramir C. |
| 1081 | We'll Be Okay | Michael Ramir C. |
| 1140 | Funkee Monkeee | Michael Ramir C. |
| 1183 | Karma | Michael Ramir C. |
| 1210 | Can't Get You Off My Mind | Michael Ramir C. |
## 二、案例展示(有成就感、积极)
| ID | 音乐名 | 艺术家 |
|----|--------|--------|
| 3 | Dance with Me | Ahjay Stelino |
| 4 | Delightful | Ahjay Stelino |
| 5 | Feeling Happy | Ahjay Stelino |
| 8 | Jumping Around | Ahjay Stelino |
| 11 | Just Kidding | Ahjay Stelino |
| 12 | Playground Fun | Ahjay Stelino |
| 13 | Summer Fun | Ahjay Stelino |
| 31 | Dreaming Big | Ahjay Stelino |
| 32 | Driving Ambition | Ahjay Stelino |
| 34 | Raising Me Higher | Ahjay Stelino |
| 91 | Summer's Here | Ahjay Stelino |
| 288 | One More Dance | Arulo |
| 339 | Villa Penthouse | Arulo |
| 350 | Follow Me Home | Arulo |
| 528 | You Got Jazz | Diego Nava |
| 529 | Walking in the Park | Diego Nava |
| 532 | A Happy Child | Diego Nava |
| 621 | BRIDGE No 98 | Eugenio Mininni |
| 684 | Classical vibes 4 | Grigoriy Nuzhny |
| 801 | Happy Home | Michael Ramir C. |
| 823 | Be Happy 2 | Michael Ramir C. |
| 839 | Tears of Joy | Michael Ramir C. |
| 872 | Gimme that Groove! | Michael Ramir C. |
| 897 | A Very Happy Christmas | Michael Ramir C. |
| 953 | Feel Alive | Michael Ramir C. |
| 1000 | I Can Hear Your Heartbeat | Michael Ramir C. |
| 1001 | I Do! | Michael Ramir C. |
| 1052 | Baby Yohan | Michael Ramir C. |
| 1140 | Funkee Monkeee | Michael Ramir C. |
| 1183 | Karma | Michael Ramir C. |
| 1210 | Can't Get You Off My Mind | Michael Ramir C. |
## 三、促销活动(轻快、有能量)
同案例展示分类,推荐节奏更欢快的:
- 3.mp3, 4.mp3, 5.mp3, 8.mp3, 11.mp3, 12.mp3, 13.mp3, 31.mp3, 32.mp3, 34.mp3, 91.mp3
- 288.mp3, 339.mp3, 350.mp3, 528.mp3, 529.mp3, 532.mp3, 621.mp3
- 801.mp3, 823.mp3, 839.mp3, 872.mp3, 897.mp3, 953.mp3
- 1000.mp3, 1001.mp3, 1052.mp3, 1140.mp3, 1183.mp3, 1210.mp3
## 四、家居生活(温馨、治愈)
| ID | 音乐名 | 艺术家 |
|----|--------|--------|
| 22 | Piano Reflections | Ahjay Stelino |
| 127 | Valley Sunset | Alejandro Magaña (A. M.) |
| 138 | Forest Treasure | Alejandro Magaña (A. M.) |
| 139 | Spirit in the Woods | Alejandro Magaña (A. M.) |
| 147 | Spirit in the Woods 2 | Alejandro Magaña (A. M.) |
| 168 | Staring at the Night Sky | Alejandro Magaña (A. M.) |
| 169 | Zanarkand Forest | Alejandro Magaña (A. M.) |
| 199 | Loner | Arulo |
| 250 | Island Beat | Arulo |
| 282 | Sweet September | Arulo |
| 292 | Relax Beat | Arulo |
| 322 | Life's a Movie | Arulo |
| 324 | Smooth Meditation | Arulo |
| 340 | Nap Time | Arulo |
| 345 | Nature Meditation | Arulo |
| 350 | Follow Me Home | Arulo |
| 416 | Young Trizzy | Arulo |
| 441 | Meditation | Arulo |
| 442 | Nature Yoga | Arulo |
| 443 | Serene View | Arulo |
| 444 | Yoga Song | Arulo |
| 493 | Beautiful Dream | Diego Nava |
| 528 | You Got Jazz | Diego Nava |
| 529 | Walking in the Park | Diego Nava |
| 532 | A Happy Child | Diego Nava |
| 568 | Focus on Yourself | Eugenio Mininni |
| 584 | Rest Now | Eugenio Mininni |
| 593 | Opalescent | Eugenio Mininni |
| 594 | River Flow | Eugenio Mininni |
| 617 | Wind Leaves | Eugenio Mininni |
| 620 | B.O.R.N | Eugenio Mininni |
| 623 | Deep Urban | Eugenio Mininni |
| 628 | Summer Dream | Eugenio Mininni |
| 629 | Skeeomaver Sound | Eugenio Mininni |
| 633 | Xanthos | Eugenio Mininni |
| 652 | Soul Jazz | Francisco Alvear |
| 664 | Pop One | Francisco Alvear |
| 695 | Pop 05 | Grigoriy Nuzhny |
| 700 | Pop 03 | Grigoriy Nuzhny |
| 713 | Classical 6 | Jonny S. |
| 720 | New Bass 01 | Lily J |
| 726 | Uplifting Bass | Lily J |
| 729 | Pop Track 03 | Lily J |
| 738 | Hip Hop 02 | Lily J |
| 744 | House 02 | Lily J |
| 749 | Relaxation 05 | Lily J |
| 759 | Romantic 05 | Lily J |
| 770 | Autofahren | Mauro Urbina |
| 779 | Oh | Michael Ramir C. |
| 801 | Happy Home | Michael Ramir C. |
| 802 | Here Comes The Train | Michael Ramir C. |
| 804 | I Love You Grandma | Michael Ramir C. |
| 813 | Magical moment | Michael Ramir C. |
| 816 | Please | Michael Ramir C. |
| 821 | At the Playhouse | Michael Ramir C. |
| 832 | I'm Going Home | Michael Ramir C. |
| 834 | It's Love | Michael Ramir C. |
| 837 | Life is a Dream | Michael Ramir C. |
| 839 | Tears of Joy | Michael Ramir C. |
| 840 | That's the Way of Life | Michael Ramir C. |
| 847 | It's April | Michael Ramir C. |
| 852 | Music and Life | Michael Ramir C. |
| 856 | Salty and Sweet | Michael Ramir C. |
| 872 | Gimme that Groove! | Michael Ramir C. |
| 897 | A Very Happy Christmas | Michael Ramir C. |
| 953 | Feel Alive | Michael Ramir C. |
| 963 | Just Keep Walking | Michael Ramir C. |
| 970 | Night Sky Hip Hop | Michael Ramir C. |
| 993 | Finding Myself | Michael Ramir C. |
| 1000 | I Can Hear Your Heartbeat | Michael Ramir C. |
| 1001 | I Do! | Michael Ramir C. |
| 1052 | Baby Yohan | Michael Ramir C. |
| 1081 | We'll Be Okay | Michael Ramir C. |
| 1140 | Funkee Monkeee | Michael Ramir C. |
| 1167 | Close Up | Michael Ramir C. |
| 1183 | Karma | Michael Ramir C. |
| 1210 | Can't Get You Off My Mind | Michael Ramir C. |
## 五、智能家居(科技感、高级感)
| ID | 音乐名 | 艺术家 |
|----|--------|--------|
| 105 | See Line Funk | Alejandro Magaña |
| 113 | House Fest | Alejandro Magaña (A. M.) |
| 114 | Kodama Night Town | Alejandro Magaña (A. M.) |
| 122 | Slow Rain | Alejandro Magaña (A. M.) |
| 124 | Techno Fest Vibes | Alejandro Magaña (A. M.) |
| 130 | Tech House vibes | Alejandro Magaña (A. M.) |
| 132 | Hazy After Hours | Alejandro Magaña (A. M.) |
| 134 | Deep Techno Ambience | Alejandro Magaña (A. M.) |
| 136 | Infected Mushroom Vibes | Alejandro Magaña (A. M.) |
| 137 | Goa Trance Mantra | Alejandro Magaña (A. M.) |
| 140 | Cyberpunk City | Alejandro Magaña (A. M.) |
| 157 | Infected Vibes | Alejandro Magaña (A. M.) |
| 160 | Minimal Emotion | Alejandro Magaña (A. M.) |
| 162 | Minimal Techno 01 | Alejandro Magaña (A. M.) |
| 166 | Trance Party | Alejandro Magaña (A. M.) |
| 173 | Better Times are Coming | Alejandro Magaña (A. M.) |
| 175 | Digital Clouds | Alejandro Magaña (A. M.) |
| 180 | Gear | Andrew Ev |
| 181 | Pop | Andrew Ev |
| 184 | Vastness | Andrew Ev |
| 199 | Loner | Arulo |
| 251 | Ambient | Arulo |
| 292 | Relax Beat | Arulo |
| 324 | Smooth Meditation | Arulo |
| 340 | Nap Time | Arulo |
| 371 | Cat Walk | Arulo |
| 416 | Young Trizzy | Arulo |
| 441 | Meditation | Arulo |
| 442 | Nature Yoga | Arulo |
| 443 | Serene View | Arulo |
| 444 | Yoga Song | Arulo |
| 464 | Sci-Fi Score | Arulo |
| 470 | Golden Storm | Diego Nava |
| 471 | Rising Forest | Diego Nava |
| 480 | Curiosity | Diego Nava |
| 517 | Jungle Voices | Diego Nava |
| 568 | Focus on Yourself | Eugenio Mininni |
| 584 | Rest Now | Eugenio Mininni |
| 593 | Opalescent | Eugenio Mininni |
| 594 | River Flow | Eugenio Mininni |
| 609 | Moon Walk | Eugenio Mininni |
| 616 | What it Takes | Eugenio Mininni |
| 617 | Wind Leaves | Eugenio Mininni |
| 620 | B.O.R.N | Eugenio Mininni |
| 623 | Deep Urban | Eugenio Mininni |
| 628 | Summer Dream | Eugenio Mininni |
| 629 | Skeeomaver Sound | Eugenio Mininni |
| 633 | Xanthos | Eugenio Mininni |
| 652 | Soul Jazz | Francisco Alvear |
| 664 | Pop One | Francisco Alvear |
| 695 | Pop 05 | Grigoriy Nuzhny |
| 700 | Pop 03 | Grigoriy Nuzhny |
| 713 | Classical 6 | Jonny S. |
| 720 | New Bass 01 | Lily J |
| 726 | Uplifting Bass | Lily J |
| 729 | Pop Track 03 | Lily J |
| 738 | Hip Hop 02 | Lily J |
| 744 | House 02 | Lily J |
| 749 | Relaxation 05 | Lily J |
| 759 | Romantic 05 | Lily J |
| 770 | Autofahren | Mauro Urbina |
| 779 | Oh | Michael Ramir C. |
| 801 | Happy Home | Michael Ramir C. |
| 802 | Here Comes The Train | Michael Ramir C. |
| 804 | I Love You Grandma | Michael Ramir C. |
| 813 | Magical moment | Michael Ramir C. |
| 816 | Please | Michael Ramir C. |
| 821 | At the Playhouse | Michael Ramir C. |
| 832 | I'm Going Home | Michael Ramir C. |
| 834 | It's Love | Michael Ramir C. |
| 837 | Life is a Dream | Michael Ramir C. |
| 839 | Tears of Joy | Michael Ramir C. |
| 840 | That's the Way of Life | Michael Ramir C. |
| 847 | It's April | Michael Ramir C. |
| 852 | Music and Life | Michael Ramir C. |
| 856 | Salty and Sweet | Michael Ramir C. |
| 872 | Gimme that Groove! | Michael Ramir C. |
| 897 | A Very Happy Christmas | Michael Ramir C. |
| 953 | Feel Alive | Michael Ramir C. |
| 963 | Just Keep Walking | Michael Ramir C. |
| 970 | Night Sky Hip Hop | Michael Ramir C. |
| 993 | Finding Myself | Michael Ramir C. |
| 1000 | I Can Hear Your Heartbeat | Michael Ramir C. |
| 1001 | I Do! | Michael Ramir C. |
| 1052 | Baby Yohan | Michael Ramir C. |
| 1081 | We'll Be Okay | Michael Ramir C. |
| 1140 | Funkee Monkeee | Michael Ramir C. |
| 1167 | Close Up | Michael Ramir C. |
| 1183 | Karma | Michael Ramir C. |
| 1210 | Can't Get You Off My Mind | Michael Ramir C. |
---
## 使用说明
1. 所有音乐文件在 `mixkit_bgm/` 目录下,文件名为 `{ID}.mp3`
2. 运营人员可试听挑选,将选中的音乐上传到七牛云 CDN
3. 上传后通过 `POST /api/v1/update/releases` 或直接写 SQL 入库
4. 分类字段建议: `knowledge` | `showcase` | `promotion` | `lifestyle` | `tech`
-260
View File
@@ -1,260 +0,0 @@
# 项目名词统一梳理报告
> 本文档梳理了美家卡智影项目中所有核心功能在用户界面和开发代码中的命名现状,识别出不一致问题,并提出统一标准方案。
---
## 一、核心功能总览(10个业务域)
| # | source_type | 用户界面名称 | 开发层关键命名 | 计费模式 |
|---|-------------|-------------|---------------|---------|
| 1 | `script` | 脚本生成 | `ScriptCreation.tsx`, `scriptApi` | 固定 5 |
| 2 | `polish` | 文案润色 | `scriptApi.polish`, `polish_content` | 固定 1 |
| 3 | `title` | 标题生成 | `scriptApi.generateTitle`, `generate_title` | 固定 1 |
| 4 | `tts` | 音频合成 / 配音 | `VoiceDubbing.tsx`, `synthesizeTTS`, `voice.ts` | 按秒计费 |
| 5 | `voice_clone` | 声音复刻 / 声音克隆 | `VoiceMaterialLibrary.tsx`, `voice.ts` 声音克隆 API | 固定 200 |
| 6 | `video` | 视频生成 / 对口型 | `VideoGeneration.tsx`, `vidu.ts`, `lipSync` | 按秒计费 |
| 7 | `caption` | 字幕生成 | `createTask('subtitle', ...)` | 免费 |
| 8 | `subtitle_burn` | 字幕烧录 | `SubtitleBurning.tsx`, `subtitle_burn` | 固定 2 |
| 9 | `cover_design` | 封面设计 / 封面制作 / 封面生成 | `CoverDesign.tsx`, `cover_design` | 固定 2 |
| 10 | `compose` | 压制成片 / 视频合成 | `VideoComposite.tsx`, `videoComposite.ts`, `videoCompose.ts`, `video_processing.rs`, `video_compose.rs` | 固定 5 |
---
## 二、问题分类
### A类:后端分类映射错误(数据层污染)
文件:`python-api/app/services/point_service.py` 中的 `_CATEGORY_MAP`
| source_type | 当前映射 | 正确映射 | 影响 |
|-------------|---------|---------|------|
| `polish` | "脚本生成" ❌ | "文案润色" | 用户流水显示错误分类 |
| `title` | "脚本生成" ❌ | "标题生成" | 用户流水显示错误分类 |
| `voice_clone` | "音频合成" ❌ | "声音复刻" | 用户流水显示错误分类 |
> **严重程度:高**。这会导致积分明细中用户无法区分自己到底消费了什么服务。
---
### B类:同一功能多词混用(用户层混乱)
#### B1. TTS / 音频合成 / 配音
| 位置 | 用词 |
|------|------|
| 步骤标签(Step 2 | **音频合成** |
| 页面文件名 | `VoiceDubbing.tsx` |
| 页面注释 | "语音**配音**页面" |
| 进度弹窗 | "生成**配音**"、"**配音**已就绪" |
| 按钮文案 | "生成**配音**" |
| 字段标签 | "**配音**文案" |
| 错误提示 | "请返回第二步重新生成**配音**" |
| TermsModal | "AI **配音**TTS" |
| API 模块注释 | "TTS 合成" |
| 积分明细(UsageDetail)筛选 | "**音频合成**" |
**问题**:步骤标签用"音频合成",但页面内全部用"配音"。用户从步骤导航点进来,看到的内容全是"配音",会产生"这是同一个功能吗?"的困惑。
#### B2. 封面设计 / 封面制作 / 封面生成
| 位置 | 用词 |
|------|------|
| 步骤标签(Step 5 | **封面制作** |
| 页面文件名 | `CoverDesign.tsx` |
| 页面注释 | "**封面制作**页面" |
| 进度弹窗 | "**封面生成**"、"**封面生成**完成" |
| 积分 description | "**封面设计**" |
| 按钮文案 | "立即生成封面图" |
| 预览区标题 | "**封面**预览" |
**问题**"制作"、"设计"、"生成"三个词混用。
#### B3. 声音复刻 / 声音克隆
| 位置 | 用词 |
|------|------|
| 侧边栏 Sidebar | **声音克隆** |
| 积分明细 UsageDetail | **声音复刻** |
| API 模块注释(`voice.ts` | "**声音克隆** API" |
| 页面标题(`VoiceMaterialLibrary.tsx` | "**声音克隆**" |
**问题**:同一功能两个不同的中文名称。
#### B4. 视频生成 / 对口型
| 位置 | 用词 |
|------|------|
| 步骤标签(Step 3 | **视频生成** |
| 积分明细 | **视频生成** |
| 技术实现注释/日志 | "**对口型**任务"、"**对口型**视频"、"**对口型**处理中..." |
| 错误提示 | "**对口型**任务失败"、"**对口型**任务超时" |
| 字段名 | `lipSyncTaskId`, `lipSyncVideoPath` |
**问题**:技术实现术语"对口型"泄露到用户可见文案中。
---
### C类:开发命名与业务命名不匹配
| 业务名称 | 开发命名现状 | 问题 |
|---------|-------------|------|
| 压制成片 | `compose` (source_type) / `composite` (API 模块名) / `videoComposite.ts` / `videoCompose.ts` / `video_processing.rs` / `video_compose.rs` / `VideoComposite.tsx` | **同一业务 5 种不同命名**,开发人员无法一眼看出这些代码对应同一个功能 |
| 音频合成 | `tts` (source_type) / `VoiceDubbing.tsx` (页面) / `synthesizeTTS` (函数) / `voice.ts` (API 模块) | 页面文件名 `VoiceDubbing` 与步骤名"音频合成"语义不匹配 |
| 文案润色 | `polish` (source_type/API) / `polish_content` (service) | 基本对应,但"polish"在代码中同时指"画面描述润色"和"配音文案润色" |
---
### D类:术语在注释/文案中的不统一
- `ScriptCreation.tsx` 中:`voiceover` 字段的注释混用"配音文案"、"画外音"
- `VideoGeneration.tsx` 中:用户错误提示混用"返回第二步重新生成配音"和"回到第2步重新生成音频"
- `voice.ts` 中:API 模块标题为"TTS 合成、批量合成、声音克隆",但对应的功能页面叫"音频合成"
---
## 三、统一标准方案
### 3.1 用户层统一名称(用户可见的所有文案)
| source_type | 统一名称 | 子类型/说明 |
|-------------|---------|------------|
| `script` | **脚本生成** | — |
| `polish` | **文案润色** | 含画面描述润色、配音文案润色 |
| `title` | **标题生成** | 封面主/副标题 |
| `tts` | **配音合成** | 步骤标签、页面标题、按钮、进度统一用此 |
| `voice_clone` | **声音复刻** | 统一用"复刻",不用"克隆" |
| `video` | **视频生成** | 技术实现是对口型,但用户界面禁止出现"对口型" |
| `caption` | **字幕生成** | 从视频提取字幕文本 |
| `subtitle_burn` | **字幕烧录** | 将字幕文件烧录到视频画面中 |
| `cover_design` | **封面设计** | 统一用"设计",不用"制作/生成" |
| `compose` | **压制成片** | FFmpeg 拼接输出最终成品视频 |
**说明**
- **配音合成**:选择这个词是因为它比"音频合成"更贴近用户理解("我给视频配个音"),又比单独的"配音"更像一个功能名称。步骤标签从"音频合成"改为"配音合成"。
- **声音复刻**"复刻"比"克隆"更符合国内 AI 产品用语习惯(如剪映用"声音克隆",但通义/讯飞多用"声音复刻")。考虑到积分明细已用"复刻",统一到此。
- **封面设计**:文件名已经是 `CoverDesign`,积分 description 也是"封面设计",步骤标签从"封面制作"改为此,形成统一。
- **视频生成**:技术实现是 Vidu 对口型,但所有用户文案(含错误提示、进度提示)统一用"视频生成"。
### 3.2 开发层统一命名
#### source_type(数据库/API 层,已较规范,保持不变)
```
script / polish / title / tts / voice_clone / video / caption / subtitle_burn / cover_design / compose
```
#### 前端文件/模块命名
| 业务 | 当前文件名 | 建议文件名 | 理由 |
|------|----------|----------|------|
| 脚本生成 | `ScriptCreation.tsx` | ✅ 保持不变 | 语义清晰 |
| 文案润色 | 无独立页面(在 ScriptCreation 内) | — | — |
| 标题生成 | 无独立页面(在 CoverDesign/SubtitleBurning 内调用) | — | — |
| 配音合成 | `VoiceDubbing.tsx` | `VoiceSynthesis.tsx` | `VoiceDubbing` 侧重"配音"动作,不够功能化;`Synthesis``synthesizeTTS` 对应 |
| 声音复刻 | `VoiceMaterialLibrary.tsx` | ✅ 保持不变 | 页面本身是素材库,其中包含声音复刻功能,可以接受 |
| 视频生成 | `VideoGeneration.tsx` | ✅ 保持不变 | 语义清晰 |
| 字幕生成 | 无独立页面(在 VoiceDubbing 内调用) | — | — |
| 字幕烧录 | `SubtitleBurning.tsx` | ✅ 保持不变 | 语义清晰 |
| 封面设计 | `CoverDesign.tsx` | ✅ 保持不变 | 语义清晰 |
| 压制成片 | `VideoComposite.tsx` | `VideoCompose.tsx` | 与 source_type `compose` 一致。注意当前已有 `videoCompose.ts`(上传模块),需先厘清两者边界 |
> ⚠️ **关于 `videoCompose.ts` vs `videoComposite.ts`**
> - `videoCompose.ts`:提供 `uploadVideoFile`(上传本地视频到后端→七牛云),文件注释写"压制成片 IPC 模块",实际做的是上传,命名混乱。
> - `videoComposite.ts`:提供 `compositeApi.synthesis`(调用 Rust 压制成片),命名与业务对应。
> **建议**`videoCompose.ts` 改名为 `videoUpload.ts`(或合并到七牛上传模块),`VideoComposite.tsx` 改名为 `VideoCompose.tsx`。
#### 后端文件/模块命名
| 业务 | 当前命名 | 建议 | 理由 |
|------|---------|------|------|
| 压制成片 | `video_compose.rs` + `video_processing.rs` | ✅ 保持两个文件,但统一对外 command 名 | `video_processing.rs` 是业务逻辑层,`video_compose.rs` 是 command 层,分层合理。只需统一 Rust command 名和响应消息 |
---
## 四、具体修改清单
### 必改(数据层错误)
1. **`python-api/app/services/point_service.py`**
- `_CATEGORY_MAP["polish"]` → "文案润色"
- `_CATEGORY_MAP["title"]` → "标题生成"
- `_CATEGORY_MAP["voice_clone"]` → "声音复刻"
2. **`python-api/app/api/v1/script.py`**(如果 description 硬编码了错误分类)
- `description="【文案润色】"` ✅ 已正确
- `title` 端点消费记录需要确认 description 格式
### 用户文案统一
3. **Step 标签(`tauri-app/src/pages/VideoCreation/index.tsx`**
- Step 2: "音频合成" → "配音合成"
- Step 5: "封面制作" → "封面设计"
4. **配音合成页面(`tauri-app/src/pages/VideoCreation/VoiceDubbing.tsx`**
- 页面注释:"语音配音页面" → "配音合成页面"
- 进度:`show('生成配音')``show('配音合成')`
- 进度:`update('正在生成配音...')``update('正在合成配音...')`
- 成功:`success('配音已就绪')``success('配音合成完成')`
- 按钮:`生成配音``合成配音`
- 右侧标题:`配音文案``配音文本`
- 字段标签:`配音``配音文本`
- 错误提示中所有"配音"保持不动("重新生成配音"是动作描述,不需要改)
5. **封面设计页面(`tauri-app/src/pages/VideoCreation/CoverDesign.tsx`**
- 进度:`show('封面生成')``show('封面设计')`
- 成功:`success('封面生成完成')``success('封面设计完成')`
- 错误:`封面生成失败``封面设计失败`
- 按钮:`立即生成封面图``立即设计封面`
- 积分 description`封面设计` ✅ 已正确
6. **声音复刻(`tauri-app/src/components/Layout/Sidebar.tsx` + `tauri-app/src/pages/ContentManagement/VoiceMaterialLibrary.tsx` + `tauri-app/src/api/modules/voice.ts`**
- Sidebar: "声音克隆" → "声音复刻"
- `voice.ts` 注释:"声音克隆" → "声音复刻"
- `VoiceMaterialLibrary.tsx` 标题和文案
7. **视频生成页面(`tauri-app/src/pages/VideoCreation/VideoGeneration.tsx`**
- 所有用户可见的"对口型"改为"视频生成"
- 进度:`show('视频生成')` ✅ 已正确
- 进度:`update('正在提交对口型任务...')``update('正在提交视频生成任务...')`
- 进度:`update('正在等待对口型处理...')``update('正在等待视频处理...')`
- 进度:`update('对口型处理中...')``update('视频处理中...')`
- 进度:`update('正在下载对口型视频...')``update('正在下载生成视频...')`
- 错误:`对口型任务失败``视频生成失败`
- 错误:`对口型任务超时``视频生成超时`
- 日志/注释中的"对口型"可以保留(开发层)
8. **压制成片页面(`tauri-app/src/pages/VideoCreation/VideoComposite.tsx`**
- 镜头列表中的`配音`标签 → `配音文本`
9. **TermsModal`tauri-app/src/components/Modal/TermsModal.tsx`**
- "AI 配音(TTS" → "AI 配音合成(TTS"
- "声音复刻" ✅ 已正确
10. **积分明细(`tauri-app/src/pages/Profile/UsageDetail.tsx`**
- `voice_clone` 筛选标签:"声音复刻" ✅ 已正确
- `tts` 筛选标签:"音频合成" → "配音合成"
- `cover_design` 筛选标签:"封面设计" ✅ 已正确
11. **我的作品(`tauri-app/src/pages/ContentManagement/MyWorks.tsx`**
- 空状态文案 ✅ 已改为"压制成片"
### 开发层整理(建议项,不影响用户)
12. **`tauri-app/src/api/modules/videoCompose.ts`**
- 文件注释写"压制成片 IPC 模块",实际做的是上传视频。建议改名或修正注释。
13. **`tauri-app/src/pages/VideoCreation/VideoComposite.tsx`**
- 建议未来改名为 `VideoCompose.tsx`,与 source_type `compose` 一致。
---
## 五、确认清单
请确认以下决策:
1. **Step 2 标签**"音频合成" → "配音合成"(还是保持"音频合成"?)
2. **Step 5 标签**"封面制作" → "封面设计"(是否接受?)
3. **声音功能**:统一为"声音复刻"(放弃"声音克隆")?
4. **视频生成**:所有用户文案中的"对口型"全部替换为"视频生成"
5. **文件改名**`VoiceDubbing.tsx``VoiceSynthesis.tsx``VideoComposite.tsx``VideoCompose.tsx` 是否执行?
+1 -1
View File
@@ -170,4 +170,4 @@ Tauri updater 插件已内置跨平台安装逻辑,前端代码无需区分平
| `tauri-app/src-tauri/tauri.conf.json` | updater 配置:公钥 + endpoint URL |
| `python-api/scripts/publish_release.py` | 发版脚本(扫描 .sig → 上传七牛云 → 写数据库) |
| `python-api/app/api/v1/update.py` | 后端更新检查 API |
| `python-api/app/models/update.py` | 数据库模型(`app_releases` / `release_packages` |
| `python-api/app/models/update.py` | 数据库模型(`mjk_app_releases` / `mjk_app_release_packages` |
+192
View File
@@ -0,0 +1,192 @@
# Windows 11 开发环境搭建指南
> 适用场景:全新重装系统后的 Windows 11,国内网络环境。
---
## 前置说明
- **WebView2**Windows 11 自带,无需安装。
- **WSL2**Windows 11 默认支持,Docker Desktop 会自动启用。
- **全程使用 cmd + 官网 .exe 安装包**,不依赖 PowerShell 脚本。
---
## 一、基础工具安装(图形界面,双击下一步)
按顺序安装,装完一个再装下一个。
### 1. Git
- 下载:https://git-scm.com/download/win
- 安装:全默认,一路 Next。
### 2. Node.js 22 LTS
- 下载:https://nodejs.org/
- 安装:勾选 **"Automatically install necessary tools"**(会自动装 Python 2.7 等构建工具)。
### 3. Visual Studio Build Tools 2022
- 下载:https://aka.ms/vs/17/release/vs_BuildTools.exe
- 安装:只勾选 **"使用 C++ 的桌面开发"**(约 8GB),其他全取消。
### 4. Rust
- 下载:https://rustup.rs/ → 点击 `rustup-init.exe (64-bit)`
- 安装:选 **1) Proceed with default installation**(默认 MSVC 工具链)。
---
## 二、国内镜像配置(cmd 执行)
打开 **cmdWin+R → cmd**,逐行执行:
```cmd
:: ========== npm 镜像 ==========
npm config set registry https://registry.npmmirror.com
:: ========== Rust 镜像 ==========
mkdir "%USERPROFILE%\.cargo" 2>nul
echo [source.crates-io] > "%USERPROFILE%\.cargo\config.toml"
echo replace-with = 'ustc' >> "%USERPROFILE%\.cargo\config.toml"
echo [source.ustc] >> "%USERPROFILE%\.cargo\config.toml"
echo registry = "sparse+https://mirrors.ustc.edu.cn/crates.io-index/" >> "%USERPROFILE%\.cargo\config.toml"
setx RUSTUP_UPDATE_ROOT https://mirrors.ustc.edu.cn/rust-static/rustup
setx RUSTUP_DIST_SERVER https://mirrors.ustc.edu.cn/rust-static
:: ========== Python 镜像(预留,方案 B 用到) ==========
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
pip config set global.trusted-host pypi.tuna.tsinghua.edu.cn
echo 镜像配置完成,请关闭并重新打开 cmd
```
**执行完后,关闭 cmd,重新打开**,再执行验证:
```cmd
npm config get registry
cargo --version
```
---
## 三、方案 A:只跑前端(连测试环境后端)
### 1. 拉代码
```cmd
git clone <你的仓库地址>
cd meijiaka-zy\tauri-app
```
### 2. 装依赖
```cmd
npm ci
```
### 3. 启动
```cmd
npm run tauri dev
```
前端默认连接 `https://dev.tapi.meijiaka.cn/api/v1`,无需本地后端。
---
## 四、方案 B:前后端都本地跑
在方案 A 基础上继续。
### 1. Python 3.13
- 下载:https://www.python.org/ftp/python/3.13.0/python-3.13.0-amd64.exe
- 安装:**务必勾选 "Add python.exe to PATH"**,然后 Install Now。
### 2. 安装 uv
```cmd
pip install uv
```
### 3. Docker Desktop
- 下载:https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe
- 安装:默认,装完**重启电脑**。
- 重启后打开 Docker Desktop,等左下角状态变绿。
### 4. 后端启动
```cmd
cd meijiaka-zy\python-api
:: 安装依赖
uv pip install -e ".[dev]"
:: 复制环境变量
copy .env.example .env
:: 启动数据库
docker compose -f docker-compose.test.yml up -d db redis
:: 数据库迁移
alembic upgrade head
:: 启动 API(终端 1
make run
:: 或:uvicorn app.main:app --reload --port 8000
```
如果需要异步调度器(脚本/TTS/字幕生成等),另开终端:
```cmd
cd meijiaka-zy\python-api
make scheduler
:: 或:python -m app.scheduler.main
```
### 5. 前端启动(连本地后端)
```cmd
cd meijiaka-zy\tauri-app
npm run tauri dev
```
前端 Vite 开发服务器会代理 API 请求到 `localhost:8000`。如果代理异常,检查 `tauri-app/src/api/client.ts` 中的 `PYTHON_API_BASE_URL`
---
## 五、验证清单
全部装完后,在 cmd 里执行:
```cmd
git --version
node -v
npm -v
rustc --version
cargo --version
python --version
uv --version
docker --version
```
每个都要有版本号输出。
---
## 六、常见问题
| 现象 | 原因 | 解决 |
|------|------|------|
| `npm ci` 卡住 | 镜像没配好 | 检查 `npm config get registry` 是否为 `registry.npmmirror.com` |
| `cargo build` 卡住 | Rust 镜像没生效 | 关闭 cmd 重新打开,或检查 `%USERPROFILE%\.cargo\config.toml` |
| Tauri 编译报错 `link.exe not found` | VS Build Tools 没装 C++ 桌面开发 | 重装,确保勾选了该工作负载 |
| `tauri dev` 白屏 | 前端代理地址错误 | 检查 `client.ts` 里的 base URL |
| Docker 启动失败 | WSL2 未启用 | 控制面板 → 程序和功能 → 启用 Windows 功能 → 勾选 **适用于 Linux 的 Windows 子系统** |
| `python` 命令找不到 | 安装时没勾选 Add to PATH | 重装 Python,务必勾选 |
| `alembic` 命令找不到 | 没在虚拟环境里 | 确保在 `python-api` 目录下执行,`uv pip install` 已经装了 |
+8 -3
View File
@@ -4,7 +4,7 @@
# === 基础配置 ===
APP_NAME=美家卡智影 API
APP_VERSION=1.5.18
APP_VERSION=1.6.6
# ⚠️ 生产环境必须设为 false
DEBUG=true
ENV=development
@@ -36,7 +36,7 @@ ALGORITHM=HS256
# === CORS 配置 ===
# 本地开发: 允许 localhost
# 测试/生产服: 填写实际域名,如 https://app.yourdomain.com
CORS_ORIGINS=http://localhost:1420,http://127.0.0.1:1420,http://localhost:8080
CORS_ORIGINS=http://localhost:1420,http://127.0.0.1:1420,http://localhost:8080,tauri://localhost,http://tauri.localhost,https://tauri.localhost
# === AI 平台配置 ===
@@ -47,6 +47,9 @@ VOLCENGINE_API_KEY=your-volcengine-api-key
VOLCENGINE_CAPTION_APPID=your-caption-appid
VOLCENGINE_CAPTION_TOKEN=your-caption-token
# 火山 MediaKit
VOLCENGINE_MEDIAKIT_TOKEN=your-mediakit-token
# Vidu(TTS、声音复刻、对口型)
VIDU_API_KEY=your-vidu-api-key
@@ -71,7 +74,9 @@ SMS_APP_ID=your-sms-app-id
SMS_SECRET_KEY=your-16-24-32-byte-aes-key
SMS_BASE_URL=https://bjksmtn.b2m.cn/inter/sendSingleSMS
# SMS_EXTENDED_CODE= # 扩展码(选填)
# 免验证码登录白名单(逗号分隔),名单内的手机号登录时跳过验证码校验
# SMS_CODE_WHITELIST=13800138000,13900139000
# === 日志配置 ===
# 生产环境建议 INFO
LOG_LEVEL=DEBUG
LOG_LEVEL=ERROR
-1
View File
@@ -1 +0,0 @@
3.13
+1 -1
View File
@@ -1 +1 @@
{"http:Pn60lJXcaOGKvMjn5qv-OMr7wR1lp1p8QG7Ul6NK:media-liche": {"upHosts": ["http://upload-z2.qiniup.com", "http://up-z2.qiniup.com"], "ioHosts": ["http://iovip-z2.qbox.me"], "rsHosts": ["http://rs-z2.qbox.me"], "rsfHosts": ["http://rsf-z2.qbox.me"], "apiHosts": ["http://api-z2.qiniu.com"], "deadline": 1778077071}, "http:Pn60lJXcaOGKvMjn5qv-OMr7wR1lp1p8QG7Ul6NK:img-liche": {"upHosts": ["http://upload-z2.qiniup.com", "http://up-z2.qiniup.com"], "ioHosts": ["http://iovip-z2.qbox.me"], "rsHosts": ["http://rs-z2.qbox.me"], "rsfHosts": ["http://rsf-z2.qbox.me"], "apiHosts": ["http://api-z2.qiniu.com"], "deadline": 1776433218}}
{"http:Pn60lJXcaOGKvMjn5qv-OMr7wR1lp1p8QG7Ul6NK:media-liche": {"upHosts": ["http://upload-z2.qiniup.com", "http://up-z2.qiniup.com"], "ioHosts": ["http://iovip-z2.qbox.me"], "rsHosts": ["http://rs-z2.qbox.me"], "rsfHosts": ["http://rsf-z2.qbox.me"], "apiHosts": ["http://api-z2.qiniu.com"], "deadline": 1779898302}, "http:Pn60lJXcaOGKvMjn5qv-OMr7wR1lp1p8QG7Ul6NK:img-liche": {"upHosts": ["http://upload-z2.qiniup.com", "http://up-z2.qiniup.com"], "ioHosts": ["http://iovip-z2.qbox.me"], "rsHosts": ["http://rs-z2.qbox.me"], "rsfHosts": ["http://rsf-z2.qbox.me"], "apiHosts": ["http://api-z2.qiniu.com"], "deadline": 1776433218}}
+3 -2
View File
@@ -20,17 +20,18 @@ load_dotenv()
# 导入模型
from app.db.session import Base
from app.models.bgm_music import BgmMusic # noqa
from app.models.broll_category import BrollCategory # noqa
from app.models.broll_material import BrollMaterial # noqa
from app.models.broll_tag import BrollTag # noqa
from app.models.cover_background import CoverBackground # noqa
from app.models.point_batch import PointBatch # noqa
from app.models.point_recharge_order import PointRechargeOrder # noqa
from app.models.point_transaction import PointTransaction # noqa
from app.models.update import AppRelease, ReleasePackage # noqa
from app.models.user import User # noqa
from app.models.user_device import UserDevice # noqa
from app.models.user_point import UserPoint # noqa
from app.models.cover_background import CoverBackground # noqa
from app.models.update import AppRelease, ReleasePackage # noqa
# this is the Alembic Config object
config = context.config
@@ -0,0 +1,28 @@
"""add_url_to_bgm_music
Revision ID: 100366516fbd
Revises: 7172a476e5b2
Create Date: 2026-05-24 15:24:11.076162
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '100366516fbd'
down_revision: Union[str, Sequence[str], None] = '7172a476e5b2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column('mjk_bgm_musics', sa.Column('url', sa.String(length=1024), nullable=True, comment='七牛云 URL'))
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column('mjk_bgm_musics', 'url')
@@ -0,0 +1,41 @@
"""make_bgm_music_url_non_nullable
Revision ID: 7149f61a2f9c
Revises: 7172a476e5b2
Create Date: 2026-05-21 10:45:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7149f61a2f9c'
down_revision: Union[str, Sequence[str], None] = '100366516fbd'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# BGM 云端化改造后,url 字段为必填(七牛云 CDN 地址)
op.alter_column(
'mjk_bgm_musics',
'url',
existing_type=sa.String(length=1024),
nullable=False,
comment='七牛云 URL',
)
def downgrade() -> None:
"""Downgrade schema."""
op.alter_column(
'mjk_bgm_musics',
'url',
existing_type=sa.String(length=1024),
nullable=True,
comment='七牛云 URL',
)
@@ -0,0 +1,46 @@
"""add_bgm_music_table
Revision ID: 7172a476e5b2
Revises: d8f4912d7a52
Create Date: 2026-05-23 13:56:46.013156
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7172a476e5b2'
down_revision: Union[str, Sequence[str], None] = 'd8f4912d7a52'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('mjk_bgm_musics',
sa.Column('title', sa.String(length=255), nullable=False, comment='音乐名称'),
sa.Column('artist', sa.String(length=255), nullable=True, comment='艺术家'),
sa.Column('category', sa.String(length=32), nullable=False, comment='场景分类'),
sa.Column('file_path', sa.String(length=512), nullable=False, comment='相对文件路径'),
sa.Column('duration', sa.Float(), nullable=True, comment='时长(秒)'),
sa.Column('status', sa.String(length=16), nullable=False, comment='状态: active/inactive'),
sa.Column('sort_order', sa.Integer(), nullable=False, comment='排序权重'),
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_mjk_bgm_musics_category'), 'mjk_bgm_musics', ['category'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_mjk_bgm_musics_category'), table_name='mjk_bgm_musics')
op.drop_table('mjk_bgm_musics')
# ### end Alembic commands ###
@@ -0,0 +1,41 @@
"""add filename to release_package unique constraint
Revision ID: 7d855b38fe83
Revises: 8d901bc90e67
Create Date: 2026-05-26 22:55:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '7d855b38fe83'
down_revision: Union[str, Sequence[str], None] = '8d901bc90e67'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# 删除旧约束(release_id + platform + architecture
op.drop_constraint('uix_app_pkg_platform_arch', 'mjk_app_release_packages', type_='unique')
# 创建新约束(release_id + platform + architecture + filename
op.create_unique_constraint(
'uix_app_pkg_platform_arch_filename',
'mjk_app_release_packages',
['release_id', 'platform', 'architecture', 'filename']
)
def downgrade() -> None:
"""Downgrade schema."""
# 删除新约束
op.drop_constraint('uix_app_pkg_platform_arch_filename', 'mjk_app_release_packages', type_='unique')
# 恢复旧约束
op.create_unique_constraint(
'uix_app_pkg_platform_arch',
'mjk_app_release_packages',
['release_id', 'platform', 'architecture']
)
@@ -0,0 +1,29 @@
"""rename mjk_release_packages to mjk_app_release_packages
Revision ID: 8d901bc90e67
Revises: 7149f61a2f9c
Create Date: 2026-05-26 10:05:16.921079
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '8d901bc90e67'
down_revision: Union[str, Sequence[str], None] = '7149f61a2f9c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.execute("ALTER TABLE IF EXISTS mjk_release_packages RENAME TO mjk_app_release_packages")
op.execute("ALTER INDEX IF EXISTS uix_pkg_platform_arch RENAME TO uix_app_pkg_platform_arch")
def downgrade() -> None:
"""Downgrade schema."""
op.execute("ALTER INDEX IF EXISTS uix_app_pkg_platform_arch RENAME TO uix_pkg_platform_arch")
op.execute("ALTER TABLE IF EXISTS mjk_app_release_packages RENAME TO mjk_release_packages")
@@ -0,0 +1,48 @@
"""rename_old_table_prefix_for_update_tables
Revision ID: d8f4912d7a52
Revises: c3a0e1c71ce6
Create Date: 2026-05-20 18:02:45.186600
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd8f4912d7a52'
down_revision: Union[str, Sequence[str], None] = 'c3a0e1c71ce6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# 将旧环境(cbd4068 前)创建的 app_releases / release_packages 重命名为 mjk_ 前缀
# 使用 IF EXISTS 兼容:新环境已在 initial_schema 中创建了正确前缀的表名
op.execute(
"ALTER TABLE IF EXISTS app_releases RENAME TO mjk_app_releases"
)
op.execute(
"ALTER INDEX IF EXISTS ix_app_releases_version "
"RENAME TO ix_mjk_app_releases_version"
)
op.execute(
"ALTER TABLE IF EXISTS release_packages RENAME TO mjk_release_packages"
)
def downgrade() -> None:
"""Downgrade schema."""
op.execute(
"ALTER TABLE IF EXISTS mjk_app_releases RENAME TO app_releases"
)
op.execute(
"ALTER INDEX IF EXISTS ix_mjk_app_releases_version "
"RENAME TO ix_app_releases_version"
)
op.execute(
"ALTER TABLE IF EXISTS mjk_release_packages RENAME TO release_packages"
)
+1
View File
@@ -20,3 +20,4 @@ class Method:
CAPTION = "caption"
AUTO_ALIGN = "auto_align"
VIDEO_GENERATE = "video_generate"
REMOVE_BACKGROUND = "remove_background"
+27 -8
View File
@@ -218,24 +218,35 @@ class ViduAdapter(PlatformAdapter, SyncCapable, TaskCapable, CallbackCapable):
callback_url: str | None = None,
) -> bool:
"""验证 Vidu 回调 HMAC-SHA256 签名"""
signature = headers.get("X-HMAC-SIGNATURE")
algorithm = headers.get("X-HMAC-ALGORITHM")
access_key = headers.get("X-HMAC-ACCESS-KEY")
signed_headers_str = headers.get("X-HMAC-SIGNED-HEADERS")
date = headers.get("Date")
import logging
logger = logging.getLogger(__name__)
# HTTP 头大小写不敏感:建立小写 key 的查找表
headers_lower = {k.lower(): v for k, v in headers.items()}
signature = headers_lower.get("x-hmac-signature")
algorithm = headers_lower.get("x-hmac-algorithm")
access_key = headers_lower.get("x-hmac-access-key")
signed_headers_str = headers_lower.get("x-hmac-signed-headers")
date = headers_lower.get("date")
if not all([signature, algorithm, access_key, signed_headers_str, date]):
logger.warning(f"[Vidu] 签名验证失败: 缺少必要头, headers={list(headers.keys())}")
return False
if algorithm != "hmac-sha256":
logger.warning(f"[Vidu] 签名验证失败: 不支持的算法 {algorithm}")
return False
if access_key != "vidu":
logger.warning(f"[Vidu] 签名验证失败: access_key 不匹配 {access_key}")
return False
header_names = [h.strip() for h in signed_headers_str.split(";") if h.strip()]
header_values: dict[str, str] = {}
for name in header_names:
value = headers.get(name)
# 签名头名也可能大小写不一致,统一用小写查找
value = headers_lower.get(name.lower())
if value is None:
logger.warning(f"[Vidu] 签名验证失败: 缺少签名头 {name}")
return False
header_values[name] = value
@@ -258,7 +269,15 @@ class ViduAdapter(PlatformAdapter, SyncCapable, TaskCapable, CallbackCapable):
hmac.new(secret.encode("utf-8"), signing_string.encode("utf-8"), hashlib.sha256).digest()
).decode("utf-8")
return hmac.compare_digest(signature, expected)
if not hmac.compare_digest(signature, expected):
logger.warning(
f"[Vidu] 签名验证失败: callback_url={callback_url}, "
f"signing_string={repr(signing_string)}, "
f"expected={expected[:20]}..., received={signature[:20]}..."
)
return False
return True
async def verify_nonce(
self,
@@ -289,5 +308,5 @@ class ViduAdapter(PlatformAdapter, SyncCapable, TaskCapable, CallbackCapable):
return TaskStatus(
state=self.normalize_state(state),
result={"video_url": video_url, "creations": creations, "task_id": task_id} if video_url else {"task_id": task_id},
error_message=data.get("err_code") or data.get("message") if state == "failed" else None,
error_message=(data.get("err_code") or data.get("message")) if state == "failed" else None,
)
@@ -0,0 +1,95 @@
"""
火山引擎 MediaKit Adapter
==========================
实现 PlatformAdapter + SyncCapable。
直接接入 VolcengineMediakitProvider,提供标准 Protocol 接口。
"""
from __future__ import annotations
import logging
from typing import Any
from app.ai.adapters.base import AdapterResponse, PlatformAdapter, SyncCapable
from app.ai.adapters.constants import Method
from app.ai.providers.volcengine_mediakit_provider import VolcengineMediakitProvider
from app.core.exceptions import PlatformError, PlatformErrorType
logger = logging.getLogger(__name__)
class VolcengineMediakitAdapter(PlatformAdapter, SyncCapable):
"""火山引擎 MediaKit 平台标准 Adapter"""
platform_id = "volcengine_mediakit"
def __init__(self, provider: VolcengineMediakitProvider):
self.provider = provider
# ── PlatformAdapter ──
async def health(self) -> AdapterResponse:
try:
# 用无效 URL 测试连通性(400 说明网络通且认证通过)
await self.provider.remove_background(
image_url="https://example.com/health-check.jpg",
scene="general",
)
return AdapterResponse(success=True)
except PlatformError as e:
if e.error_type in (
PlatformErrorType.AUTH_FAILED,
PlatformErrorType.BAD_REQUEST,
):
return AdapterResponse(success=True)
return AdapterResponse(
success=False,
error_message=str(e),
retryable=e.retryable,
)
except Exception as e:
return AdapterResponse(
success=False,
error_message=str(e),
retryable=False,
)
async def close(self) -> None:
await self.provider.close()
# ── SyncCapable ──
async def call(self, method: str, payload: dict[str, Any]) -> AdapterResponse:
try:
if method == Method.REMOVE_BACKGROUND:
result = await self.provider.remove_background(
image_url=payload["image_url"],
scene=payload.get("scene", "general"),
need_contour=payload.get("need_contour", False),
contour_color=payload.get("contour_color", "#FFFFFF"),
contour_size=payload.get("contour_size", 10),
need_crop_background=payload.get("need_crop_background", False),
)
data = result.get("data", {})
return AdapterResponse(
success=True,
data={"image_url": data.get("image_url")},
)
else:
return AdapterResponse(
success=False,
error_message=f"不支持的方法: {method}",
retryable=False,
)
except PlatformError:
raise
except Exception as e:
raise PlatformError(
f"MediaKit {method} 调用失败: {e}",
platform="volcengine_mediakit",
retryable=False,
error_type=PlatformErrorType.UNKNOWN,
) from e
View File
@@ -25,7 +25,7 @@
文案调整要求:微调仅针对句式口语化优化,把书面提问话术改成抖音接地气口播大白话,不改变每个环节询问的项目、品牌、工艺、收费、责任划分等核心信息,全部细节原样保留。
字数与时长控制:纯文字 + 数字(扣除标点)严格控制在 400-480 字,按每秒 4 个纯文字计算,对应时长 100-120s,讲解环节完整、节奏适中,不啰嗦不拖沓,适配短视频完播习惯。
内容适配性:十大问题衔接自然,每个施工环节独立成段适配空镜分镜,直击半包业主不会询价、容易被低价套路、后期增项扯皮的核心痛点,逐条给到可直接照着问的实用话术。
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领取装修报价注意事项、评论区关键词领资料的核心逻辑。
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领取装修报价注意事项、评论区回复关键词领资料的核心逻辑。
【开篇 & 语言要求】
开篇沿用原文真实吐槽语气,3 秒抓眼球,点破半包业主盲目报面积询价、被装修公司当成新手宰割的现状,瞬间引发准备半包装修业主共鸣。
全程口语化大白话,小白一听就懂、可直接照搬拿去问装修公司,站业主立场拆解半包询价所有关键点,条理清晰干货满满,不生硬说教,贴合口播传播节奏。
@@ -38,7 +38,7 @@
第四,吊顶,问用的是木龙骨还是轻钢龙骨?石膏板是什么牌子的?做单层还是做双层?七字拐、八字缝有没有做?
第五,砌墙,问墙固用什么牌子,是油工刷还是开工就刷?挂网是局部还是全屋挂网?全挂要不要加钱?腻子的话,我只认国产一线品牌,其他我都不要。墙顶面我只要顺平就好,柜子后面、踢脚线、门口、窗口局部都要找平就行。乳胶漆用的是什么牌子,有没有刷底漆?是刷几遍,都要给我备注上。
最后,装修用的材料,如果发现是以次充好,该怎么赔?工人安全是谁来负责?工期耽误了又该怎么赔?施工不达标,要不要整改?整改费用谁出?
这些问题你不搞清楚,后期肯定扯皮。我整理了装修报价注意事项,评论区报价,拿去用
这些问题你不搞清楚,后期肯定扯皮。我整理了装修报价注意事项,评论区回复报价,拿去用
【内置完整素材库标题】
合同签署
卧室原始结构-毛坯基础
@@ -27,7 +27,7 @@
文案调整要求:仅做口语化精简优化,把直白问句改成顺口口播表述,不替换、不删减任何品牌,保持每类主材三个推荐品牌完整不变,原意丝毫不改。
字数与时长控制:纯文字 + 数字扣除标点,严格控制在 170-190 字,按每秒 4 个字计算,对应时长 42.5-47.5s,内容精炼、节奏紧凑,适配短平快知识口播。
内容适配性:打乱顺序后语句依然衔接自然,每条独立清晰,直接给到可照搬的主材品牌清单,解决业主选材纠结、怕踩坑的核心痛点,实用性拉满。
结尾范式:完整保留原文结尾引导原话,仅可轻微优化口语流畅度,不改动评论区关键词、领取材料推荐清单的核心引流逻辑。
结尾范式:完整保留原文结尾引导原话,仅可轻微优化口语流畅度,不改动评论区回复关键词、领取材料推荐清单的核心引流逻辑。
【开篇 & 语言要求】
无开篇铺垫,直接切入主材品牌推荐干货;全程短句口语化、接地气,直白罗列品牌,简单好记、业主可直接收藏对照选材。
可微调句式语序,严禁替换、删减任意主材品牌,不改变推荐逻辑和原意,语句简短利落,适配短时长口播节奏。
@@ -44,7 +44,7 @@
瓷砖胶买谁家?德高、大禹、神工。
乳胶漆买谁家?立邦、多乐士、三棵树。
玻璃胶买谁家?瓦克、西卡、百得。
记不住的,我这里有材料推荐清单,评论区材料,直接拿走。
记不住的,我这里有材料推荐清单,评论区回复材料,直接拿走。
【内置完整素材库标题】
合同签署
卧室原始结构-毛坯基础
@@ -30,7 +30,7 @@
文案调整要求:仅做口语化精简优化,把问句改成顺口口播表述,不删减、不替换任何一个品牌名称,完整保留每品类三大推荐品牌,原意丝毫不变。
字数与时长控制:纯文字 + 数字扣除标点,严格控制在 220-240 字,按每秒 4 个字核算,对应时长 55-60s,内容精炼紧凑、节奏适中,适配短平快知识口播。
内容适配性:打乱顺序后语句衔接自然,逐条清晰罗列,业主可直接对照抄作业选品牌,解决选材纠结、怕踩坑、不会分辨好坏的核心痛点,实用性极强。
结尾范式:完整保留原文结尾引导原话,仅轻微优化口语流畅度,不改动新房装修人群定位、评论区关键词领取装修避坑手册的核心引流逻辑。
结尾范式:完整保留原文结尾引导原话,仅轻微优化口语流畅度,不改动新房装修人群定位、评论区回复关键词领取装修避坑手册的核心引流逻辑。
【开篇 & 语言要求】
无开篇铺垫,直接切入品牌推荐干货;全程短句大白话、接地气,直白罗列靠谱品牌,简单好记、装修可直接照搬参考。
可微调句式语序,严禁改动、删减、替换任意品类及对应品牌,不改变推荐逻辑与原意,语句简短利落,适配中短时长口播节奏。
@@ -33,7 +33,7 @@
第六,材料假一罚十,品牌型号对好,你确认后再施工。防止装修公司以次充好,偷换材料。
第七,甲醛检测不合格,装修公司整改并承担所有费用。避免入住后甲醛超标,维权无门。
第八,违约责任划清楚,违约金和逾期赔付金额写明白。保障自己权益,让装修公司不敢随意违约。
准备装修的,我整理了合同模板,评论区装修就能领!帮你装修少踩坑、省麻烦!
准备装修的,我整理了合同模板,评论区回复装修就能领!帮你装修少踩坑、省麻烦!
【内置完整素材库标题】
合同签署
卧室原始结构-毛坯基础
@@ -21,7 +21,7 @@
全程口语化大白话,小白易懂,不生硬说教,站业主共情立场,贴合原文口语化风格。
可微调句式,不得篡改原文中工期、赔偿金比例、付款节点、材料条款等核心数字数据,每句必须带标点断句。
【细节固定要求】
结尾必须固定话术:我整理了装修全流程避坑指南,抠合同直接拿走。同时保留原文结尾“记不住的,我整理了装修合同样本,评论区合同,直接拿着对照检查,少踩坑!”
结尾必须固定话术:我整理了装修全流程避坑指南,抠合同直接拿走。同时保留原文结尾“记不住的,我整理了装修合同样本,评论区回复合同,直接拿着对照检查,少踩坑!”
总分镜数量固定12–20个,每个分镜时长3–8秒,可保留两位小数。
【内置固定原文案】
新房装修签合同千万注意这6个点,玩的都是文字游戏,耐心听我讲完,少踩一个坑等于多赚一笔钱。
@@ -31,7 +31,7 @@
第四,材料调换坑。很多公司条款上面写着,当材料断货时,可用同等价钱调换,但有这条,偷工减料就成了理所当然。同价产品很难界定,同价的杂牌你敢用吗?这条必须划掉。
第五,安全责任。有80%的公司只写按安全标准施工,但别不提出事谁负责?一旦发生安全事故,就是扯不完的皮。合同里必须注明工人人身安全及财产损失全部由装修公司承担。
第六,也是最恶心的一点,很多公司把单方面解约违约金写得很高,他们根本不会主动解约,这条就是为了绑死你。违约金超过20%,你发现问题也不敢换人,所以超过20%直接拉黑,别犹豫。
记不住的,我整理了装修合同样本,评论区合同,直接拿走对照检查,少踩坑!
记不住的,我整理了装修合同样本,评论区回复合同,直接拿走对照检查,少踩坑!
【内置完整素材库标题】
合同签署
卧室原始结构-毛坯基础
@@ -18,7 +18,7 @@
文案调整要求:微调仅针对句式口语化优化,把书面合同话术改成抖音口播接地气大白话,不改变违约金比例、付款节点金额、备注 5 条硬性约定等所有核心数字和规则,完整保留原文原意。
字数与时长控制:纯文字 + 数字(扣除标点)严格控制在 400-480 字,按每秒 4 个纯文字计算,对应时长 100-120s,讲解条款细致不啰嗦,节奏适中,适配短视频完播率。
内容适配性:三大要点及备注条款衔接自然,每部分独立适配空镜分镜,直击业主签约被套路、后期加价维权难的核心痛点,每一条都讲清陷阱、整改方法和保障作用,实用性极强。
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领取装修合同模板、评论区关键词引导的核心逻辑。
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领取装修合同模板、评论区回复关键词引导的核心逻辑。
【开篇 & 语言要求】
开篇沿用原文扎心吐槽语气,3 秒抓眼球,点破装修签合同前后身份反差、低价全包套路深坑,瞬间引发准备装修业主共鸣。
全程口语化大白话,通俗易懂、接地气,站业主立场拆解合同陷阱,条理清晰、干货满满,不生硬说教,适配口播传播节奏。
@@ -31,7 +31,7 @@
第五,贴砖时要在场,检查平整度空鼓率,阴阳角方正、缝隙均匀才合格。
第六,木工吊顶必在场,拐角整板、接缝做 V 型槽,杜绝后期乳胶漆开裂。
第七,刮腻子一定要在场,严禁往腻子加胶水,不然甲醛超标变毒气房。
准备装修的朋友,我整理了避坑手册,评论区避坑直接领取参考!
准备装修的朋友,我整理了避坑手册,评论区回复避坑直接领取参考!
【内置完整素材库标题】
合同签署
卧室原始结构-毛坯基础
@@ -21,7 +21,7 @@
文案调整要求:微调仅针对句式口语化优化,把书面表述改成抖音接地气口播大白话,不改变每个节点的施工要求、到场必要性、后期隐患,所有细节完整保留。
字数与时长控制:纯文字 + 数字(扣除标点)严格控制在 360-440 字,按每秒 4 个纯文字计算,对应时长 90-110s,内容精炼不啰嗦,节奏适中符合短视频完播习惯。
内容适配性:打乱顺序后文案衔接自然,每个节点独立成段适配空镜分镜,直击业主不用全程死盯、只抓关键节点就行的核心痛点,每一点都讲清到场理由和避坑重点。
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领资料、评论区关键词、福利引导的核心逻辑。
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领资料、评论区回复关键词、福利引导的核心逻辑。
【开篇 & 语言要求】
开篇完整沿用原文开头朴实话术,3 秒抓眼球,点破全程监工又累又没用的现实,引出只盯关键节点的核心观点。
全程口语化大白话,小白易懂、接地气实在,站普通业主视角共情讲解,不生硬说教,语气真诚接地气。
@@ -34,7 +34,7 @@
第四,吊顶时,你必须在场,确认好使用的是轻钢龙骨,别让师傅偷换用木龙骨,再直接封上石膏板,后期变形发霉,等你发现那就晚了。
第五,全屋定制安装,你必须在场,通过五金孔检查板材品质,还要叮嘱师傅做好封边,少做一步,你家都可能甲醛超标。
第六,房子做完闭水试验,你必须亲自去楼下邻居家看看有没有漏水,如果只让师傅拍照片,你根本不知道他是什么时候拍的。真出了问题还得你来赔付。
记不住的,我整理了装修全流程避坑手册。评论区避坑,拿去用。
记不住的,我整理了装修全流程避坑手册。评论区回复避坑,拿去用。
【内置完整素材库标题】
合同签署
卧室原始结构-毛坯基础
@@ -21,7 +21,7 @@
文案调整要求:微调仅针对句式口语化优化,改成抖音口播接地气大白话,不改变每个要点的施工场景、业主行为、带来的影响,完整保留原意不变。
字数与时长控制:纯文字 + 数字(扣除标点)严格控制在 400-480 字,按每秒 4 个纯文字计算,对应时长 100-120s,讲解饱满不拖沓,符合短视频完播习惯。
内容适配性:6 个要点讲解衔接自然,每点独立成段适配空镜分镜,聚焦业主不懂行乱指挥、盲目加活的通病,既讲做法又讲背后利弊,真实接地气、容易引发共鸣。
结尾范式:以 “如果你也准备新房装修,我整理了一份装修全流程避坑手册。评论区避坑,拿去用。” 为核心句式,保留原文结尾结构和领资料引导话术,仅可轻微优化口语流畅度,不改动核心逻辑。
结尾范式:以 “如果你也准备新房装修,我整理了一份装修全流程避坑手册。评论区回复避坑,拿去用。” 为核心句式,保留原文结尾结构和领资料引导话术,仅可轻微优化口语流畅度,不改动核心逻辑。
【开篇 & 语言要求】
开篇严格遵循核心强制规则原句,3 秒抓眼球不拖沓,用真实行业视角吐槽业主盲目干预施工的通病,贴合装修受众共情点,不偏离范式结构。
全程口语化大白话,小白易懂、不生硬说教,站客观中立角度讲解,语气接地气有真实感,贴合口播传播特点。
@@ -34,7 +34,7 @@
第四,木工师傅高高兴兴来了,你却告诉他,所有接缝处都要做 V 字型槽,转角处要做到 T 字型。师傅一听就知道你是懂行的。后期墙面是不容易开裂了,又给师傅增加好多活儿。
第五,瓦工师傅来了,懂行的业主要求把卫生间先找坡度,地漏做成回形地漏,这样不仅下水快,还好看,可这又得浪费师傅半天时间,重新找坡度。
第六,瓦工还没结束,部分业主已经提前买好了地漏和油烟止逆阀,要求师傅一并装上。这下好了,之后安装电器的师傅想赚点外快都不行。
如果你也准备新房装修,我整理了一份装修全流程避坑手册。评论区避坑,拿去用。
如果你也准备新房装修,我整理了一份装修全流程避坑手册。评论区回复避坑,拿去用。
【内置完整素材库标题】
合同签署
卧室原始结构-毛坯基础
@@ -9,7 +9,7 @@
【核心强制规则】
开头范式:保留原文完整开头结构与核心原意,仅微调口语语气,不篡改句意,直击全屋定制合同签完仍乱加价、套路多的痛点,引出3个必看避坑要点。
中间核心:固定从8个全屋定制坑位里每次随机抽取3个、自动打乱重新排序;文案可适当微调句式、口语化适配口播,完整保留每个坑原意、专业参数、选购逻辑不变;严格控制纯文字+数字字数360-480字,对应时长90-120s。
结尾范式:完整保留原文结尾结构和领资料引导话术,仅可轻微优化口语流畅度,不改动领福利、评论区关键词的核心逻辑。
结尾范式:完整保留原文结尾结构和领资料引导话术,仅可轻微优化口语流畅度,不改动领福利、评论区回复关键词的核心逻辑。
【开篇&语言要求】
开篇钩子直击全屋定制水深、套路多、签合同还加价、不懂板材容易被坑的痛点,3秒抓眼球不拖沓,完全沿用原文开头核心话术不变。
全程口语化大白话,小白易懂、不生硬说教,站业主共情立场,贴合原文接地气口播风格。
@@ -24,7 +24,7 @@
第六就是铰链,你问他什么品牌,但凡跟你说是他们自有品牌,直接让他有多远滚多远。他又不是生产队的驴,啥都能生产。多半是找小工厂代工的,别为了省那点钱,铰链就认准汉高、东泰、德蒂,每天都要开关,咱们可不能马虎。
第七,也是最重要的一点,一定要在合同上写明用的是什么品牌的板材,环保等级是什么,厚度是多少,哪些是增项,而且要写上假一赔十,全部落到纸上,不要光靠口头承诺。
第八,全屋定制,不管是橱柜也好,衣柜也好,一线品牌和六线品牌做出来都是一模一样的。说白了,所有全屋定制都是板材的二道贩子,咱们就找本地工厂,关键看设计和安装。
要是还有不懂的、近期准备新房装修的朋友,我整理了一份装修避坑手册供你参考,评论区避坑,拿去用。
要是还有不懂的、近期准备新房装修的朋友,我整理了一份装修避坑手册供你参考,评论区回复避坑,拿去用。
【内置完整素材库标题】
合同签署
卧室原始结构-毛坯基础
@@ -26,7 +26,7 @@
3. 厨卫水电必走顶,漏水易发现好维修,其他地方走地省材料。
4. 验收必做水管打压30分钟无渗漏,电路测通断再签字。
水电是隐蔽工程,紧盯施工别偷懒,别等返工才追悔莫及!
近期准备装修的可以找我领装修避坑手册,评论区避坑,直接拿走。
近期准备装修的可以找我领装修避坑手册,评论区回复避坑,直接拿走。
【内置完整素材库标题】
合同签署
卧室原始结构-毛坯基础
@@ -25,7 +25,7 @@
文案调整要求:仅做口语化句式微调,把书面表述改成接地气口播大白话,不改动任何施工细节、工艺要求、禁忌标准,完整保留 10 条话术核心原意。
字数与时长控制:纯文字 + 数字(扣除标点)严格控制在 440-480 字,按每秒 4 个纯文字计算,对应时长 110-120s,讲解饱满不拖沓,符合短视频用户完播习惯。
内容适配性:打乱顺序后文案衔接自然,每条话术独立成点、逻辑通顺,贴合业主瓦工进场监工刚需,直击无效送礼不如专业话术管用的核心痛点,每一条都明确施工标准和避坑要点。
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领资料、评论区关键词、福利引导的核心逻辑。
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领资料、评论区回复关键词、福利引导的核心逻辑。
【开篇 & 语言要求】
开篇完整沿用原文开头句式和吐槽语气,3 秒抓眼球,直击业主花钱送礼无效监工的通病,引出专业监工话术。
全程口语化大白话,接地气、通俗易懂,站装修业主视角共情讲解,不生硬说教。
@@ -42,7 +42,7 @@
第八句,师傅,所有的转角都要海棠角,后期我要做美缝,千万别给我做阳角条。
第九句,师傅需要贴止逆阀的地方一定要帮我贴一块整砖。我的止逆阀也买回来,你按这个开孔以后,顺手帮我装上吧。
第十句,师傅,我家橱柜和浴室柜不打算装挡水条,所以对墙面阴阳角的垂直度要求比较高,麻烦你上点心啊。
准备新房装修的朋友,我整理了装修全流程避坑手册。评论区避坑,拿去用。
准备新房装修的朋友,我整理了装修全流程避坑手册。评论区回复避坑,拿去用。
【内置完整素材库标题】
合同签署
卧室原始结构-毛坯基础
@@ -20,7 +20,7 @@
2. 文案调整要求:微调仅针对句式口语化优化,比如将书面化表述改为抖音/视频号口播常用的接地气语气,补充轻微危害提示(结合美缝反碱脱落、水电标识撕毁的隐患),不改变每个坑的核心信息——如验收等待五六天、美缝等待一周、禁止洒水、保留水电标识、定制复尺周期一个月等核心时间节点和禁忌,所有细节完全保留,贴合原文原意。
3. 字数与时长控制:纯文字+数字(扣除标点)严格控制在400-480字,按每秒4个纯文字计算,对应时长100-120s,既保证每个避坑点讲解透彻,补充必要危害提示,又不拖沓,符合短视频用户观看习惯,避免用户划走。
4. 内容适配性:5个避坑要点讲解时需衔接自然,每个坑独立成段(分镜对应空镜),不重复、不冗余,重点突出“停工避坑”核心,贴合业主担心被装修公司催促、怕后期出问题自己担责、想合理利用停工时间的核心痛点,每段讲解都紧扣“为什么不能做、怎么做才对”的逻辑,与原文保持一致,结合参考内容完善危害提示,增强说服力。
结尾范式:以“如果你们也在准备新房装修,不知道还有哪些坑要避,评论区 ‘装修’,我把整理好的装修避坑手册,免费发给你们,帮你们省时间、省钱!记得关注我,装修不踩坑!”为核心句式,保留原文结尾结构和领资料引导话术,仅可轻微优化口语流畅度,不改动领福利、评论区关键词、关注引导的核心逻辑。
结尾范式:以“如果你们也在准备新房装修,不知道还有哪些坑要避,评论区回复 ‘装修’,我把整理好的装修避坑手册,免费发给你们,帮你们省时间、省钱!记得关注我,装修不踩坑!”为核心句式,保留原文结尾结构和领资料引导话术,仅可轻微优化口语流畅度,不改动领福利、评论区回复关键词、关注引导的核心逻辑。
【开篇&语言要求】
开篇严格遵循核心强制规则的警示性句式,3秒抓眼球不拖沓,用犀利语气点出瓷砖铺贴后被催工期、盲目施工后期担责的痛点,贴合装修业主避坑需求,不偏离范式结构。
全程口语化大白话,小白易懂、不生硬说教,站业主共情立场,用警示性语气讲解,贴合口播传播特点,增强代入感,补充的危害提示通俗易懂,让业主清晰了解违规操作的后果。
@@ -32,7 +32,7 @@
第三,瓷砖铺完后千万不要洒水,你洒水养护的是下面的水泥砂浆,那活儿,瓦工铺的时候就应该把墙面地面打湿再贴,铺完了再打扫干净,盖好保护膜就可以了,别多此一举。
第四,墙面的水电标识贴不要撕,这是给后期安装师傅看的。你一撕,人家打孔打到水管电线,你就等着哭吧,不仅维修麻烦,还可能引发安全隐患。
最后,停工这几天也别闲着。闲着你就可以让定制商家上门复尺,提前下单,定制周期差不多一个月,到时候你家油工结束了,这些东西正好能装,一点儿不耽误工期。
如果你们也在准备新房装修,不知道还有哪些坑要避,评论区 “装修”,我把整理好的装修避坑手册,免费发给你们,帮你们省时间、省钱!记得关注我,装修不踩坑!
如果你们也在准备新房装修,不知道还有哪些坑要避,评论区回复 “装修”,我把整理好的装修避坑手册,免费发给你们,帮你们省时间、省钱!记得关注我,装修不踩坑!
【内置完整素材库标题】
合同签署
卧室原始结构-毛坯基础
@@ -21,7 +21,7 @@
文案调整要求:微调仅针对句式口语化优化,把直白叙述话术改成抖音口播接地气大白话,不改变每一步施工做法、选材建议、隐患危害等所有核心信息,完整保留原文原意。
字数与时长控制:纯文字 + 数字(扣除标点)严格控制在 440-480 字,按每秒 4 个纯文字计算,对应时长 110-120s,讲解收尾细节细致不啰嗦,节奏适中,适配短视频完播率。
内容适配性:7 个收尾要点衔接自然,每一条独立适配空镜分镜,直击业主硬装完工急于入住、忽略隐蔽收尾细节,后期返工闹心的核心痛点,每一条都讲清做法、原因和避坑作用,实用性极强。
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领取装修全流程避坑手册、评论区关键词引导的核心逻辑。
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领取装修全流程避坑手册、评论区回复关键词引导的核心逻辑。
【开篇 & 语言要求】
开篇沿用原文警示吐槽语气,3 秒抓眼球,点破硬装刚结束着急搬软装、忽略收尾细节入住就留隐患闹矛盾的真实痛点,瞬间引发装修完工业主共鸣。
全程口语化大白话,通俗易懂、接地气,站业主立场拆解装修收尾细节,条理清晰、干货满满,不生硬说教,适配口播传播节奏。
@@ -29,7 +29,7 @@
第四、门口、踢脚线、衣柜周围重点找平,别留难看缝隙。
第五、吊顶钉子眼一定要人工刷防锈漆,防止后期生锈难看。
第六、油工验收合格再给钱,面子工程必须把好质量关。
准备装修的朋友,评论区避坑直接领取装修流程避坑手册!直接拿着对照参考,少踩坑!
准备装修的朋友,评论区回复避坑直接领取装修流程避坑手册!直接拿着对照参考,少踩坑!
【内置完整素材库标题】
合同签署
卧室原始结构-毛坯基础
+1 -1
View File
@@ -128,7 +128,7 @@ class ViduProvider:
logger.info(f"[Vidu TTS] 请求参数: text_length={len(text)}")
logger.info(f"[Vidu LipSync] 提交请求: url={url}, body={body}")
logger.info(f"[Vidu TTS] 提交请求: url={url}, body={body}")
try:
resp = await self.client.post(url, json=body)
@@ -0,0 +1,174 @@
"""
火山引擎 MediaKit Provider
===========================
直接封装火山引擎 MediaKit HTTP API
- 图像背景移除(/api/v1/tools/remove-image-background/sync
使用 httpx.AsyncClient,支持外部注入(由 lifespan 管理生命周期)。
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from app.config import get_settings
from app.core.exceptions import PlatformError, PlatformErrorType
logger = logging.getLogger(__name__)
def _map_mediakit_error(status: int, message: str, code: int | None = None) -> PlatformError:
"""把 MediaKit 错误映射为标准 PlatformError"""
error_mapping = {
400: (PlatformErrorType.BAD_REQUEST, False),
401: (PlatformErrorType.AUTH_FAILED, False),
403: (PlatformErrorType.AUTH_FAILED, False),
429: (PlatformErrorType.RATE_LIMIT, True),
500: (PlatformErrorType.SERVER_ERROR, True),
502: (PlatformErrorType.SERVER_ERROR, True),
503: (PlatformErrorType.SERVER_ERROR, True),
}
error_type, retryable = error_mapping.get(status, (PlatformErrorType.UNKNOWN, False))
return PlatformError(
message, platform="volcengine_mediakit",
retryable=retryable, error_type=error_type,
status_code=status,
)
class VolcengineMediakitProvider:
"""火山引擎 MediaKit Provider
直接调用 MediaKit HTTP API,不做业务层处理。
"""
BASE_URL = "https://mediakit.cn-beijing.volces.com"
REMOVE_BG_PATH = "/api/v1/tools/remove-image-background/sync"
DEFAULT_TIMEOUT = 60.0
def __init__(
self,
token: str | None = None,
client: httpx.AsyncClient | None = None,
):
settings = get_settings()
self.token = token or settings.VOLCENGINE_MEDIAKIT_TOKEN or ""
if not self.token:
raise PlatformError(
"VOLCENGINE_MEDIAKIT_TOKEN 未配置",
platform="volcengine_mediakit",
retryable=False,
error_type=PlatformErrorType.BAD_REQUEST,
)
if client is not None:
self.client = client
self._owns_client = False
else:
self.client = httpx.AsyncClient(timeout=self.DEFAULT_TIMEOUT)
self._owns_client = True
def _get_headers(self) -> dict:
return {
"Authorization": f"Bearer; {self.token}",
"Content-Type": "application/json",
}
async def close(self) -> None:
"""关闭 HTTP 客户端"""
if self._owns_client and self.client and not self.client.is_closed:
await self.client.aclose()
async def remove_background(
self,
image_url: str,
scene: str = "general",
need_contour: bool = False,
contour_color: str = "#FFFFFF",
contour_size: int = 10,
need_crop_background: bool = False,
) -> dict[str, Any]:
"""同步抠图,返回原始 JSON
Args:
image_url: 原始图片 URL
scene: 场景类型
need_contour: 是否为主体生成描边(仅 human/product 场景生效)
contour_color: 描边颜色,十六进制 RGB 格式
contour_size: 描边宽度(px),范围 [1, 100]
need_crop_background: 是否裁剪透明背景到刚好包裹主体
Returns:
{"code": 0, "message": "Success", "data": {"image_url": "https://..."}}
"""
payload: dict[str, Any] = {"image_url": image_url, "scene": scene}
if need_contour:
payload["need_contour"] = True
payload["contour_color"] = contour_color
payload["contour_size"] = max(1, min(100, contour_size))
if need_crop_background:
payload["need_crop_background"] = True
try:
response = await self.client.post(
f"{self.BASE_URL}{self.REMOVE_BG_PATH}",
json=payload,
headers=self._get_headers(),
)
response.raise_for_status()
data = response.json()
# 火山引擎 MediaKit 有两种响应格式:
# 格式1: {"code": 0, "message": "...", "data": {...}}
# 格式2: {"success": true, "result": {...}, "expires_at": ...}
code = data.get("code")
if code is not None:
# 格式1
if code != 0:
logger.warning(
f"[MediaKit] 抠图业务失败: code={code}, "
f"message={data.get('message', 'N/A')}, "
f"raw_response={data}, image_url={image_url[:80]}..."
)
raise _map_mediakit_error(
response.status_code,
data.get("message", f"抠图失败: code={code}"),
code=code,
)
return data
else:
# 格式2
if not data.get("success", False):
logger.warning(
f"[MediaKit] 抠图业务失败: success=false, "
f"raw_response={data}, image_url={image_url[:80]}..."
)
raise _map_mediakit_error(
response.status_code,
"抠图失败: 平台返回失败状态",
)
# 将格式2标准化为格式1,方便上层统一处理
return {
"code": 0,
"message": "Success",
"data": data.get("result", {}),
}
except PlatformError:
raise
except httpx.HTTPStatusError as e:
raise _map_mediakit_error(
e.response.status_code, f"HTTP错误: {e.response.status_code}"
) from e
except (httpx.NetworkError, httpx.TimeoutException) as e:
raise PlatformError(
f"MediaKit 网络错误: {e}", platform="volcengine_mediakit",
retryable=True, error_type=PlatformErrorType.TIMEOUT,
) from e
except Exception as e:
raise _map_mediakit_error(500, f"抠图失败: {str(e)}") from e
+43
View File
@@ -0,0 +1,43 @@
"""
背景音乐 API
===========
提供装修行业场景化 BGM 列表查询。
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db
from app.crud.bgm_music import bgm_music
from app.schemas.bgm_music import BgmMusicItem, BgmMusicListResponse
from app.schemas.common import ApiResponse
router = APIRouter()
@router.get("/bgm-musics", response_model=ApiResponse[BgmMusicListResponse])
async def list_bgm_musics(
category: str | None = Query(None, description="场景分类筛选"),
db: AsyncSession = Depends(get_db),
) -> ApiResponse[BgmMusicListResponse]:
"""
获取背景音乐列表
按场景分类返回可用的背景音乐列表。
分类说明:
- knowledge: 知识科普(极简、低频)
- showcase: 案例展示(积极、有成就感)
- promotion: 促销活动(轻快、有能量)
- lifestyle: 家居生活(温馨、治愈)
- tech: 智能家居(科技感、高级感)
"""
items = await bgm_music.get_active_by_category(db, category=category)
return ApiResponse(
code=200,
message="success",
data=BgmMusicListResponse(
items=[BgmMusicItem.model_validate(item) for item in items],
total=len(items),
),
)
+262
View File
@@ -0,0 +1,262 @@
"""
图片处理 API
============
提供图片上传(七牛云)和 AI 抠图(火山引擎 MediaKit)功能。
"""
import io
import logging
import time
import uuid
from pathlib import Path
import httpx
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.config import get_settings
from app.models.user import User
from app.schemas.common import ApiResponse, success_response
from app.services import point_service as ps
from app.services.qiniu_service import get_qiniu_service
from app.services.volcengine_mediakit_service import VolcengineMediakitService
from app.utils.file_validation import check_upload_file
router = APIRouter(tags=["Image"])
logger = logging.getLogger(__name__)
settings = get_settings()
# ── Dependencies ──
async def get_mediakit_service(request: Request) -> VolcengineMediakitService:
"""FastAPI Depends:从 app.state 获取全局 VolcengineMediakitService 实例。"""
service = getattr(request.app.state, "volcengine_mediakit_service", None)
if service is None:
raise HTTPException(
status_code=503,
detail="MediaKit 服务未初始化,请检查配置",
)
return service
# ── Schemas ──
class ImageUploadResponse(BaseModel):
"""图片上传响应"""
url: str = Field(..., description="七牛云图片 URL")
key: str = Field(..., description="七牛云文件 key")
size: int = Field(..., description="文件大小(字节)")
class RemoveBackgroundResponse(BaseModel):
"""抠图响应"""
url: str = Field(..., description="抠图结果图片 URL")
class RemoveBackgroundRequest(BaseModel):
"""抠图请求"""
image_url: str = Field(..., description="原始图片 URL")
scene: str = Field(default="human", description="场景类型:general(通用)、human(人物,默认白色描边)或 product(商品)")
# ── Endpoints ──
@router.post("/upload/image", response_model=ApiResponse[ImageUploadResponse])
async def upload_image(
file: UploadFile = File(..., description="图片文件"),
current_user: User = Depends(get_current_user),
) -> ApiResponse[ImageUploadResponse]:
"""
上传图片到七牛云
支持格式:jpg, jpeg, png, gif, webp
返回七牛云永久访问 URL。
"""
try:
allowed_types = {
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
}
content_type = file.content_type or ""
# 如果 content_type 为空,尝试从文件名推断
if not content_type:
ext = Path(file.filename or "").suffix.lower()
ext_to_mime = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
}
content_type = ext_to_mime.get(ext, "")
if content_type not in allowed_types:
raise HTTPException(
status_code=400,
detail=f"不支持的图片格式: {content_type},请上传 jpg/png/gif/webp 图片",
)
# 读取文件内容
content = await file.read()
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}"
# 上传到七牛云
qiniu = get_qiniu_service()
bucket, domain = qiniu._get_bucket_and_domain("image")
file_key = qiniu.generate_key("image", unique_name)
stream = io.BytesIO(content)
result = await qiniu.upload_stream_async(
stream=stream,
key=file_key,
mime_type=content_type or "image/jpeg",
bucket=bucket,
domain=domain,
)
url = result.get("url")
returned_key = result.get("key")
if not url:
raise HTTPException(status_code=500, detail="上传到七牛云失败:未返回 URL")
logger.info(f"[Upload] 图片上传成功: {url[:80]}..., size={len(content)}")
return success_response(
data=ImageUploadResponse(
url=url,
key=returned_key or file_key,
size=len(content),
)
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[Upload] 图片上传失败: {e}")
raise HTTPException(status_code=500, detail=f"上传失败: {e}")
@router.post("/image/remove-background", response_model=ApiResponse[RemoveBackgroundResponse])
async def remove_background(
req: RemoveBackgroundRequest,
current_user: User = Depends(get_current_user),
mediakit_service: VolcengineMediakitService = Depends(get_mediakit_service),
db: AsyncSession = Depends(get_db),
) -> ApiResponse[RemoveBackgroundResponse]:
"""
AI 抠图(火山引擎 MediaKit
移除图片背景,返回透明背景图片 URL。
每次调用消耗 10 积分。
"""
# 前置积分检查
required_points = ps._calculate_cost("cover_avatar")
check = await ps.check_balance(db, current_user.id, required_points)
if not check["sufficient"]:
raise HTTPException(
status_code=402,
detail=f"积分不足,需要 {required_points} 积分,当前余额 {check['balance']}",
)
try:
logger.info(
f"[RemoveBackground] 开始抠图: image_url={req.image_url[:80]}..., scene={req.scene}"
)
result = await mediakit_service.remove_background(
image_url=req.image_url,
scene=req.scene,
)
if not result.image_url:
logger.error(
f"[RemoveBackground] 抠图返回空 URL: raw={result.raw}"
)
raise HTTPException(status_code=500, detail="抠图失败:未返回结果图片 URL")
logger.info(f"[RemoveBackground] 抠图成功: {result.image_url[:80]}...")
# 下载抠图结果并转存到七牛云(避免前端 CORS 问题)
try:
async with httpx.AsyncClient(timeout=60.0) as client:
img_resp = await client.get(result.image_url, follow_redirects=True)
img_resp.raise_for_status()
img_content = img_resp.content
if not img_content:
raise HTTPException(status_code=500, detail="抠图结果下载失败:内容为空")
# 上传到七牛云 image bucket
qiniu = get_qiniu_service()
bucket, domain = qiniu._get_bucket_and_domain("image")
unique_name = f"{uuid.uuid4().hex[:16]}.png"
file_key = qiniu.generate_key("image", unique_name)
stream = io.BytesIO(img_content)
upload_result = await qiniu.upload_stream_async(
stream=stream,
key=file_key,
mime_type="image/png",
bucket=bucket,
domain=domain,
)
qiniu_url = upload_result.get("url")
if not qiniu_url:
raise HTTPException(status_code=500, detail="抠图结果转存到七牛云失败")
logger.info(f"[RemoveBackground] 结果已转存七牛云: {qiniu_url[:80]}...")
# 后置扣费(服务已调用成功)
await ps.consume(
db,
user_id=current_user.id,
points=required_points,
source_type="cover_avatar",
source_id=f"cover_avatar_{current_user.id}_{int(time.time() * 1000)}",
description="【封面形象抠图】",
)
await db.commit()
return success_response(
data=RemoveBackgroundResponse(url=qiniu_url),
message="抠图成功",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[RemoveBackground] 结果转存失败: {e}")
raise HTTPException(status_code=500, detail=f"抠图结果转存失败: {e}")
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(
f"[RemoveBackground] 抠图失败: image_url={req.image_url[:80]}..., error={e}"
)
raise HTTPException(status_code=500, detail=f"抠图失败: {e}")
+8
View File
@@ -7,9 +7,11 @@ from fastapi import APIRouter
from app.api.v1 import (
auth,
bgm_music,
caption,
cover_background,
events,
image,
materials,
points,
script,
@@ -59,5 +61,11 @@ api_router.include_router(cover_background.router, tags=["Cover Background"])
# 积分系统模块
api_router.include_router(points.router, tags=["Points"])
# 图片处理模块(上传 + 抠图)
api_router.include_router(image.router, tags=["Image"])
# 背景音乐模块
api_router.include_router(bgm_music.router, tags=["BGM Music"])
# 应用更新模块
api_router.include_router(update.router, prefix="/update", tags=["Update"])
+11 -6
View File
@@ -7,7 +7,7 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -44,25 +44,30 @@ async def check_update(
latest: AppRelease | None = result.scalar_one_or_none()
if not latest:
raise HTTPException(status_code=status.HTTP_204_NO_CONTENT)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# 已是最新版本(或更高)
if latest.version == version:
raise HTTPException(status_code=status.HTTP_204_NO_CONTENT)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# 查询对应平台的包
# 查询对应平台的包(优先返回 updater 用的包:有 signature 的 .app.tar.gz / .exe
result = await db.execute(
select(ReleasePackage).where(
ReleasePackage.release_id == latest.id,
ReleasePackage.platform == target,
ReleasePackage.architecture == arch,
).order_by(
# 有 signature 的排前面(updater 包),空 signature 的排后面(dmg 安装包)
ReleasePackage.signature.desc()
)
)
pkg: ReleasePackage | None = result.scalar_one_or_none()
pkgs = result.scalars().all()
# 取第一个:优先有 signature 的 updater 包;如果没有则取任意一个
pkg: ReleasePackage | None = pkgs[0] if pkgs else None
if not pkg:
# 该平台无包,返回 204(避免报错阻断用户)
raise HTTPException(status_code=status.HTTP_204_NO_CONTENT)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# 构建 Tauri 格式的响应
platform_key = f"{target}-{arch}"
+3 -97
View File
@@ -18,6 +18,7 @@ from app.config import get_settings
from app.models.user import User
from app.schemas.common import ApiResponse, success_response
from app.services.qiniu_service import get_qiniu_service
from app.utils.file_validation import check_upload_file
router = APIRouter(prefix="/upload", tags=["Upload"])
@@ -25,101 +26,6 @@ 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):
"""上传响应"""
@@ -173,7 +79,7 @@ async def upload_video(
raise HTTPException(status_code=400, detail="文件内容为空")
# 校验大小和魔数
_check_upload_file(
check_upload_file(
content,
settings.UPLOAD_MAX_VIDEO_SIZE,
content_type,
@@ -269,7 +175,7 @@ async def upload_audio(
raise HTTPException(status_code=400, detail="文件内容为空")
# 校验大小和魔数
_check_upload_file(
check_upload_file(
content,
settings.UPLOAD_MAX_AUDIO_SIZE,
content_type,
+6 -2
View File
@@ -43,7 +43,11 @@ async def vidu_callback(request: Request):
body_bytes = await request.body()
headers_dict = dict(request.headers)
logger.info(f"[Vidu] 收到回调: url={request.url}, body={body_bytes.decode('utf-8', errors='replace')[:500]}")
# 使用 APP_BASE_URL 构建 callback_url,确保与提交任务时传给 Vidu 的一致
#Nginx 反向代理可能导致 request.url 的 scheme 为 http,与 Vidu 签名时的 https 不一致)
app_base_url = get_settings().app_base_url
callback_url = f"{app_base_url}/api/v1/vidu/callback" if app_base_url else str(request.url)
logger.info(f"[Vidu] 收到回调: request_url={request.url}, callback_url={callback_url}, body={body_bytes.decode('utf-8', errors='replace')[:500]}")
try:
task_status = await gateway.handle_webhook(
@@ -51,7 +55,7 @@ async def vidu_callback(request: Request):
headers=headers_dict,
body=body_bytes,
secret=get_settings().VIDU_API_KEY,
callback_url=str(request.url),
callback_url=callback_url,
)
except PlatformError as e:
logger.warning(f"[Vidu] 回调验证失败: {e}")
+20 -2
View File
@@ -24,7 +24,7 @@ class Settings(BaseSettings):
# 应用基础配置
APP_NAME: str = Field(default="美家卡智影 API", description="应用名称")
APP_VERSION: str = Field(default="1.5.18", description="应用版本")
APP_VERSION: str = Field(default="1.6.6", description="应用版本")
DEBUG: bool = Field(default=False, description="调试模式")
ENV: Literal["development", "staging", "production"] = Field(
default="development", description="运行环境"
@@ -88,7 +88,7 @@ class Settings(BaseSettings):
# CORS 配置
CORS_ORIGINS: str = Field(
default="http://localhost:1420,http://127.0.0.1:1420,http://localhost:8080,http://127.0.0.1:8080,tauri://localhost",
default="http://localhost:1420,http://127.0.0.1:1420,http://localhost:8080,http://127.0.0.1:8080,tauri://localhost,http://tauri.localhost,https://tauri.localhost",
description="允许的跨域来源(逗号分隔)",
)
@@ -106,6 +106,9 @@ class Settings(BaseSettings):
VOLCENGINE_CAPTION_APPID: str | None = Field(default=None, description="火山字幕 AppID")
VOLCENGINE_CAPTION_TOKEN: str | None = Field(default=None, description="火山字幕 Token")
# 火山引擎 MediaKit 服务(背景移除等多媒体处理)
VOLCENGINE_MEDIAKIT_TOKEN: str | None = Field(default=None, description="火山引擎 MediaKit Token")
# Vidu 密钥(base_url 已从 Settings 移除,改用 config/platform-config.yaml 配置)
VIDU_API_KEY: str | None = Field(default=None, description="Vidu API Key")
@@ -134,6 +137,10 @@ class Settings(BaseSettings):
SMS_EXTENDED_CODE: str | None = Field(
default=None, description="B2M 短信平台扩展码(选填)"
)
SMS_CODE_WHITELIST: str = Field(
default="",
description="免验证码登录白名单(逗号分隔的手机号,如 13800138000,13900139000",
)
@@ -175,6 +182,17 @@ class Settings(BaseSettings):
"""是否使用 Redis"""
return bool(self.REDIS_HOST)
@property
def sms_code_whitelist_set(self) -> set[str]:
"""免验证码登录白名单(去重、去空格)"""
if not self.SMS_CODE_WHITELIST:
return set()
return {
mobile.strip()
for mobile in self.SMS_CODE_WHITELIST.split(",")
if mobile.strip()
}
@lru_cache
def get_settings() -> Settings:
+2
View File
@@ -10,6 +10,7 @@ CRUD 模块
user_obj = await user.get(db, id="xxx")
"""
from app.crud.bgm_music import bgm_music
from app.crud.broll_category import broll_category
from app.crud.broll_material import broll_material
from app.crud.cover_background import cover_background
@@ -17,6 +18,7 @@ from app.crud.user import user
__all__ = [
"user",
"bgm_music",
"broll_category",
"broll_material",
"cover_background",
+37
View File
@@ -0,0 +1,37 @@
"""
背景音乐 CRUD
============
"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud.base import CRUDBase
from app.models.bgm_music import BgmMusic
class BgmMusicCRUD(CRUDBase[BgmMusic]):
"""背景音乐数据访问"""
def __init__(self) -> None:
super().__init__(BgmMusic)
async def get_active_by_category(
self, db: AsyncSession, *, category: str | None = None
) -> list[BgmMusic]:
"""
获取指定分类下状态为 active 的音乐列表
Args:
category: 场景分类,None 表示获取全部
"""
query = select(BgmMusic).where(BgmMusic.status == "active")
if category:
query = query.where(BgmMusic.category == category)
query = query.order_by(BgmMusic.sort_order.asc(), BgmMusic.id.asc())
result = await db.execute(query)
return list(result.scalars().all())
# 导出实例
bgm_music = BgmMusicCRUD()
+36 -1
View File
@@ -66,6 +66,10 @@ async def lifespan(app: FastAPI):
timeout=httpx.Timeout(60.0, connect=5.0),
limits=httpx.Limits(max_connections=10, max_keepalive_connections=10),
),
"volcengine_mediakit": httpx.AsyncClient(
timeout=httpx.Timeout(60.0, connect=5.0),
limits=httpx.Limits(max_connections=10, max_keepalive_connections=10),
),
"default": httpx.AsyncClient(
timeout=httpx.Timeout(30.0, connect=5.0),
limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
@@ -90,6 +94,18 @@ async def lifespan(app: FastAPI):
logger.warning(f"Volcengine Caption Provider 初始化跳过: {e}")
app.state.volcengine_caption_provider = None
# 火山 Mediakit Provider
from app.ai.providers.volcengine_mediakit_provider import VolcengineMediakitProvider
try:
app.state.volcengine_mediakit_provider = VolcengineMediakitProvider(
client=app.state.http_clients["volcengine_mediakit"]
)
logger.info("Volcengine Mediakit Provider initialized")
except Exception as e:
logger.warning(f"Volcengine Mediakit Provider 初始化跳过: {e}")
app.state.volcengine_mediakit_provider = None
# 火山方舟 Provider(可选,需要 API Key
try:
from app.ai.providers.volcengine_provider import VolcengineProvider
@@ -104,6 +120,7 @@ async def lifespan(app: FastAPI):
from app.ai.adapters.vidu_adapter import ViduAdapter
from app.ai.adapters.volcengine_ark_adapter import VolcengineArkAdapter
from app.ai.adapters.volcengine_caption_adapter import VolcengineCaptionAdapter
from app.ai.adapters.volcengine_mediakit_adapter import VolcengineMediakitAdapter
from app.platform_gateway import PlatformGateway
app.state.vidu_adapter = ViduAdapter(app.state.vidu_provider)
@@ -122,6 +139,15 @@ async def lifespan(app: FastAPI):
)
logger.info("VolcengineCaptionAdapter initialized")
if app.state.volcengine_mediakit_provider:
app.state.volcengine_mediakit_adapter = VolcengineMediakitAdapter(
app.state.volcengine_mediakit_provider
)
app.state.platform_gateway.register(
"volcengine_mediakit", app.state.volcengine_mediakit_adapter
)
logger.info("VolcengineMediakitAdapter initialized")
if app.state.volcengine_provider:
app.state.volcengine_ark_adapter = VolcengineArkAdapter(
app.state.volcengine_provider
@@ -145,6 +171,7 @@ async def lifespan(app: FastAPI):
# 初始化 Service(传入 Gateway
from app.services.vidu_service import ViduService
from app.services.volcengine_caption_service import VolcengineCaptionService
from app.services.volcengine_mediakit_service import VolcengineMediakitService
app.state.vidu_service = ViduService(app.state.platform_gateway)
logger.info("Vidu Service initialized")
@@ -157,6 +184,14 @@ async def lifespan(app: FastAPI):
else:
app.state.volcengine_caption_service = None
if app.state.volcengine_mediakit_provider:
app.state.volcengine_mediakit_service = VolcengineMediakitService(
app.state.platform_gateway
)
logger.info("Volcengine Mediakit Service initialized")
else:
app.state.volcengine_mediakit_service = None
# LLM Gateway(可选,向后兼容)
if app.state.volcengine_provider:
from app.ai.gateways.llm_gateway import LLMGateway
@@ -326,9 +361,9 @@ def main():
workers=settings.WORKERS if not settings.DEBUG else 1,
reload=settings.DEBUG,
log_level=settings.LOG_LEVEL.lower(),
access_log=False,
)
if __name__ == "__main__":
main()
# test
+2
View File
@@ -7,6 +7,7 @@
"""
from app.models.base import BaseModel, BaseModelBigInt
from app.models.bgm_music import BgmMusic
from app.models.broll_category import BrollCategory
from app.models.broll_material import BrollMaterial
from app.models.broll_tag import BrollTag
@@ -21,6 +22,7 @@ from app.models.user_point import UserPoint
__all__ = [
"BaseModel",
"BaseModelBigInt",
"BgmMusic",
"User",
"UserDevice",
"UserPoint",
+50
View File
@@ -0,0 +1,50 @@
"""
背景音乐模型
==========
装修行业场景化 BGM 库,按知识科普/案例展示/促销活动/家居生活/智能家居
五个场景分类管理。
"""
from sqlalchemy import Float, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BaseModelBigInt
class BgmMusic(BaseModelBigInt):
"""
背景音乐表
Attributes:
title: 音乐名称
artist: 艺术家
category: 场景分类 (knowledge/showcase/promotion/lifestyle/tech)
file_path: 相对文件路径(如 knowledge/3_Dance_with_Me.mp3
duration: 时长(秒)
status: 状态 (active/inactive)
sort_order: 排序权重(越小越靠前)
"""
__tablename__ = "mjk_bgm_musics"
title: Mapped[str] = mapped_column(String(255), nullable=False, comment="音乐名称")
artist: Mapped[str] = mapped_column(String(255), nullable=True, comment="艺术家")
category: Mapped[str] = mapped_column(
String(32), nullable=False, index=True, comment="场景分类"
)
file_path: Mapped[str] = mapped_column(
String(512), nullable=False, comment="相对文件路径"
)
url: Mapped[str] = mapped_column(
String(1024), nullable=False, comment="七牛云 URL"
)
duration: Mapped[float] = mapped_column(
Float, nullable=True, comment="时长(秒)"
)
status: Mapped[str] = mapped_column(
String(16), default="active", nullable=False, comment="状态: active/inactive"
)
sort_order: Mapped[int] = mapped_column(
Integer, default=0, nullable=False, comment="排序权重"
)
+2 -2
View File
@@ -38,7 +38,7 @@ class AppRelease(Base):
class ReleasePackage(Base):
"""平台安装包信息"""
__tablename__ = "mjk_release_packages"
__tablename__ = "mjk_app_release_packages"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
release_id: Mapped[int] = mapped_column(
@@ -60,5 +60,5 @@ class ReleasePackage(Base):
release: Mapped["AppRelease"] = relationship("AppRelease", back_populates="packages")
__table_args__ = (
UniqueConstraint("release_id", "platform", "architecture", name="uix_pkg_platform_arch"),
UniqueConstraint("release_id", "platform", "architecture", "filename", name="uix_app_pkg_platform_arch_filename"),
)
+29
View File
@@ -0,0 +1,29 @@
"""
背景音乐 Schema
==============
"""
from pydantic import BaseModel, Field
class BgmMusicItem(BaseModel):
"""背景音乐项"""
id: int = Field(description="音乐ID")
title: str = Field(description="音乐名称")
artist: str | None = Field(default=None, description="艺术家")
category: str = Field(description="场景分类")
file_path: str = Field(description="相对文件路径")
url: str = Field(description="七牛云 URL")
duration: float | None = Field(default=None, description="时长(秒)")
sort_order: int = Field(default=0, description="排序权重")
class Config:
from_attributes = True
class BgmMusicListResponse(BaseModel):
"""背景音乐列表响应"""
items: list[BgmMusicItem] = Field(description="音乐列表")
total: int = Field(description="总数")
+4 -2
View File
@@ -18,6 +18,7 @@ from typing import Any
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.redis_client import get_redis_client
from app.core.security import (
create_access_token,
@@ -188,8 +189,9 @@ async def login_with_sms(
5. 创建/覆盖设备记录
6. 签发双 Token
"""
# 1. 校验验证码
if not await verify_sms_code(mobile, code):
# 1. 校验验证码(白名单内的手机号跳过校验)
settings = get_settings()
if mobile not in settings.sms_code_whitelist_set and not await verify_sms_code(mobile, code):
raise ValueError("验证码错误或已过期")
# 2. 查询用户(不再自动注册)
+20 -5
View File
@@ -164,7 +164,7 @@ def _estimate_max_cost(source_type: str, param: dict | None = None) -> int:
# ── 余额查询 ──────────────────────────────────────────
async def get_user_balance(db: AsyncSession, user_id: UUID | str) -> dict:
"""获取用户积分余额快照"""
"""获取用户积分余额快照(实时计算,排除已过期批次)。"""
result = await db.execute(
select(UserPoint).where(UserPoint.user_id == user_id)
)
@@ -178,8 +178,21 @@ async def get_user_balance(db: AsyncSession, user_id: UUID | str) -> dict:
"total_expired": 0,
}
# 实时计算可用余额(排除已过期批次),避免 expire_batches 延迟时数据不一致
from sqlalchemy import func as _func
available_result = await db.execute(
select(_func.coalesce(_func.sum(PointBatch.remaining), 0))
.where(
PointBatch.user_id == user_id,
PointBatch.remaining > 0,
PointBatch.expired_at > _now(),
)
)
available_balance = available_result.scalar() or 0
return {
"balance": up.balance,
"balance": available_balance,
"total_recharged": up.total_recharged,
"total_consumed": up.total_consumed,
"total_expired": up.total_expired,
@@ -316,6 +329,7 @@ _CATEGORY_MAP: dict[str, str] = {
"compose": "压制成片",
"subtitle_burn": "字幕烧录",
"cover_design": "封面设计",
"cover_avatar": "封面形象",
"wxpay": "充值",
"compensation": "充值",
"invite": "充值",
@@ -387,9 +401,10 @@ async def consume(
db.add(up)
await db.flush()
# 3. 余额检查(在同一事务内,避免竞态)
if not allow_negative and up.balance < points:
raise ValueError(f"积分不足,当前余额 {up.balance},需要 {points} 积分")
# 3. 余额检查:用实时可用余额(未过期批次 remaining 总和),避免 expire_batches 延迟导致超扣
available = sum(b.remaining for b in batches)
if not allow_negative and available < points:
raise ValueError(f"积分不足,当前可用余额 {available},需要 {points} 积分")
remaining_to_deduct = points
for batch in batches:
@@ -0,0 +1,89 @@
"""
火山引擎 MediaKit Service
=========================
通过 PlatformGateway 调用 MediaKit 第三方 API,自身负责:
- 参数校验
- 结果提取与格式化
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any
from app.ai.adapters.constants import Method
from app.platform_gateway import PlatformGateway
logger = logging.getLogger(__name__)
@dataclass
class RemoveBackgroundResult:
"""抠图结果"""
image_url: str = ""
raw: dict[str, Any] = field(default_factory=dict)
class VolcengineMediakitService:
"""火山引擎 MediaKit 服务封装"""
# 支持的场景
SUPPORTED_SCENES = {"general", "human", "product"}
def __init__(self, gateway: PlatformGateway):
self.gateway = gateway
async def remove_background(
self,
image_url: str,
scene: str = "human",
) -> RemoveBackgroundResult:
"""同步抠图
Args:
image_url: 原始图片 URL
scene: 场景类型,"general"(通用)、"human"(人物)或 "product"(商品)
Returns:
RemoveBackgroundResult: 包含抠图结果图片 URL
Raises:
ValueError: 参数校验失败
PlatformError: 平台调用失败
"""
if not image_url:
raise ValueError("image_url 不能为空")
if scene not in self.SUPPORTED_SCENES:
raise ValueError(f"不支持的场景: {scene},可选: {self.SUPPORTED_SCENES}")
# 人物/商品场景默认启用白色描边 + 裁剪背景
enable_contour = scene in ("human", "product")
payload = {
"image_url": image_url,
"scene": scene,
"need_contour": enable_contour,
"contour_color": "#FFFFFF",
"contour_size": 20,
"need_crop_background": enable_contour,
}
response = await self.gateway.call_sync(
platform="volcengine_mediakit",
method=Method.REMOVE_BACKGROUND,
payload=payload,
)
if not response.success:
raise RuntimeError(
response.error_message or "抠图失败"
)
result_image_url = (response.data or {}).get("image_url", "")
return RemoveBackgroundResult(
image_url=result_image_url,
raw=response.data or {},
)
+105
View File
@@ -0,0 +1,105 @@
"""
文件校验工具
==========
提供文件头魔数校验和上传文件统一校验功能,
防止 MIME 伪造攻击和危险文件上传。
"""
from fastapi import HTTPException
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}文件内容与实际格式不符,可能存在安全风险",
)
+17
View File
@@ -87,6 +87,23 @@ platforms:
qps: 5
burst: 10
# ── 火山引擎媒体处理(MediaKit)──
volcengine_mediakit:
name: "火山引擎媒体处理"
provider: "volcengine_mediakit"
base_url: "https://mediakit.cn-beijing.volces.com"
rate_limit:
qps: 5
burst: 10
models: []
methods:
remove_background:
timeout: 120
max_connections: 10
rate_limit:
qps: 5
burst: 10
# ── 任务默认模型映射 ──
# 当调用方未指定模型时,按任务类型选择默认模型
task_defaults:
+11 -9
View File
@@ -24,10 +24,13 @@ fixed_costs:
voice_clone: 200
# 字幕烧录:将生成的字幕文件烧录到视频中(FFmpeg 合成)
subtitle_burn: 2
subtitle_burn: 5
# 封面设计:根据视频内容自动生成封面图
cover_design: 2
cover_design: 5
# 封面形象:上传人物照片 AI 抠图生成透明背景形象
cover_avatar: 10
# 压制成片:将多个素材片段合并为最终视频(FFmpeg 拼接)
compose: 5
@@ -52,11 +55,11 @@ duration_based_costs:
# 计算依据:中文正常朗读语速约 200~250 字/分钟,取 240 字/分钟:
# 60秒 ÷ 240字 = 0.25 秒/字
# 注意:此为经验值,未经过 Vidu TTS 实测校准。实际时长受标点停顿、
# 数字/英文混合、TTS 角色风格等因素影响,误差约 ±20%。
# 数字/英文混合、TTS 角色风格等因素影响,误差约 ±20%。
# TODO: 收集实测数据后校准此值。
seconds_per_char: 0.25
# ── 视频生成(对口型)──
# ── 视频生成 ──
# 计费公式:max(min_points, ceil(实际视频秒数) × multiplier)
# 说明:秒数先向上取整为整数,再乘以 multiplier。不足 1 秒按 1 秒计算。
# 示例:4.5秒视频 → ceil(4.5) × 1 = 5 积分;0.8秒 → ceil(0.8) × 1 = 1 积分
@@ -66,8 +69,7 @@ duration_based_costs:
# 预估参数(执行业务前检查余额时使用)
estimation:
# 是否直接使用输入素材秒数作为预估上限
# 视频生成时长 = 输入音频/素材时长,因此用 input_seconds 预估最准确。
# 视频生成时长 = 输入音频时长,因此用 input_seconds 预估最准确
# 调用方需传入 input_seconds 参数。
use_input_seconds: true
@@ -77,10 +79,10 @@ duration_based_costs:
# 支持积分赠送:points 为实际到账积分数,amount_rmb 为支付金额(分)。
# label 为空时不显示标签角标。
recharge_options:
- { price: 10000, points: 2000, label: "", validity_days: 180 }
- { price: 10000, points: 2000, label: "", validity_days: 90 }
- { price: 50000, points: 11000, label: "热销", validity_days: 180 }
- { price: 100000, points: 23000, label: "推荐", validity_days: 365 }
- { price: 500000, points: 125000, label: "超值", validity_days: 0 }
- { price: 100000, points: 22500, label: "推荐", validity_days: 180 }
- { price: 500000, points: 120000, label: "超值", validity_days: 365 }
# ── 免费业务(不扣积分)───────────────────────────────
Regular → Executable
+12 -6
View File
@@ -15,6 +15,7 @@ echo "========================================"
PROJECT_DIR="/opt/meijiaka-zy"
GIT_REPO="http://git2.haodian.cn/xiaoyu/meijiaka-zy.git"
API_PORT=8081
COMPOSE_FILE="docker-compose.test.yml"
# 1. 检查 Docker
echo "[1/7] 检查 Docker 环境..."
@@ -34,7 +35,8 @@ docker compose version || echo "docker-compose 版本: $(docker-compose --versio
echo "[2/7] 更新代码..."
if [ -d "$PROJECT_DIR/.git" ]; then
cd "$PROJECT_DIR"
git pull origin master
git fetch origin master
git reset --hard origin/master
else
git clone "$GIT_REPO" "$PROJECT_DIR"
cd "$PROJECT_DIR"
@@ -83,12 +85,12 @@ echo "✅ 环境变量检查通过"
# 5. 构建镜像
echo "[5/7] 构建 Docker 镜像..."
docker compose -f docker-compose.test.yml build --no-cache
docker compose -f "$COMPOSE_FILE" build --pull
# 6. 启动服务
echo "[6/7] 启动服务..."
docker compose -f docker-compose.test.yml down 2>/dev/null || true
docker compose -f docker-compose.test.yml up -d
docker compose -f "$COMPOSE_FILE" down 2>/dev/null || true
docker compose -f "$COMPOSE_FILE" up -d
# 7. 等待并验证
echo "[7/7] 等待服务启动..."
@@ -98,6 +100,10 @@ MAX_RETRY=12
RETRY=0
while [ $RETRY -lt $MAX_RETRY ]; do
if curl -s http://localhost:$API_PORT/health | grep -q "healthy"; then
# 验证 scheduler 容器也在运行
if ! docker ps --filter "name=meijiaka-zy-scheduler" --filter "status=running" -q | grep -q .; then
echo "⚠️ API 已就绪,但 scheduler 容器未运行,请检查日志"
fi
echo ""
echo "========================================"
echo " ✅ 测试服部署成功!"
@@ -114,8 +120,8 @@ while [ $RETRY -lt $MAX_RETRY ]; do
echo ""
echo " 常用命令:"
echo " cd $PROJECT_DIR/python-api"
echo " docker compose -f docker-compose.test.yml logs -f"
echo " docker compose -f docker-compose.test.yml restart api"
echo " docker compose -f $COMPOSE_FILE logs -f"
echo " docker compose -f $COMPOSE_FILE restart api"
echo "========================================"
exit 0
fi
+4 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "meijiaka-ai-api"
version = "1.5.18"
version = "1.6.6"
description = "美家卡智影 - AI 视频创作后端 API"
authors = [{ name = "Meijiaka Team" }]
readme = "README.md"
@@ -61,6 +61,9 @@ dependencies = [
# 音频时长探测(TTS 扣费用)
"mutagen~=1.47.0",
# 图像处理(智能抠图合成封面)
"Pillow>=11.0.0",
]
[project.optional-dependencies]
File diff suppressed because it is too large Load Diff
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
导入 Mixkit BGM 音频文件到数据库并上传七牛云
扫描 mixkit_bgm/ 目录下的分类文件夹,读取音频元数据并插入 mjk_bgm_musics 表
同时上传音频到七牛云视频/音频 bucket,保存 URL。
"""
import asyncio
import os
import sys
from pathlib import Path
# 将项目根目录加入 Python 路径
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
# 加载环境变量
from dotenv import load_dotenv
load_dotenv()
from mutagen.mp3 import MP3
from sqlalchemy import select
from app.db.session import AsyncSessionLocal
from app.models.bgm_music import BgmMusic
from app.services.qiniu_service import get_qiniu_service
BGM_BASE_DIR = Path("/Users/0fun/work/meijiaka-zy/mixkit_bgm")
def parse_title(filename: str) -> str:
"""从文件名解析标题,如 '105_See_Line_Funk.mp3' -> 'See Line Funk'"""
# 去掉扩展名
name = filename.rsplit(".", 1)[0]
# 去掉开头的编号前缀(如 105_
parts = name.split("_", 1)
if len(parts) > 1 and parts[0].isdigit():
title_part = parts[1]
else:
title_part = name
# 将下划线替换为空格
return title_part.replace("_", " ")
def get_duration(filepath: Path) -> float | None:
"""获取音频文件时长(秒)"""
try:
audio = MP3(str(filepath))
return audio.info.length
except Exception as e:
print(f" 无法读取时长: {filepath.name} ({e})")
return None
async def import_bgm():
"""扫描目录并导入数据库,同时上传七牛云"""
if not BGM_BASE_DIR.exists():
print(f"错误: BGM 目录不存在: {BGM_BASE_DIR}")
return
categories = [d.name for d in BGM_BASE_DIR.iterdir() if d.is_dir()]
print(f"发现分类: {categories}")
# 初始化七牛云服务
try:
qiniu = get_qiniu_service()
print("七牛云服务初始化成功")
except ValueError as e:
print(f"七牛云配置错误: {e}")
return
async with AsyncSessionLocal() as session:
# 清空现有数据(可选)
result = await session.execute(select(BgmMusic))
existing = result.scalars().all()
if existing:
print(f"数据库中已有 {len(existing)} 条 BGM 记录,将删除后重新导入")
for row in existing:
await session.delete(row)
await session.commit()
total = 0
upload_ok = 0
upload_fail = 0
for category in sorted(categories):
cat_dir = BGM_BASE_DIR / category
files = sorted([f for f in cat_dir.iterdir() if f.suffix.lower() in (".mp3", ".wav", ".m4a")])
print(f"\n分类 [{category}]: {len(files)}")
for idx, filepath in enumerate(files):
title = parse_title(filepath.name)
relative_path = f"{category}/{filepath.name}"
duration = get_duration(filepath)
# 上传七牛云
qiniu_key = f"meijiaka-zy/bgm/{relative_path}"
url = None
try:
upload_result = qiniu.upload_file(
local_path=str(filepath),
key=qiniu_key,
file_type="audio",
check_duplicate=True,
)
url = upload_result.get("url")
if upload_result.get("isDuplicate"):
print(f" + {title} (复用已有文件)")
else:
print(f" + {title} (上传成功)")
upload_ok += 1
except Exception as e:
print(f" ! {title} (上传失败: {e})")
upload_fail += 1
# 上传失败也继续入库,只是 url 为空
bgm = BgmMusic(
title=title,
artist=None,
category=category,
file_path=relative_path,
url=url,
duration=duration,
status="active",
sort_order=idx,
)
session.add(bgm)
total += 1
await session.commit()
print(f"\n导入完成,共 {total}")
print(f" 上传成功: {upload_ok}")
print(f" 上传失败: {upload_fail}")
if __name__ == "__main__":
asyncio.run(import_bgm())
+57 -17
View File
@@ -24,6 +24,9 @@ import sys
from pathlib import Path
import httpx
from dotenv import load_dotenv
load_dotenv()
def find_packages(bundle_dir: Path) -> list[dict]:
@@ -37,21 +40,30 @@ def find_packages(bundle_dir: Path) -> list[dict]:
print(f"警告: 签名文件存在但安装包缺失: {pkg_file}")
continue
# 解析文件名:{name}_{version}_{target}_{arch}.app.tar.gz
parts = pkg_file.stem.split("_")
# 最后一个部分可能是 arch,需要处理
arch = "aarch64" if "aarch64" in pkg_file.name or "arm64" in pkg_file.name else "x86_64"
signature = sig_file.read_text().strip()
file_size = pkg_file.stat().st_size
filename = pkg_file.name
packages.append({
"platform": "darwin",
"architecture": arch,
"filename": pkg_file.name,
"local_path": str(pkg_file),
"signature": signature,
"file_size": pkg_file.stat().st_size,
})
# 判断文件名是否包含架构标识
has_arch_marker = any(marker in filename for marker in ["aarch64", "arm64", "x86_64"])
if has_arch_marker:
# 文件名含架构标识,按标识解析
arch = "aarch64" if "aarch64" in filename or "arm64" in filename else "x86_64"
archs = [arch]
else:
# Universal Binary:同时支持 x86_64 和 aarch64
archs = ["x86_64", "aarch64"]
for arch in archs:
packages.append({
"platform": "darwin",
"architecture": arch,
"filename": filename,
"local_path": str(pkg_file),
"signature": signature,
"file_size": file_size,
})
# Windows: .exe + .exe.sig
for sig_file in bundle_dir.rglob("*.exe.sig"):
@@ -93,6 +105,29 @@ def find_packages(bundle_dir: Path) -> list[dict]:
"file_size": pkg_file.stat().st_size,
})
# macOS DMG: 给新用户首次安装用(无签名文件)
for dmg_file in bundle_dir.rglob("*.dmg"):
filename = dmg_file.name
# 判断文件名是否包含架构标识
has_arch_marker = any(marker in filename for marker in ["aarch64", "arm64", "x86_64"])
if has_arch_marker:
arch = "aarch64" if "aarch64" in filename or "arm64" in filename else "x86_64"
archs = [arch]
else:
# Universal DMG:同时支持 x86_64 和 aarch64
archs = ["x86_64", "aarch64"]
for arch in archs:
packages.append({
"platform": "darwin",
"architecture": arch,
"filename": filename,
"local_path": str(dmg_file),
"signature": "", # DMG 无签名(非 updater 包)
"file_size": dmg_file.stat().st_size,
})
return packages
@@ -165,16 +200,21 @@ def main():
print(f" - {p['platform']}-{p['architecture']}: {p['filename']} ({p['file_size'] / 1024 / 1024:.1f} MB)")
# 2. 上传到七牛云(或构造 URL)
# 按 local_path 去重,同一个文件只上传一次
uploaded = {} # local_path -> file_url
for p in packages:
if args.skip_upload:
if not args.base_url:
print("错误: --skip-upload 时必须提供 --base-url")
sys.exit(1)
p["file_url"] = f"{args.base_url.rstrip('/')}/{p['filename']}"
p["file_url"] = f"{args.base_url.rstrip('/')}/{p['platform']}/{p['filename']}"
else:
key = f"releases/{args.version}/{p['filename']}"
print(f"上传 {p['filename']} 到七牛云...")
p["file_url"] = upload_to_qiniu(p["local_path"], key)
local_path = p["local_path"]
if local_path not in uploaded:
key = f"meijiaka-zy/releases/{args.version}/{p['platform']}/{p['filename']}"
print(f"上传 {p['filename']} ({p['platform']}) 到七牛云...")
uploaded[local_path] = upload_to_qiniu(local_path, key)
p["file_url"] = uploaded[local_path]
# 删除临时字段
del p["local_path"]
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""
BGM 种子数据导入脚本
==================
用于部署环境初始化 BGM 数据读取同目录下的 bgm_seed_data.json
129 首系统背景音乐的元数据含七牛云 URL写入数据库
执行方式 API 容器内:
python scripts/seed_bgm.py
环境变量依赖:
DATABASE_URL 同应用配置容器启动时已注入
"""
import asyncio
import json
import os
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from dotenv import load_dotenv
load_dotenv()
from sqlalchemy import select, func
from app.db.session import AsyncSessionLocal
from app.models.bgm_music import BgmMusic
SEED_FILE = Path(__file__).parent / "bgm_seed_data.json"
async def seed_bgm():
if not SEED_FILE.exists():
print(f"错误: 种子数据文件不存在: {SEED_FILE}")
return
with open(SEED_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
async with AsyncSessionLocal() as session:
# 检查是否已有数据
count = await session.scalar(select(func.count(BgmMusic.id)))
if count and count > 0:
print(f"数据库中已有 {count} 条 BGM 记录,跳过导入")
print("如需强制重新导入,请先清空 mjk_bgm_musics 表")
return
for idx, item in enumerate(data):
bgm = BgmMusic(
title=item["title"],
artist=item.get("artist"),
category=item["category"],
file_path=item["file_path"],
url=item["url"],
duration=item.get("duration"),
status=item.get("status", "active"),
sort_order=item.get("sort_order", idx),
)
session.add(bgm)
await session.commit()
print(f"种子数据导入完成,共 {len(data)}")
if __name__ == "__main__":
asyncio.run(seed_bgm())
+61 -1
View File
@@ -944,7 +944,7 @@ wheels = [
[[package]]
name = "meijiaka-ai-api"
version = "1.5.18"
version = "1.6.6"
source = { virtual = "." }
dependencies = [
{ name = "aiohttp" },
@@ -957,6 +957,7 @@ dependencies = [
{ name = "openai" },
{ name = "orjson" },
{ name = "passlib", extra = ["bcrypt"] },
{ name = "pillow" },
{ name = "psycopg2-binary" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
@@ -999,6 +1000,7 @@ requires-dist = [
{ name = "openai", specifier = "~=1.58.0" },
{ name = "orjson", specifier = ">=3.11.0" },
{ name = "passlib", extras = ["bcrypt"], specifier = "~=1.7.4" },
{ name = "pillow", specifier = ">=11.0.0" },
{ name = "pip-audit", marker = "extra == 'dev'", specifier = "~=2.7.0" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = "~=4.0.0" },
{ name = "psycopg2-binary", specifier = "~=2.9.10" },
@@ -1280,6 +1282,64 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
]
[[package]]
name = "pillow"
version = "12.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
]
[[package]]
name = "pip"
version = "26.0.1"
+5 -5
View File
@@ -11,11 +11,11 @@
DO $$
DECLARE
v_mobile TEXT := '13800138000'; -- ← 修改:手机号
v_nickname TEXT := '新用户昵称'; -- ← 修改:昵称(可为空)
v_mobile TEXT := '13950003857'; -- ← 修改:手机号
v_nickname TEXT := '怀松'; -- ← 修改:昵称(可为空)
v_source TEXT := 'manual'; -- ← 修改:注册来源:manual / invite / promotion
v_invited_by UUID := NULL; -- ← 修改:邀请人 user_id(没有则留 NULL
v_gift_points INT := 100; -- ← 修改:赠送初始积分(0 表示不赠送)
v_gift_points INT := 2000; -- ← 修改:赠送初始积分(0 表示不赠送)
v_gift_days INT := 365; -- ← 修改:赠送积分有效期(天)
v_user_id UUID;
v_batch_id BIGINT;
@@ -108,8 +108,8 @@ END $$;
DO $$
DECLARE
v_mobile TEXT := '13800138000'; -- ← 修改:目标用户手机号
v_gift_points INT := 500; -- ← 修改:赠送积分数量
v_mobile TEXT := '13860199646'; -- ← 修改:目标用户手机号
v_gift_points INT := 5000; -- ← 修改:赠送积分数量
v_gift_days INT := 180; -- ← 修改:有效期(天)
v_reason TEXT := '运营活动赠送'; -- ← 修改:赠送原因(写入流水描述)
v_user_id UUID;
+16 -1
View File
@@ -224,10 +224,25 @@ def main():
print(" - python-api/.env.example")
if len(sys.argv) > 1:
# 自动提交版本更新,确保 tag 落在正确的 commit 上
subprocess.run(
["git", "add", "-A"],
cwd=ROOT,
check=True,
capture_output=True,
)
subprocess.run(
["git", "commit", "-m", f"bump version to {version}"],
cwd=ROOT,
check=True,
capture_output=True,
)
print(f"✅ 已提交: bump version to {version}")
create_git_tag(version)
print("\n下一步:")
print(f" git add -A && git commit -m 'bump version to {version}'")
print(f" git push && git push origin v{version}")
print(f" # 如果使用 GitHub Actions,同时推送到 GitHub remote:")
print(f" git push github-new && git push github-new v{version}")
if __name__ == "__main__":
+95 -180
View File
@@ -1,16 +1,21 @@
#!/usr/bin/env python3
"""生成 macOS Big Sur 风格圆角矩形图标"""
"""生成圆角图标:原图(透明背景 logo)作为整体,缩放居中,圆角外透明"""
import os
from PIL import Image, ImageDraw, ImageFilter
from PIL import Image, ImageDraw
ICONS_DIR = "/Users/0fun/work/meijiaka-zy/tauri-app/src-tauri/icons"
SOURCE_PNG = "/tmp/original-source-icon.png"
SOURCE_PNG = "/Users/0fun/work/meijiaka-zy/tauri-app/public/assets/logo.png"
# macOS Big Sur 圆角比例 ≈ 22.6%
CORNER_RATIO = 0.226
CORNER_RATIO_MACOS = 0.226
# Windows 11 风格圆角比例 ≈ 18%Fluent Design 圆角矩形)
CORNER_RATIO_WINDOWS = 0.18
# 内容占画布比例(参考腾讯视频 ≈ 80.5%)
CONTENT_RATIO = 0.805
# 输出尺寸列表 (文件名, 尺寸)
PNG_SIZES = [
("icon.png", 512),
("128x128@2x.png", 256),
@@ -19,7 +24,6 @@ PNG_SIZES = [
("64x64.png", 64),
]
# Windows Store/Square 尺寸
SQUARE_SIZES = [
("Square310x310Logo.png", 310),
("Square284x284Logo.png", 284),
@@ -35,175 +39,143 @@ SQUARE_SIZES = [
def create_rounded_rect_mask(size: int, radius: int) -> Image.Image:
"""创建圆角矩形蒙版"""
"""创建圆角矩形蒙版(硬边缘)"""
mask = Image.new("L", (size, size), 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle((0, 0, size, size), radius=radius, fill=255)
return mask
def create_big_sur_background(size: int) -> Image.Image:
"""创建 macOS Big Sur 风格圆角矩形底板(微妙渐变)"""
radius = int(size * CORNER_RATIO)
def prepare_source(source: Image.Image, canvas_size: int = 1024) -> Image.Image:
"""将源图居中放置在正方形透明画布上,作为后续处理的统一源图"""
src_w, src_h = source.size
canvas = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0))
# 顶部稍亮、底部稍暗的微妙渐变
bg = Image.new("RGBA", (size, size), (255, 255, 255, 0))
draw = ImageDraw.Draw(bg)
for y in range(size):
val = int(250 - (y / size) * 10)
draw.line([(0, y), (size, y)], fill=(val, val, val, 255))
# 等比缩放,长边充满画布的 CONTENT_RATIO
target_size = int(canvas_size * CONTENT_RATIO)
ratio = max(target_size / src_w, target_size / src_h)
new_w = int(src_w * ratio)
new_h = int(src_h * ratio)
resized = source.resize((new_w, new_h), Image.LANCZOS)
# 圆角裁剪
mask = create_rounded_rect_mask(size, radius)
masked = Image.new("RGBA", (size, size), (255, 255, 255, 0))
masked.paste(bg, mask=mask)
return masked
# 居中放置
left = (canvas_size - new_w) // 2
top = (canvas_size - new_h) // 2
canvas.paste(resized, (left, top), resized)
return canvas
def create_shadow_layer(size: int, radius: int) -> Image.Image:
"""创建底板外部阴影(用于大尺寸)"""
pad = int(size * 0.06)
canvas_size = size + pad * 2
shadow = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0))
draw = ImageDraw.Draw(shadow)
draw.rounded_rectangle(
(pad, pad, pad + size, pad + size),
radius=radius,
fill=(0, 0, 0, 40),
)
shadow = shadow.filter(ImageFilter.GaussianBlur(radius=pad // 2))
return shadow, pad
def compose_icon(size: int, source: Image.Image, rounded: bool = True) -> Image.Image:
"""macOS / Linux 图标:内容占画布 80.5%,大圆角"""
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
def clean_source_icon(source: Image.Image) -> Image.Image:
"""清理原始图标中的幽灵半透明像素(如内层圆角矩形轮廓)"""
rgba = source.convert("RGBA")
pixels = rgba.load()
width, height = rgba.size
for y in range(height):
for x in range(width):
r, g, b, a = pixels[x, y]
if a < 250:
pixels[x, y] = (0, 0, 0, 0)
else:
pixels[x, y] = (r, g, b, 255)
return rgba
def _prepare_m_icon(source: Image.Image, plate_size: int) -> Image.Image:
"""裁剪 M 的透明边距,宽度充满底板,保持完整形状(不裁剪)"""
bbox = source.getbbox()
if bbox:
m_cropped = source.crop(bbox)
else:
m_cropped = source
# 保持宽高比,宽度充满底板,高度自然留白
m_cropped.thumbnail((plate_size, plate_size), Image.LANCZOS)
return m_cropped
def compose_icon(size: int, source: Image.Image, add_shadow: bool = False) -> Image.Image:
"""将 M 图标合成到圆角矩形底板上(底板占画布80%,四周留透明边距)"""
# macOS 图标底板占画布约 80%,四周留透明呼吸边距
plate_size = int(size * 0.80)
plate_size = int(size * CONTENT_RATIO)
plate_offset = (size - plate_size) // 2
if add_shadow and size >= 128:
shadow, pad = create_shadow_layer(plate_size, int(plate_size * CORNER_RATIO))
canvas = Image.new("RGBA", (size + pad * 2, size + pad * 2), (0, 0, 0, 0))
canvas.paste(shadow, (0, 0), shadow)
# 源图等比缩放,短边充满 plate_size
src_w, src_h = source.size
ratio = max(plate_size / src_w, plate_size / src_h)
new_w = int(src_w * ratio)
new_h = int(src_h * ratio)
resized = source.resize((new_w, new_h), Image.LANCZOS)
bg = create_big_sur_background(plate_size)
canvas.paste(bg, (pad + plate_offset, pad + plate_offset), bg)
# 居中裁剪到 plate_size
left = (new_w - plate_size) // 2
top = (new_h - plate_size) // 2
img = resized.crop((left, top, left + plate_size, top + plate_size))
m_img = _prepare_m_icon(source, plate_size)
offset_x = (plate_size - m_img.width) // 2
offset_y = (plate_size - m_img.height) // 2
canvas.paste(m_img, (pad + plate_offset + offset_x, pad + plate_offset + offset_y), m_img)
return canvas
if rounded:
# 圆角蒙版裁剪(macOS / Linux
radius = int(plate_size * CORNER_RATIO_MACOS)
mask = create_rounded_rect_mask(plate_size, radius)
canvas.paste(img, (plate_offset, plate_offset), mask)
else:
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
# 正方形填满
canvas.paste(img, (plate_offset, plate_offset))
return canvas
bg = create_big_sur_background(plate_size)
canvas.paste(bg, (plate_offset, plate_offset), bg)
m_img = _prepare_m_icon(source, plate_size)
offset_x = (plate_size - m_img.width) // 2
offset_y = (plate_size - m_img.height) // 2
canvas.paste(m_img, (plate_offset + offset_x, plate_offset + offset_y), m_img)
def compose_icon_windows(size: int, source: Image.Image, rounded: bool = True) -> Image.Image:
"""Windows 图标:原图填满整个画布 100%,支持轻微圆角"""
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
return canvas
# 源图等比缩放,短边充满画布
src_w, src_h = source.size
ratio = max(size / src_w, size / src_h)
new_w = int(src_w * ratio)
new_h = int(src_h * ratio)
resized = source.resize((new_w, new_h), Image.LANCZOS)
# 居中裁剪到画布尺寸
left = (new_w - size) // 2
top = (new_h - size) // 2
img = resized.crop((left, top, left + size, top + size))
if rounded:
# Windows 11 风格轻微圆角
radius = max(1, int(size * CORNER_RATIO_WINDOWS))
mask = create_rounded_rect_mask(size, radius)
canvas.paste(img, (0, 0), mask)
else:
canvas.paste(img, (0, 0))
return canvas
def generate_icns(source: Image.Image, output_path: str):
"""生成 macOS .icns 文件"""
import tempfile
import subprocess
import shutil
sizes = [16, 32, 64, 128, 256, 512, 1024]
iconset_dir = tempfile.mkdtemp(suffix=".iconset")
for sz in sizes:
img = compose_icon(sz, source)
img = compose_icon(sz, source, rounded=True)
img.save(os.path.join(iconset_dir, f"icon_{sz}x{sz}.png"))
if sz <= 512:
img2x = compose_icon(sz * 2, source)
img2x = compose_icon(sz * 2, source, rounded=True)
img2x.save(os.path.join(iconset_dir, f"icon_{sz}x{sz}@2x.png"))
subprocess.run(
["iconutil", "-c", "icns", iconset_dir, "-o", output_path],
check=True,
)
# 清理
import shutil
shutil.rmtree(iconset_dir)
def generate_ico(source: Image.Image, output_path: str):
"""生成 Windows .ico 文件(手动组装,支持多分辨率 PNG 嵌入)"""
"""生成 Windows .ico 文件,包含更多尺寸以支持高 DPI"""
import struct
import io
sizes = [16, 24, 32, 48, 64, 128, 256]
# 添加 20x20 和 40x40 以支持 Windows 高 DPI (125%, 150%)
sizes = [16, 20, 24, 32, 40, 48, 64, 128, 256]
png_datas = []
entries = []
for sz in sizes:
img = compose_icon(sz, source)
# Windows 图标使用轻微圆角
img = compose_icon_windows(sz, source, rounded=True)
buf = io.BytesIO()
img.save(buf, format="PNG")
data = buf.getvalue()
png_datas.append(data)
entries.append((sz, len(data)))
# ICO 文件头: Reserved(2) + Type(2) + Count(2)
ico = struct.pack("<HHH", 0, 1, len(sizes))
# 计算数据偏移量: 文件头(6) + 目录项(16 * count)
data_offset = 6 + 16 * len(sizes)
# ICONDIRENTRY
for sz, size_bytes in entries:
width = sz if sz < 256 else 0
height = sz if sz < 256 else 0
ico += struct.pack(
"<BBBBHHII",
width, # Width
height, # Height
0, # Colors (0 for >256)
0, # Reserved
1, # Color planes
32, # Bits per pixel
size_bytes, # Size in bytes
data_offset, # Offset
width, height, 0, 0, 1, 32, size_bytes, data_offset,
)
data_offset += size_bytes
# 追加图像数据
for data in png_datas:
ico += data
@@ -211,89 +183,32 @@ def generate_ico(source: Image.Image, output_path: str):
f.write(ico)
def generate_android(source: Image.Image, android_dir: str):
"""生成 Android 图标"""
android_sizes = {
"mipmap-hdpi": 72,
"mipmap-mdpi": 48,
"mipmap-xhdpi": 96,
"mipmap-xxhdpi": 144,
"mipmap-xxxhdpi": 192,
}
for folder, sz in android_sizes.items():
folder_path = os.path.join(android_dir, folder)
os.makedirs(folder_path, exist_ok=True)
img = compose_icon(sz, source)
img.save(os.path.join(folder_path, "ic_launcher.png"))
# foreground / background / round / monochrome 等可能不需要更新
def generate_ios(source: Image.Image, ios_dir: str):
"""生成 iOS 图标"""
ios_sizes = [
("AppIcon-20x20@1x.png", 20),
("AppIcon-20x20@2x.png", 40),
("AppIcon-20x20@3x.png", 60),
("AppIcon-29x29@1x.png", 29),
("AppIcon-29x29@2x.png", 58),
("AppIcon-29x29@3x.png", 87),
("AppIcon-40x40@1x.png", 40),
("AppIcon-40x40@2x.png", 80),
("AppIcon-40x40@3x.png", 120),
("AppIcon-512@2x~ipad.png", 1024),
("AppIcon-512x512@1x.png", 512),
("AppIcon-512x512@2x.png", 1024),
("AppIcon-60x60@2x.png", 120),
("AppIcon-60x60@3x.png", 180),
("AppIcon-76x76@1x.png", 76),
("AppIcon-76x76@2x.png", 152),
("AppIcon-83.5x83.5@2x.png", 167),
("AppIcon-Notification@3x.png", 60),
]
for filename, sz in ios_sizes:
img = compose_icon(sz, source)
img.save(os.path.join(ios_dir, filename))
def main():
source_raw = Image.open(SOURCE_PNG).convert("RGBA")
source = clean_source_icon(source_raw)
# 加载原始 logo(透明背景)
raw_source = Image.open(SOURCE_PNG).convert("RGBA")
print(f"原始 logo 尺寸: {raw_source.size}")
print(f"源图标尺寸: {source.size}")
# 预处理:居中放置在 1024x1024 透明画布上
source = prepare_source(raw_source, canvas_size=1024)
print(f"预处理后源图尺寸: {source.size}")
# 生成基础 PNG
# macOS / Linux PNG 图标(大圆角 + 透明边距)
for filename, size in PNG_SIZES:
path = os.path.join(ICONS_DIR, filename)
img = compose_icon(size, source)
img.save(path)
compose_icon(size, source, rounded=True).save(path)
print(f"已生成: {filename} ({size}x{size})")
# 生成 Square LogoWindows
# Windows Square Logo轻微圆角 + 填满画布
for filename, size in SQUARE_SIZES:
path = os.path.join(ICONS_DIR, filename)
img = compose_icon(size, source)
img.save(path)
compose_icon_windows(size, source, rounded=True).save(path)
print(f"已生成: {filename} ({size}x{size})")
# 生成 .icns
icns_path = os.path.join(ICONS_DIR, "icon.icns")
generate_icns(source, icns_path)
print(f"已生成: icon.icns")
generate_icns(source, os.path.join(ICONS_DIR, "icon.icns"))
print("已生成: icon.icns")
# 生成 .ico
ico_path = os.path.join(ICONS_DIR, "icon.ico")
generate_ico(source, ico_path)
print(f"已生成: icon.ico")
# 生成 Android
android_dir = os.path.join(ICONS_DIR, "android")
generate_android(source, android_dir)
print(f"已生成: Android 图标")
# 生成 iOS
ios_dir = os.path.join(ICONS_DIR, "ios")
generate_ios(source, ios_dir)
print(f"已生成: iOS 图标")
generate_ico(source, os.path.join(ICONS_DIR, "icon.ico"))
print("已生成: icon.ico")
print("\n全部完成!")
+1 -1
View File
@@ -9,7 +9,7 @@
**美家卡智影**(产品名)是一款基于 Tauri v2 + React 19 + TypeScript 的桌面端 AI 视频创作应用。
- **产品标识**: `cn.meijiaka.ai-video`
- **版本**: `1.5.18`
- **版本**: `1.6.6`
- **窗口尺寸**: 1200×800,不可缩放(`resizable: false`
- **核心功能**: AI 脚本生成、AI 配音合成、视频生成、压制成片(FFmpeg)、项目本地持久化
+5 -5
View File
@@ -23,12 +23,12 @@
### 2.2 密钥管理
**公钥**
- 文件: `src-tauri/tauri.key.pub`
- 文件: `src-tauri/.tauri-signing-key.pub`
- 已嵌入 `tauri.conf.json``plugins.updater.pubkey`
- **已提交到 Git 仓库**(公钥可以公开)
**私钥**
- 文件: `tauri.key`**已删除,未提交到 Git**
- 文件: `.tauri-signing-key`**已加入 .gitignore,未提交到 Git**
- 必须保存在安全位置(如 GitLab CI/CD Variables、1Password、公司密码管理器)
- **切勿泄露或提交到仓库**
@@ -48,11 +48,11 @@
```bash
cd tauri-app
npm run tauri signer generate -- --write-keys src-tauri/tauri.key
npm run tauri signer generate -- --write-keys src-tauri/.tauri-signing-key
```
然后:
1. 将新生成的公钥(`tauri.key.pub` 内容)更新到 `tauri.conf.json`
1. 将新生成的公钥(`.tauri-signing-key.pub` 内容)更新到 `tauri.conf.json`
2. 将新生成的私钥保存到 GitLab CI/CD Variables
3. **旧版本客户端将无法通过自动更新升级**(公钥不匹配),必须重新下载安装包
@@ -120,5 +120,5 @@ npm run tauri signer generate -- --write-keys src-tauri/tauri.key
## 五、相关文件
- `src-tauri/tauri.conf.json` — updater 公钥配置
- `src-tauri/tauri.key.pub` — minisign 公钥(已提交)
- `src-tauri/.tauri-signing-key.pub` — minisign 公钥(已提交)
- `.gitlab-ci.yml` — CI 构建流程与签名环境变量
+65 -46
View File
@@ -1,12 +1,12 @@
{
"name": "tauri-app",
"version": "1.5.18",
"version": "1.6.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tauri-app",
"version": "1.5.18",
"version": "1.6.6",
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"@tanstack/react-virtual": "^3.13.23",
@@ -28,7 +28,7 @@
"zustand": "^5.0.12"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@tauri-apps/cli": "^2.11.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/qrcode": "^1.5.6",
@@ -1818,7 +1818,9 @@
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.10.0",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz",
"integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
@@ -1832,21 +1834,23 @@
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.10.0",
"@tauri-apps/cli-darwin-x64": "2.10.0",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0",
"@tauri-apps/cli-linux-arm64-gnu": "2.10.0",
"@tauri-apps/cli-linux-arm64-musl": "2.10.0",
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.0",
"@tauri-apps/cli-linux-x64-gnu": "2.10.0",
"@tauri-apps/cli-linux-x64-musl": "2.10.0",
"@tauri-apps/cli-win32-arm64-msvc": "2.10.0",
"@tauri-apps/cli-win32-ia32-msvc": "2.10.0",
"@tauri-apps/cli-win32-x64-msvc": "2.10.0"
"@tauri-apps/cli-darwin-arm64": "2.11.2",
"@tauri-apps/cli-darwin-x64": "2.11.2",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.2",
"@tauri-apps/cli-linux-arm64-musl": "2.11.2",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.2",
"@tauri-apps/cli-linux-x64-gnu": "2.11.2",
"@tauri-apps/cli-linux-x64-musl": "2.11.2",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.2",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.2",
"@tauri-apps/cli-win32-x64-msvc": "2.11.2"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.10.0",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz",
"integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==",
"cpu": [
"arm64"
],
@@ -1861,9 +1865,9 @@
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz",
"integrity": "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz",
"integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==",
"cpu": [
"x64"
],
@@ -1878,9 +1882,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz",
"integrity": "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz",
"integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==",
"cpu": [
"arm"
],
@@ -1895,13 +1899,16 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz",
"integrity": "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz",
"integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1912,13 +1919,16 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz",
"integrity": "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz",
"integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1929,13 +1939,16 @@
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz",
"integrity": "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz",
"integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1946,13 +1959,16 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz",
"integrity": "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz",
"integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1963,13 +1979,16 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz",
"integrity": "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz",
"integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1980,9 +1999,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz",
"integrity": "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz",
"integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==",
"cpu": [
"arm64"
],
@@ -1997,9 +2016,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz",
"integrity": "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz",
"integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==",
"cpu": [
"ia32"
],
@@ -2014,9 +2033,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz",
"integrity": "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz",
"integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==",
"cpu": [
"x64"
],
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "tauri-app",
"private": true,
"version": "1.5.18",
"version": "1.6.6",
"type": "module",
"scripts": {
"dev": "vite",
@@ -39,7 +39,7 @@
"zustand": "^5.0.12"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@tauri-apps/cli": "^2.11.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/qrcode": "^1.5.6",
+3
View File
@@ -5,3 +5,6 @@
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
# Sidecar binaries (FFmpeg / ffprobe) — large platform-specific executables
/binaries/
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIwOEVEODY4MTgyRkJFRTMKUldUanZpOFlhTmlPSUJzS0FLL1NMUEgzLzRtNXpsT1FoTXZlS3JLOHJvak5KeThIeDJQRFpJZWgK
+2 -1
View File
@@ -4219,12 +4219,13 @@ dependencies = [
[[package]]
name = "tauri-app"
version = "1.5.18"
version = "1.6.6"
dependencies = [
"base64 0.22.1",
"chrono",
"dirs 5.0.1",
"fs2",
"log",
"reqwest 0.12.28",
"serde",
"serde_json",
+4 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "tauri-app"
version = "1.5.18"
version = "1.6.6"
description = "美家卡智影 - AI 视频创作桌面应用"
authors = ["美家卡科技"]
edition = "2021"
@@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["protocol-asset"] }
tauri = { version = "2", features = ["protocol-asset", "devtools"] }
tauri-plugin-opener = "2"
tauri-plugin-shell = "2"
tauri-plugin-fs = "2"
@@ -27,6 +27,7 @@ tauri-plugin-updater = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = "0.4"
dirs = "5"
# HTTP 客户端: 精简功能
# 使用 default-tls(默认)+ json,无需额外 features
@@ -43,6 +44,6 @@ thiserror = "1"
# 文件锁(跨平台)
fs2 = "0.4"
# 异步运行时定时器(FFmpeg 超时保护)
tokio = { version = "1", features = ["time"] }
tokio = { version = "1", features = ["time", "sync"] }
tauri-plugin-single-instance = "2"
+4
View File
@@ -8,5 +8,9 @@
</array>
<key>CFBundleDevelopmentRegion</key>
<string>zh_CN</string>
<key>CFBundleIconFile</key>
<string>icon</string>
<key>CFBundleIconName</key>
<string>icon</string>
</dict>
</plist>
@@ -51,6 +51,9 @@
},
"dialog:default",
"dialog:allow-open",
"updater:default"
"core:window:allow-show",
"core:window:allow-set-focus",
"updater:default",
"process:allow-restart"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""
生成 DMG 背景图 美家卡智影
画布: 660 x 400 px
"""
import subprocess
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
W, H = 660, 400
BG = (249, 250, 251)
TEXT_PRIMARY = (17, 24, 39)
TEXT_SECONDARY = (107, 114, 128)
TEXT_TERTIARY = (156, 163, 175)
def get_font(size, bold=False):
result = subprocess.run(
["fc-match", "-f", "%{file}", "PingFang SC"],
capture_output=True, text=True
)
path = result.stdout.strip()
if path and Path(path).exists():
idx = 2 if bold else 0
return ImageFont.truetype(path, size, index=idx)
return ImageFont.load_default()
font_title = get_font(22, bold=True)
font_body = get_font(13)
font_caption = get_font(11)
bg = Image.new("RGBA", (W, H), (*BG, 255))
draw = ImageDraw.Draw(bg)
# ── 顶部标题 ──
draw.text((W // 2, 55), "拖拽到 Applications 文件夹",
fill=TEXT_PRIMARY, font=font_title, anchor="mm")
# ── 中部留白:系统图标区域 y≈140~300 ──
# ── 底部提示文字 ──
tip_lines = [
'首次打开如遇拦截,请右键应用图标选择"打开"',
'或前往 系统设置 → 隐私与安全性 → 点击"仍要打开"',
]
for i, line in enumerate(tip_lines):
draw.text((W // 2, 325 + i * 22), line,
fill=TEXT_SECONDARY, font=font_body, anchor="mm")
# ── 版本号 ──
draw.text((W // 2, H - 14), "v1.5.18",
fill=TEXT_TERTIARY, font=font_caption, anchor="mm")
output = bg.convert("RGB")
output.save("dmg-background.png", "PNG")
print("✅ Generated: dmg-background.png (660x400)")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1004 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Some files were not shown because too many files have changed in this diff Show More