diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index df768106..a962ea58 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-11 本地服务器管理入口采用 SSH alias + egui 桌面面板 + +- 背景:release / dev 等服务器的日常巡检已有 systemd、健康巡检 timer 和 HTTP 探测口径,但开发者本地仍需要在多个 SSH alias 间手工切换命令并重复执行启停操作。 +- 决策:新增 `server-rs/crates/server-manager-panel` 作为本地 egui 桌面工具;服务器来源只读取本机 `~/.ssh/config` 的具体 `Host` alias,不保存服务器密钥或凭据;巡检通过 `ssh sh -s` 执行只读脚本,服务操作只允许 `start`、`stop`、`restart` 并限制 systemd unit 名字符集。 +- 影响范围:本地运维工具入口、`package.json` 的 `server-manager:panel`、开发运维文档和团队共享工作流。 +- 验证方式:`cargo check -p server-manager-panel --manifest-path server-rs/Cargo.toml`、`cargo test -p server-manager-panel --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md`。 + ## 2026-06-10 公开作品互动能力进入后台全局配置 - 背景:作品详情页的点赞和改造能力原本由前端和各玩法 handler 的硬编码能力矩阵决定,后台无法临时关闭某类公开作品的互动入口,直接关闭创作入口又会误伤已有作品读取和游玩。 @@ -32,6 +40,13 @@ - 验证方式:从 release 执行 `git ls-remote http://10.2.0.10/GenarrativeAI/Genarrative.git HEAD` 应返回 HEAD;公网来源伪造 `Host: 10.2.0.10` 访问 dev 公网 80 应返回 `403`;`https://git.genarrative.world/` 原入口应保持 `200`。 - 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## 2026-06-08 通用分享统一为作品分享卡片 + +- 背景:已发布作品的分享入口需要同时支持网页复制链接、下载可传播的分享卡,以及微信小程序内的九宫切图;推荐页在小程序内直接使用系统“分享到聊天”时,宿主快照只截页面中部,容易裁掉游戏主体,且原生分享默认只能拿到小程序页面启动参数。 +- 决策:统一分享入口继续收口到 `PublishShareModal`,分享卡展示作品封面、作品类型、作品名称和公开作品号,底部提供“复制链接”和“下载卡片”。普通 H5 复制公开作品 H5 URL;微信小程序 WebView 内复制小程序 `pages/web-view/index` 路径,缺少直达参数时补 `targetPath=/works/detail` 与 `work=<公开作品号>`,由小程序原生 WebView 页转成 H5 作品详情 URL。当 H5 运行在微信小程序 WebView 内且存在封面图时,额外显示“九宫切图”,跳转小程序原生 `pages/share-grid/index`,由原生页按 3x3 从左到右、从上到下裁切并保存。推荐页当前作品会通过 `wx.miniProgram.postMessage` 同步给小程序原生 `web-view` 页,右上角系统分享优先使用该目标生成带作品参数的小程序路径。小程序运行态通过根节点标记启用推荐页 runtime 快照安全区,把游戏画面等比缩放到分享快照中部。 +- 影响范围:`src/components/common/PublishShareModal.tsx`、`src/components/common/publishShareModalModel.ts`、`src/components/common/publishShareCardImage.ts`、`src/services/wechatMiniProgramShareGrid.ts`、`src/services/wechatMiniProgramShareTarget.ts`、`miniprogram/pages/web-view/`、`miniprogram/pages/share-grid/`、推荐页 runtime CSS 和平台玩法链路文档。 +- 验证方式:`npm run test -- src/components/common/PublishShareModal.test.tsx miniprogram/pages/web-view/index.test.js src/services/wechatMiniProgramShareTarget.test.ts`、`npm run test -- miniprogram/pages/share-grid/index.test.js`、`npm run test -- src/index.test.ts -t "mini program recommend runtime"`、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 ## 2026-06-08 微信能力按领域收口 - 背景:微信登录、订阅消息、普通微信支付和小程序虚拟支付能力曾分散在 `api-server` 根模块、`platform-auth` 与 `platform-wechat`,支付协议细节和业务 handler 边界不够清晰。 @@ -48,6 +63,415 @@ - 验证方式:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`,并确认旧 `/api/creation//*`、历史 `/api/runtime//agent/*` 与公开 runtime 路由外部契约不变。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-08 PlatformUiKit 弹窗与复制反馈收口 + +- 背景:前端已有 `UnifiedModal` 统一遮罩和无障碍外壳,但业务页面仍反复手写“知道了”“确认 / 取消”“危险确认”的 footer 按钮和关闭禁用逻辑。 +- 决策:简单提示、确认 / 取消和危险确认统一使用 `src/components/common/UnifiedConfirmDialog.tsx`;剪贴板复制反馈统一使用 `src/components/common/useCopyFeedback.ts`,可点击复制按钮统一使用 `src/components/common/CopyFeedbackButton.tsx` 承载图标、三态文案、可访问名称、纯图标模式和动作按钮外观入口,作品号 / 用户号等短代码 chip 统一使用 `src/components/common/CopyCodeButton.tsx` 承载代码、三态后缀和默认可访问名称,非按钮复制提示统一使用 `src/components/common/CopyFeedbackMessage.tsx`,白底平台状态提示统一使用 `src/components/common/PlatformStatusMessage.tsx`,无操作空态 / 轻量读取态统一使用 `src/components/common/PlatformEmptyState.tsx`,平台动作按钮统一使用 `src/components/common/PlatformActionButton.tsx` 承载 platform / profile 两类样式族、尺寸、圆角、对齐、宽度和禁用态;认证表单的提交、验证码、第三方登录和邀请码提交按钮使用 `size="lg"` 复用 48px 高度,统一创作工作台、统一创作页壳层、玩法创作工作台、结果页返回按钮和反馈页 header 返回使用 `tone="ghost"`,生成 / 提交 / 发布按钮使用主动作,自定义世界实体目录、RPG 首页作品卡删除、创作中心错误重试和素材槽的小动作使用 `size="xs"` 或 `shape="pill"` 收口,推荐回复和列表内动作使用 `align="start"` 承接左对齐,上传控件等需要 label 语义时使用 `PlatformActionButton asChild="label"`,不把文件输入伪装成普通 button。普通平台图标动作按钮和图标上传 label 统一使用 `src/components/common/PlatformIconButton.tsx` 承载 `platform-icon-button` 外观、可访问名称、默认 `type="button"`、`asChild="label"` 和可选 title;历史图片选择弹窗、RPG 发布检查弹窗、RPG 首页搜索结果清空、creative-agent 侧边栏关闭 / 外观 / 设置入口、creation-agent 参考图移除、敲木鱼结果页新增主题标签入口、拼图结果页标签生成 / 标签新增 / 关卡详情关闭 / 发布弹窗关闭 / 删除关卡入口、视觉小说结果页素材选择 / 音频生成 / 保存草稿 / 运行配置入口,以及抓大鹅结果页标签生成 / 标签新增 / 物品素材删除 / 参考图上传入口已先迁移;图标上传控件必须保留 label + file input 语义。平台 / 个人中心弹窗关闭按钮统一使用 `src/components/common/PlatformModalCloseButton.tsx` 承载 profile / profileCompact / floating / floatingPlain / platformIcon 五类圆形关闭按钮、默认图标和可访问名称;认证入口、邀请码弹窗、抓大鹅结果页弹窗关闭等平台头部关闭按钮使用 `variant="platformIcon"`,不在业务 JSX 中手写 `platform-icon-button` + X 图标。RPG / 拼图 / 抓大鹅 / 跳一跳 / 敲木鱼 / 拼消消 / 宝贝识物 / 方洞 / 汪汪声浪结果页,拼消消 / 宝贝识物 / 视觉小说 / 汪汪声浪创作工作台,发布检查、素材生成面板和自定义世界实体目录中的错误 / 成功 / 信息 / 警告 / 中性提示使用 `PlatformStatusMessage surface="platform"` 复用平台 banner token;个人中心弹窗、账号安全弹窗、认证入口、验证码提示、统一创作工作台和通用创作输入区的错误 / 成功 / 信息 / 警告提示使用 `PlatformStatusMessage surface="profile"` 复用 profile token,不再把 `platform-profile-error` / `platform-profile-success` 或 `platform-banner--danger / success / info / warning / neutral` 作为业务 JSX 接口。`UnifiedModal` 继续作为底层模态窗口 Module。已有弹窗栈内的二级确认使用 `UnifiedConfirmDialog portal={false}` 内嵌到当前层级。特殊确认按钮外观通过 `confirmClassName` 适配,不让业务页重新手写 footer;`UnifiedConfirmDialog` 自身的 footer 按钮也复用 `PlatformActionButton`。带复制状态、渠道按钮、媒体预览或复杂网格的弹窗可以保留专用 Module,但普通确认按钮、普通动作按钮、普通图标动作按钮、复制按钮动作外观、复制状态机、copied / failed 按钮 / toast 分支、基础错误 / 成功提示条、无操作空态和普通弹窗关闭按钮不再直接写进业务页面。运行态 HUD、输入 Composer 发送 / 上传按钮、复制三态图标按钮或需要专用交互禁用语义的图标按钮先保留专用布局,等对应场景验证时再迁移。业务代码中的阻断提示、删除确认和公开作品失效恢复不得继续调用浏览器原生 `window.alert` / `window.confirm`,应由页面壳层或编辑器壳层用 `UnifiedConfirmDialog` 承接。简单确认需要像素风时使用 `UnifiedConfirmDialog variant="pixel"`,不再为同类确认单独维护壳层和按钮。 +- 2026-06-10 追加:推荐页运行态卡片底部的点赞 / 分享 / 改造入口,以及创作中心公开作品卡右上角分享入口统一迁移到 `PlatformIconButton`;这类和 swipe / drag 手势耦合的图标动作必须继续保留业务局部 class 与 `onPointerDown` / `onClick` 里的 `stopPropagation`,只把按钮语义、可访问名称和默认 `type="button"` 收口到共享组件,避免图标动作误触推荐卡切换、整卡打开或残留左滑状态。 +- 2026-06-10 追加:标准泥点消耗确认弹窗统一收口到 `src/components/common/PlatformMudPointConfirmDialog.tsx`;该 Module 专门承接“确认消耗泥点 + 消耗 N 泥点”的同形态确认骨架,当前已覆盖 `PuzzleCreationWorkspace.tsx`、`Match3DCreationWorkspace.tsx`、`PuzzleResultView.tsx` 与 `Match3DResultView.tsx`。后续遇到同形态泥点确认时,业务页只传点数、补充说明和确认回调,不再重复拼接 `UnifiedConfirmDialog` 正文;`RpgCreationRoleAssetStudioModalImpl` 这类节奏和内容结构不同的泥点弹层继续单独评估,留作后续轮次处理。 +- 2026-06-10 追加:`RpgCreationRoleAssetStudioModalImpl.tsx` 的角色形象生成 / 动作草稿生成确认也并入 `PlatformMudPointConfirmDialog`;共享组件通过自定义 title 与补充说明承接工坊语义,工坊页不再单独维护 `UnifiedConfirmDialog` 的标准泥点文案骨架。后续同类“确认消耗泥点 + 补充说明”场景继续优先复用该 Module。 +- 2026-06-10 追加:平台危险确认统一收口到 `src/components/common/PlatformDangerConfirmDialog.tsx`;该 Module 专门承接“确认 / 取消 + 危险主动作”的标准骨架,当前已覆盖 `PlatformEntryFlowShellImpl.tsx` 的删除作品确认、`RpgCreationResultViewImpl.tsx` 的重新生成确认和 `CustomWorldEntityCatalog.tsx` 的删除角色 / 批量删除确认。后续删除、覆盖、清空等危险动作优先复用该 Module,不再在业务页重复拼接 `UnifiedConfirmDialog` 的 `showCancel + confirmTone=\"danger\"` 组合。 +- 2026-06-10 追加:平台未保存离开确认统一收口到 `src/components/common/PlatformUnsavedLeaveConfirmDialog.tsx`;该 Module 专门承接“继续编辑 + 确认离开”的标准骨架,当前已覆盖 `RpgCreationEntityEditorShared.tsx` 里的关闭未保存修改、生成结果未保存退出和普通结果未保存退出确认。后续同类未保存离开场景优先复用该 Module,不再在业务页重复拼接 `UnifiedConfirmDialog` 的 `showCancel + cancelLabel=\"继续编辑\"` 组合和重复壳层 class。 +- 2026-06-10 追加:平台单按钮已读状态统一收口到 `src/components/common/PlatformAcknowledgeStatusDialog.tsx`;该 Module 专门承接“状态提示 + 知道了”的单按钮确认已读语义,当前已覆盖 `BigFishResultView.tsx` 的发布失败提示、`RpgEntryHomeView.tsx` 的支付结果提示、`RpgCreationEntityEditorShared.tsx` 的编辑器 notice、`PlatformEntryFlowShellImpl.tsx` 的泥点提示 / 作品不可用 / 搜索未命中提示,以及 `CustomWorldEntityCatalog.tsx` 的“无法删除”阻断提示。后续同类 status-dialog 场景优先复用该 Module,不再在业务页重复拼装 `action={{ label: '知道了', onClick: onClose }}`。 +- 2026-06-10 追加:RPG 首页个人中心里的统计卡、统计骨架、常用功能入口、设置行和法律信息入口统一抽到 `src/components/platform-entry/PlatformProfilePrimitives.tsx`;这组纯展示原子以后优先通过 props 接收图片资源、点击回调和展示文案,不再继续塞回 `RpgEntryHomeView` 的账户控制逻辑里。新建 `PlatformProfilePrimitives.test.tsx` 作为组件级护栏,页面级布局与法律入口继续由 `RpgEntryHomeView.recharge.test.tsx` 兜底。 +- 2026-06-10 追加:RPG 首页个人中心的充值 / 钱包 / 每日任务 / 邀请 / 兑换码等商业与账户控制逻辑统一收口到 `src/components/platform-entry/usePlatformProfileCenterController.ts`;controller 负责账户动作分流、商业状态派生与相关面板控制,`RpgEntryHomeView` 只保留展示、昵称头像编辑、扫码入口和页面级交互编排,不在页面组件里继续堆叠账户控制分支。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。 +- 2026-06-10 追加:RPG 首页个人中心的“玩过 / 可继续”历史弹层统一抽到 `src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx`;`RpgEntryHomeView` 不再内联 `SaveArchiveCard`、`ProfilePlayedWorksModal` 和未连通的 `ProfileSaveArchivesModal`。当前产品语义已经把存档恢复并入“玩过”弹层的“可继续”分区,因此 controller 里的 `ProfilePopupPanel` 也去掉了没有真实入口的 `saveArchives` 分支。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。 +- 2026-06-10 追加:个人中心标准头部弹窗与白底副弹层的共享壳层统一抽到 `src/components/platform-entry/PlatformProfileModalShell.tsx`;标准头部弹窗优先复用 `PlatformProfileModalShell`,白底副弹层优先复用 `PlatformProfileSecondaryModalShell`,不再在业务页重复手写 profile overlay、header、title、description、floating close 和关闭策略。昵称修改、账户充值、每日任务、兑换码、泥点账单、“玩过 / 可继续”以及邀请相关弹层已接入这套壳层。 +- 2026-06-10 追加:RPG 首页个人中心的邀请好友 / 填邀请码 / 玩家社区三态弹层统一抽到 `src/components/platform-entry/PlatformProfileReferralModal.tsx`;首页不再内联邀请码规范化、社区二维码卡片和邀请用户头像行,后续 profile 侧同类二级弹层优先按“独立组件 + `PlatformProfileSecondaryModalShell`”继续收口。 +- 2026-06-10 追加:RPG 首页个人中心的账户充值弹层统一抽到 `src/components/platform-entry/PlatformProfileRechargeModal.tsx`;充值 tab、套餐卡片、Native 二维码生成和确认支付入口不再内联在 `RpgEntryHomeView`,后续 profile 侧充值入口优先复用同一个组件。 +- 2026-06-10 追加:RPG 首页个人中心的泥点账单、每日任务和兑换码弹层统一抽到 `src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx`、`src/components/platform-entry/PlatformProfileTaskCenterModal.tsx` 与 `src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx`;`RpgEntryHomeView` 只保留打开条件和数据流,标准 profile 弹层内容以后优先沉到 `platform-entry` 独立组件,不在首页继续堆叠。 +- 2026-06-10 追加:个人中心支付结果提示与支付确认遮罩统一抽到 `src/components/common/PlatformStatusDialog.tsx`,扫码面板统一抽到 `src/components/platform-entry/PlatformProfileQrScannerModal.tsx`;`RpgEntryHomeView` 只保留支付结果 kind 到 `success / loading / cancel / error` 的映射、确认遮罩开关和扫码结果写回,不再内联 profile 状态弹层壳层、二维码摄像头启动或 `BarcodeDetector` 轮询。后续 profile 侧同类“状态图标 + 标题正文 + 可选主动作”弹层优先复用 `PlatformStatusDialog`,扫码类弹层优先复用 `PlatformProfileQrScannerModal`。 +- 2026-06-10 追加:`PlatformStatusDialog` 支持自定义图标、图标可访问标签以及动作按钮 surface / size / className 透传,用来承接玩法结果页里保留品牌视觉但语义仍是“状态结果弹层”的场景;大鱼吃小鱼结果页的发布失败弹层已迁移到这套组件,业务页不再保留 `UnifiedConfirmDialog + PlatformIconBadge` 的专用组合。 +- 2026-06-10 追加:`PlatformStatusDialog` 继续支持 header notice 布局、body content、close button、backdrop / Escape 关闭路径,用来承接“提示 / 规则阻断 / 作品不可用 / 泥点不足”这类带标题栏的状态 notice;平台入口的 `draftGenerationPointNotice`、`workNotFoundRecoveryDialog` 和 RPG 大编辑器里的 `EditorNoticeDialog` 已迁移到这套共享组件,不再各自维护 `UnifiedConfirmDialog` 壳层和关闭策略。 +- 2026-06-10 追加:`CustomWorldEntityCatalog` 的 `minimum-playable` 规则阻断提示也统一迁到 `PlatformStatusDialog`,不再和删除角色 / 批量删除共用 `UnifiedConfirmDialog` 配置;同日平台入口公开编号搜索把 error 分支从用户摘要 modal 中拆出,未命中结果单独走 `PlatformStatusDialog`,命中用户继续保留 `UnifiedModal + PlatformSubpanel` 信息布局。 +- 2026-06-11 追加:`PlatformAsyncStatePanel` 继续从 profile modal 与作品架扩展到 RPG 首页公开分区;`RpgEntryHomeView.tsx` 的移动端排行、发现页寓教于乐 / 默认公开 feed、桌面首页“今日游戏 / 推荐”、桌面发现页寓教于乐 / 默认公开 feed,以及“我的创作”分区已统一改成 `loadingState / emptyState / children` 三态 slot。页面级 `platformError` 继续留在状态壳外层,保证错误提示可以和内容并存;`recommend runtime`、分类筛选等含运行态或二级筛选语义的分支暂不硬并入这一轮。 +- 2026-06-11 追加:暗色 / 像素 modal 的标准 footer 布局统一抽到 `src/components/common/PlatformDarkModalFooter.tsx`;该组件只负责 dark footer 的分隔线、padding 和常见动作区排布,不持有“取消 / 确认”业务语义。`NpcModals.tsx` 的交易 / 赠礼 / 招募 footer、`SelectionCustomizationModals.tsx` 的 `SelectionModal` footer、`RpgAdventurePanelOverlays.tsx` 的 goal panel footer,以及 `InventoryItemViews.tsx` 的详情 footer wrapper 已接入;sticky 工作台 footer、正文内单 CTA 收尾和 runtime HUD 工具条暂不并入这一抽象。 +- 2026-06-11 追加:桌面首页里的轻量可点击扁平行开始统一收口到 `src/components/common/PlatformNavigableListItem.tsx`;目前已覆盖 `RpgEntryHomeView.tsx` 的搜索结果行、桌面“最近作品”、桌面“最近浏览”以及桌面“今日游戏”趋势行。组件只承接 `button + left content + right affordance` 结构、默认 `type="button"` 与 `leading / trailing` 插槽,暂不扩成覆盖教培 promo card、分类卡片、世界卡或 runtime 列表项的万能 row primitive。 +- 2026-06-11 追加:`PlatformNavigableListItem` 继续扩展到 profile 设置行;`src/components/platform-entry/PlatformProfilePrimitives.tsx` 的 `ProfileSettingsRow` 已改成委托共享 `button + leading + trailing` 骨架,继续保留本地 `platform-profile-settings-row` class 承接分隔线、icon 胶囊和字号微调。后续 profile / 账户中心里的同类轻量导航行优先直接复用共享行骨架,不再回退成原生 ` + + + diff --git a/miniprogram/pages/share-grid/index.wxss b/miniprogram/pages/share-grid/index.wxss new file mode 100644 index 00000000..e34ca465 --- /dev/null +++ b/miniprogram/pages/share-grid/index.wxss @@ -0,0 +1,60 @@ +page { + background: #fffdf9; +} + +.share-grid-page { + min-height: 100vh; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + padding: 48rpx; + background: #fffdf9; +} + +.share-grid-card { + width: 100%; + max-width: 560rpx; + box-sizing: border-box; + border: 1rpx solid rgba(127, 85, 57, 0.18); + border-radius: 16rpx; + background: rgba(255, 255, 255, 0.92); + padding: 36rpx; + box-shadow: 0 24rpx 68rpx rgba(127, 85, 57, 0.12); +} + +.share-grid-title { + color: #332820; + font-size: 34rpx; + font-weight: 700; + line-height: 1.35; +} + +.share-grid-text { + margin-top: 18rpx; + color: rgba(51, 40, 32, 0.68); + font-size: 26rpx; + line-height: 1.55; +} + +.share-grid-text--danger { + color: #b84a3d; +} + +.share-grid-button { + margin-top: 28rpx; + width: 100%; + border-radius: 8rpx; + background: #7f5539; + color: #fffdf9; + font-size: 28rpx; + line-height: 2.6; +} + +.share-grid-canvas { + position: fixed; + left: -9999px; + top: -9999px; + width: 1px; + height: 1px; +} diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index c7d221dc..b417a495 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -10,6 +10,13 @@ const { WEB_VIEW_ENTRY_URL, WEB_VIEW_SOURCE_QUERY, } = require('../../config'); +const { + appendHashParams, + buildWebViewSharePath, + buildWebViewShareTimelineQuery, + resolveShareTargetFromWebViewMessage, + resolveWebViewUrlFromRuntimeConfig, +} = require('./index.shared'); const MINI_PROGRAM_CLIENT_TYPE = 'mini_program'; const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program'; @@ -19,7 +26,6 @@ const AUTH_RESULT_STORAGE_KEY = 'genarrative:mini-program-auth-result'; const AUTH_ACTION_LOGIN = 'login'; const PAY_RESULT_RECHECK_DELAY_MS = 120; const WEB_VIEW_SHARE_TITLE = '陶泥儿'; -const WEB_VIEW_SHARE_PATH = '/pages/web-view/index'; function showWebViewShareMenu() { if (typeof wx.showShareMenu !== 'function') { @@ -32,17 +38,25 @@ function showWebViewShareMenu() { }); } -function buildWebViewShareAppMessage() { +function resolveNativeShareQuery(page) { + return ( + (page && page._currentShareTarget) || + (page && page._lastLaunchQuery) || + {} + ); +} + +function buildWebViewShareAppMessage(query = {}) { return { title: WEB_VIEW_SHARE_TITLE, - path: WEB_VIEW_SHARE_PATH, + path: buildWebViewSharePath(query), }; } -function buildWebViewShareTimeline() { +function buildWebViewShareTimeline(query = {}) { return { title: WEB_VIEW_SHARE_TITLE, - query: '', + query: buildWebViewShareTimelineQuery(query), }; } @@ -59,50 +73,6 @@ function isConfiguredApiBaseUrl(value) { return /^https:\/\/[^/]+/i.test(String(value || '').trim()); } -function appendQuery(url, query) { - const pairs = Object.keys(query) - .filter((key) => query[key]) - .map( - (key) => - `${encodeURIComponent(key)}=${encodeURIComponent(String(query[key]))}`, - ); - - if (pairs.length === 0) { - return url; - } - - return `${url}${url.includes('?') ? '&' : '?'}${pairs.join('&')}`; -} - -function appendHashParams(url, params) { - const nextKeys = new Set(Object.keys(params).filter((key) => params[key])); - const pairs = Object.keys(params) - .filter((key) => params[key]) - .map( - (key) => - `${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`, - ); - if (pairs.length === 0) { - return url; - } - - const hashIndex = url.indexOf('#'); - const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url; - const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : ''; - const keptHashParts = rawHash.split('&').filter((part) => { - if (!part) { - return false; - } - const [rawKey = ''] = part.split('='); - try { - return !nextKeys.has(decodeURIComponent(rawKey)); - } catch (_error) { - return !nextKeys.has(rawKey); - } - }); - return `${baseUrl}#${keptHashParts.concat(pairs).join('&')}`; -} - function parseBooleanQueryFlag(value) { return value === true || value === '1' || value === 'true' || value === 'yes'; } @@ -233,22 +203,16 @@ function shouldReturnToPreviousPage(query) { return String((query && query.returnTo) || '').trim() === 'previous'; } -function resolveWebViewUrl(authResult) { +function resolveWebViewUrl(authResult, launchQuery = {}) { const runtimeConfig = resolveMiniProgramRuntimeConfig(); const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim(); if (!isConfiguredEntryUrl(entryUrl)) { return ''; } - const sourcedUrl = appendQuery(entryUrl, runtimeConfig.sourceQuery); - if (!authResult || !authResult.token) { - return sourcedUrl; - } - - return appendHashParams(sourcedUrl, { - auth_provider: 'wechat', - auth_token: authResult.token, - auth_binding_status: authResult.bindingStatus, + return resolveWebViewUrlFromRuntimeConfig(authResult, launchQuery, { + ...runtimeConfig, + webViewEntryUrl: String(runtimeConfig.webViewEntryUrl || '').trim(), }); } @@ -467,7 +431,7 @@ Page({ loading: false, phoneBindingRequired: false, returnToPreviousPage: false, - webViewUrl: resolveWebViewUrl(null), + webViewUrl: resolveWebViewUrl(null, query), }); return; } @@ -572,7 +536,7 @@ Page({ nicknameRequired: false, phoneBindingRequired: false, returnToPreviousPage, - webViewUrl: resolveWebViewUrl(authResult), + webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}), }); } catch (error) { this.setData({ @@ -600,7 +564,7 @@ Page({ loading: false, nicknameRequired: false, phoneBindingRequired: false, - webViewUrl: resolveWebViewUrl(authResult), + webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}), }); } @@ -674,7 +638,10 @@ Page({ loading: false, nicknameRequired: false, phoneBindingRequired: false, - webViewUrl: resolveWebViewUrl(nextAuthResult), + webViewUrl: resolveWebViewUrl( + nextAuthResult, + this._lastLaunchQuery || {}, + ), }); } catch (error) { this.setData({ @@ -712,15 +679,19 @@ Page({ }, handleWebViewMessage(event) { + const shareTarget = resolveShareTargetFromWebViewMessage(event.detail); + if (shareTarget) { + this._currentShareTarget = shareTarget; + } // 中文注释:支付和订阅消息都由独立 native 页面承接,web-view 消息只保留调试输出。 console.info('[web-view] message', event.detail); }, onShareAppMessage() { - return buildWebViewShareAppMessage(); + return buildWebViewShareAppMessage(resolveNativeShareQuery(this)); }, onShareTimeline() { - return buildWebViewShareTimeline(); + return buildWebViewShareTimeline(resolveNativeShareQuery(this)); }, }); diff --git a/miniprogram/pages/web-view/index.shared.js b/miniprogram/pages/web-view/index.shared.js new file mode 100644 index 00000000..5f05096a --- /dev/null +++ b/miniprogram/pages/web-view/index.shared.js @@ -0,0 +1,188 @@ +const ALLOWED_TARGET_PATHS = new Set(['/works/detail']); +const SHARE_TARGET_MESSAGE_TYPE = 'genarrative:share-target'; +const WEB_VIEW_SHARE_PATH = '/pages/web-view/index'; + +function trimTrailingSlash(value) { + return String(value || '').trim().replace(/\/+$/u, ''); +} + +function appendQuery(url, query) { + const rawUrl = String(url || ''); + const pairs = Object.keys(query) + .filter((key) => query[key]) + .map( + (key) => + `${encodeURIComponent(key)}=${encodeURIComponent(String(query[key]))}`, + ); + + if (pairs.length === 0) { + return rawUrl; + } + + const hashIndex = rawUrl.indexOf('#'); + const baseUrl = hashIndex >= 0 ? rawUrl.slice(0, hashIndex) : rawUrl; + const hash = hashIndex >= 0 ? rawUrl.slice(hashIndex) : ''; + return `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}${pairs.join('&')}${hash}`; +} + +function appendHashParams(url, params) { + const nextKeys = new Set(Object.keys(params).filter((key) => params[key])); + const pairs = Object.keys(params) + .filter((key) => params[key]) + .map( + (key) => + `${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`, + ); + if (pairs.length === 0) { + return url; + } + + const hashIndex = url.indexOf('#'); + const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url; + const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : ''; + const keptHashParts = rawHash.split('&').filter((part) => { + if (!part) { + return false; + } + const [rawKey = ''] = part.split('='); + try { + return !nextKeys.has(decodeURIComponent(rawKey)); + } catch (_error) { + return !nextKeys.has(rawKey); + } + }); + return `${baseUrl}#${keptHashParts.concat(pairs).join('&')}`; +} + +function normalizeTargetPath(value) { + const trimmed = String(value || '').trim(); + if (!trimmed.startsWith('/')) { + return ''; + } + + const normalized = trimmed.replace(/\/+$/u, '') || '/'; + return ALLOWED_TARGET_PATHS.has(normalized) ? normalized : ''; +} + +function resolveLaunchTargetQuery(query) { + const targetPath = normalizeTargetPath(query && query.targetPath); + const work = String((query && query.work) || '').trim(); + if (!targetPath || !work) { + return {}; + } + + return { + targetPath, + work, + }; +} + +function buildWebViewSharePath(query = {}, basePath = WEB_VIEW_SHARE_PATH) { + const launchTarget = resolveLaunchTargetQuery(query); + if (!launchTarget.targetPath) { + return basePath; + } + + return appendQuery(basePath, { + targetPath: launchTarget.targetPath, + work: launchTarget.work, + }); +} + +function buildWebViewShareTimelineQuery(query = {}) { + const launchTarget = resolveLaunchTargetQuery(query); + if (!launchTarget.targetPath) { + return ''; + } + + return new URLSearchParams({ + targetPath: launchTarget.targetPath, + work: launchTarget.work, + }).toString(); +} + +function normalizeShareTargetMessageData(value) { + const message = value && value.data ? value.data : value; + if (!message || message.type !== SHARE_TARGET_MESSAGE_TYPE) { + return null; + } + + const payload = message.payload || {}; + const launchTarget = resolveLaunchTargetQuery(payload); + if (!launchTarget.targetPath) { + return null; + } + + return { + ...launchTarget, + title: String(payload.title || '').trim(), + }; +} + +function resolveShareTargetFromWebViewMessage(detail) { + const dataList = detail && Array.isArray(detail.data) ? detail.data : []; + for (let index = dataList.length - 1; index >= 0; index -= 1) { + const target = normalizeShareTargetMessageData(dataList[index]); + if (target) { + return target; + } + } + + return normalizeShareTargetMessageData(detail); +} + +function appendLaunchTargetToEntryUrl(entryUrl, query) { + const launchTarget = resolveLaunchTargetQuery(query); + if (!launchTarget.targetPath) { + return entryUrl; + } + + const rawEntryUrl = String(entryUrl || '').trim(); + const hashIndex = rawEntryUrl.indexOf('#'); + const entryWithoutHash = + hashIndex >= 0 ? rawEntryUrl.slice(0, hashIndex) : rawEntryUrl; + const hash = hashIndex >= 0 ? rawEntryUrl.slice(hashIndex) : ''; + const queryIndex = entryWithoutHash.indexOf('?'); + const entryBase = + queryIndex >= 0 ? entryWithoutHash.slice(0, queryIndex) : entryWithoutHash; + const entrySearch = + queryIndex >= 0 ? entryWithoutHash.slice(queryIndex) : ''; + const targetUrl = `${trimTrailingSlash(entryBase)}${launchTarget.targetPath}${entrySearch}${hash}`; + + return appendQuery(targetUrl, { + work: launchTarget.work, + }); +} + +function resolveWebViewUrlFromRuntimeConfig( + authResult, + launchQuery = {}, + runtimeConfig = {}, +) { + const entryUrl = appendLaunchTargetToEntryUrl( + String(runtimeConfig.webViewEntryUrl || '').trim(), + launchQuery, + ); + const sourcedUrl = appendQuery(entryUrl, runtimeConfig.sourceQuery || {}); + if (!authResult || !authResult.token) { + return sourcedUrl; + } + + return appendHashParams(sourcedUrl, { + auth_provider: 'wechat', + auth_token: authResult.token, + auth_binding_status: authResult.bindingStatus, + }); +} + +module.exports = { + appendHashParams, + appendLaunchTargetToEntryUrl, + appendQuery, + buildWebViewSharePath, + buildWebViewShareTimelineQuery, + normalizeTargetPath, + resolveShareTargetFromWebViewMessage, + resolveLaunchTargetQuery, + resolveWebViewUrlFromRuntimeConfig, +}; diff --git a/miniprogram/pages/web-view/index.test.js b/miniprogram/pages/web-view/index.test.js new file mode 100644 index 00000000..a04adbc5 --- /dev/null +++ b/miniprogram/pages/web-view/index.test.js @@ -0,0 +1,110 @@ +import { describe, expect, test } from 'vitest'; + +import webViewBridge from './index.shared.js'; + +const { + appendLaunchTargetToEntryUrl, + buildWebViewSharePath, + buildWebViewShareTimelineQuery, + resolveShareTargetFromWebViewMessage, + resolveWebViewUrlFromRuntimeConfig, +} = webViewBridge; + +const runtimeConfig = { + sourceQuery: { + clientType: 'mini_program', + clientRuntime: 'wechat_mini_program', + }, + webViewEntryUrl: 'https://www.genarrative.world', +}; + +describe('mini program web-view launch target', () => { + test('opens the H5 public work detail when launch query carries work params', () => { + expect( + appendLaunchTargetToEntryUrl('https://www.genarrative.world?foo=bar', { + targetPath: '/works/detail', + work: 'BB-12345678', + }), + ).toBe( + 'https://www.genarrative.world/works/detail?foo=bar&work=BB-12345678', + ); + + const webViewUrl = resolveWebViewUrlFromRuntimeConfig( + null, + { + targetPath: '/works/detail', + work: 'BB-12345678', + }, + runtimeConfig, + ); + const url = new URL(webViewUrl); + expect(url.pathname).toBe('/works/detail'); + expect(url.searchParams.get('work')).toBe('BB-12345678'); + expect(url.searchParams.get('clientRuntime')).toBe('wechat_mini_program'); + }); + + test('ignores unsupported launch target paths', () => { + const webViewUrl = resolveWebViewUrlFromRuntimeConfig( + null, + { + targetPath: '/admin', + work: 'BB-12345678', + }, + runtimeConfig, + ); + const url = new URL(webViewUrl); + expect(url.pathname).toBe('/'); + expect(url.searchParams.get('work')).toBeNull(); + }); + + test('keeps public work params in native mini program share paths', () => { + const sharePath = buildWebViewSharePath({ + targetPath: '/works/detail', + work: 'BB-12345678', + }); + const url = new URL(sharePath, 'https://mini.test'); + + expect(url.pathname).toBe('/pages/web-view/index'); + expect(url.searchParams.get('targetPath')).toBe('/works/detail'); + expect(url.searchParams.get('work')).toBe('BB-12345678'); + expect( + buildWebViewShareTimelineQuery({ + targetPath: '/works/detail', + work: 'BB-12345678', + }), + ).toBe('targetPath=%2Fworks%2Fdetail&work=BB-12345678'); + }); + + test('reads the latest H5 recommended work share target from web-view messages', () => { + expect( + resolveShareTargetFromWebViewMessage({ + data: [ + { + data: { + type: 'genarrative:share-target', + payload: { + targetPath: '/works/detail', + work: 'PZ-0001', + title: '旧作品', + }, + }, + }, + { + data: { + type: 'genarrative:share-target', + payload: { + targetPath: '/works/detail', + work: 'BB-12345678', + title: '汪汪声浪', + }, + }, + }, + ], + }), + ).toEqual({ + targetPath: '/works/detail', + work: 'BB-12345678', + title: '汪汪声浪', + }); + }); +}); diff --git a/package.json b/package.json index b9055ebf..c4b39b8d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dev:api-server": "node scripts/dev.mjs api-server", "dev:web": "node scripts/dev.mjs web", "dev:admin-web": "node scripts/dev.mjs admin-web", + "server-manager:panel": "cargo run -p server-manager-panel --manifest-path server-rs/Cargo.toml", "dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh", "otel:debug": "node scripts/run-otelcol.mjs debug", "otel:rider": "node scripts/run-otelcol.mjs rider", diff --git a/scripts/dev.mjs b/scripts/dev.mjs index dd737e08..eb959365 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -37,6 +37,7 @@ const manifestPath = resolve(serverRsDir, 'Cargo.toml'); const modulePath = resolve(serverRsDir, 'crates/spacetime-module'); const viteCliPath = resolve(repoRoot, 'scripts/vite-cli.mjs'); const adminWebDir = resolve(repoRoot, 'apps/admin-web'); +const LOCAL_DEV_RUSTC_WRAPPER_BYPASS = process.platform === 'win32' ? 'rustc' : '/usr/bin/env'; const SERVICE_NAMES = ['spacetime', 'api-server', 'web', 'admin-web']; const SERVICE_ALIASES = new Map([ @@ -399,6 +400,39 @@ function requireCommand(command) { } } +function isSccacheRustcWrapper(value) { + const wrapper = String(value ?? '').trim(); + if (!wrapper) { + return false; + } + + const command = wrapper.split(/[\\/]/).pop()?.toLowerCase(); + return command === 'sccache' || command === 'sccache.exe'; +} + +function buildLocalRustProcessEnv(env, options = {}) { + const mergedEnv = {...env}; + const wrappers = [ + String(mergedEnv.RUSTC_WRAPPER ?? '').trim(), + String(mergedEnv.CARGO_BUILD_RUSTC_WRAPPER ?? '').trim(), + ].filter(Boolean); + const customWrapper = wrappers.find((wrapper) => !isSccacheRustcWrapper(wrapper)); + if (customWrapper) { + mergedEnv.RUSTC_WRAPPER = customWrapper; + mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = customWrapper; + return mergedEnv; + } + + mergedEnv.RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS; + mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS; + if (options.log !== false) { + console.warn( + '[dev:rust] 本地 dev 构建绕过项目 sccache wrapper,避免缓存进程异常阻断启动。', + ); + } + return mergedEnv; +} + function readWorkspaceSpacetimeVersion() { const manifestText = readFileSync(manifestPath, 'utf8'); const match = /^spacetimedb\s*=\s*(?:"([^"]+)"|\{[^}]*version\s*=\s*"([^"]+)")/mu.exec( @@ -776,7 +810,7 @@ class DevRunner { this.writeDevStackState(); } - async prepareLinuxPortRange(command) { + async prepareLinuxPortRange() { if (process.platform !== 'linux') { return; } @@ -1228,7 +1262,7 @@ class DevRunner { } async publishSpacetimeModule() { - const env = {...this.baseEnv}; + const env = buildLocalRustProcessEnv(this.baseEnv); this.prepareMigrationBootstrapSecret(env); const args = buildSpacetimePublishArgs({ @@ -1291,7 +1325,7 @@ class DevRunner { await this.ensureApiServerSpacetimeToken(); const mergedEnv = buildApiServerProcessEnv({ - baseEnv: this.baseEnv, + baseEnv: buildLocalRustProcessEnv(this.baseEnv), options: this.options, state: this.state, }); @@ -2124,19 +2158,20 @@ function buildApiServerProcessEnv({baseEnv, options, state}) { } export { - DevRunner, assertReusableSpacetimeProcessVersionMatchesWorkspace, assertSpacetimeToolVersionMatchesWorkspace, buildApiServerProcessEnv, buildDevStackSnapshot, + buildLocalRustProcessEnv, buildSpacetimePublishArgs, createDevServerSpawnOptions, createWatchConfigs, - isSpacetimePublishPermissionError, + DevRunner, isDirectModuleExecution, + isSpacetimePublishPermissionError, normalizeCargoVersionRequirement, - parseSpacetimeToolVersion, parseArgs, + parseSpacetimeToolVersion, resolveDevStackStatePath, shouldAcceptWatchEvent, }; diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index cf3640b5..b4d3d80e 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -5,19 +5,20 @@ import {join} from 'node:path'; import {afterEach, describe, expect, test, vi} from 'vitest'; import { - DevRunner, assertReusableSpacetimeProcessVersionMatchesWorkspace, assertSpacetimeToolVersionMatchesWorkspace, buildApiServerProcessEnv, buildDevStackSnapshot, + buildLocalRustProcessEnv, buildSpacetimePublishArgs, createDevServerSpawnOptions, createWatchConfigs, + DevRunner, isDirectModuleExecution, isSpacetimePublishPermissionError, normalizeCargoVersionRequirement, - parseSpacetimeToolVersion, parseArgs, + parseSpacetimeToolVersion, resolveDevStackStatePath, shouldAcceptWatchEvent, } from './dev.mjs'; @@ -185,6 +186,35 @@ describe('dev scheduler api-server env', () => { }); }); +describe('dev scheduler Rust build env', () => { + test('local dev Rust env bypasses project sccache wrapper', () => { + const env = buildLocalRustProcessEnv( + { + RUSTC_WRAPPER: '/usr/bin/sccache', + CARGO_BUILD_RUSTC_WRAPPER: 'sccache', + }, + {log: false}, + ); + + expect(env.RUSTC_WRAPPER).not.toBe('/usr/bin/sccache'); + expect(env.RUSTC_WRAPPER).not.toBe('sccache'); + expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(env.RUSTC_WRAPPER); + }); + + test('local dev Rust env keeps healthy custom wrapper untouched', () => { + const env = buildLocalRustProcessEnv( + { + RUSTC_WRAPPER: 'custom-wrapper', + CARGO_BUILD_RUSTC_WRAPPER: 'sccache', + }, + {log: false}, + ); + + expect(env.RUSTC_WRAPPER).toBe('custom-wrapper'); + expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('custom-wrapper'); + }); +}); + describe('dev scheduler stack state file', () => { test('状态文件路径固定在根目录 .app/dev-stack.json', () => { expect(resolveDevStackStatePath('C:\\repo\\Genarrative')).toBe( diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index f285faaf..99cf595c 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -2,6 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "adler2" version = "2.0.1" @@ -35,6 +51,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -64,6 +81,31 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.11.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -173,6 +215,26 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "x11rb", +] + [[package]] name = "argon2" version = "0.5.3" @@ -197,6 +259,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + [[package]] name = "async-stream" version = "0.3.6" @@ -324,6 +392,21 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -377,6 +460,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + [[package]] name = "brotli" version = "3.5.0" @@ -409,6 +501,20 @@ name = "bytemuck" version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "byteorder-lite" @@ -432,6 +538,57 @@ dependencies = [ "serde_core", ] +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.11.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.11.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + [[package]] name = "castaway" version = "0.2.4" @@ -474,6 +631,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + [[package]] name = "chrono" version = "0.4.44" @@ -498,6 +664,43 @@ dependencies = [ "inout", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.16.3" @@ -540,12 +743,46 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -607,6 +844,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -665,9 +908,15 @@ dependencies = [ "openssl-sys", "pkg-config", "vcpkg", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "darling" version = "0.23.0" @@ -763,6 +1012,22 @@ dependencies = [ "subtle", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -774,24 +1039,183 @@ dependencies = [ "syn", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + [[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecolor" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "parking_lot", + "percent-encoding", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3" +dependencies = [ + "ahash", + "bitflags 2.11.1", + "emath", + "epaint", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", +] + +[[package]] +name = "egui-wgpu" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e4d209971c84b2352a06174abdba701af1e552ce56b144d96f2bd50a3c91236" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.18", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec6687e5bb551702f4ad10ac428bab12acf9d53047ebb1082d4a0ed8c6251a29" +dependencies = [ + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6420863ea1d90e750f75075231a260030ad8a9f30a7cef82cdc966492dc4c4eb" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "memoffset", + "profiling", + "wasm-bindgen", + "web-sys", + "winit", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "emath" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32" +dependencies = [ + "bytemuck", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -839,6 +1263,30 @@ dependencies = [ "syn", ] +[[package]] +name = "epaint" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "log", + "nohash-hasher", + "parking_lot", + "profiling", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862" + [[package]] name = "equivalent" version = "1.0.2" @@ -852,9 +1300,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "ethnum" version = "1.5.3" @@ -879,6 +1333,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -922,13 +1382,40 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "foreign-types-shared", + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -937,6 +1424,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1044,6 +1537,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1084,12 +1587,101 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.11.1", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + [[package]] name = "h2" version = "0.3.27" @@ -1109,6 +1701,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1121,7 +1725,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1131,6 +1735,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "equivalent", + "foldhash 0.2.0", "rayon", "serde", "serde_core", @@ -1154,12 +1759,24 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + [[package]] name = "hmac" version = "0.12.1" @@ -1342,7 +1959,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -1499,6 +2116,7 @@ dependencies = [ "moxcms", "num-traits", "png", + "tiff", "zune-core", "zune-jpeg", ] @@ -1593,6 +2211,64 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -1639,6 +2315,12 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + [[package]] name = "langchainrust" version = "0.2.18" @@ -1690,6 +2372,34 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags 2.11.1", + "libc", + "plain", + "redox_syscall 0.8.1", +] + [[package]] name = "libwebp-sys" version = "0.9.6" @@ -1712,6 +2422,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1724,6 +2440,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1794,6 +2516,24 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -2074,6 +2814,31 @@ dependencies = [ "pxfm", ] +[[package]] +name = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap 2.14.0", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "thiserror 2.0.18", + "unicode-ident", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -2091,6 +2856,36 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -2113,7 +2908,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2148,6 +2943,300 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.1", + "block2", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", ] [[package]] @@ -2164,7 +3253,7 @@ checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" dependencies = [ "bitflags 2.11.1", "cfg-if", - "foreign-types", + "foreign-types 0.3.2", "libc", "once_cell", "openssl-macros", @@ -2284,6 +3373,25 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2302,7 +3410,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -2397,6 +3505,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "platform-agent" version = "0.1.0" @@ -2543,12 +3657,41 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "pom" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "postscript" version = "0.14.1" @@ -2589,6 +3732,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2598,6 +3750,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + [[package]] name = "prometheus" version = "0.14.0" @@ -2668,6 +3826,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2679,9 +3846,9 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -2699,7 +3866,7 @@ dependencies = [ "lru-slab", "rand 0.9.4", "ring", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "rustls-pki-types", "slab", @@ -2718,7 +3885,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", "windows-sys 0.59.0", ] @@ -2809,6 +3976,12 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + [[package]] name = "rayon" version = "1.12.0" @@ -2829,6 +4002,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2838,6 +4020,15 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -2887,6 +4078,12 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + [[package]] name = "reqwest" version = "0.11.27" @@ -2987,6 +4184,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3002,6 +4205,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -3011,8 +4227,8 @@ dependencies = [ "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", - "windows-sys 0.59.0", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] [[package]] @@ -3071,6 +4287,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -3153,7 +4378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.9.4", "core-foundation-sys", "libc", "security-framework-sys", @@ -3292,6 +4517,13 @@ dependencies = [ "syn", ] +[[package]] +name = "server-manager-panel" +version = "0.1.0" +dependencies = [ + "eframe", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3384,6 +4616,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "similar" version = "2.7.0" @@ -3408,12 +4656,93 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.11.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.11.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.18", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + [[package]] name = "socket2" version = "0.5.10" @@ -3776,6 +5105,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -3854,7 +5189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3877,8 +5212,8 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", - "windows-sys 0.59.0", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] @@ -3943,6 +5278,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -4303,6 +5652,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "tungstenite" version = "0.27.0" @@ -4340,6 +5695,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.2", +] + [[package]] name = "type1-encoding-parser" version = "0.1.1" @@ -4382,6 +5746,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -4454,6 +5824,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -4589,6 +5969,141 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.11.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.95" @@ -4609,6 +6124,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webbrowser" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +dependencies = [ + "core-foundation 0.10.1", + "jni", + "log", + "ndk-context", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + [[package]] name = "webp" version = "0.3.1" @@ -4643,13 +6174,109 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wgpu" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +dependencies = [ + "arrayvec", + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "log", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.11.1", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap 2.14.0", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "27.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libloading", + "log", + "naga", + "portable-atomic", + "portable-atomic-util", + "raw-window-handle", + "renderdoc-sys", + "thiserror 2.0.18", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +dependencies = [ + "bitflags 2.11.1", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.18", + "web-sys", +] + [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -4868,6 +6495,57 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.11.1", + "block2", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + [[package]] name = "winnow" version = "1.0.1" @@ -4987,6 +6665,69 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.11.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "yoke" version = "0.8.2" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index cdc461bd..41834fac 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -40,6 +40,7 @@ members = [ "crates/platform-wechat", "crates/platform-speech", "crates/platform-agent", + "crates/server-manager-panel", "crates/shared-contracts", "crates/shared-kernel", "crates/shared-logging", diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index 613ce234..c6430554 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -4,7 +4,11 @@ use axum::http::StatusCode; use serde_json::json; use spacetime_client::SpacetimeClientError; -use crate::{http_error::AppError, state::AppState}; +use crate::{ + http_error::AppError, + state::AppState, + wallet_refund_outbox::{WalletRefundOutboxEnqueueOutcome, WalletRefundOutboxRecord}, +}; pub(crate) const ASSET_OPERATION_POINTS_COST: u64 = 1; @@ -90,22 +94,11 @@ async fn consume_asset_operation_points( .await { Ok(_) => Ok(true), - Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { - // 中文注释:外部生图不应被 Maincloud 钱包短暂 503 阻断;此时跳过扣费,让业务链路继续,避免用户重复点击。 - tracing::warn!( - owner_user_id, - asset_kind, - asset_id, - error = %error, - "资产操作泥点预扣因 SpacetimeDB 连接不可用而降级跳过" - ); - Ok(false) - } Err(error) => Err(map_asset_operation_wallet_error(error)), } } -/// 外部生成或发布 mutation 失败后补偿退款;退款失败只记日志,避免覆盖原始业务错误。 +/// 外部生成或发布 mutation 失败后补偿退款;立即退款失败会进入 outbox,避免覆盖原始业务错误。 async fn refund_asset_operation_points( state: &AppState, owner_user_id: &str, @@ -117,22 +110,74 @@ async fn refund_asset_operation_points( "asset_operation_refund:{}:{}:{}", owner_user_id, asset_kind, asset_id ); + let created_at_micros = current_utc_micros(); if let Err(error) = state .spacetime_client() .refund_profile_wallet_points( owner_user_id.to_string(), points_cost, - ledger_id, - current_utc_micros(), + ledger_id.clone(), + created_at_micros, ) .await { + let refund_error = error.to_string(); + if let Some(outbox) = state.wallet_refund_outbox() { + match outbox + .enqueue(WalletRefundOutboxRecord { + owner_user_id: owner_user_id.to_string(), + amount: points_cost, + ledger_id: ledger_id.clone(), + created_at_micros, + asset_kind: asset_kind.to_string(), + asset_id: asset_id.to_string(), + }) + .await + { + Ok(WalletRefundOutboxEnqueueOutcome::Enqueued) => { + tracing::warn!( + owner_user_id, + asset_kind, + asset_id, + ledger_id, + error = %refund_error, + "资产操作失败后的泥点退款立即执行失败,已写入 wallet refund outbox" + ); + return; + } + Ok(WalletRefundOutboxEnqueueOutcome::Dropped { reason }) => { + tracing::error!( + owner_user_id, + asset_kind, + asset_id, + ledger_id, + reason, + error = %refund_error, + "资产操作失败后的泥点退款立即执行失败,且 wallet refund outbox 因容量限制丢弃" + ); + return; + } + Err(outbox_error) => { + tracing::error!( + owner_user_id, + asset_kind, + asset_id, + ledger_id, + refund_error = %refund_error, + outbox_error = %outbox_error, + "资产操作失败后的泥点退款立即执行失败,且写入 wallet refund outbox 失败" + ); + return; + } + } + } tracing::error!( owner_user_id, asset_kind, asset_id, - error = %error, - "资产操作失败后的泥点退款失败" + ledger_id, + error = %refund_error, + "资产操作失败后的泥点退款失败,且 wallet refund outbox 未启用" ); } } @@ -185,7 +230,7 @@ mod tests { use super::*; #[test] - fn asset_operation_billing_skips_spacetime_connectivity_errors() { + fn asset_operation_connectivity_errors_are_classified_for_non_billing_fallbacks() { assert_eq!(ASSET_OPERATION_POINTS_COST, 1); assert!(should_skip_asset_operation_billing_for_connectivity( &SpacetimeClientError::ConnectDropped diff --git a/server-rs/crates/api-server/src/bark_battle.rs b/server-rs/crates/api-server/src/bark_battle.rs index fa33c9ee..51f775ce 100644 --- a/server-rs/crates/api-server/src/bark_battle.rs +++ b/server-rs/crates/api-server/src/bark_battle.rs @@ -306,11 +306,12 @@ pub async fn generate_bark_battle_image_asset( .filter(|value| !value.is_empty()) .map(ToString::to_string); let points_cost = resolve_bark_battle_image_asset_points_cost(&state, &payload).await; + let billing_asset_id = request_context.request_id().to_string(); let result = execute_billable_asset_operation_with_cost( &state, &owner_user_id, bark_battle_slot_asset_kind(&slot), - asset_id.as_str(), + billing_asset_id.as_str(), points_cost, async { generate_and_persist_bark_battle_image_asset( diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index 1fc8636a..e8969117 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -722,7 +722,7 @@ pub async fn execute_big_fish_action( "big_fish_publish_game" => Some("big_fish_publish_game"), _ => None, }; - let billing_asset_id = format!("{session_id}:{now}"); + let billing_asset_id = format!("{}:{}:{}", session_id, action, request_context.request_id()); let session_operation = async { match action.as_str() { "big_fish_compile_draft" => { diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 3ca4a2a6..b23bb782 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -32,6 +32,11 @@ pub struct AppConfig { pub tracking_outbox_batch_size: usize, pub tracking_outbox_flush_interval: Duration, pub tracking_outbox_max_bytes: u64, + pub wallet_refund_outbox_enabled: bool, + pub wallet_refund_outbox_dir: PathBuf, + pub wallet_refund_outbox_batch_size: usize, + pub wallet_refund_outbox_flush_interval: Duration, + pub wallet_refund_outbox_max_bytes: u64, pub log_filter: String, pub otel_enabled: bool, pub admin_username: Option, @@ -183,6 +188,11 @@ impl Default for AppConfig { tracking_outbox_batch_size: 500, tracking_outbox_flush_interval: Duration::from_millis(1_000), tracking_outbox_max_bytes: 256 * 1024 * 1024, + wallet_refund_outbox_enabled: true, + wallet_refund_outbox_dir: PathBuf::from("server-rs/.data/wallet-refund-outbox"), + wallet_refund_outbox_batch_size: 100, + wallet_refund_outbox_flush_interval: Duration::from_millis(1_000), + wallet_refund_outbox_max_bytes: 64 * 1024 * 1024, log_filter: "info,tower_http=info".to_string(), otel_enabled: false, admin_username: None, @@ -409,6 +419,27 @@ impl AppConfig { { config.tracking_outbox_max_bytes = max_bytes; } + if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED"]) { + config.wallet_refund_outbox_enabled = enabled; + } + if let Some(dir) = read_first_non_empty_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_DIR"]) { + config.wallet_refund_outbox_dir = PathBuf::from(dir); + } + if let Some(batch_size) = + read_first_usize_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE"]) + { + config.wallet_refund_outbox_batch_size = batch_size; + } + if let Some(flush_interval_ms) = + read_first_positive_u64_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS"]) + { + config.wallet_refund_outbox_flush_interval = Duration::from_millis(flush_interval_ms); + } + if let Some(max_bytes) = + read_first_positive_u64_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES"]) + { + config.wallet_refund_outbox_max_bytes = max_bytes; + } if let Some(otel_enabled) = read_first_bool_env(&["GENARRATIVE_OTEL_ENABLED"]) { config.otel_enabled = otel_enabled; } @@ -1380,6 +1411,11 @@ mod tests { std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES"); + std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED"); + std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_DIR"); + std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE"); + std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS"); + std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES"); std::env::remove_var("GENARRATIVE_OTEL_ENABLED"); std::env::set_var("GENARRATIVE_API_LISTEN_BACKLOG", "2048"); std::env::set_var("GENARRATIVE_API_WORKER_THREADS", "6"); @@ -1396,6 +1432,14 @@ mod tests { std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE", "250"); std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS", "2000"); std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES", "1048576"); + std::env::set_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED", "false"); + std::env::set_var( + "GENARRATIVE_WALLET_REFUND_OUTBOX_DIR", + "/tmp/genarrative-wallet-refund-outbox", + ); + std::env::set_var("GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE", "50"); + std::env::set_var("GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS", "3000"); + std::env::set_var("GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES", "524288"); std::env::set_var("GENARRATIVE_OTEL_ENABLED", "true"); } @@ -1421,6 +1465,17 @@ mod tests { std::time::Duration::from_millis(2_000) ); assert_eq!(config.tracking_outbox_max_bytes, 1_048_576); + assert!(!config.wallet_refund_outbox_enabled); + assert_eq!( + config.wallet_refund_outbox_dir, + std::path::PathBuf::from("/tmp/genarrative-wallet-refund-outbox") + ); + assert_eq!(config.wallet_refund_outbox_batch_size, 50); + assert_eq!( + config.wallet_refund_outbox_flush_interval, + std::time::Duration::from_millis(3_000) + ); + assert_eq!(config.wallet_refund_outbox_max_bytes, 524_288); assert!(config.otel_enabled); unsafe { @@ -1436,6 +1491,11 @@ mod tests { std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES"); + std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED"); + std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_DIR"); + std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE"); + std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS"); + std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES"); std::env::remove_var("GENARRATIVE_OTEL_ENABLED"); } } diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index d235f028..be751aa4 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -547,11 +547,12 @@ pub async fn generate_custom_world_scene_image( require_openai_image_settings(&state) .map_err(|error| custom_world_ai_error_response(&request_context, error))?; let asset_id = format!("custom-scene-{}", current_utc_millis()); + let billing_asset_id = request_context.request_id().to_string(); let asset = execute_billable_asset_operation( &state, &owner_user_id, "scene_image", - asset_id.as_str(), + billing_asset_id.as_str(), async { let settings = require_openai_image_settings(&state)?.with_external_api_audit_context( &request_context, @@ -806,11 +807,12 @@ pub async fn generate_custom_world_cover_image( require_dashscope_settings(&state) .map_err(|error| custom_world_ai_error_response(&request_context, error))?; let asset_id = format!("custom-cover-{}", current_utc_millis()); + let billing_asset_id = request_context.request_id().to_string(); let asset = execute_billable_asset_operation( &state, &owner_user_id, "custom_world_cover", - asset_id.as_str(), + billing_asset_id.as_str(), async { let settings = require_dashscope_settings(&state)?; let http_client = build_dashscope_http_client(&settings)?; @@ -1011,11 +1013,12 @@ pub async fn generate_custom_world_opening_cg( .map_err(|error| custom_world_ai_error_response(&request_context, error))?; let opening_cg_id = normalized.opening_cg_id.clone(); + let billing_asset_id = request_context.request_id().to_string(); let generated = execute_billable_asset_operation_with_cost( &state, &owner_user_id, "custom_world_opening_cg", - opening_cg_id.as_str(), + billing_asset_id.as_str(), OPENING_CG_POINTS_COST, async { let image_settings = require_openai_image_settings(&state)? diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 1867c754..3b7d8e8c 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -89,6 +89,7 @@ mod tracking_outbox; mod vector_engine_audio_generation; mod visual_novel; mod volcengine_speech; +mod wallet_refund_outbox; mod wechat; mod wooden_fish; mod work_author; @@ -115,6 +116,7 @@ use crate::{ config::AppConfig, state::{AppState, AppStateInitError}, tracking_outbox::TrackingOutbox, + wallet_refund_outbox::WalletRefundOutbox, }; const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024; @@ -125,6 +127,7 @@ const AUTH_STORE_STARTUP_RETRY_INTERVAL: Duration = Duration::from_secs(5); struct ShutdownContext { app_state: Option, tracking_outbox: Option>, + wallet_refund_outbox: Option>, outbox_flush_timeout: Duration, } @@ -178,11 +181,16 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> { if let Some(outbox) = tracking_outbox.clone() { outbox.spawn_worker(); } + let wallet_refund_outbox = state.wallet_refund_outbox(); + if let Some(outbox) = wallet_refund_outbox.clone() { + outbox.spawn_worker(); + } ( build_router(state.clone()), ShutdownContext { app_state: Some(state), tracking_outbox, + wallet_refund_outbox, outbox_flush_timeout, }, ) @@ -192,6 +200,7 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> { ShutdownContext { app_state: None, tracking_outbox: None, + wallet_refund_outbox: None, outbox_flush_timeout, }, ), @@ -271,12 +280,8 @@ async fn finalize_shutdown(context: ShutdownContext) { state.mark_not_ready(); } - let Some(outbox) = context.tracking_outbox else { - return; - }; - if context.outbox_flush_timeout.is_zero() { - warn!("api-server 退出时 tracking outbox flush timeout 为 0,跳过主动 flush"); + warn!("api-server 退出时 outbox flush timeout 为 0,跳过主动 flush"); return; } @@ -284,22 +289,45 @@ async fn finalize_shutdown(context: ShutdownContext) { .outbox_flush_timeout .as_millis() .min(u128::from(u64::MAX)) as u64; - info!(timeout_ms, "api-server 退出前封存并 flush tracking outbox"); - match timeout(context.outbox_flush_timeout, outbox.flush_for_shutdown()).await { - Ok(Ok(())) => { - info!("api-server 退出前 tracking outbox flush 完成"); + if let Some(outbox) = context.tracking_outbox { + info!(timeout_ms, "api-server 退出前封存并 flush tracking outbox"); + match timeout(context.outbox_flush_timeout, outbox.flush_for_shutdown()).await { + Ok(Ok(())) => { + info!("api-server 退出前 tracking outbox flush 完成"); + } + Ok(Err(error)) => { + warn!( + error = %error, + "api-server 退出前 tracking outbox flush 未完成,已保留本地文件等待下次启动重试" + ); + } + Err(_) => { + warn!( + timeout_ms, + "api-server 退出前 tracking outbox flush 超时,已保留本地文件等待下次启动重试" + ); + } } - Ok(Err(error)) => { - warn!( - error = %error, - "api-server 退出前 tracking outbox flush 未完成,已保留本地文件等待下次启动重试" - ); - } - Err(_) => { - warn!( - timeout_ms, - "api-server 退出前 tracking outbox flush 超时,已保留本地文件等待下次启动重试" - ); + } + + if let Some(outbox) = context.wallet_refund_outbox { + info!(timeout_ms, "api-server 退出前 flush wallet refund outbox"); + match timeout(context.outbox_flush_timeout, outbox.flush_for_shutdown()).await { + Ok(Ok(())) => { + info!("api-server 退出前 wallet refund outbox flush 完成"); + } + Ok(Err(error)) => { + warn!( + error = %error, + "api-server 退出前 wallet refund outbox flush 未完成,已保留本地文件等待下次启动重试" + ); + } + Err(_) => { + warn!( + timeout_ms, + "api-server 退出前 wallet refund outbox flush 超时,已保留本地文件等待下次启动重试" + ); + } } } } diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index 1513d3ee..61828c65 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -1,4 +1,4 @@ -use std::{ +use std::{ collections::BTreeMap, convert::Infallible, future::Future, @@ -65,10 +65,7 @@ use spacetime_client::{ use crate::{ api_response::json_success_body, - asset_billing::{ - execute_billable_asset_operation_with_cost, map_asset_operation_wallet_error, - should_skip_asset_operation_billing_for_connectivity, - }, + asset_billing::{execute_billable_asset_operation_with_cost, map_asset_operation_wallet_error}, auth::{AuthenticatedAccessToken, RuntimePrincipal}, config::AppConfig, generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha, @@ -354,13 +351,6 @@ impl Match3DItemAssetsGenerationPlan { Self::Replace(plan) => plan.requested_item_names.len(), } } - - fn billing_fingerprint_source(&self) -> String { - match self { - Self::Append(plan) => format!("append:{}", plan.requested_item_names.join("|")), - Self::Replace(plan) => format!("replace:{}", plan.requested_item_names.join("|")), - } - } } fn serialize_match3d_generated_item_assets(assets: &[Match3DGeneratedItemAsset]) -> Option { diff --git a/server-rs/crates/api-server/src/match3d/draft.rs b/server-rs/crates/api-server/src/match3d/draft.rs index eb99ec38..3c8a8c2d 100644 --- a/server-rs/crates/api-server/src/match3d/draft.rs +++ b/server-rs/crates/api-server/src/match3d/draft.rs @@ -162,7 +162,12 @@ pub(super) async fn compile_match3d_draft_for_session( let initial_tags = requested_tags .clone() .unwrap_or_else(|| fallback_work_metadata.tags.clone()); - let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros()); + let billing_asset_id = format!( + "{}:{}:{}", + session_id, + profile_id, + request_context.request_id() + ); let points_cost = crate::creation_entry_config::resolve_creation_entry_mud_point_cost( state, "match3d", @@ -514,15 +519,6 @@ async fn consume_match3d_draft_generation_points( .await { Ok(_) => Ok(true), - Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { - tracing::warn!( - owner_user_id, - billing_asset_id, - error = %error, - "抓大鹅草稿泥点预扣因 SpacetimeDB 连接不可用而降级跳过" - ); - Ok(false) - } Err(error) => Err(match3d_error_response( request_context, MATCH3D_AGENT_PROVIDER, diff --git a/server-rs/crates/api-server/src/match3d/handlers.rs b/server-rs/crates/api-server/src/match3d/handlers.rs index 6eed1281..bc3462ef 100644 --- a/server-rs/crates/api-server/src/match3d/handlers.rs +++ b/server-rs/crates/api-server/src/match3d/handlers.rs @@ -751,7 +751,6 @@ pub async fn generate_match3d_background_image_for_work( )?; let prompt = normalize_match3d_background_prompt(payload.prompt.as_str()); ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?; - let prompt_fingerprint = build_match3d_prompt_fingerprint(prompt.as_str()); let context = load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id) @@ -763,7 +762,12 @@ pub async fn generate_match3d_background_image_for_work( config, assets, } = context; - let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, prompt_fingerprint); + let billing_asset_id = format!( + "{}:{}:{}", + session_id, + profile_id, + request_context.request_id() + ); let (generated_background, generated_assets) = execute_billable_asset_operation_with_cost( &state, owner_user_id.as_str(), @@ -860,7 +864,6 @@ pub async fn generate_match3d_container_image_for_work( )?; let prompt = normalize_match3d_background_prompt(payload.prompt.as_str()); ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?; - let prompt_fingerprint = build_match3d_prompt_fingerprint(prompt.as_str()); let context = load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id) @@ -874,7 +877,9 @@ pub async fn generate_match3d_container_image_for_work( } = context; let billing_asset_id = format!( "{}:{}:{}:container", - session_id, profile_id, prompt_fingerprint + session_id, + profile_id, + request_context.request_id() ); let (generated_background, generated_assets) = execute_billable_asset_operation_with_cost( &state, @@ -1017,7 +1022,7 @@ pub async fn generate_match3d_item_assets_for_work( session_id, profile_id, billed_item_count, - build_match3d_prompt_fingerprint(generation_plan.billing_fingerprint_source().as_str()) + request_context.request_id() ); let generated_assets = execute_billable_asset_operation_with_cost( &state, diff --git a/server-rs/crates/api-server/src/match3d/item_assets.rs b/server-rs/crates/api-server/src/match3d/item_assets.rs index 670d3ca8..5de20f2e 100644 --- a/server-rs/crates/api-server/src/match3d/item_assets.rs +++ b/server-rs/crates/api-server/src/match3d/item_assets.rs @@ -1208,14 +1208,6 @@ pub(super) fn normalize_match3d_background_prompt(raw: &str) -> String { .to_string() } -pub(super) fn build_match3d_prompt_fingerprint(value: &str) -> String { - let mut hash = 0u32; - for character in value.chars() { - hash = hash.wrapping_mul(31).wrapping_add(character as u32); - } - format!("{hash:08x}") -} - pub(super) fn build_fallback_match3d_background_prompt(config: &Match3DConfigJson) -> String { let theme = config.theme_text.trim(); let normalized_theme = if theme.is_empty() { "抓大鹅" } else { theme }; diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index b428f2f2..6581676c 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -1,6 +1,5 @@ use std::{ - collections::{BTreeMap, HashSet}, - sync::{Mutex, OnceLock}, + collections::BTreeMap, time::{Instant, SystemTime, UNIX_EPOCH}, }; @@ -56,17 +55,19 @@ use spacetime_client::{ PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, - PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, - PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, - PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, - PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, - PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, - PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, - PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, - PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput, - PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput, - PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, - PuzzleWorkUpsertRecordInput, SpacetimeClientError, + PuzzleAudioAssetRecord, PuzzleBackgroundCompileTaskClaimRecordInput, + PuzzleBackgroundCompileTaskReleaseRecordInput, PuzzleCreatorIntentRecord, + PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, + PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, + PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, + PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, + PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, + PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, + PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, + PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, + PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput, + PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, + SpacetimeClientError, }; use std::convert::Infallible; @@ -134,38 +135,51 @@ const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str = const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024"; const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536"; -static PUZZLE_BACKGROUND_COMPILE_TASKS: OnceLock>> = OnceLock::new(); - -fn puzzle_background_compile_tasks() -> &'static Mutex> { - PUZZLE_BACKGROUND_COMPILE_TASKS.get_or_init(|| Mutex::new(HashSet::new())) +fn build_puzzle_background_compile_task_id(session_id: &str) -> String { + format!("puzzle_initial_background:{session_id}") } -fn try_register_puzzle_background_compile_task(session_id: &str) -> bool { - match puzzle_background_compile_tasks().lock() { - Ok(mut tasks) => tasks.insert(session_id.to_string()), - Err(error) => { +fn build_puzzle_background_compile_claim_id(task_id: &str, request_id: &str) -> String { + format!("{task_id}:{request_id}") +} + +async fn release_claimed_puzzle_background_compile_task( + state: &PuzzleApiState, + task_id: &str, + claim_id: &str, + session_id: &str, + owner_user_id: &str, +) { + let result = state + .spacetime_client() + .release_puzzle_background_compile_task(PuzzleBackgroundCompileTaskReleaseRecordInput { + task_id: task_id.to_string(), + claim_id: claim_id.to_string(), + session_id: session_id.to_string(), + owner_user_id: owner_user_id.to_string(), + }) + .await; + match result { + Ok(true) => {} + Ok(false) => { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, + task_id, + claim_id, session_id, - error = %error, - "拼图后台生成任务注册表锁已损坏,允许本次任务继续" + owner_user_id, + "拼图首图后台生成任务释放未命中当前 claim" ); - true - } - } -} - -fn unregister_puzzle_background_compile_task(session_id: &str) { - match puzzle_background_compile_tasks().lock() { - Ok(mut tasks) => { - tasks.remove(session_id); } Err(error) => { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, + task_id, + claim_id, session_id, + owner_user_id, error = %error, - "拼图后台生成任务注册表解锁失败,忽略清理" + "拼图首图后台生成任务释放失败" ); } } diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index 2fa1a265..d7a2b0bd 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -588,7 +588,7 @@ pub async fn execute_puzzle_agent_action( let owner_user_id = authenticated.claims().user_id().to_string(); let now = current_utc_micros(); let action = payload.action.trim().to_string(); - let billing_asset_id = format!("{session_id}:{now}"); + let billing_asset_id = format!("{}:{}:{}", session_id, action, request_context.request_id()); let mut operation_consumed_points = 0; tracing::info!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, @@ -695,156 +695,207 @@ pub async fn execute_puzzle_agent_action( Err(response) => return Err(response), }; let session = if ai_redraw { - if !try_register_puzzle_background_compile_task(&compile_session_id) { - tracing::info!( - provider = PUZZLE_AGENT_API_BASE_PROVIDER, - session_id = %compile_session_id, - owner_user_id = %owner_user_id, - "拼图首图后台生成任务已存在,本次 action 直接返回生成中会话" - ); - state - .spacetime_client() - .get_puzzle_agent_session(compile_session_id.clone(), owner_user_id.clone()) - .await - .map(mark_puzzle_initial_generation_started_snapshot) - .map_err(map_puzzle_client_error) - } else { - let compiled_session = state - .spacetime_client() - .compile_puzzle_agent_draft( - compile_session_id.clone(), - owner_user_id.clone(), - now, - ) - .await - .map_err(map_puzzle_compile_error); - match compiled_session { - Ok(compiled_session) => { - let response_session = mark_puzzle_initial_generation_started_snapshot( - compiled_session.clone(), - ); - let background_state = state.clone(); - let background_request_context = request_context.clone(); - let background_session_id = compile_session_id.clone(); - let background_owner_user_id = owner_user_id.clone(); - let background_prompt_text = prompt_text.map(str::to_string); - let background_reference_image_src = - primary_reference_image_src.map(str::to_string); - let background_image_model = payload.image_model.clone(); - let background_points_cost = puzzle_draft_generation_points_cost; - let background_work_name = compiled_session - .draft - .as_ref() - .map(|draft| draft.work_title.clone()); - let background_billing_asset_id = - format!("{background_session_id}:compile_puzzle_draft"); - tokio::spawn(async move { - let operation_owner_user_id = background_owner_user_id.clone(); - let background_root_state = background_state.root_state().clone(); - let operation_state = background_state.clone(); - let result = execute_billable_asset_operation_with_cost( - &background_root_state, - &background_owner_user_id, - "puzzle_initial_image", - &background_billing_asset_id, - background_points_cost, - async move { - generate_puzzle_initial_cover_from_compiled_session( - &operation_state, - &background_request_context, - compiled_session, - operation_owner_user_id, - background_prompt_text.as_deref(), - background_reference_image_src.as_deref(), - background_image_model.as_deref(), - current_utc_micros(), - ) - .await - }, - ) - .await; - match result { - Ok(session) => { - send_generation_result_subscribe_message_after_completion( - &background_root_state, - GenerationResultSubscribeMessage { - owner_user_id: background_owner_user_id.clone(), - task_name: Some("拼图".to_string()), - work_name: session - .draft - .as_ref() - .map(|draft| draft.work_title.clone()), - status: - GenerationResultSubscribeMessageStatus::Succeeded, - consumed_points: background_points_cost, - completed_at_micros: current_utc_micros(), - page: Some("/pages/web-view/index".to_string()), - }, - ) - .await; - tracing::info!( - provider = PUZZLE_AGENT_API_BASE_PROVIDER, - session_id = %session.session_id, - owner_user_id = %background_owner_user_id, - "拼图首图后台生成任务完成" - ); - } - Err(error) => { - let error_message = error.body_text(); - let failed_at_micros = current_utc_micros(); - let failure_result = background_state - .spacetime_client() - .mark_puzzle_draft_generation_failed( - PuzzleDraftCompileFailureRecordInput { - session_id: background_session_id.clone(), - owner_user_id: background_owner_user_id.clone(), - error_message: error_message.clone(), - failed_at_micros, - }, + let background_task_id = + build_puzzle_background_compile_task_id(&compile_session_id); + let background_claim_id = build_puzzle_background_compile_claim_id( + &background_task_id, + request_context.request_id(), + ); + let claim_result = state + .spacetime_client() + .claim_puzzle_background_compile_task( + PuzzleBackgroundCompileTaskClaimRecordInput { + task_id: background_task_id.clone(), + claim_id: background_claim_id.clone(), + session_id: compile_session_id.clone(), + owner_user_id: owner_user_id.clone(), + claimed_at_micros: current_utc_micros(), + }, + ) + .await + .map_err(map_puzzle_client_error); + match claim_result { + Ok(false) => { + tracing::info!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %compile_session_id, + owner_user_id = %owner_user_id, + task_id = %background_task_id, + "拼图首图后台生成任务已存在,本次 action 直接返回生成中会话" + ); + state + .spacetime_client() + .get_puzzle_agent_session( + compile_session_id.clone(), + owner_user_id.clone(), + ) + .await + .map(mark_puzzle_initial_generation_started_snapshot) + .map_err(map_puzzle_client_error) + } + Ok(true) => { + let compiled_session = state + .spacetime_client() + .compile_puzzle_agent_draft( + compile_session_id.clone(), + owner_user_id.clone(), + now, + ) + .await + .map_err(map_puzzle_compile_error); + match compiled_session { + Ok(compiled_session) => { + let response_session = + mark_puzzle_initial_generation_started_snapshot( + compiled_session.clone(), + ); + let background_state = state.clone(); + let background_request_context = request_context.clone(); + let background_session_id = compile_session_id.clone(); + let background_owner_user_id = owner_user_id.clone(); + let background_task_id = background_task_id.clone(); + let background_claim_id = background_claim_id.clone(); + let background_prompt_text = prompt_text.map(str::to_string); + let background_reference_image_src = + primary_reference_image_src.map(str::to_string); + let background_image_model = payload.image_model.clone(); + let background_points_cost = puzzle_draft_generation_points_cost; + let background_work_name = compiled_session + .draft + .as_ref() + .map(|draft| draft.work_title.clone()); + let background_billing_asset_id = format!( + "{background_session_id}:compile_puzzle_draft:{}", + background_request_context.request_id() + ); + tokio::spawn(async move { + let operation_owner_user_id = background_owner_user_id.clone(); + let background_root_state = + background_state.root_state().clone(); + let operation_state = background_state.clone(); + let result = execute_billable_asset_operation_with_cost( + &background_root_state, + &background_owner_user_id, + "puzzle_initial_image", + &background_billing_asset_id, + background_points_cost, + async move { + generate_puzzle_initial_cover_from_compiled_session( + &operation_state, + &background_request_context, + compiled_session, + operation_owner_user_id, + background_prompt_text.as_deref(), + background_reference_image_src.as_deref(), + background_image_model.as_deref(), + current_utc_micros(), ) - .await; - if let Err(mark_error) = failure_result { - tracing::warn!( - provider = PUZZLE_AGENT_API_BASE_PROVIDER, - session_id = %background_session_id, - owner_user_id = %background_owner_user_id, - message = %mark_error, - "拼图首图后台生成失败态回写失败" - ); - } else { + .await + }, + ) + .await; + match result { + Ok(session) => { send_generation_result_subscribe_message_after_completion( &background_root_state, GenerationResultSubscribeMessage { owner_user_id: background_owner_user_id.clone(), task_name: Some("拼图".to_string()), - work_name: background_work_name.clone(), + work_name: session + .draft + .as_ref() + .map(|draft| draft.work_title.clone()), status: - GenerationResultSubscribeMessageStatus::Failed, - consumed_points: 0, - completed_at_micros: failed_at_micros, + GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: background_points_cost, + completed_at_micros: current_utc_micros(), page: Some("/pages/web-view/index".to_string()), }, ) .await; + tracing::info!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session.session_id, + owner_user_id = %background_owner_user_id, + "拼图首图后台生成任务完成" + ); + } + Err(error) => { + let error_message = error.body_text(); + let failed_at_micros = current_utc_micros(); + let failure_result = background_state + .spacetime_client() + .mark_puzzle_draft_generation_failed( + PuzzleDraftCompileFailureRecordInput { + session_id: background_session_id.clone(), + owner_user_id: background_owner_user_id + .clone(), + error_message: error_message.clone(), + failed_at_micros, + }, + ) + .await; + if let Err(mark_error) = failure_result { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %background_session_id, + owner_user_id = %background_owner_user_id, + message = %mark_error, + "拼图首图后台生成失败态回写失败" + ); + } else { + send_generation_result_subscribe_message_after_completion( + &background_root_state, + GenerationResultSubscribeMessage { + owner_user_id: background_owner_user_id + .clone(), + task_name: Some("拼图".to_string()), + work_name: background_work_name.clone(), + status: + GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: failed_at_micros, + page: Some( + "/pages/web-view/index".to_string(), + ), + }, + ) + .await; + } + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %background_session_id, + owner_user_id = %background_owner_user_id, + message = %error_message, + "拼图首图后台生成任务失败" + ); } - tracing::warn!( - provider = PUZZLE_AGENT_API_BASE_PROVIDER, - session_id = %background_session_id, - owner_user_id = %background_owner_user_id, - message = %error_message, - "拼图首图后台生成任务失败" - ); } - } - unregister_puzzle_background_compile_task(&background_session_id); - }); - Ok(response_session) - } - Err(error) => { - unregister_puzzle_background_compile_task(&compile_session_id); - Err(error) + release_claimed_puzzle_background_compile_task( + &background_state, + &background_task_id, + &background_claim_id, + &background_session_id, + &background_owner_user_id, + ) + .await; + }); + Ok(response_session) + } + Err(error) => { + release_claimed_puzzle_background_compile_task( + &state, + &background_task_id, + &background_claim_id, + &compile_session_id, + &owner_user_id, + ) + .await; + Err(error) + } } } + Err(error) => Err(error), } } else { compile_puzzle_draft_with_uploaded_cover( @@ -2231,7 +2282,7 @@ pub async fn use_puzzle_runtime_prop( } }; let should_sync_freeze_boundary = matches!(prop_kind.as_str(), "freezeTime" | "freeze_time"); - let billing_asset_id = format!("{}:{}:{}", run_id, prop_kind, current_utc_micros()); + let billing_asset_id = format!("{}:{}:{}", run_id, prop_kind, request_context.request_id()); let reducer_owner_user_id = owner_user_id.clone(); let reducer_run_id = run_id.clone(); let fallback_run_id = run_id.clone(); diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index d8882747..57653736 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -41,6 +41,7 @@ use tracing::{info, warn}; use crate::config::AppConfig; use crate::puzzle_gallery_cache::PuzzleGalleryCache; use crate::tracking_outbox::TrackingOutbox; +use crate::wallet_refund_outbox::WalletRefundOutbox; use crate::wechat::pay::{build_wechat_pay_config, map_wechat_pay_init_error}; use crate::wechat::provider::build_wechat_provider; use crate::work_author::{ @@ -263,6 +264,7 @@ pub struct AppStateInner { spacetime_client: SpacetimeClient, puzzle_gallery_cache: PuzzleGalleryCache, tracking_outbox: Option>, + wallet_refund_outbox: Option>, llm_client: Option, creative_agent_gpt5_client: Option, creative_agent_executor: Arc, @@ -406,6 +408,8 @@ impl AppState { procedure_timeout: config.spacetime_procedure_timeout, }); let tracking_outbox = TrackingOutbox::from_config(&config, spacetime_client.clone()); + let wallet_refund_outbox = + WalletRefundOutbox::from_config(&config, spacetime_client.clone()); let llm_client = build_llm_client(&config)?; let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?; let http_request_permit_pools = HttpRequestPermitPools::from_config(&config); @@ -441,6 +445,7 @@ impl AppState { spacetime_client, puzzle_gallery_cache: PuzzleGalleryCache::new(), tracking_outbox, + wallet_refund_outbox, llm_client, creative_agent_gpt5_client, creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor), @@ -922,6 +927,10 @@ impl AppState { self.tracking_outbox.clone() } + pub fn wallet_refund_outbox(&self) -> Option> { + self.wallet_refund_outbox.clone() + } + pub fn llm_client(&self) -> Option<&LlmClient> { self.llm_client.as_ref() } diff --git a/server-rs/crates/api-server/src/wallet_refund_outbox.rs b/server-rs/crates/api-server/src/wallet_refund_outbox.rs new file mode 100644 index 00000000..0169cd2a --- /dev/null +++ b/server-rs/crates/api-server/src/wallet_refund_outbox.rs @@ -0,0 +1,463 @@ +use std::{ + fmt, + path::{Path, PathBuf}, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use spacetime_client::{SpacetimeClient, SpacetimeClientError}; +use tokio::{ + fs::{self, File, OpenOptions}, + io::{AsyncReadExt, AsyncWriteExt}, + sync::{Mutex, Notify}, + time::sleep, +}; +use tracing::{debug, warn}; + +use crate::config::AppConfig; + +const PENDING_FILE_PREFIX: &str = "refund-"; +const CORRUPT_FILE_PREFIX: &str = "corrupt-"; +const TEMP_FILE_PREFIX: &str = "tmp-"; +const OUTBOX_FILE_EXTENSION: &str = ".json"; + +#[derive(Clone)] +pub struct WalletRefundOutbox { + dir: PathBuf, + batch_size: usize, + flush_interval: Duration, + max_bytes: u64, + spacetime_client: SpacetimeClient, + enqueue_lock: Arc>, + flush_notify: Arc, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct WalletRefundOutboxRecord { + pub owner_user_id: String, + pub amount: u64, + pub ledger_id: String, + pub created_at_micros: i64, + pub asset_kind: String, + pub asset_id: String, +} + +#[derive(Debug)] +pub enum WalletRefundOutboxEnqueueOutcome { + Enqueued, + Dropped { reason: &'static str }, +} + +#[derive(Debug)] +pub enum WalletRefundOutboxError { + Io(std::io::Error), + Json(serde_json::Error), + Spacetime(SpacetimeClientError), +} + +impl WalletRefundOutbox { + pub fn from_config(config: &AppConfig, spacetime_client: SpacetimeClient) -> Option> { + if !config.wallet_refund_outbox_enabled { + return None; + } + + Some(Arc::new(Self { + dir: config.wallet_refund_outbox_dir.clone(), + batch_size: config.wallet_refund_outbox_batch_size.max(1), + flush_interval: config.wallet_refund_outbox_flush_interval, + max_bytes: config.wallet_refund_outbox_max_bytes, + spacetime_client, + enqueue_lock: Arc::new(Mutex::new(())), + flush_notify: Arc::new(Notify::new()), + })) + } + + pub async fn enqueue( + &self, + record: WalletRefundOutboxRecord, + ) -> Result { + let _guard = self.enqueue_lock.lock().await; + fs::create_dir_all(&self.dir).await?; + + let pending_path = self.pending_path_for_ledger(&record.ledger_id); + if fs::metadata(&pending_path).await.is_ok() { + self.flush_notify.notify_one(); + return Ok(WalletRefundOutboxEnqueueOutcome::Enqueued); + } + + let bytes = serde_json::to_vec(&record)?; + let line_bytes = bytes.len().min(u64::MAX as usize) as u64; + let current_bytes = directory_size_if_exists(&self.dir).unwrap_or(0); + if current_bytes.saturating_add(line_bytes) > self.max_bytes { + return Ok(WalletRefundOutboxEnqueueOutcome::Dropped { + reason: "max_bytes", + }); + } + + let temp_path = self.temp_path(); + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&temp_path) + .await?; + file.write_all(&bytes).await?; + file.flush().await?; + file.sync_data().await?; + drop(file); + if fs::metadata(&pending_path).await.is_ok() { + let _ = fs::remove_file(&temp_path).await; + self.flush_notify.notify_one(); + return Ok(WalletRefundOutboxEnqueueOutcome::Enqueued); + } + fs::rename(&temp_path, &pending_path).await?; + sync_directory_metadata(&self.dir).await?; + self.flush_notify.notify_one(); + Ok(WalletRefundOutboxEnqueueOutcome::Enqueued) + } + + pub fn spawn_worker(self: Arc) { + tokio::spawn(async move { + loop { + tokio::select! { + _ = sleep(self.flush_interval) => { + if let Err(error) = self.flush_pending_files_once().await { + warn!(error = %error, "wallet refund outbox 重放退款失败,将保留文件等待重试"); + } + } + _ = self.flush_notify.notified() => { + if let Err(error) = self.flush_pending_files_once().await { + warn!(error = %error, "wallet refund outbox 主动重放退款失败,将保留文件等待重试"); + } + } + } + } + }); + } + + pub async fn flush_for_shutdown(&self) -> Result<(), WalletRefundOutboxError> { + self.flush_pending_files_once().await + } + + async fn flush_pending_files_once(&self) -> Result<(), WalletRefundOutboxError> { + fs::create_dir_all(&self.dir).await?; + let pending_files = self.list_pending_files().await?; + for path in pending_files.into_iter().take(self.batch_size) { + let record = match read_refund_record(&path).await { + Ok(record) => record, + Err(error) if error.is_data_corruption() => { + let corrupt_path = self.corrupt_path_for(&path); + fs::rename(&path, &corrupt_path).await?; + sync_directory_metadata(&self.dir).await?; + warn!( + error = %error, + source = %path.display(), + target = %corrupt_path.display(), + "wallet refund outbox 文件无法解析,已隔离" + ); + continue; + } + Err(error) => return Err(error), + }; + + match self + .spacetime_client + .refund_profile_wallet_points( + record.owner_user_id.clone(), + record.amount, + record.ledger_id.clone(), + record.created_at_micros, + ) + .await + { + Ok(_) => { + match fs::remove_file(&path).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + sync_directory_metadata(&self.dir).await?; + debug!( + ledger_id = %record.ledger_id, + owner_user_id = %record.owner_user_id, + asset_kind = %record.asset_kind, + asset_id = %record.asset_id, + path = %path.display(), + "wallet refund outbox 退款已重放并删除文件" + ); + } + Err(error) => return Err(WalletRefundOutboxError::Spacetime(error)), + } + } + Ok(()) + } + + async fn list_pending_files(&self) -> Result, WalletRefundOutboxError> { + let mut entries = fs::read_dir(&self.dir).await?; + let mut files = Vec::new(); + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if name.starts_with(PENDING_FILE_PREFIX) && name.ends_with(OUTBOX_FILE_EXTENSION) { + files.push(path); + } + } + files.sort(); + Ok(files) + } + + fn pending_path_for_ledger(&self, ledger_id: &str) -> PathBuf { + self.dir.join(format!( + "{PENDING_FILE_PREFIX}{}{OUTBOX_FILE_EXTENSION}", + ledger_id_hash(ledger_id) + )) + } + + fn temp_path(&self) -> PathBuf { + self.dir.join(format!( + "{TEMP_FILE_PREFIX}{}-{uuid}{OUTBOX_FILE_EXTENSION}", + current_unix_micros(), + uuid = uuid::Uuid::new_v4() + )) + } + + fn corrupt_path_for(&self, path: &Path) -> PathBuf { + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("unknown.json"); + self.dir.join(format!( + "{CORRUPT_FILE_PREFIX}{}-{uuid}-{name}", + current_unix_micros(), + uuid = uuid::Uuid::new_v4() + )) + } +} + +impl fmt::Debug for WalletRefundOutbox { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WalletRefundOutbox") + .field("dir", &self.dir) + .field("batch_size", &self.batch_size) + .field("flush_interval", &self.flush_interval) + .field("max_bytes", &self.max_bytes) + .finish() + } +} + +impl fmt::Display for WalletRefundOutboxError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(f, "{error}"), + Self::Json(error) => write!(f, "{error}"), + Self::Spacetime(error) => write!(f, "{error}"), + } + } +} + +impl From for WalletRefundOutboxError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl From for WalletRefundOutboxError { + fn from(value: serde_json::Error) -> Self { + Self::Json(value) + } +} + +impl WalletRefundOutboxError { + fn is_data_corruption(&self) -> bool { + matches!(self, Self::Json(_)) + } +} + +async fn read_refund_record( + path: &Path, +) -> Result { + let mut file = File::open(path).await?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).await?; + Ok(serde_json::from_slice::(&bytes)?) +} + +fn directory_size_if_exists(path: &Path) -> Result { + if !path.is_dir() { + return Ok(0); + } + + let mut total = 0u64; + for entry in std::fs::read_dir(path)? { + let entry = entry?; + if !is_pending_outbox_file_name(&entry.file_name()) { + continue; + } + let metadata = entry.metadata()?; + if metadata.is_file() { + total = total.saturating_add(metadata.len()); + } + } + Ok(total) +} + +fn current_unix_micros() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_micros() +} + +fn ledger_id_hash(ledger_id: &str) -> String { + hex::encode(Sha256::digest(ledger_id.as_bytes())) +} + +fn is_pending_outbox_file_name(name: &std::ffi::OsStr) -> bool { + name.to_str().is_some_and(|value| { + value.starts_with(PENDING_FILE_PREFIX) && value.ends_with(OUTBOX_FILE_EXTENSION) + }) +} + +async fn sync_directory_metadata(path: &Path) -> Result<(), WalletRefundOutboxError> { + let path = path.to_path_buf(); + tokio::task::spawn_blocking(move || { + let dir = std::fs::File::open(path)?; + dir.sync_all() + }) + .await + .map_err(|error| std::io::Error::new(std::io::ErrorKind::Other, error.to_string()))??; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_record(ledger_id: &str) -> WalletRefundOutboxRecord { + WalletRefundOutboxRecord { + owner_user_id: "user-1".to_string(), + amount: 2, + ledger_id: ledger_id.to_string(), + created_at_micros: 1_713_680_000_000_000, + asset_kind: "puzzle_initial_image".to_string(), + asset_id: "asset-1".to_string(), + } + } + + fn test_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "genarrative-wallet-refund-outbox-{name}-{}", + current_unix_micros() + )); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + fn test_outbox(dir: PathBuf, max_bytes: u64) -> Arc { + let config = AppConfig { + wallet_refund_outbox_dir: dir, + wallet_refund_outbox_batch_size: 500, + wallet_refund_outbox_flush_interval: Duration::from_secs(60), + wallet_refund_outbox_max_bytes: max_bytes, + ..AppConfig::default() + }; + WalletRefundOutbox::from_config( + &config, + SpacetimeClient::new(spacetime_client::SpacetimeClientConfig { + server_url: "http://127.0.0.1:1".to_string(), + database: "missing".to_string(), + token: None, + pool_size: 1, + procedure_timeout: Duration::from_millis(10), + }), + ) + .expect("outbox should be enabled") + } + + #[tokio::test] + async fn enqueue_is_idempotent_per_ledger_id() { + let dir = test_dir("idempotent"); + let outbox = test_outbox(dir.clone(), 1024 * 1024); + + outbox.enqueue(sample_record("ledger-1")).await.unwrap(); + outbox.enqueue(sample_record("ledger-1")).await.unwrap(); + + let pending_count = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| is_pending_outbox_file_name(&entry.file_name())) + .count(); + assert_eq!(pending_count, 1); + + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn enqueue_drops_when_outbox_exceeds_max_bytes() { + let dir = test_dir("max-bytes"); + let outbox = test_outbox(dir.clone(), 1); + + let outcome = outbox.enqueue(sample_record("ledger-1")).await.unwrap(); + + assert!(matches!( + outcome, + WalletRefundOutboxEnqueueOutcome::Dropped { + reason: "max_bytes" + } + )); + assert!(!dir.exists() || std::fs::read_dir(&dir).unwrap().next().is_none()); + + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn flush_quarantines_corrupt_file() { + let dir = test_dir("corrupt"); + std::fs::create_dir_all(&dir).unwrap(); + let pending_path = dir.join(format!("{PENDING_FILE_PREFIX}bad{OUTBOX_FILE_EXTENSION}")); + std::fs::write(&pending_path, b"{not-json}").unwrap(); + let outbox = test_outbox(dir.clone(), 1024 * 1024); + + outbox.flush_pending_files_once().await.unwrap(); + + assert!(!pending_path.exists()); + let corrupt_count = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(CORRUPT_FILE_PREFIX)) + }) + .count(); + assert_eq!(corrupt_count, 1); + + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn shutdown_flush_keeps_file_when_spacetime_is_unavailable() { + let dir = test_dir("shutdown"); + let outbox = test_outbox(dir.clone(), 1024 * 1024); + + outbox.enqueue(sample_record("ledger-1")).await.unwrap(); + let result = outbox.flush_for_shutdown().await; + + assert!( + matches!(result, Err(WalletRefundOutboxError::Spacetime(_))), + "missing test SpacetimeDB should keep refund file for retry" + ); + let pending_count = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| is_pending_outbox_file_name(&entry.file_name())) + .count(); + assert_eq!(pending_count, 1); + + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/server-rs/crates/module-puzzle/src/application.rs b/server-rs/crates/module-puzzle/src/application.rs index eca056e2..ee1061ba 100644 --- a/server-rs/crates/module-puzzle/src/application.rs +++ b/server-rs/crates/module-puzzle/src/application.rs @@ -20,6 +20,14 @@ pub struct PuzzleAgentSessionProcedureResult { pub error_message: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleBackgroundCompileTaskProcedureResult { + pub ok: bool, + pub claimed: bool, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleWorksProcedureResult { diff --git a/server-rs/crates/module-puzzle/src/commands.rs b/server-rs/crates/module-puzzle/src/commands.rs index 85f975e5..994ecd9e 100644 --- a/server-rs/crates/module-puzzle/src/commands.rs +++ b/server-rs/crates/module-puzzle/src/commands.rs @@ -68,6 +68,25 @@ pub struct PuzzleDraftCompileInput { pub compiled_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleBackgroundCompileTaskClaimInput { + pub task_id: String, + pub claim_id: String, + pub session_id: String, + pub owner_user_id: String, + pub claimed_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleBackgroundCompileTaskReleaseInput { + pub task_id: String, + pub claim_id: String, + pub session_id: String, + pub owner_user_id: String, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleDraftCompileFailureInput { diff --git a/server-rs/crates/server-manager-panel/Cargo.toml b/server-rs/crates/server-manager-panel/Cargo.toml new file mode 100644 index 00000000..cf41e79f --- /dev/null +++ b/server-rs/crates/server-manager-panel/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "server-manager-panel" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +eframe = { version = "0.33", default-features = false, features = [ + "default_fonts", + "glow", + "wayland", + "x11", +] } diff --git a/server-rs/crates/server-manager-panel/src/app.rs b/server-rs/crates/server-manager-panel/src/app.rs new file mode 100644 index 00000000..29463956 --- /dev/null +++ b/server-rs/crates/server-manager-panel/src/app.rs @@ -0,0 +1,577 @@ +use eframe::egui; + +use crate::health::{ + DiskSnapshot, HealthLevel, MemorySnapshot, ProbeSnapshot, ServerHealthReport, ServiceSnapshot, +}; +use crate::remote::{ + RemoteEvent, RemoteReceiver, RemoteSender, ServiceAction, channel, spawn_health_check, + spawn_service_action, +}; +use crate::ssh_config::{SshAlias, discover_ssh_aliases}; + +const DEFAULT_MANAGED_SERVICES: &[&str] = &[ + "genarrative-api.service", + "spacetimedb.service", + "nginx.service", + "genarrative-health-patrol.timer", + "genarrative-database-backup.timer", +]; + +#[derive(Debug)] +pub struct ServerManagerApp { + servers: Vec, + selected_alias: Option, + sidebar_collapsed: bool, + tx: RemoteSender, + rx: RemoteReceiver, + pending_confirmation: Option, + custom_service_name: String, +} + +impl Default for ServerManagerApp { + fn default() -> Self { + let (tx, rx) = channel(); + let aliases = discover_ssh_aliases(); + let selected_alias = aliases.first().map(|alias| alias.name.clone()); + Self { + servers: aliases.into_iter().map(ServerState::new).collect(), + selected_alias, + sidebar_collapsed: false, + tx, + rx, + pending_confirmation: None, + custom_service_name: String::new(), + } + } +} + +impl eframe::App for ServerManagerApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.drain_remote_events(ctx); + self.render_confirm_dialog(ctx); + + egui::TopBottomPanel::top("top_bar").show(ctx, |ui| { + ui.horizontal(|ui| { + if ui + .button(if self.sidebar_collapsed { + "展开侧栏" + } else { + "收起侧栏" + }) + .clicked() + { + self.sidebar_collapsed = !self.sidebar_collapsed; + } + if ui.button("重新读取 SSH alias").clicked() { + self.reload_aliases(); + } + if let Some(alias) = self.selected_alias.clone() { + if ui.button("刷新当前服务器").clicked() { + self.refresh_server(&alias); + } + } + ui.separator(); + ui.label("本地 SSH alias 管理"); + }); + }); + + if !self.sidebar_collapsed { + egui::SidePanel::left("server_sidebar") + .resizable(true) + .default_width(260.0) + .width_range(180.0..=360.0) + .show(ctx, |ui| self.render_sidebar(ui)); + } + + egui::CentralPanel::default().show(ctx, |ui| { + if self.servers.is_empty() { + self.render_empty_state(ui); + return; + } + + let Some(alias) = self.selected_alias.clone() else { + self.render_empty_state(ui); + return; + }; + + if let Some(index) = self.server_index(&alias) { + self.render_server_detail(ui, index); + } else { + ui.label("请选择服务器"); + } + }); + } +} + +impl ServerManagerApp { + fn drain_remote_events(&mut self, ctx: &egui::Context) { + while let Ok(event) = self.rx.try_recv() { + match event { + RemoteEvent::Health { alias, result } => { + if let Some(server) = self.server_mut(&alias) { + server.loading = false; + match result { + Ok(report) => { + server.error = None; + server.report = Some(report); + } + Err(error) => { + server.error = Some(error); + } + } + } + } + RemoteEvent::ServiceAction { + alias, + service, + action, + result, + } => { + if let Some(server) = self.server_mut(&alias) { + server.action_in_progress = None; + server.action_log = Some(format!( + "{} {}: {}\n{}{}", + action.label(), + service, + result.summary, + result.stdout, + result.stderr + )); + server.loading = true; + spawn_health_check(alias, self.tx.clone()); + } + } + } + ctx.request_repaint(); + } + } + + fn render_sidebar(&mut self, ui: &mut egui::Ui) { + ui.heading("服务器"); + ui.add_space(8.0); + let mut refresh_alias: Option = None; + + for server in &mut self.servers { + let selected = self.selected_alias.as_deref() == Some(server.alias.name.as_str()); + let response = ui.selectable_label(selected, server_label(server)); + if response.clicked() { + self.selected_alias = Some(server.alias.name.clone()); + } + response.on_hover_text(server.alias.source.display().to_string()); + ui.horizontal(|ui| { + let status = server.status(); + ui.colored_label(level_color(status), status.label()); + if server.loading { + ui.spinner(); + } + if ui.small_button("刷新").clicked() { + refresh_alias = Some(server.alias.name.clone()); + } + }); + ui.add_space(6.0); + } + + if let Some(alias) = refresh_alias { + self.refresh_server(&alias); + } + } + + fn render_empty_state(&mut self, ui: &mut egui::Ui) { + ui.vertical_centered(|ui| { + ui.heading("未发现 SSH alias"); + ui.label("请在 ~/.ssh/config 中配置 Host alias 后重新读取。"); + if ui.button("重新读取").clicked() { + self.reload_aliases(); + } + }); + } + + fn render_server_detail(&mut self, ui: &mut egui::Ui, index: usize) { + let alias = self.servers[index].alias.name.clone(); + let status = self.servers[index].status(); + let loading = self.servers[index].loading; + let report = self.servers[index].report.clone(); + let error = self.servers[index].error.clone(); + let action_log = self.servers[index].action_log.clone(); + + ui.horizontal(|ui| { + ui.heading(&alias); + ui.colored_label(level_color(status), status.label()); + if loading { + ui.spinner(); + } + }); + ui.add_space(8.0); + + if let Some(error) = error { + ui.colored_label(warning_color(), format!("SSH 巡检失败:{error}")); + ui.add_space(8.0); + } + + if let Some(report) = report { + self.render_report(ui, &alias, &report); + } else { + ui.label("尚未执行巡检。"); + } + + ui.add_space(12.0); + self.render_service_controls(ui, &alias, index); + + if let Some(log) = action_log { + ui.add_space(12.0); + egui::CollapsingHeader::new("最近一次服务操作输出") + .default_open(true) + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut log.clone()) + .font(egui::TextStyle::Monospace) + .desired_rows(8) + .interactive(false), + ); + }); + } + } + + fn render_report(&self, ui: &mut egui::Ui, alias: &str, report: &ServerHealthReport) { + egui::ScrollArea::vertical().show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + info_chip(ui, "主机", value_or_dash(&report.host.hostname)); + info_chip(ui, "内核", value_or_dash(&report.host.kernel)); + info_chip(ui, "运行时间", value_or_dash(&report.host.uptime)); + info_chip(ui, "检查时间", value_or_dash(&report.checked_at)); + }); + + ui.add_space(10.0); + egui::CollapsingHeader::new("硬件状态") + .default_open(true) + .show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + info_chip(ui, "CPU", value_or_dash(&report.hardware.cpu_model)); + info_chip(ui, "核心", value_or_dash(&report.hardware.cpu_cores)); + info_chip(ui, "负载", value_or_dash(&report.hardware.load_average)); + }); + ui.add_space(6.0); + memory_row(ui, "内存", &report.hardware.memory); + memory_row(ui, "Swap", &report.hardware.swap); + ui.add_space(6.0); + for disk in &report.hardware.disks { + disk_row(ui, disk); + } + ui.add_space(6.0); + for sensor in &report.hardware.sensors { + ui.label(sensor); + } + }); + + egui::CollapsingHeader::new("服务状态") + .default_open(true) + .show(ui, |ui| { + egui::Grid::new(format!("{alias}_services")) + .striped(true) + .show(ui, |ui| { + ui.strong("服务"); + ui.strong("状态"); + ui.strong("子状态"); + ui.strong("Unit"); + ui.end_row(); + for service in &report.services { + service_row(ui, service); + } + }); + }); + + egui::CollapsingHeader::new("HTTP 探测") + .default_open(true) + .show(ui, |ui| { + egui::Grid::new(format!("{alias}_probes")) + .striped(true) + .show(ui, |ui| { + ui.strong("探测"); + ui.strong("状态码"); + ui.strong("耗时"); + ui.strong("目标"); + ui.end_row(); + for probe in &report.probes { + probe_row(ui, probe); + } + }); + }); + + if let Some(patrol) = &report.health_patrol { + egui::CollapsingHeader::new("生产健康巡检") + .default_open(true) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.colored_label(level_color(patrol.level), &patrol.status); + ui.label(value_or_dash(&patrol.checked_at)); + ui.label(value_or_dash(&patrol.summary)); + }); + }); + } + + egui::CollapsingHeader::new("原始巡检输出").show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut report.raw_output.clone()) + .font(egui::TextStyle::Monospace) + .desired_rows(12) + .interactive(false), + ); + }); + }); + } + + fn render_service_controls(&mut self, ui: &mut egui::Ui, alias: &str, index: usize) { + ui.heading("服务控制"); + ui.add_space(4.0); + + let action_in_progress = self.servers[index].action_in_progress.clone(); + for service in DEFAULT_MANAGED_SERVICES { + ui.horizontal(|ui| { + ui.label(*service); + for action in [ + ServiceAction::Start, + ServiceAction::Stop, + ServiceAction::Restart, + ] { + let disabled = action_in_progress.is_some(); + if ui + .add_enabled(!disabled, egui::Button::new(action.label())) + .clicked() + { + self.pending_confirmation = Some(ServiceConfirmation { + alias: alias.to_owned(), + service: (*service).to_owned(), + action, + }); + } + } + }); + } + + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label("其他 unit"); + ui.text_edit_singleline(&mut self.custom_service_name); + if ui.button("启动").clicked() { + self.confirm_custom_service(alias, ServiceAction::Start); + } + if ui.button("关闭").clicked() { + self.confirm_custom_service(alias, ServiceAction::Stop); + } + if ui.button("重启").clicked() { + self.confirm_custom_service(alias, ServiceAction::Restart); + } + }); + + if let Some(action) = action_in_progress { + ui.label(format!("正在执行:{action}")); + } + } + + fn render_confirm_dialog(&mut self, ctx: &egui::Context) { + let Some(confirmation) = self.pending_confirmation.clone() else { + return; + }; + + egui::Window::new("确认服务操作") + .collapsible(false) + .resizable(false) + .show(ctx, |ui| { + ui.label(format!( + "确认在 {} 上{} {}?", + confirmation.alias, + confirmation.action.label(), + confirmation.service + )); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("确认").clicked() { + self.execute_service_action(&confirmation); + self.pending_confirmation = None; + } + if ui.button("取消").clicked() { + self.pending_confirmation = None; + } + }); + }); + } + + fn reload_aliases(&mut self) { + let aliases = discover_ssh_aliases(); + self.servers = aliases.into_iter().map(ServerState::new).collect(); + self.selected_alias = self.servers.first().map(|server| server.alias.name.clone()); + } + + fn refresh_server(&mut self, alias: &str) { + if let Some(server) = self.server_mut(alias) { + server.loading = true; + server.error = None; + } + spawn_health_check(alias.to_owned(), self.tx.clone()); + } + + fn confirm_custom_service(&mut self, alias: &str, action: ServiceAction) { + let service = self.custom_service_name.trim(); + if service.is_empty() { + return; + } + self.pending_confirmation = Some(ServiceConfirmation { + alias: alias.to_owned(), + service: service.to_owned(), + action, + }); + } + + fn execute_service_action(&mut self, confirmation: &ServiceConfirmation) { + if let Some(server) = self.server_mut(&confirmation.alias) { + server.action_in_progress = Some(format!( + "{} {}", + confirmation.action.label(), + confirmation.service + )); + server.action_log = None; + } + spawn_service_action( + confirmation.alias.clone(), + confirmation.service.clone(), + confirmation.action, + self.tx.clone(), + ); + } + + fn server_index(&self, alias: &str) -> Option { + self.servers + .iter() + .position(|server| server.alias.name == alias) + } + + fn server_mut(&mut self, alias: &str) -> Option<&mut ServerState> { + self.servers + .iter_mut() + .find(|server| server.alias.name == alias) + } +} + +#[derive(Debug, Clone)] +struct ServiceConfirmation { + alias: String, + service: String, + action: ServiceAction, +} + +#[derive(Debug)] +struct ServerState { + alias: SshAlias, + report: Option, + loading: bool, + error: Option, + action_in_progress: Option, + action_log: Option, +} + +impl ServerState { + fn new(alias: SshAlias) -> Self { + Self { + alias, + report: None, + loading: false, + error: None, + action_in_progress: None, + action_log: None, + } + } + + fn status(&self) -> HealthLevel { + if self.error.is_some() { + HealthLevel::Critical + } else { + self.report + .as_ref() + .map(|report| report.status) + .unwrap_or(HealthLevel::Unknown) + } + } +} + +fn server_label(server: &ServerState) -> String { + let prefix = match server.status() { + HealthLevel::Ok => "[OK]", + HealthLevel::Warning => "[!]", + HealthLevel::Critical => "[X]", + HealthLevel::Unknown => "[?]", + }; + format!("{prefix} {}", server.alias.name) +} + +fn service_row(ui: &mut egui::Ui, service: &ServiceSnapshot) { + ui.label(&service.name); + ui.colored_label(level_color(service.level), &service.active); + ui.label(&service.sub); + ui.label(&service.unit_file); + ui.end_row(); +} + +fn probe_row(ui: &mut egui::Ui, probe: &ProbeSnapshot) { + ui.label(&probe.name); + ui.colored_label(level_color(probe.level), &probe.http_code); + ui.label( + probe + .elapsed_ms + .map(|elapsed| format!("{elapsed}ms")) + .unwrap_or_else(|| "-".to_owned()), + ); + ui.label(&probe.target); + ui.end_row(); +} + +fn memory_row(ui: &mut egui::Ui, label: &str, memory: &MemorySnapshot) { + let percent = memory.used_percent.unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(label); + ui.add(egui::ProgressBar::new(f32::from(percent) / 100.0).text(format!("{percent}%"))); + ui.label(format!( + "已用 {} / 总计 {},可用 {}", + value_or_dash(&memory.used), + value_or_dash(&memory.total), + value_or_dash(&memory.available) + )); + }); +} + +fn disk_row(ui: &mut egui::Ui, disk: &DiskSnapshot) { + let percent = disk.used_percent.unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(&disk.mount); + ui.add(egui::ProgressBar::new(f32::from(percent) / 100.0).text(format!("{percent}%"))); + ui.label(format!( + "{} 已用 {} / {},可用 {}", + disk.filesystem, disk.used, disk.size, disk.available + )); + }); +} + +fn info_chip(ui: &mut egui::Ui, label: &str, value: &str) { + ui.group(|ui| { + ui.vertical(|ui| { + ui.small(label); + ui.label(value); + }); + }); +} + +fn value_or_dash(value: &str) -> &str { + if value.trim().is_empty() { "-" } else { value } +} + +fn level_color(level: HealthLevel) -> egui::Color32 { + match level { + HealthLevel::Ok => egui::Color32::from_rgb(38, 166, 91), + HealthLevel::Warning => egui::Color32::from_rgb(214, 137, 16), + HealthLevel::Critical => egui::Color32::from_rgb(205, 66, 70), + HealthLevel::Unknown => egui::Color32::from_rgb(120, 126, 136), + } +} + +fn warning_color() -> egui::Color32 { + egui::Color32::from_rgb(205, 66, 70) +} diff --git a/server-rs/crates/server-manager-panel/src/fonts.rs b/server-rs/crates/server-manager-panel/src/fonts.rs new file mode 100644 index 00000000..298e0e04 --- /dev/null +++ b/server-rs/crates/server-manager-panel/src/fonts.rs @@ -0,0 +1,128 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; + +use eframe::egui::{FontData, FontDefinitions, FontFamily}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CjkFontCandidate { + pub path: PathBuf, + pub index: u32, +} + +pub fn install_cjk_font(ctx: &eframe::egui::Context) -> Option { + let candidate = find_cjk_font_candidate()?; + let bytes = std::fs::read(&candidate.path).ok()?; + let mut font_data = FontData::from_owned(bytes); + font_data.index = candidate.index; + + let mut definitions = FontDefinitions::default(); + definitions + .font_data + .insert("genarrative-cjk".to_owned(), Arc::new(font_data)); + + // 中文注释:作为 fallback 注入,保留 egui 默认拉丁/图标字体,同时补齐中文 glyph。 + for family in [FontFamily::Proportional, FontFamily::Monospace] { + definitions + .families + .entry(family) + .or_default() + .push("genarrative-cjk".to_owned()); + } + + ctx.set_fonts(definitions); + Some(candidate) +} + +pub fn find_cjk_font_candidate() -> Option { + if let Ok(path) = std::env::var("GENARRATIVE_SERVER_PANEL_CJK_FONT") { + if let Some(candidate) = parse_font_spec(&path) { + return Some(candidate); + } + } + + const KNOWN_PATHS: &[(&str, u32)] = &[ + ("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 2), + ("/usr/share/fonts/opentype/noto/NotoSansCJK-Medium.ttc", 2), + ("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", 0), + ("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", 0), + ( + "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", + 0, + ), + ( + "/home/dsk/.local/share/fonts/genarrative-cjk/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", + 0, + ), + ]; + + for (path, index) in KNOWN_PATHS { + if Path::new(path).is_file() { + return Some(CjkFontCandidate { + path: PathBuf::from(path), + index: *index, + }); + } + } + + for family in [ + "Noto Sans CJK SC", + "WenQuanYi Zen Hei", + "Droid Sans Fallback", + ] { + if let Some(candidate) = find_with_fc_match(family) { + return Some(candidate); + } + } + + None +} + +fn parse_font_spec(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + let (path, index) = trimmed + .rsplit_once('|') + .and_then(|(path, index)| Some((path, index.parse().ok()?))) + .unwrap_or((trimmed, 0)); + let path = PathBuf::from(path); + path.is_file().then_some(CjkFontCandidate { path, index }) +} + +fn find_with_fc_match(family: &str) -> Option { + let output = Command::new("fc-match") + .arg("-f") + .arg("%{file}|%{index}\n") + .arg(family) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + stdout.lines().find_map(parse_font_spec) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_font_path_with_index() { + let candidate = parse_font_spec("/tmp/missing-font.ttc|2"); + assert_eq!(candidate, None); + } + + #[test] + fn finds_existing_system_cjk_font() { + let candidate = find_cjk_font_candidate(); + assert!( + candidate + .as_ref() + .is_some_and(|candidate| candidate.path.is_file()), + "expected at least one CJK font on this development host" + ); + } +} diff --git a/server-rs/crates/server-manager-panel/src/health.rs b/server-rs/crates/server-manager-panel/src/health.rs new file mode 100644 index 00000000..beacc619 --- /dev/null +++ b/server-rs/crates/server-manager-panel/src/health.rs @@ -0,0 +1,474 @@ +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum HealthLevel { + Unknown, + Ok, + Warning, + Critical, +} + +impl HealthLevel { + pub fn label(self) -> &'static str { + match self { + HealthLevel::Unknown => "未知", + HealthLevel::Ok => "正常", + HealthLevel::Warning => "警告", + HealthLevel::Critical => "异常", + } + } + + pub fn rank(self) -> u8 { + match self { + HealthLevel::Unknown => 1, + HealthLevel::Ok => 0, + HealthLevel::Warning => 2, + HealthLevel::Critical => 3, + } + } +} + +#[derive(Debug, Clone)] +pub struct ServerHealthReport { + pub status: HealthLevel, + pub checked_at: String, + pub host: HostSnapshot, + pub hardware: HardwareSnapshot, + pub services: Vec, + pub probes: Vec, + pub health_patrol: Option, + pub raw_output: String, +} + +#[derive(Debug, Clone, Default)] +pub struct HostSnapshot { + pub hostname: String, + pub kernel: String, + pub uptime: String, +} + +#[derive(Debug, Clone, Default)] +pub struct HardwareSnapshot { + pub cpu_model: String, + pub cpu_cores: String, + pub load_average: String, + pub memory: MemorySnapshot, + pub swap: MemorySnapshot, + pub disks: Vec, + pub sensors: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct MemorySnapshot { + pub total: String, + pub used: String, + pub free: String, + pub available: String, + pub used_percent: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct DiskSnapshot { + pub mount: String, + pub filesystem: String, + pub size: String, + pub used: String, + pub available: String, + pub used_percent: Option, +} + +#[derive(Debug, Clone)] +pub struct ServiceSnapshot { + pub name: String, + pub active: String, + pub sub: String, + pub unit_file: String, + pub level: HealthLevel, +} + +#[derive(Debug, Clone)] +pub struct ProbeSnapshot { + pub name: String, + pub target: String, + pub http_code: String, + pub elapsed_ms: Option, + pub level: HealthLevel, +} + +#[derive(Debug, Clone)] +pub struct HealthPatrolSnapshot { + pub status: String, + pub checked_at: String, + pub summary: String, + pub level: HealthLevel, +} + +pub fn parse_health_report(raw_output: &str) -> ServerHealthReport { + let mut sections: BTreeMap> = BTreeMap::new(); + let mut current = String::new(); + + for line in raw_output.lines() { + if let Some(name) = parse_section_marker(line) { + current = name.to_owned(); + sections.entry(current.clone()).or_default(); + } else if !current.is_empty() { + sections + .entry(current.clone()) + .or_default() + .push(line.to_owned()); + } + } + + let mut report = ServerHealthReport { + status: HealthLevel::Unknown, + checked_at: section_value(§ions, "checked_at").unwrap_or_default(), + host: parse_host(§ions), + hardware: parse_hardware(§ions), + services: parse_services(§ions), + probes: parse_probes(§ions), + health_patrol: parse_health_patrol(§ions), + raw_output: raw_output.to_owned(), + }; + report.status = summarize_report(&report); + report +} + +pub fn summarize_report(report: &ServerHealthReport) -> HealthLevel { + let mut status = HealthLevel::Ok; + for level in report + .services + .iter() + .map(|service| service.level) + .chain(report.probes.iter().map(|probe| probe.level)) + .chain(report.health_patrol.iter().map(|patrol| patrol.level)) + { + if level.rank() > status.rank() { + status = level; + } + } + + if let Some(used_percent) = report.hardware.memory.used_percent { + let memory_level = if used_percent >= 95 { + HealthLevel::Critical + } else if used_percent >= 85 { + HealthLevel::Warning + } else { + HealthLevel::Ok + }; + if memory_level.rank() > status.rank() { + status = memory_level; + } + } + + for disk in &report.hardware.disks { + let disk_level = match disk.used_percent { + Some(percent) if percent >= 95 => HealthLevel::Critical, + Some(percent) if percent >= 85 => HealthLevel::Warning, + _ => HealthLevel::Ok, + }; + if disk_level.rank() > status.rank() { + status = disk_level; + } + } + + status +} + +fn parse_section_marker(line: &str) -> Option<&str> { + line.strip_prefix("==GENARRATIVE_PANEL:") + .and_then(|rest| rest.strip_suffix("==")) +} + +fn section_value(sections: &BTreeMap>, name: &str) -> Option { + sections.get(name).and_then(|lines| { + lines + .iter() + .map(|line| line.trim()) + .find(|line| !line.is_empty()) + .map(str::to_owned) + }) +} + +fn parse_host(sections: &BTreeMap>) -> HostSnapshot { + HostSnapshot { + hostname: section_value(sections, "hostname").unwrap_or_default(), + kernel: section_value(sections, "kernel").unwrap_or_default(), + uptime: section_value(sections, "uptime").unwrap_or_default(), + } +} + +fn parse_hardware(sections: &BTreeMap>) -> HardwareSnapshot { + HardwareSnapshot { + cpu_model: section_value(sections, "cpu_model").unwrap_or_default(), + cpu_cores: section_value(sections, "cpu_cores").unwrap_or_default(), + load_average: section_value(sections, "load_average").unwrap_or_default(), + memory: parse_memory(section_value(sections, "memory").as_deref()), + swap: parse_memory(section_value(sections, "swap").as_deref()), + disks: parse_disks(sections), + sensors: sections.get("sensors").cloned().unwrap_or_default(), + } +} + +fn parse_memory(value: Option<&str>) -> MemorySnapshot { + let Some(value) = value else { + return MemorySnapshot::default(); + }; + let parts: Vec<&str> = value.split('|').collect(); + MemorySnapshot { + total: parts.first().copied().unwrap_or_default().to_owned(), + used: parts.get(1).copied().unwrap_or_default().to_owned(), + free: parts.get(2).copied().unwrap_or_default().to_owned(), + available: parts.get(3).copied().unwrap_or_default().to_owned(), + used_percent: parts.get(4).and_then(|value| parse_percent(value)), + } +} + +fn parse_disks(sections: &BTreeMap>) -> Vec { + sections + .get("disks") + .into_iter() + .flatten() + .filter_map(|line| { + let parts: Vec<&str> = line.split('|').collect(); + (parts.len() >= 6).then(|| DiskSnapshot { + filesystem: parts[0].to_owned(), + size: parts[1].to_owned(), + used: parts[2].to_owned(), + available: parts[3].to_owned(), + used_percent: parse_percent(parts[4]), + mount: parts[5].to_owned(), + }) + }) + .collect() +} + +fn parse_services(sections: &BTreeMap>) -> Vec { + sections + .get("services") + .into_iter() + .flatten() + .filter_map(|line| { + let parts: Vec<&str> = line.split('|').collect(); + (parts.len() >= 4).then(|| { + let active = parts[1].to_owned(); + let sub = parts[2].to_owned(); + let level = if active == "active" { + HealthLevel::Ok + } else if active == "unknown" || active == "inactive" { + HealthLevel::Warning + } else { + HealthLevel::Critical + }; + ServiceSnapshot { + name: parts[0].to_owned(), + active, + sub, + unit_file: parts[3].to_owned(), + level, + } + }) + }) + .collect() +} + +fn parse_probes(sections: &BTreeMap>) -> Vec { + sections + .get("probes") + .into_iter() + .flatten() + .filter_map(|line| { + let parts: Vec<&str> = line.split('|').collect(); + (parts.len() >= 4).then(|| { + let http_code = parts[2].to_owned(); + let elapsed_ms = parts[3].parse().ok(); + let level = if http_code.starts_with('2') { + HealthLevel::Ok + } else if http_code == "000" { + HealthLevel::Critical + } else { + HealthLevel::Critical + }; + ProbeSnapshot { + name: parts[0].to_owned(), + target: parts[1].to_owned(), + http_code, + elapsed_ms, + level, + } + }) + }) + .collect() +} + +fn parse_health_patrol(sections: &BTreeMap>) -> Option { + let line = section_value(sections, "health_patrol")?; + let parts: Vec<&str> = line.split('|').collect(); + let status = parts.first().copied().unwrap_or_default().to_owned(); + let level = match status.as_str() { + "OK" => HealthLevel::Ok, + "WARNING" => HealthLevel::Warning, + "CRITICAL" => HealthLevel::Critical, + _ => HealthLevel::Unknown, + }; + Some(HealthPatrolSnapshot { + status, + checked_at: parts.get(1).copied().unwrap_or_default().to_owned(), + summary: parts.get(2).copied().unwrap_or_default().to_owned(), + level, + }) +} + +fn parse_percent(value: &str) -> Option { + value.trim_end_matches('%').parse().ok() +} + +pub const HEALTH_SCRIPT: &str = r#"set -eu + +print_section() { + printf '==GENARRATIVE_PANEL:%s==\n' "$1" +} + +print_section checked_at +date -Is 2>/dev/null || date + +print_section hostname +hostname 2>/dev/null || true + +print_section kernel +uname -srmo 2>/dev/null || uname -a 2>/dev/null || true + +print_section uptime +uptime -p 2>/dev/null || uptime 2>/dev/null || true + +print_section cpu_model +awk -F: '/model name/ {gsub(/^[ \t]+/, "", $2); print $2; exit}' /proc/cpuinfo 2>/dev/null || true + +print_section cpu_cores +nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || true + +print_section load_average +cat /proc/loadavg 2>/dev/null | awk '{print $1" "$2" "$3}' || true + +print_section memory +awk ' + /^MemTotal:/ {total=$2} + /^MemFree:/ {free=$2} + /^MemAvailable:/ {available=$2} + END { + if (total > 0) { + used = total - free + percent = int((used * 100 + total / 2) / total) + printf "%.1f GiB|%.1f GiB|%.1f GiB|%.1f GiB|%d%%\n", total/1048576, used/1048576, free/1048576, available/1048576, percent + } + } +' /proc/meminfo 2>/dev/null || true + +print_section swap +awk ' + /^SwapTotal:/ {total=$2} + /^SwapFree:/ {free=$2} + END { + if (total > 0) { + used = total - free + percent = int((used * 100 + total / 2) / total) + printf "%.1f GiB|%.1f GiB|%.1f GiB|%.1f GiB|%d%%\n", total/1048576, used/1048576, free/1048576, free/1048576, percent + } else { + print "0 GiB|0 GiB|0 GiB|0 GiB|0%" + } + } +' /proc/meminfo 2>/dev/null || true + +print_section disks +for mount in / /var /opt /stdb /data; do + if [ -e "$mount" ]; then + df -hP "$mount" 2>/dev/null | awk 'NR == 2 {print $1"|"$2"|"$3"|"$4"|"$5"|"$6}' + fi +done | awk '!seen[$6]++' + +print_section sensors +if command -v sensors >/dev/null 2>&1; then + sensors 2>/dev/null | sed -n '1,20p' +else + echo "sensors 未安装" +fi + +print_section services +for service in genarrative-api.service spacetimedb.service nginx.service genarrative-health-patrol.timer genarrative-database-backup.timer; do + active=$(systemctl is-active "$service" 2>/dev/null || true) + sub=$(systemctl show "$service" -p SubState --value 2>/dev/null || true) + unit_file=$(systemctl show "$service" -p UnitFileState --value 2>/dev/null || true) + [ -n "$active" ] || active="unknown" + [ -n "$sub" ] || sub="unknown" + [ -n "$unit_file" ] || unit_file="unknown" + printf '%s|%s|%s|%s\n' "$service" "$active" "$sub" "$unit_file" +done + +print_section probes +probe() { + name="$1" + url="$2" + tmp=$(mktemp) + code=$(curl -fsS -m 5 -o /dev/null -w '%{http_code}|%{time_total}' "$url" 2>"$tmp" || true) + if [ -z "$code" ]; then + code="000|0" + fi + http_code=${code%%|*} + time_total=${code#*|} + elapsed_ms=$(awk "BEGIN {printf \"%d\", $time_total * 1000}") + printf '%s|%s|%s|%s\n' "$name" "$url" "$http_code" "$elapsed_ms" + rm -f "$tmp" +} +probe "api:/healthz" "http://127.0.0.1:8082/healthz" +probe "api:/readyz" "http://127.0.0.1:8082/readyz" +probe "spacetimedb:/v1/ping" "http://127.0.0.1:3101/v1/ping" +probe "public:/api/creation-entry/config" "http://127.0.0.1:8082/api/creation-entry/config" +probe "public:/api/runtime/puzzle/gallery" "http://127.0.0.1:8082/api/runtime/puzzle/gallery" + +print_section health_patrol +if [ -r /var/lib/genarrative/health-patrol/status.json ]; then + node -e ' + const fs = require("fs"); + const payload = JSON.parse(fs.readFileSync("/var/lib/genarrative/health-patrol/status.json", "utf8")); + const status = payload.status || "UNKNOWN"; + const checkedAt = payload.checkedAt || ""; + const checks = Array.isArray(payload.checks) ? payload.checks : []; + const summary = checks.filter((check) => check.status && check.status !== "OK").slice(0, 3).map((check) => `${check.name}:${check.status}`).join(","); + console.log(`${status}|${checkedAt}|${summary}`); + ' 2>/dev/null || echo "UNKNOWN||状态文件解析失败" +else + echo "UNKNOWN||未找到 /var/lib/genarrative/health-patrol/status.json" +fi +"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_report_sections() { + let report = parse_health_report( + r#"==GENARRATIVE_PANEL:checked_at== +2026-06-11T12:00:00+08:00 +==GENARRATIVE_PANEL:hostname== +release +==GENARRATIVE_PANEL:memory== +2.0 GiB|1.0 GiB|1.0 GiB|1.0 GiB|50% +==GENARRATIVE_PANEL:disks== +/dev/sda1|40G|20G|20G|50%|/ +==GENARRATIVE_PANEL:services== +genarrative-api.service|active|running|enabled +spacetimedb.service|failed|failed|enabled +==GENARRATIVE_PANEL:probes== +api:/readyz|http://127.0.0.1:8082/readyz|200|18 +==GENARRATIVE_PANEL:health_patrol== +WARNING|2026-06-11T11:59:00Z|journal:WARNING +"#, + ); + + assert_eq!(report.host.hostname, "release"); + assert_eq!(report.hardware.memory.used_percent, Some(50)); + assert_eq!(report.services.len(), 2); + assert_eq!(report.probes[0].http_code, "200"); + assert_eq!(report.status, HealthLevel::Critical); + } +} diff --git a/server-rs/crates/server-manager-panel/src/lib.rs b/server-rs/crates/server-manager-panel/src/lib.rs new file mode 100644 index 00000000..4b8838f4 --- /dev/null +++ b/server-rs/crates/server-manager-panel/src/lib.rs @@ -0,0 +1,5 @@ +pub mod app; +pub mod fonts; +pub mod health; +pub mod remote; +pub mod ssh_config; diff --git a/server-rs/crates/server-manager-panel/src/main.rs b/server-rs/crates/server-manager-panel/src/main.rs new file mode 100644 index 00000000..888f683b --- /dev/null +++ b/server-rs/crates/server-manager-panel/src/main.rs @@ -0,0 +1,21 @@ +use eframe::egui; +use server_manager_panel::app::ServerManagerApp; +use server_manager_panel::fonts::install_cjk_font; + +fn main() -> eframe::Result<()> { + let native_options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([1180.0, 760.0]) + .with_min_inner_size([920.0, 620.0]), + ..Default::default() + }; + + eframe::run_native( + "Genarrative 服务器管理面板", + native_options, + Box::new(|cc| { + install_cjk_font(&cc.egui_ctx); + Ok(Box::new(ServerManagerApp::default())) + }), + ) +} diff --git a/server-rs/crates/server-manager-panel/src/remote.rs b/server-rs/crates/server-manager-panel/src/remote.rs new file mode 100644 index 00000000..61b9d5b7 --- /dev/null +++ b/server-rs/crates/server-manager-panel/src/remote.rs @@ -0,0 +1,231 @@ +use std::io::Write; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::health::{HEALTH_SCRIPT, ServerHealthReport, parse_health_report}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServiceAction { + Start, + Stop, + Restart, +} + +impl ServiceAction { + pub fn as_systemctl_arg(self) -> &'static str { + match self { + ServiceAction::Start => "start", + ServiceAction::Stop => "stop", + ServiceAction::Restart => "restart", + } + } + + pub fn label(self) -> &'static str { + match self { + ServiceAction::Start => "启动", + ServiceAction::Stop => "关闭", + ServiceAction::Restart => "重启", + } + } +} + +#[derive(Debug, Clone)] +pub struct RemoteCommandResult { + pub success: bool, + pub summary: String, + pub stdout: String, + pub stderr: String, +} + +#[derive(Debug)] +pub enum RemoteEvent { + Health { + alias: String, + result: Result, + }, + ServiceAction { + alias: String, + service: String, + action: ServiceAction, + result: RemoteCommandResult, + }, +} + +pub type RemoteSender = mpsc::Sender; +pub type RemoteReceiver = mpsc::Receiver; + +pub fn channel() -> (RemoteSender, RemoteReceiver) { + mpsc::channel() +} + +pub fn spawn_health_check(alias: String, tx: RemoteSender) { + thread::spawn(move || { + let result = + run_ssh_script(&alias, HEALTH_SCRIPT, Duration::from_secs(20)).and_then(|output| { + if output.success { + Ok(parse_health_report(&output.stdout)) + } else { + Err(format_remote_error(&output)) + } + }); + let _ = tx.send(RemoteEvent::Health { alias, result }); + }); +} + +pub fn spawn_service_action( + alias: String, + service: String, + action: ServiceAction, + tx: RemoteSender, +) { + thread::spawn(move || { + let result = if is_safe_service_name(&service) { + run_ssh_script( + &alias, + &build_service_action_script(&service, action), + Duration::from_secs(20), + ) + .unwrap_or_else(|error| RemoteCommandResult { + success: false, + summary: error, + stdout: String::new(), + stderr: String::new(), + }) + } else { + RemoteCommandResult { + success: false, + summary: "服务名包含不允许的字符".to_owned(), + stdout: String::new(), + stderr: String::new(), + } + }; + let _ = tx.send(RemoteEvent::ServiceAction { + alias, + service, + action, + result, + }); + }); +} + +pub fn is_safe_service_name(service: &str) -> bool { + !service.is_empty() + && service.len() <= 128 + && service.bytes().all(|byte| { + matches!( + byte, + b'a'..=b'z' + | b'A'..=b'Z' + | b'0'..=b'9' + | b'.' + | b'_' + | b'-' + | b'@' + | b':' + ) + }) +} + +fn build_service_action_script(service: &str, action: ServiceAction) -> String { + format!( + r#"set -eu +service='{service}' +action='{action}' +if [ "$(id -u)" = "0" ]; then + systemctl "$action" "$service" +else + sudo -n systemctl "$action" "$service" +fi +systemctl is-active "$service" || true +systemctl status "$service" --no-pager -l -n 12 || true +"#, + service = service, + action = action.as_systemctl_arg() + ) +} + +fn run_ssh_script( + alias: &str, + script: &str, + timeout: Duration, +) -> Result { + let started = Instant::now(); + let mut child = Command::new("ssh") + .arg("-o") + .arg("BatchMode=yes") + .arg("-o") + .arg("ConnectTimeout=8") + .arg(alias) + .arg("sh") + .arg("-s") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("无法启动 ssh: {error}"))?; + + { + // 中文注释:写完脚本后必须关闭 stdin,让远端 `sh -s` 收到 EOF 并开始退出。 + let Some(mut stdin) = child.stdin.take() else { + return Err("无法写入 ssh stdin".to_owned()); + }; + stdin + .write_all(script.as_bytes()) + .map_err(|error| format!("写入远端脚本失败: {error}"))?; + } + + loop { + match child.try_wait() { + Ok(Some(_status)) => { + let output = child + .wait_with_output() + .map_err(|error| format!("读取 ssh 输出失败: {error}"))?; + let success = output.status.success(); + return Ok(RemoteCommandResult { + success, + summary: if success { + "执行成功".to_owned() + } else { + format!("ssh 退出码 {:?}", output.status.code()) + }, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + Ok(None) if started.elapsed() >= timeout => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("ssh 执行超过 {} 秒", timeout.as_secs())); + } + Ok(None) => thread::sleep(Duration::from_millis(80)), + Err(error) => return Err(format!("等待 ssh 进程失败: {error}")), + } + } +} + +fn format_remote_error(result: &RemoteCommandResult) -> String { + let stderr = result.stderr.trim(); + let stdout = result.stdout.trim(); + if !stderr.is_empty() { + format!("{}: {}", result.summary, stderr) + } else if !stdout.is_empty() { + format!("{}: {}", result.summary, stdout) + } else { + result.summary.clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allows_systemd_unit_names_only() { + assert!(is_safe_service_name("genarrative-api.service")); + assert!(is_safe_service_name("worker@1.service")); + assert!(!is_safe_service_name("api.service;rm -rf /")); + assert!(!is_safe_service_name("")); + } +} diff --git a/server-rs/crates/server-manager-panel/src/ssh_config.rs b/server-rs/crates/server-manager-panel/src/ssh_config.rs new file mode 100644 index 00000000..163b1013 --- /dev/null +++ b/server-rs/crates/server-manager-panel/src/ssh_config.rs @@ -0,0 +1,143 @@ +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SshAlias { + pub name: String, + pub source: PathBuf, +} + +pub fn discover_ssh_aliases() -> Vec { + let Some(home) = std::env::var_os("HOME") else { + return Vec::new(); + }; + let config_path = PathBuf::from(home).join(".ssh/config"); + discover_from_file(&config_path) +} + +pub fn discover_from_file(path: &Path) -> Vec { + let mut visited = HashSet::new(); + let mut aliases = Vec::new(); + discover_inner(path, &mut visited, &mut aliases); + dedupe_aliases(aliases) +} + +fn discover_inner(path: &Path, visited: &mut HashSet, aliases: &mut Vec) { + let Ok(canonical) = path.canonicalize() else { + return; + }; + if !visited.insert(canonical.clone()) { + return; + } + let Ok(content) = fs::read_to_string(&canonical) else { + return; + }; + + for line in content.lines() { + let trimmed = trim_comment(line); + let mut parts = trimmed.split_whitespace(); + let Some(keyword) = parts.next() else { + continue; + }; + if keyword.eq_ignore_ascii_case("host") { + aliases.extend(parts.filter_map(|name| { + is_concrete_alias(name).then(|| SshAlias { + name: name.to_owned(), + source: canonical.clone(), + }) + })); + } else if keyword.eq_ignore_ascii_case("include") { + for include in parts { + for include_path in expand_include_path(include, canonical.parent()) { + discover_inner(&include_path, visited, aliases); + } + } + } + } +} + +fn dedupe_aliases(aliases: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut deduped = Vec::new(); + for alias in aliases { + if seen.insert(alias.name.clone()) { + deduped.push(alias); + } + } + deduped +} + +fn trim_comment(line: &str) -> &str { + line.split('#').next().unwrap_or("").trim() +} + +fn is_concrete_alias(value: &str) -> bool { + !value.is_empty() + && !value.starts_with('-') + && !value.starts_with('!') + && !value.contains('*') + && !value.contains('?') + && !value.contains('%') + && !value.contains('/') +} + +fn expand_include_path(raw: &str, parent: Option<&Path>) -> Vec { + if raw.contains('*') || raw.contains('?') { + // 中文注释:SSH Include 支持复杂 glob;面板只解析普通文件,避免误扫过大目录。 + return Vec::new(); + } + let expanded = if let Some(rest) = raw.strip_prefix("~/") { + std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join(rest)) + } else { + let path = PathBuf::from(raw); + if path.is_absolute() { + Some(path) + } else { + parent.map(|base| base.join(path)) + } + }; + expanded.into_iter().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_parser_ignores_wildcards_and_negations() { + let mut aliases = Vec::new(); + let source = PathBuf::from("/tmp/config"); + for line in [ + "Host dev release *.internal !blocked", + "Host github.com", + "Host ?pattern", + "Host -bad", + ] { + let trimmed = trim_comment(line); + let mut parts = trimmed.split_whitespace(); + let keyword = parts.next().unwrap(); + if keyword.eq_ignore_ascii_case("host") { + aliases.extend(parts.filter_map(|name| { + is_concrete_alias(name).then(|| SshAlias { + name: name.to_owned(), + source: source.clone(), + }) + })); + } + } + + let names: Vec<_> = dedupe_aliases(aliases) + .into_iter() + .map(|alias| alias.name) + .collect(); + assert_eq!(names, ["dev", "release", "github.com"]); + } + + #[test] + fn comment_trimming_keeps_plain_aliases() { + assert_eq!(trim_comment(" Host dev # release host "), "Host dev"); + } +} diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 6b43db21..fc4cd607 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -51,7 +51,8 @@ pub use mapper::{ PublicWorkGalleryEntryRecord, PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, - PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, + PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBackgroundCompileTaskClaimRecordInput, + PuzzleBackgroundCompileTaskReleaseRecordInput, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleClearActionRequest, PuzzleClearActionResponse, PuzzleClearActionType, PuzzleClearBoardCell, PuzzleClearBoardSnapshot, PuzzleClearCardAsset, PuzzleClearDraftResponse, PuzzleClearGenerationStatus, PuzzleClearImageAsset, PuzzleClearNextLevelRequest, @@ -348,7 +349,7 @@ type ProcedureResultSender = type ReducerResultSender = Arc>>>>; struct SpacetimeConnectionPool { - slots: Vec>, + slots: Vec, permits: Arc, } @@ -371,8 +372,10 @@ impl SpacetimeStageError { } struct PooledConnectionSlot { - connection: Option, - in_use: bool, + // 槽位占用标记独立成原子量:抢占/复位不依赖锁,租约 Drop 兜底可以同步完成。 + in_use: AtomicBool, + // in_use=true 的持有者独占本槽连接,正常情况下锁上不会有竞争。 + connection: tokio::sync::Mutex>, } struct PooledConnection { @@ -385,9 +388,28 @@ struct PooledConnection { struct PooledConnectionLease { slot_index: usize, connection: Option, + pool: Arc, _permit: OwnedSemaphorePermit, } +impl Drop for PooledConnectionLease { + // 租约 Drop 兜底:请求 future 被取消(如客户端断开导致 handler 被丢弃)时, + // 也必须归还连接并复位槽位,否则槽位会永久停留在 in_use 状态、连接池逐渐耗尽。 + fn drop(&mut self) { + let slot = &self.pool.slots[self.slot_index]; + if let Some(connection) = self.connection.take() { + if !connection.is_broken() { + if let Ok(mut slot_connection) = slot.connection.try_lock() { + *slot_connection = Some(connection); + } + // try_lock 理论上不会失败(in_use 持有者独占);万一失败只丢弃连接,不丢槽位。 + } + } + slot.in_use.store(false, Ordering::Release); + // _permit 随 Drop 自动归还信号量。 + } +} + impl SpacetimeClient { pub fn new(config: SpacetimeClientConfig) -> Self { let pool_size = config.pool_size.max(1) as usize; @@ -400,11 +422,9 @@ impl SpacetimeClient { ..config }; let slots = (0..pool_size) - .map(|_| { - tokio::sync::Mutex::new(PooledConnectionSlot { - connection: None, - in_use: false, - }) + .map(|_| PooledConnectionSlot { + in_use: AtomicBool::new(false), + connection: tokio::sync::Mutex::new(None), }) .collect::>(); let pool = Arc::new(SpacetimeConnectionPool { @@ -678,42 +698,49 @@ impl SpacetimeClient { ) })?; - loop { - for (slot_index, slot) in self.pool.slots.iter().enumerate() { - if let Ok(mut slot_guard) = slot.try_lock() { - if slot_guard.in_use { - continue; - } - let reusable_connection = slot_guard - .connection - .take() - .filter(|connection| !connection.is_broken()); - slot_guard.in_use = true; - drop(slot_guard); + // 持有 permit 即保证最多 pool_size 个并发持有者,必然能抢到一个空闲槽位; + // CAS 抢占后立即构造租约,后续任何失败/取消都由租约 Drop 兜底复位槽位。 + let slot_index = self + .pool + .slots + .iter() + .position(|slot| { + slot.in_use + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + }) + .ok_or_else(|| { + SpacetimeStageError::new( + SpacetimeClientStage::PoolAcquire, + SpacetimeClientError::Runtime( + "SpacetimeDB 连接池 permit 与槽位状态不一致".to_string(), + ), + ) + })?; - let connection = if let Some(connection) = reusable_connection { - connection - } else { - match self.build_pooled_connection(operation_timeout).await { - Ok(connection) => connection, - Err(error) => { - let mut slot_guard = self.pool.slots[slot_index].lock().await; - slot_guard.in_use = false; - return Err(error); - } - } - }; + let mut lease = PooledConnectionLease { + slot_index, + connection: None, + pool: self.pool.clone(), + _permit: permit, + }; - return Ok(PooledConnectionLease { - slot_index, - connection: Some(connection), - _permit: permit, - }); - } - } + let reusable_connection = self.pool.slots[slot_index] + .connection + .lock() + .await + .take() + .filter(|connection| !connection.is_broken()); - tokio::task::yield_now().await; - } + let connection = if let Some(connection) = reusable_connection { + connection + } else { + // 建连失败时直接返回错误,槽位与 permit 由 lease Drop 自动归还。 + self.build_pooled_connection(operation_timeout).await? + }; + + lease.connection = Some(connection); + Ok(lease) } async fn build_pooled_connection( @@ -911,18 +938,10 @@ impl SpacetimeClient { Ok(subscription) } - async fn release_connection(&self, mut lease: PooledConnectionLease) { - let mut slot_guard = self.pool.slots[lease.slot_index].lock().await; - slot_guard.in_use = false; - let Some(connection) = lease.connection.take() else { - slot_guard.connection = None; - return; - }; - if connection.is_broken() { - slot_guard.connection = None; - } else { - slot_guard.connection = Some(connection); - } + async fn release_connection(&self, lease: PooledConnectionLease) { + // 显式归还与“请求被取消”的隐式归还共用同一套租约 Drop 兜底逻辑, + // 保证任何路径下槽位与 permit 都会复位,连接池不会被慢慢泄漏占满。 + drop(lease); } // 超时后必须统一归还租约;若连接已先一步断开则回传断线,否则标记坏连接并回传超时。 @@ -1127,4 +1146,78 @@ mod tests { SpacetimeClientError::Runtime(_) )); } + + fn test_client(pool_size: u32, procedure_timeout: Duration) -> SpacetimeClient { + SpacetimeClient::new(SpacetimeClientConfig { + // 指向本机不可达端口:测试只验证连接池行为,不需要真实 SpacetimeDB。 + server_url: "http://127.0.0.1:9".to_string(), + database: "pool-test".to_string(), + token: None, + pool_size, + procedure_timeout, + }) + } + + /// 复现线上故障机制:修复前请求 future 被取消时租约不会归还,槽位永久停留在 in_use, + /// 后续 acquire 拿着 permit 空转挂死。修复后租约 Drop 必须同时复位槽位与 permit。 + #[tokio::test] + async fn dropped_lease_releases_slot_and_permit() { + let client = test_client(1, Duration::from_millis(200)); + let permit = client + .pool + .permits + .clone() + .acquire_owned() + .await + .expect("permit should acquire"); + client.pool.slots[0].in_use.store(true, Ordering::SeqCst); + assert_eq!(client.pool.permits.available_permits(), 0); + + // 模拟请求被取消:租约未经过 release_connection 直接被 Drop。 + let lease = PooledConnectionLease { + slot_index: 0, + connection: None, + pool: client.pool.clone(), + _permit: permit, + }; + drop(lease); + + assert!( + !client.pool.slots[0].in_use.load(Ordering::SeqCst), + "租约 Drop 后槽位必须复位,否则连接池会被泄漏占满" + ); + assert_eq!( + client.pool.permits.available_permits(), + 1, + "租约 Drop 后 permit 必须归还" + ); + } + + /// 池内 permit 全部被占用(持续在途请求)时,acquire 必须在超时窗口内返回 + /// pool_acquire 超时,而不是无限等待。 + #[tokio::test] + async fn acquire_times_out_at_pool_acquire_when_pool_is_busy() { + let client = test_client(1, Duration::from_millis(200)); + let _held_permit = client + .pool + .permits + .clone() + .acquire_owned() + .await + .expect("permit should acquire"); + + let result = tokio::time::timeout( + Duration::from_secs(5), + client.acquire_connection_with_timeout(Duration::from_millis(200)), + ) + .await + .expect("acquire 必须在超时窗口内返回,而不是空转挂死"); + + let error = match result { + Ok(_) => panic!("池占满时应返回 pool_acquire 超时"), + Err(error) => error, + }; + assert_eq!(error.stage, SpacetimeClientStage::PoolAcquire); + assert!(matches!(error.error, SpacetimeClientError::Timeout)); + } } diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index c1f4c069..6ba49d7d 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -101,7 +101,8 @@ pub use self::puzzle::{ PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, - PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, + PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBackgroundCompileTaskClaimRecordInput, + PuzzleBackgroundCompileTaskReleaseRecordInput, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, @@ -199,10 +200,10 @@ pub(crate) use self::public_work::{ map_public_work_gallery_entry, map_public_work_gallery_entry_to_detail_entry, }; pub(crate) use self::puzzle::{ - map_puzzle_agent_session_procedure_result, map_puzzle_gallery_card_view_row, - map_puzzle_run_procedure_result, map_puzzle_work_procedure_result, - map_puzzle_works_procedure_result, map_runtime_profile_wallet_ledger_source_type_back, - parse_puzzle_agent_stage_record, + map_puzzle_agent_session_procedure_result, map_puzzle_background_compile_task_procedure_result, + map_puzzle_gallery_card_view_row, map_puzzle_run_procedure_result, + map_puzzle_work_procedure_result, map_puzzle_works_procedure_result, + map_runtime_profile_wallet_ledger_source_type_back, parse_puzzle_agent_stage_record, }; pub(crate) use self::puzzle_clear::{ map_puzzle_clear_agent_session_procedure_result, map_puzzle_clear_gallery_card_view_row, diff --git a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs index ae57c440..d11564de 100644 --- a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs @@ -13,6 +13,16 @@ pub(crate) fn map_puzzle_agent_session_procedure_result( Ok(map_puzzle_agent_session_snapshot(session)) } +pub(crate) fn map_puzzle_background_compile_task_procedure_result( + result: PuzzleBackgroundCompileTaskProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result.claimed) +} + pub(crate) fn map_puzzle_work_procedure_result( result: PuzzleWorkProcedureResult, ) -> Result { @@ -614,6 +624,23 @@ pub struct PuzzleFormDraftSaveRecordInput { pub saved_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleBackgroundCompileTaskClaimRecordInput { + pub task_id: String, + pub claim_id: String, + pub session_id: String, + pub owner_user_id: String, + pub claimed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleBackgroundCompileTaskReleaseRecordInput { + pub task_id: String, + pub claim_id: String, + pub session_id: String, + pub owner_user_id: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleAgentMessageSubmitRecordInput { pub session_id: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index cb53cae4..55677168 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -204,6 +204,7 @@ pub mod chapter_progression_table; pub mod chapter_progression_type; pub mod checkpoint_wooden_fish_run_procedure; pub mod claim_profile_task_reward_and_return_procedure; +pub mod claim_puzzle_background_compile_task_procedure; pub mod claim_puzzle_work_point_incentive_procedure; pub mod clear_database_migration_import_chunks_procedure; pub mod clear_platform_browse_history_and_return_procedure; @@ -628,6 +629,11 @@ pub mod puzzle_anchor_item_type; pub mod puzzle_anchor_pack_type; pub mod puzzle_anchor_status_type; pub mod puzzle_audio_asset_type; +pub mod puzzle_background_compile_task_claim_input_type; +pub mod puzzle_background_compile_task_procedure_result_type; +pub mod puzzle_background_compile_task_release_input_type; +pub mod puzzle_background_compile_task_row_type; +pub mod puzzle_background_compile_task_table; pub mod puzzle_board_snapshot_type; pub mod puzzle_cell_position_type; pub mod puzzle_clear_agent_session_create_input_type; @@ -766,6 +772,7 @@ pub mod redeem_profile_reward_code_procedure; pub mod refresh_session_table; pub mod refresh_session_type; pub mod refund_profile_wallet_points_and_return_procedure; +pub mod release_puzzle_background_compile_task_procedure; pub mod remix_big_fish_work_procedure; pub mod remix_custom_world_profile_procedure; pub mod remix_puzzle_work_procedure; @@ -1312,6 +1319,7 @@ pub use chapter_progression_table::*; pub use chapter_progression_type::ChapterProgression; pub use checkpoint_wooden_fish_run_procedure::checkpoint_wooden_fish_run; pub use claim_profile_task_reward_and_return_procedure::claim_profile_task_reward_and_return; +pub use claim_puzzle_background_compile_task_procedure::claim_puzzle_background_compile_task; pub use claim_puzzle_work_point_incentive_procedure::claim_puzzle_work_point_incentive; pub use clear_database_migration_import_chunks_procedure::clear_database_migration_import_chunks; pub use clear_platform_browse_history_and_return_procedure::clear_platform_browse_history_and_return; @@ -1736,6 +1744,11 @@ pub use puzzle_anchor_item_type::PuzzleAnchorItem; pub use puzzle_anchor_pack_type::PuzzleAnchorPack; pub use puzzle_anchor_status_type::PuzzleAnchorStatus; pub use puzzle_audio_asset_type::PuzzleAudioAsset; +pub use puzzle_background_compile_task_claim_input_type::PuzzleBackgroundCompileTaskClaimInput; +pub use puzzle_background_compile_task_procedure_result_type::PuzzleBackgroundCompileTaskProcedureResult; +pub use puzzle_background_compile_task_release_input_type::PuzzleBackgroundCompileTaskReleaseInput; +pub use puzzle_background_compile_task_row_type::PuzzleBackgroundCompileTaskRow; +pub use puzzle_background_compile_task_table::*; pub use puzzle_board_snapshot_type::PuzzleBoardSnapshot; pub use puzzle_cell_position_type::PuzzleCellPosition; pub use puzzle_clear_agent_session_create_input_type::PuzzleClearAgentSessionCreateInput; @@ -1874,6 +1887,7 @@ pub use redeem_profile_reward_code_procedure::redeem_profile_reward_code; pub use refresh_session_table::*; pub use refresh_session_type::RefreshSession; pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return; +pub use release_puzzle_background_compile_task_procedure::release_puzzle_background_compile_task; pub use remix_big_fish_work_procedure::remix_big_fish_work; pub use remix_custom_world_profile_procedure::remix_custom_world_profile; pub use remix_puzzle_work_procedure::remix_puzzle_work; @@ -2569,6 +2583,7 @@ pub struct DbUpdate { public_work_play_daily_stat: __sdk::TableUpdate, puzzle_agent_message: __sdk::TableUpdate, puzzle_agent_session: __sdk::TableUpdate, + puzzle_background_compile_task: __sdk::TableUpdate, puzzle_clear_agent_session: __sdk::TableUpdate, puzzle_clear_event: __sdk::TableUpdate, puzzle_clear_gallery_card_view: __sdk::TableUpdate, @@ -2854,6 +2869,11 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "puzzle_agent_session" => db_update.puzzle_agent_session.append( puzzle_agent_session_table::parse_table_update(table_update)?, ), + "puzzle_background_compile_task" => { + db_update.puzzle_background_compile_task.append( + puzzle_background_compile_task_table::parse_table_update(table_update)?, + ) + } "puzzle_clear_agent_session" => db_update.puzzle_clear_agent_session.append( puzzle_clear_agent_session_table::parse_table_update(table_update)?, ), @@ -3373,6 +3393,12 @@ impl __sdk::DbUpdate for DbUpdate { &self.puzzle_agent_session, ) .with_updates_by_pk(|row| &row.session_id); + diff.puzzle_background_compile_task = cache + .apply_diff_to_table::( + "puzzle_background_compile_task", + &self.puzzle_background_compile_task, + ) + .with_updates_by_pk(|row| &row.task_id); diff.puzzle_clear_agent_session = cache .apply_diff_to_table::( "puzzle_clear_agent_session", @@ -3828,6 +3854,9 @@ impl __sdk::DbUpdate for DbUpdate { "puzzle_agent_session" => db_update .puzzle_agent_session .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "puzzle_background_compile_task" => db_update + .puzzle_background_compile_task + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "puzzle_clear_agent_session" => db_update .puzzle_clear_agent_session .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -4192,6 +4221,9 @@ impl __sdk::DbUpdate for DbUpdate { "puzzle_agent_session" => db_update .puzzle_agent_session .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "puzzle_background_compile_task" => db_update + .puzzle_background_compile_task + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "puzzle_clear_agent_session" => db_update .puzzle_clear_agent_session .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -4410,6 +4442,7 @@ pub struct AppliedDiff<'r> { public_work_play_daily_stat: __sdk::TableAppliedDiff<'r, PublicWorkPlayDailyStat>, puzzle_agent_message: __sdk::TableAppliedDiff<'r, PuzzleAgentMessageRow>, puzzle_agent_session: __sdk::TableAppliedDiff<'r, PuzzleAgentSessionRow>, + puzzle_background_compile_task: __sdk::TableAppliedDiff<'r, PuzzleBackgroundCompileTaskRow>, puzzle_clear_agent_session: __sdk::TableAppliedDiff<'r, PuzzleClearAgentSessionRow>, puzzle_clear_event: __sdk::TableAppliedDiff<'r, PuzzleClearEventRow>, puzzle_clear_gallery_card_view: __sdk::TableAppliedDiff<'r, PuzzleClearGalleryCardViewRow>, @@ -4829,6 +4862,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.puzzle_agent_session, event, ); + callbacks.invoke_table_row_callbacks::( + "puzzle_background_compile_task", + &self.puzzle_background_compile_task, + event, + ); callbacks.invoke_table_row_callbacks::( "puzzle_clear_agent_session", &self.puzzle_clear_agent_session, @@ -5766,6 +5804,7 @@ impl __sdk::SpacetimeModule for RemoteModule { public_work_play_daily_stat_table::register_table(client_cache); puzzle_agent_message_table::register_table(client_cache); puzzle_agent_session_table::register_table(client_cache); + puzzle_background_compile_task_table::register_table(client_cache); puzzle_clear_agent_session_table::register_table(client_cache); puzzle_clear_event_table::register_table(client_cache); puzzle_clear_gallery_card_view_table::register_table(client_cache); @@ -5885,6 +5924,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "public_work_play_daily_stat", "puzzle_agent_message", "puzzle_agent_session", + "puzzle_background_compile_task", "puzzle_clear_agent_session", "puzzle_clear_event", "puzzle_clear_gallery_card_view", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/claim_puzzle_background_compile_task_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/claim_puzzle_background_compile_task_procedure.rs new file mode 100644 index 00000000..45b9de6d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/claim_puzzle_background_compile_task_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_background_compile_task_claim_input_type::PuzzleBackgroundCompileTaskClaimInput; +use super::puzzle_background_compile_task_procedure_result_type::PuzzleBackgroundCompileTaskProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ClaimPuzzleBackgroundCompileTaskArgs { + pub input: PuzzleBackgroundCompileTaskClaimInput, +} + +impl __sdk::InModule for ClaimPuzzleBackgroundCompileTaskArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `claim_puzzle_background_compile_task`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait claim_puzzle_background_compile_task { + fn claim_puzzle_background_compile_task(&self, input: PuzzleBackgroundCompileTaskClaimInput) { + self.claim_puzzle_background_compile_task_then(input, |_, _| {}); + } + + fn claim_puzzle_background_compile_task_then( + &self, + input: PuzzleBackgroundCompileTaskClaimInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl claim_puzzle_background_compile_task for super::RemoteProcedures { + fn claim_puzzle_background_compile_task_then( + &self, + input: PuzzleBackgroundCompileTaskClaimInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PuzzleBackgroundCompileTaskProcedureResult>( + "claim_puzzle_background_compile_task", + ClaimPuzzleBackgroundCompileTaskArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_claim_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_claim_input_type.rs new file mode 100644 index 00000000..f721ad91 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_claim_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleBackgroundCompileTaskClaimInput { + pub task_id: String, + pub claim_id: String, + pub session_id: String, + pub owner_user_id: String, + pub claimed_at_micros: i64, +} + +impl __sdk::InModule for PuzzleBackgroundCompileTaskClaimInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_procedure_result_type.rs new file mode 100644 index 00000000..8c85082e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_procedure_result_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleBackgroundCompileTaskProcedureResult { + pub ok: bool, + pub claimed: bool, + pub error_message: Option, +} + +impl __sdk::InModule for PuzzleBackgroundCompileTaskProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_release_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_release_input_type.rs new file mode 100644 index 00000000..f4d983fa --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_release_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleBackgroundCompileTaskReleaseInput { + pub task_id: String, + pub claim_id: String, + pub session_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for PuzzleBackgroundCompileTaskReleaseInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_row_type.rs new file mode 100644 index 00000000..49e1bf07 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_row_type.rs @@ -0,0 +1,66 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleBackgroundCompileTaskRow { + pub task_id: String, + pub claim_id: String, + pub session_id: String, + pub owner_user_id: String, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for PuzzleBackgroundCompileTaskRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PuzzleBackgroundCompileTaskRow`. +/// +/// Provides typed access to columns for query building. +pub struct PuzzleBackgroundCompileTaskRowCols { + pub task_id: __sdk::__query_builder::Col, + pub claim_id: __sdk::__query_builder::Col, + pub session_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for PuzzleBackgroundCompileTaskRow { + type Cols = PuzzleBackgroundCompileTaskRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + PuzzleBackgroundCompileTaskRowCols { + task_id: __sdk::__query_builder::Col::new(table_name, "task_id"), + claim_id: __sdk::__query_builder::Col::new(table_name, "claim_id"), + session_id: __sdk::__query_builder::Col::new(table_name, "session_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `PuzzleBackgroundCompileTaskRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct PuzzleBackgroundCompileTaskRowIxCols { + pub session_id: __sdk::__query_builder::IxCol, + pub task_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for PuzzleBackgroundCompileTaskRow { + type IxCols = PuzzleBackgroundCompileTaskRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + PuzzleBackgroundCompileTaskRowIxCols { + session_id: __sdk::__query_builder::IxCol::new(table_name, "session_id"), + task_id: __sdk::__query_builder::IxCol::new(table_name, "task_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for PuzzleBackgroundCompileTaskRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_table.rs new file mode 100644 index 00000000..227085b5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_table.rs @@ -0,0 +1,169 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::puzzle_background_compile_task_row_type::PuzzleBackgroundCompileTaskRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `puzzle_background_compile_task`. +/// +/// Obtain a handle from the [`PuzzleBackgroundCompileTaskTableAccess::puzzle_background_compile_task`] method on [`super::RemoteTables`], +/// like `ctx.db.puzzle_background_compile_task()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.puzzle_background_compile_task().on_insert(...)`. +pub struct PuzzleBackgroundCompileTaskTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `puzzle_background_compile_task`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait PuzzleBackgroundCompileTaskTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`PuzzleBackgroundCompileTaskTableHandle`], which mediates access to the table `puzzle_background_compile_task`. + fn puzzle_background_compile_task(&self) -> PuzzleBackgroundCompileTaskTableHandle<'_>; +} + +impl PuzzleBackgroundCompileTaskTableAccess for super::RemoteTables { + fn puzzle_background_compile_task(&self) -> PuzzleBackgroundCompileTaskTableHandle<'_> { + PuzzleBackgroundCompileTaskTableHandle { + imp: self + .imp + .get_table::("puzzle_background_compile_task"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct PuzzleBackgroundCompileTaskInsertCallbackId(__sdk::CallbackId); +pub struct PuzzleBackgroundCompileTaskDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for PuzzleBackgroundCompileTaskTableHandle<'ctx> { + type Row = PuzzleBackgroundCompileTaskRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = PuzzleBackgroundCompileTaskInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PuzzleBackgroundCompileTaskInsertCallbackId { + PuzzleBackgroundCompileTaskInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PuzzleBackgroundCompileTaskInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = PuzzleBackgroundCompileTaskDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PuzzleBackgroundCompileTaskDeleteCallbackId { + PuzzleBackgroundCompileTaskDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PuzzleBackgroundCompileTaskDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct PuzzleBackgroundCompileTaskUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for PuzzleBackgroundCompileTaskTableHandle<'ctx> { + type UpdateCallbackId = PuzzleBackgroundCompileTaskUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PuzzleBackgroundCompileTaskUpdateCallbackId { + PuzzleBackgroundCompileTaskUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PuzzleBackgroundCompileTaskUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `task_id` unique index on the table `puzzle_background_compile_task`, +/// which allows point queries on the field of the same name +/// via the [`PuzzleBackgroundCompileTaskTaskIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.puzzle_background_compile_task().task_id().find(...)`. +pub struct PuzzleBackgroundCompileTaskTaskIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PuzzleBackgroundCompileTaskTableHandle<'ctx> { + /// Get a handle on the `task_id` unique index on the table `puzzle_background_compile_task`. + pub fn task_id(&self) -> PuzzleBackgroundCompileTaskTaskIdUnique<'ctx> { + PuzzleBackgroundCompileTaskTaskIdUnique { + imp: self.imp.get_unique_constraint::("task_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PuzzleBackgroundCompileTaskTaskIdUnique<'ctx> { + /// Find the subscribed row whose `task_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache + .get_or_make_table::("puzzle_background_compile_task"); + _table.add_unique_constraint::("task_id", |row| &row.task_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse( + "TableUpdate", + "TableUpdate", + ) + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `PuzzleBackgroundCompileTaskRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait puzzle_background_compile_taskQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `PuzzleBackgroundCompileTaskRow`. + fn puzzle_background_compile_task( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl puzzle_background_compile_taskQueryTableAccess for __sdk::QueryTableAccessor { + fn puzzle_background_compile_task( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("puzzle_background_compile_task") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/release_puzzle_background_compile_task_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/release_puzzle_background_compile_task_procedure.rs new file mode 100644 index 00000000..3b85afae --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/release_puzzle_background_compile_task_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_background_compile_task_procedure_result_type::PuzzleBackgroundCompileTaskProcedureResult; +use super::puzzle_background_compile_task_release_input_type::PuzzleBackgroundCompileTaskReleaseInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ReleasePuzzleBackgroundCompileTaskArgs { + pub input: PuzzleBackgroundCompileTaskReleaseInput, +} + +impl __sdk::InModule for ReleasePuzzleBackgroundCompileTaskArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `release_puzzle_background_compile_task`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait release_puzzle_background_compile_task { + fn release_puzzle_background_compile_task( + &self, + input: PuzzleBackgroundCompileTaskReleaseInput, + ) { + self.release_puzzle_background_compile_task_then(input, |_, _| {}); + } + + fn release_puzzle_background_compile_task_then( + &self, + input: PuzzleBackgroundCompileTaskReleaseInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl release_puzzle_background_compile_task for super::RemoteProcedures { + fn release_puzzle_background_compile_task_then( + &self, + input: PuzzleBackgroundCompileTaskReleaseInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PuzzleBackgroundCompileTaskProcedureResult>( + "release_puzzle_background_compile_task", + ReleasePuzzleBackgroundCompileTaskArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index 25ec5ad9..8916e853 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -1,8 +1,10 @@ use super::*; use crate::mapper::*; +use crate::module_bindings::claim_puzzle_background_compile_task_procedure::claim_puzzle_background_compile_task; use crate::module_bindings::claim_puzzle_work_point_incentive_procedure::claim_puzzle_work_point_incentive; use crate::module_bindings::delete_puzzle_work_procedure::delete_puzzle_work; use crate::module_bindings::record_puzzle_work_like_procedure::record_puzzle_work_like; +use crate::module_bindings::release_puzzle_background_compile_task_procedure::release_puzzle_background_compile_task; use crate::module_bindings::remix_puzzle_work_procedure::remix_puzzle_work; use crate::module_bindings::save_puzzle_ui_background_procedure::save_puzzle_ui_background; @@ -194,6 +196,67 @@ impl SpacetimeClient { .await } + pub async fn claim_puzzle_background_compile_task( + &self, + input: PuzzleBackgroundCompileTaskClaimRecordInput, + ) -> Result { + let procedure_input = PuzzleBackgroundCompileTaskClaimInput { + task_id: input.task_id, + claim_id: input.claim_id, + session_id: input.session_id, + owner_user_id: input.owner_user_id, + claimed_at_micros: input.claimed_at_micros, + }; + + self.call_after_connect( + "claim_puzzle_background_compile_task", + move |connection, sender| { + connection + .procedures() + .claim_puzzle_background_compile_task_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_puzzle_background_compile_task_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn release_puzzle_background_compile_task( + &self, + input: PuzzleBackgroundCompileTaskReleaseRecordInput, + ) -> Result { + let procedure_input = PuzzleBackgroundCompileTaskReleaseInput { + task_id: input.task_id, + claim_id: input.claim_id, + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }; + + self.call_after_connect( + "release_puzzle_background_compile_task", + move |connection, sender| { + connection + .procedures() + .release_puzzle_background_compile_task_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_puzzle_background_compile_task_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn save_puzzle_generated_images( &self, input: PuzzleGeneratedImagesSaveRecordInput, diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 6f249e3b..6de73df5 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -20,8 +20,8 @@ use crate::match3d::tables::{ match_3_d_work_profile, match3d_agent_message, match3d_agent_session, match3d_runtime_run, }; use crate::puzzle::{ - puzzle_agent_message, puzzle_agent_session, puzzle_event, puzzle_leaderboard_entry, - puzzle_runtime_run, puzzle_work_profile, + puzzle_agent_message, puzzle_agent_session, puzzle_background_compile_task, puzzle_event, + puzzle_leaderboard_entry, puzzle_runtime_run, puzzle_work_profile, }; use crate::puzzle_clear::tables::{ puzzle_clear_agent_session, puzzle_clear_event, puzzle_clear_runtime_run, @@ -229,6 +229,7 @@ macro_rules! migration_tables { asset_entity_binding, asset_event, puzzle_agent_session, + puzzle_background_compile_task, puzzle_agent_message, puzzle_work_profile, puzzle_event, diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index c5ed877c..2106cccb 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -10,14 +10,16 @@ use module_puzzle::{ PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind, PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot, - PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileFailureInput, PuzzleDraftCompileInput, - PuzzleFormDraftSaveInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, - PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus, - PuzzlePublishInput, PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, - PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, - PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, - PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput, - PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, + PuzzleAgentStage, PuzzleAnchorPack, PuzzleBackgroundCompileTaskClaimInput, + PuzzleBackgroundCompileTaskProcedureResult, PuzzleBackgroundCompileTaskReleaseInput, + PuzzleDraftCompileFailureInput, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput, + PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, + PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus, PuzzlePublishInput, + PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput, + PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, PuzzleRunPropInput, + PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, + PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput, PuzzleWorkDeleteInput, + PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, PuzzleWorkPointIncentiveClaimInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, apply_selected_candidate, build_form_draft_from_seed, @@ -38,6 +40,7 @@ use spacetimedb::{ use crate::auth::user_account; const PUZZLE_POINT_INCENTIVE_DEFAULT_U64: u64 = 0; +const PUZZLE_BACKGROUND_COMPILE_TASK_LEASE_MICROS: i64 = 30 * 60 * 1_000_000; const WORK_VISIBLE_DEFAULT: bool = true; /// 拼图 Agent session 真相表。 @@ -62,6 +65,22 @@ pub struct PuzzleAgentSessionRow { updated_at: Timestamp, } +/// 拼图首图后台编译活动任务表。 +/// 中文注释:该表只保存跨 api-server 实例互斥 claim,不表达最终生成结果。 +#[spacetimedb::table( + accessor = puzzle_background_compile_task, + index(accessor = by_puzzle_background_compile_task_session_id, btree(columns = [session_id])) +)] +pub struct PuzzleBackgroundCompileTaskRow { + #[primary_key] + task_id: String, + claim_id: String, + session_id: String, + owner_user_id: String, + created_at: Timestamp, + updated_at: Timestamp, +} + /// 拼图 Agent 消息真相表。 #[spacetimedb::table( accessor = puzzle_agent_message, @@ -388,6 +407,44 @@ pub fn mark_puzzle_draft_generation_failed( } } +#[spacetimedb::procedure] +pub fn claim_puzzle_background_compile_task( + ctx: &mut ProcedureContext, + input: PuzzleBackgroundCompileTaskClaimInput, +) -> PuzzleBackgroundCompileTaskProcedureResult { + match ctx.try_with_tx(|tx| claim_puzzle_background_compile_task_tx(tx, input.clone())) { + Ok(claimed) => PuzzleBackgroundCompileTaskProcedureResult { + ok: true, + claimed, + error_message: None, + }, + Err(message) => PuzzleBackgroundCompileTaskProcedureResult { + ok: false, + claimed: false, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn release_puzzle_background_compile_task( + ctx: &mut ProcedureContext, + input: PuzzleBackgroundCompileTaskReleaseInput, +) -> PuzzleBackgroundCompileTaskProcedureResult { + match ctx.try_with_tx(|tx| release_puzzle_background_compile_task_tx(tx, input.clone())) { + Ok(released) => PuzzleBackgroundCompileTaskProcedureResult { + ok: true, + claimed: released, + error_message: None, + }, + Err(message) => PuzzleBackgroundCompileTaskProcedureResult { + ok: false, + claimed: false, + error_message: Some(message), + }, + } +} + /// 保存拼图入口表单草稿。 /// 中文注释:该 procedure 只更新 session 与创作中心草稿卡,不触发图片生成或发布校验。 #[spacetimedb::procedure] @@ -1024,6 +1081,84 @@ fn compile_puzzle_agent_draft_tx( ) } +fn claim_puzzle_background_compile_task_tx( + ctx: &TxContext, + input: PuzzleBackgroundCompileTaskClaimInput, +) -> Result { + let task_id = normalize_required_puzzle_task_field(&input.task_id, "拼图后台任务 ID")?; + let claim_id = normalize_required_puzzle_task_field(&input.claim_id, "拼图后台任务 claim ID")?; + let session_id = normalize_required_puzzle_task_field(&input.session_id, "拼图 session ID")?; + let owner_user_id = normalize_required_puzzle_task_field(&input.owner_user_id, "拼图用户 ID")?; + let claimed_at = Timestamp::from_micros_since_unix_epoch(input.claimed_at_micros); + + get_owned_session_row(ctx, &session_id, &owner_user_id)?; + if let Some(existing) = ctx + .db + .puzzle_background_compile_task() + .task_id() + .find(&task_id) + { + if !is_stale_puzzle_background_compile_task(&existing, input.claimed_at_micros) { + return Ok(false); + } + ctx.db + .puzzle_background_compile_task() + .task_id() + .delete(&task_id); + } + + ctx.db + .puzzle_background_compile_task() + .insert(PuzzleBackgroundCompileTaskRow { + task_id, + claim_id, + session_id, + owner_user_id, + created_at: claimed_at, + updated_at: claimed_at, + }); + Ok(true) +} + +fn release_puzzle_background_compile_task_tx( + ctx: &TxContext, + input: PuzzleBackgroundCompileTaskReleaseInput, +) -> Result { + let task_id = normalize_required_puzzle_task_field(&input.task_id, "拼图后台任务 ID")?; + let claim_id = normalize_required_puzzle_task_field(&input.claim_id, "拼图后台任务 claim ID")?; + let session_id = normalize_required_puzzle_task_field(&input.session_id, "拼图 session ID")?; + let owner_user_id = normalize_required_puzzle_task_field(&input.owner_user_id, "拼图用户 ID")?; + + let Some(row) = ctx + .db + .puzzle_background_compile_task() + .task_id() + .find(&task_id) + else { + return Ok(false); + }; + if row.session_id != session_id || row.owner_user_id != owner_user_id { + return Err("无权释放该拼图后台任务".to_string()); + } + if row.claim_id != claim_id { + return Ok(false); + } + + ctx.db + .puzzle_background_compile_task() + .task_id() + .delete(&task_id); + Ok(true) +} + +fn is_stale_puzzle_background_compile_task( + row: &PuzzleBackgroundCompileTaskRow, + now_micros: i64, +) -> bool { + now_micros.saturating_sub(row.updated_at.to_micros_since_unix_epoch()) + >= PUZZLE_BACKGROUND_COMPILE_TASK_LEASE_MICROS +} + fn mark_puzzle_draft_generation_failed_tx( ctx: &TxContext, input: PuzzleDraftCompileFailureInput, @@ -2950,6 +3085,14 @@ fn get_owned_session_row( Ok(row) } +fn normalize_required_puzzle_task_field(value: &str, field_name: &str) -> Result { + let normalized = value.trim(); + if normalized.is_empty() { + return Err(format!("{field_name} 不能为空")); + } + Ok(normalized.to_string()) +} + fn get_owned_run_row( ctx: &TxContext, run_id: &str, diff --git a/src/components/AdventureEntityModal.test.tsx b/src/components/AdventureEntityModal.test.tsx index 60c21e80..3fdb2641 100644 --- a/src/components/AdventureEntityModal.test.tsx +++ b/src/components/AdventureEntityModal.test.tsx @@ -1,13 +1,15 @@ /* @vitest-environment jsdom */ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; import { AnimationState, + type Character, + type CompanionRenderState, type Encounter, - type GameState, type EquipmentLoadout, + type GameState, WorldType, } from '../types'; import { AdventureEntityModal } from './AdventureEntityModal'; @@ -87,6 +89,66 @@ function createEncounter(overrides: Partial = {}): Encounter { }; } +function createPlayerCharacter(): Character { + return { + id: 'player-1', + name: '潮刃客', + title: '试剑者', + description: '测试主角', + backstory: '测试背景', + personality: '冷静', + avatar: '', + portrait: '', + assetFolder: '', + assetVariant: '', + attributes: { + strength: 5, + agility: 5, + intelligence: 5, + spirit: 5, + }, + skills: [ + { + id: 'tide-slash', + name: '潮刃突进', + animation: AnimationState.ATTACK, + damage: 16, + manaCost: 5, + cooldownTurns: 2, + range: 1, + style: 'burst', + buildBuffs: [ + { + id: 'wet-mark', + sourceType: 'skill', + sourceId: 'tide-slash', + name: '潮湿', + tags: ['控制', '潮汐'], + durationTurns: 2, + }, + ], + }, + ], + adventureOpenings: {}, + }; +} + +function createCompanionRenderState( + character: Character, +): CompanionRenderState { + return { + npcId: 'companion-1', + character, + hp: 100, + maxHp: 100, + mana: 20, + maxMana: 20, + skillCooldowns: {}, + animationState: AnimationState.IDLE, + slot: 'upper', + }; +} + afterEach(() => { vi.restoreAllMocks(); }); @@ -169,3 +231,226 @@ test('NPC 背包物品空 id 会被规范成稳定渲染 id', () => { ), ).toBe(false); }); + +test('物品空态复用暗色 PlatformEmptyState chrome', () => { + render( + undefined} + />, + ); + + const emptyState = screen.getByText('暂无物品'); + const attributeSection = screen.getByText('属性').closest('section'); + const itemSection = screen.getByText('物品').closest('section'); + + expect(emptyState.className).toContain('platform-empty-state'); + expect(emptyState.className).toContain('border-dashed'); + expect(emptyState.className).toContain('bg-black/20'); + expect(attributeSection?.className).toContain('border-white/10'); + expect(attributeSection?.className).toContain('bg-black/25'); + expect(itemSection?.className).toContain('border-white/10'); + expect(itemSection?.className).toContain('bg-black/25'); + + const levelPanel = screen.getByTestId('player-level-panel'); + + expect(levelPanel.className).toContain('border-amber-300/18'); + expect(levelPanel.className).toContain('bg-amber-500/8'); + expect(levelPanel.className).toContain('rounded-xl'); +}); + +test('最近回响纯展示小卡复用暗色 PlatformSubpanel chrome', () => { + render( + undefined} + />, + ); + + [ + 'recent-consequence-echo', + 'recent-chronicle-echo', + 'recent-carrier-echo', + 'recent-scene-residue-echo', + ].forEach((testId) => { + const panel = screen.getByTestId(testId); + + expect(panel.className).toContain('border-white/10'); + expect(panel.className).toContain('bg-black/25'); + expect(panel.className).toContain('rounded-xl'); + }); +}); + +test('私聊和队友收束复用暗色 tint PlatformSubpanel chrome', () => { + const companionCharacter = createPlayerCharacter(); + + render( + undefined} + onOpenCharacterChat={() => undefined} + />, + ); + + const privateChatPanel = screen.getByTestId('private-chat-panel'); + const companionResolutionEcho = screen.getByTestId( + 'companion-resolution-echo', + ); + const privateChatButton = screen.getByRole('button', { name: '聊天' }); + + expect(privateChatPanel.className).toContain('border-sky-400/18'); + expect(privateChatPanel.className).toContain('bg-sky-500/8'); + expect(privateChatPanel.className).toContain('rounded-[1.35rem]'); + expect(companionResolutionEcho.className).toContain('border-emerald-400/18'); + expect(companionResolutionEcho.className).toContain('bg-emerald-500/8'); + expect(companionResolutionEcho.className).toContain('rounded-xl'); + expect(privateChatButton.className).toContain( + 'platform-action-button--editor-dark', + ); + expect(privateChatButton.className).toContain('rounded-xl'); + expect(privateChatButton.className).toContain('bg-sky-400/15'); + expect(privateChatButton.className).toContain('disabled:bg-black/20'); +}); + +test('技能详情静态标签复用暗色 PlatformPillBadge chrome', () => { + render( + undefined} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: /潮刃突进/u })); + + const skillPanel = screen + .getByText('技能详情') + .closest('.pixel-modal-shell') as HTMLElement; + + const deliveryBadge = within(skillPanel).getAllByText('近战')[0]!; + const styleBadge = within(skillPanel).getAllByText('爆发')[0]!; + const buffSummaryBadge = within(skillPanel).getByText('附带 1 个状态标签'); + const buffBadge = within(skillPanel).getByText('潮湿 / 控制、潮汐 / 2 回合'); + const damagePanel = within(skillPanel) + .getByText('伤害') + .closest('section') as HTMLElement; + const descriptionPanel = within(skillPanel) + .getByText(/潮刃突进 属于爆发路线/u) + .closest('section') as HTMLElement; + const buffPanel = within(skillPanel) + .getByText('附带状态标签') + .closest('section') as HTMLElement; + + expect(deliveryBadge.className).toContain('bg-white/6'); + expect(styleBadge.className).toContain('bg-sky-500/10'); + expect(buffSummaryBadge.className).toContain('bg-emerald-500/10'); + expect(buffBadge.className).toContain('rounded-full'); + expect(buffBadge.className).toContain('bg-sky-500/10'); + expect(damagePanel.className).toContain('bg-black/25'); + expect(damagePanel.className).toContain('border-white/10'); + expect(descriptionPanel.className).toContain('bg-black/25'); + expect(buffPanel.className).toContain('bg-black/25'); +}); diff --git a/src/components/AdventureEntityModal.tsx b/src/components/AdventureEntityModal.tsx index cf275df8..39dc7970 100644 --- a/src/components/AdventureEntityModal.tsx +++ b/src/components/AdventureEntityModal.tsx @@ -12,9 +12,7 @@ import { resolveCharacterAttributeProfile, } from '../data/attributeResolver'; import { - formatBuildContributionPercent, getBuildContributionAttributeRows, - getBuildContributionQualityLabel, getCompanionBuildDamageBreakdown, getPlayerBuildDamageBreakdown, resolveMonsterOutgoingDamage, @@ -67,11 +65,11 @@ import { CharacterAnimator } from './CharacterAnimator'; import { buildCharacterSkillRenderId, getCharacterDetailSpriteStyle, - getContributionVisualStyle, getSkillDeliveryLabel, getSkillStyleLabel, } from './CharacterInfoHelpers'; import { + BuildContributionDetailPanel, CharacterAttributeGrid, CharacterIdentityBadges, CharacterSkillsList, @@ -79,6 +77,10 @@ import { PlayerLevelProgress, StatusRow, } from './CharacterInfoShared'; +import { PlatformEmptyState } from './common/PlatformEmptyState'; +import { PlatformActionButton } from './common/PlatformActionButton'; +import { PlatformPillBadge } from './common/PlatformPillBadge'; +import { PlatformSubpanel } from './common/PlatformSubpanel'; import { GENERIC_NPC_SCENE_SCALE } from './game-canvas/GameCanvasShared'; import type { GameCanvasEntitySelection } from './GameCanvas'; import { HostileNpcAnimator } from './HostileNpcAnimator'; @@ -129,12 +131,28 @@ function estimateNpcMaxMana(character: Character | null) { function Section({ title, children }: { title: string; children: ReactNode }) { return ( -
+
{title}
{children} -
+ + ); +} + +function SkillMetricCard({ label, value }: { label: string; value: number }) { + return ( + +
+ {label} +
+
{value}
+
); } @@ -226,7 +244,9 @@ function buildSelectionRenderKey(selection: GameCanvasEntitySelection | null) { }`; } -function buildStableRenderKey(parts: Array) { +function buildStableRenderKey( + parts: Array, +) { return parts .map((part, index) => { const normalized = String(part ?? '').trim(); @@ -746,29 +766,26 @@ export function AdventureEntityModal({ affinity: companionNpcState?.affinity ?? null, } satisfies CharacterChatTarget) : null; - const inventory = useMemo( - () => { - const rawInventory = - selection?.kind === 'player' - ? gameState.playerInventory - : selection?.kind === 'companion' && companionCharacter - ? buildCharacterInventoryPreviewItems( - companionCharacter, - gameState.worldType, - ) - : (npcState?.inventory ?? []); + const inventory = useMemo(() => { + const rawInventory = + selection?.kind === 'player' + ? gameState.playerInventory + : selection?.kind === 'companion' && companionCharacter + ? buildCharacterInventoryPreviewItems( + companionCharacter, + gameState.worldType, + ) + : (npcState?.inventory ?? []); - return normalizeInventoryItemRenderIds(rawInventory, selectionRenderKey); - }, - [ - companionCharacter, - gameState.playerInventory, - gameState.worldType, - npcState?.inventory, - selection?.kind, - selectionRenderKey, - ], - ); + return normalizeInventoryItemRenderIds(rawInventory, selectionRenderKey); + }, [ + companionCharacter, + gameState.playerInventory, + gameState.worldType, + npcState?.inventory, + selection?.kind, + selectionRenderKey, + ]); const attributeSchema = resolveAttributeSchema( gameState.worldType, gameState.customWorldProfile, @@ -1072,7 +1089,13 @@ export function AdventureEntityModal({ {selection.kind === 'companion' && companionChatTarget ? (
-
+
@@ -1084,8 +1107,9 @@ export function AdventureEntityModal({ : `好感达到 ${privateChatUnlockAffinity ?? DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY} 后解锁,当前 ${companionNpcState?.affinity ?? 0}。`}
- +
-
+
) : null} @@ -1122,40 +1142,54 @@ export function AdventureEntityModal({
{selectedCompanionResolution && ( -
+ 队友收束: {selectedCompanionResolution.resolutionType} ·{' '} {selectedCompanionResolution.summary} -
+ )} {relatedConsequences.length > 0 && (
{relatedConsequences.map((record, index) => ( -
{record.title} {':'} {record.summary} -
+ ))}
)} {recentChronicleEntries.length > 0 && (
{recentChronicleEntries.map((entry, index) => ( -
{entry.title} @@ -1163,31 +1197,41 @@ export function AdventureEntityModal({
{entry.summary}
-
+ ))}
)} {recentCarrierEchoes.length > 0 && ( -
+ 载体回响:{recentCarrierEchoes.join(';')} -
+ )} {sceneResidues.length > 0 && (
{sceneResidues.map((residue, index) => ( -
{residue.title} {':'} {residue.visibleClue} -
+ ))}
)} @@ -1198,7 +1242,13 @@ export function AdventureEntityModal({
{selection.kind === 'player' ? ( -
+
等级
@@ -1211,7 +1261,7 @@ export function AdventureEntityModal({ normalizedPlayerProgression.xpToNextLevel } /> -
+ ) : null}
setSelectedItemId(item.id)} /> ) : ( -
暂无物品
+ + 暂无物品 + )}
@@ -1320,70 +1376,10 @@ export function AdventureEntityModal({
-
-
-
-
-
-
- 标签概览 -
-
- {selectedContributionRow.label} -
-
-
-
- {getBuildContributionQualityLabel( - selectedContributionRow.bonusDelta, - )} -
-
- 总加成{' '} - {formatBuildContributionPercent( - selectedContributionRow.bonusDelta, - )} -
-
-
-
-
- -
-
- 属性加成 -
- - {selectedContributionAttributes.length > 0 ? ( -
- {selectedContributionAttributes.map((attribute) => ( -
-
- {attribute.label} - - {formatBuildContributionPercent( - attribute.modifierDelta, - )} - -
-
- ))} -
- ) : ( -
- 当前标签还没有可展示的属性适配明细。 -
- )} -
-
+
@@ -1444,63 +1440,52 @@ export function AdventureEntityModal({ } /> ) : ( -
+ {detailCharacter ? '当前未进入具体世界,暂时无法恢复技能预览。' : '该 NPC 当前没有独立技能演示模型,先展示真实技能数据。'} -
+ )}
- + {getSkillDeliveryLabel(selectedSkill)} - - + + {getSkillStyleLabel(selectedSkill)} - + {selectedSkill.buildBuffs?.length ? ( - + 附带 {selectedSkill.buildBuffs.length} 个状态标签 - + ) : null}
-
-
- 伤害 -
-
- {selectedSkill.damage} -
-
-
-
- 法力 -
-
- {selectedSkill.manaCost} -
-
-
-
- 冷却 -
-
- {selectedSkill.cooldownTurns} -
-
-
-
- 距离 -
-
- {selectedSkill.range} -
-
+ + + +
-
+ {selectedSkill.name} 属于{getSkillStyleLabel(selectedSkill)} 路线,通常以{getSkillDeliveryLabel(selectedSkill)}方式出手, 造成 {selectedSkill.damage} 点伤害,消耗{' '} @@ -1509,16 +1494,21 @@ export function AdventureEntityModal({ {selectedSkill.effects?.length ? ` 该技能还会触发 ${selectedSkill.effects.length} 段战斗特效。` : ''} -
+ {selectedSkill.buildBuffs?.length ? ( -
+
附带状态标签
{selectedSkill.buildBuffs.map((buff, index) => ( - {buff.name} / {buff.tags.join('、')} /{' '} {buff.durationTurns} 回合 - + ))}
-
+ ) : null} diff --git a/src/components/AffinityStatusCard.test.tsx b/src/components/AffinityStatusCard.test.tsx new file mode 100644 index 00000000..87681ae2 --- /dev/null +++ b/src/components/AffinityStatusCard.test.tsx @@ -0,0 +1,30 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import { expect, test } from 'vitest'; + +import { AffinityStatusCard } from './AffinityStatusCard'; + +test('renders affinity level with dark platform pill badge tone', () => { + render(); + + const levelBadge = screen.getAllByText('信任')[0]!; + + expect(levelBadge.className).toContain('rounded-full'); + expect(levelBadge.className).toContain('bg-amber-500/10'); + expect(levelBadge.className).toContain('text-amber-100'); +}); + +test('renders affinity summary and progress with dark PlatformSubpanel chrome', () => { + render(); + + const levelPanel = screen.getByText('好感等级').closest('section'); + const progressPanel = screen.getByText('好感进度').closest('section'); + + expect(levelPanel?.className).toContain('border-white/10'); + expect(levelPanel?.className).toContain('bg-black/25'); + expect(levelPanel?.className).toContain('rounded-xl'); + expect(progressPanel?.className).toContain('border-white/10'); + expect(progressPanel?.className).toContain('bg-black/25'); + expect(progressPanel?.className).toContain('sm:p-4'); +}); diff --git a/src/components/AffinityStatusCard.tsx b/src/components/AffinityStatusCard.tsx index 3a48ed80..5a1da1be 100644 --- a/src/components/AffinityStatusCard.tsx +++ b/src/components/AffinityStatusCard.tsx @@ -2,8 +2,12 @@ import { AFFINITY_PROGRESS_MARKERS, AFFINITY_PROGRESS_MAX, AFFINITY_PROGRESS_MIN, + type AffinityLevelId, getAffinityLevelMeta, } from '../data/affinityLevels'; +import { PlatformPillBadge } from './common/PlatformPillBadge'; +import type { PlatformPillBadgeTone } from './common/platformPillBadgeModel'; +import { PlatformSubpanel } from './common/PlatformSubpanel'; type AffinityProgressMarker = (typeof AFFINITY_PROGRESS_MARKERS)[number]; @@ -45,6 +49,16 @@ function isMarkerReached(marker: AffinityProgressMarker, affinity: number) { return affinity >= marker.value; } +function getAffinityLevelBadgeTone( + levelId: AffinityLevelId, +): PlatformPillBadgeTone { + if (levelId === 'hostile' || levelId === 'close') return 'darkRose'; + if (levelId === 'guarded') return 'darkSoft'; + if (levelId === 'friendly') return 'darkEmerald'; + if (levelId === 'trusted') return 'darkAmber'; + return 'darkSky'; +} + export function AffinityStatusCard({ affinity }: { affinity: number }) { const currentLevel = getAffinityLevelMeta(affinity); const nextLevel = getNextAffinityMarker(affinity); @@ -69,18 +83,20 @@ export function AffinityStatusCard({ affinity }: { affinity: number }) { return (
-
+
好感等级
- {currentLevel.label} - + 当前好感 {affinity} @@ -107,9 +123,14 @@ export function AffinityStatusCard({ affinity }: { affinity: number }) {

{currentLevel.description}

-
+ -
+
好感进度
@@ -215,7 +236,7 @@ export function AffinityStatusCard({ affinity }: { affinity: number }) { ); })}
-
+
); } diff --git a/src/components/BackstoryArchive.test.tsx b/src/components/BackstoryArchive.test.tsx new file mode 100644 index 00000000..bbab5588 --- /dev/null +++ b/src/components/BackstoryArchive.test.tsx @@ -0,0 +1,87 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import { expect, test } from 'vitest'; + +import { BackstoryArchive } from './BackstoryArchive'; + +test('renders backstory chapter status with dark platform pill badges', () => { + render( + , + ); + + const unlockedBadge = screen.getByText('已解锁'); + const lockedBadge = screen.getByText('需好感 60'); + + expect(unlockedBadge.className).toContain('rounded-full'); + expect(unlockedBadge.className).toContain('bg-amber-500/10'); + expect(lockedBadge.className).toContain('rounded-full'); + expect(lockedBadge.className).toContain('bg-black/20'); +}); + +test('renders public summary and chapters with dark PlatformSubpanel chrome', () => { + render( + , + ); + + const summaryPanel = screen.getByText('公开印象').closest('section'); + const unlockedPanel = screen.getByText('表层来意').closest('section'); + const lockedPanel = screen.getByText('最终底牌').closest('section'); + + expect(summaryPanel?.className).toContain('border-white/10'); + expect(summaryPanel?.className).toContain('bg-black/25'); + expect(unlockedPanel?.className).toContain('border-amber-300/18'); + expect(unlockedPanel?.className).toContain('bg-black/25'); + expect(lockedPanel?.className).toContain('border-white/10'); + expect(lockedPanel?.className).toContain('bg-black/25'); +}); + +test('renders empty archive with editor dark PlatformEmptyState chrome', () => { + render( + , + ); + + const emptyState = screen.getByText('暂无可整理的背景线索。'); + + expect(emptyState.className).toContain('platform-empty-state'); + expect(emptyState.className).toContain('border-dashed'); + expect(emptyState.className).toContain('bg-black/20'); +}); diff --git a/src/components/BackstoryArchive.tsx b/src/components/BackstoryArchive.tsx index a53d3ae4..8e8b5885 100644 --- a/src/components/BackstoryArchive.tsx +++ b/src/components/BackstoryArchive.tsx @@ -1,3 +1,7 @@ +import { PlatformEmptyState } from './common/PlatformEmptyState'; +import { PlatformPillBadge } from './common/PlatformPillBadge'; +import { PlatformSubpanel } from './common/PlatformSubpanel'; + export type BackstoryUnlockedChapter = { id: string; title: string; @@ -38,58 +42,71 @@ export function BackstoryArchive({
{publicSummary ? ( -
+
公开印象
{publicSummary}
-
+ ) : null} {unlockedChapters.map((chapter) => ( -
{chapter.title}
- + 已解锁 - +
{chapter.content}
-
+ ))} {lockedChapters.map((chapter) => ( -
{chapter.title}
- + 需好感 {chapter.affinityRequired} - +
{chapter.teaser}
-
+ ))} {!publicSummary && totalChapters === 0 ? ( -
+ 暂无可整理的背景线索。 -
+ ) : null}
); diff --git a/src/components/CharacterChatModal.test.tsx b/src/components/CharacterChatModal.test.tsx new file mode 100644 index 00000000..087c7360 --- /dev/null +++ b/src/components/CharacterChatModal.test.tsx @@ -0,0 +1,137 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { expect, test, vi } from 'vitest'; + +import type { CharacterChatModalState } from '../hooks/rpg-runtime-story'; +import type { Character } from '../types'; +import { CharacterChatModal } from './CharacterChatModal'; + +function createCharacter(): Character { + return { + id: 'hero', + name: '沈行', + title: '试剑客', + description: '测试角色', + backstory: '测试背景', + avatar: '/hero.png', + portrait: '/hero.png', + assetFolder: 'hero', + assetVariant: 'default', + attributes: { + strength: 10, + agility: 10, + intelligence: 8, + spirit: 9, + }, + personality: '冷静谨慎', + skills: [], + adventureOpenings: {}, + } as Character; +} + +function createModalState( + overrides: Partial = {}, +): CharacterChatModalState { + return { + target: { + character: createCharacter(), + npcId: 'npc-hero', + roleLabel: '队友', + hp: 80, + maxHp: 100, + mana: 24, + maxMana: 30, + }, + draft: '', + messages: [], + suggestions: ['先问问线索'], + summary: '', + isSending: false, + isLoadingSuggestions: false, + error: '暂时无法生成回复。', + ...overrides, + }; +} + +test('角色聊天错误提示复用暗色 PlatformStatusMessage chrome', () => { + render( + , + ); + + const errorMessage = screen.getByText('暂时无法生成回复。'); + + expect(errorMessage.className).toContain('platform-status-message'); + expect(errorMessage.className).toContain('border-amber-300/15'); + expect(errorMessage.className).toContain('bg-amber-500/10'); + expect(errorMessage.className).toContain('text-amber-50/90'); +}); + +test('角色聊天状态、空态和建议复用暗色 UI Kit chrome', () => { + render( + , + ); + + const hpStatus = screen.getByText('生命值 80 / 100'); + const summaryFallback = screen.getByText('你们还没有形成新的私下聊天总结。'); + const emptyHistory = screen.getByText( + '这里会保留你和该角色的私下聊天记录。输入框支持自由发挥,上方三条文本可以帮你快速起句。', + ); + const refreshButton = screen.getByRole('button', { name: '换一组' }); + const suggestionButton = screen.getByRole('button', { name: '先问问线索' }); + const draftTextarea = screen.getByPlaceholderText('对沈行说点什么...'); + + expect(hpStatus.className).toContain('border-white/10'); + expect(hpStatus.className).toContain('bg-black/25'); + expect(summaryFallback.className).toContain('border-white/10'); + expect(summaryFallback.className).toContain('bg-black/25'); + expect(emptyHistory.className).toContain('platform-empty-state'); + expect(emptyHistory.className).toContain('border-dashed'); + expect(refreshButton.className).toContain( + 'platform-action-button--editor-dark', + ); + expect(refreshButton.className).toContain('text-[10px]'); + expect(suggestionButton.className).toContain('platform-dark-option-card'); + expect(suggestionButton.className).toContain('border-white/8'); + expect(draftTextarea.className).toContain('platform-text-field--editor-dark'); + expect(draftTextarea.className).toContain('focus:border-sky-300/35'); +}); + +test('角色聊天标题栏内联关闭按钮保持共享关闭行为', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + + render( + , + ); + + const closeButton = screen.getByRole('button', { name: '关闭角色聊天' }); + await user.click(closeButton); + + expect(closeButton.className).toContain('relative'); + expect(closeButton.className).toContain('shrink-0'); + expect(closeButton.getAttribute('title')).toBe('关闭角色聊天'); + expect(onClose).toHaveBeenCalledTimes(1); +}); diff --git a/src/components/CharacterChatModal.tsx b/src/components/CharacterChatModal.tsx index 101fe663..81a9a2e3 100644 --- a/src/components/CharacterChatModal.tsx +++ b/src/components/CharacterChatModal.tsx @@ -3,6 +3,12 @@ import { useEffect, useRef } from 'react'; import type { CharacterChatModalState } from '../hooks/rpg-runtime-story'; import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; +import { PlatformActionButton } from './common/PlatformActionButton'; +import { PlatformDarkOptionCard } from './common/PlatformDarkOptionCard'; +import { PlatformEmptyState } from './common/PlatformEmptyState'; +import { PlatformStatusMessage } from './common/PlatformStatusMessage'; +import { PlatformSubpanel } from './common/PlatformSubpanel'; +import { PlatformTextField } from './common/PlatformTextField'; import { PixelCloseButton } from './PixelCloseButton'; interface CharacterChatModalProps { @@ -68,23 +74,45 @@ export function CharacterChatModal({
角色状态
-
+ 生命值 {modal.target.hp} / {modal.target.maxHp} -
-
+ + 内力 {modal.target.mana} / {modal.target.maxMana} -
-
+ + {modal.target.character.personality} -
+
聊天总结
-
+ {modal.summary || '你们还没有形成新的私下聊天总结。'} -
+
@@ -115,51 +143,57 @@ export function CharacterChatModal({ )) ) : ( -
+ 这里会保留你和该角色的私下聊天记录。输入框支持自由发挥,上方三条文本可以帮你快速起句。 -
+ )}
帮你回复
- +
{modal.suggestions.map((suggestion, index) => ( - + ))}
{modal.error && ( -
+ {modal.error} -
+ )}
-