106 Commits

Author SHA1 Message Date
46e2c94faf fix(db): 修正 drizzle-kit 在 Bun SQLite 下的配置与脚本 2026-03-05 16:59:25 +08:00
b1062a5aed refactor(api): signAndPackReport 直接返回签名 ZIP 文件 2026-03-05 16:58:59 +08:00
b193759e90 docs: 新增第三方 OpenAPI 对接指南 2026-03-05 16:44:01 +08:00
eb941c06c0 docs(api): 补全 OpenAPI 元数据与字段描述 2026-03-05 16:43:53 +08:00
eb2f6554b2 docs: 更新 signAndPackReport 为 multipart 文件上传说明 2026-03-05 16:32:49 +08:00
58d57fa148 refactor(server): 使用 multipart File 替代报告 ZIP 的 base64 上传 2026-03-05 16:32:41 +08:00
509860bba8 docs: 补充 UX 集成模式与授权对接说明 2026-03-05 16:24:21 +08:00
4e7c4e1aa5 feat(server): 实现设备授权与报告 ZIP 签名打包接口 2026-03-05 16:24:10 +08:00
8261409d7d refactor(server): 切换 SQLite 并重建设备/任务表结构 2026-03-05 16:23:30 +08:00
d2eb98d612 feat: 新增共享加密包并引入 ZIP/PGP 依赖 2026-03-05 16:23:13 +08:00
9d8a38a4c4 fix: 修正 ORPC handler 语义、加固 Electron 安全、优化构建与运行时配置
- todo.router: create 错误码 NOT_FOUND → INTERNAL_SERVER_ERROR,remove 增加存在性检查
- __root: devtools 仅在 DEV 环境渲染
- Electron: 添加 will-navigate 导航拦截、显式安全 webPreferences、deny-all 权限请求
- sidecar: 空 catch 块补充意图注释,新增 lastResolvedUrl getter
- todo.contract: 硬编码 omit 改用 generatedFieldKeys
- router: QueryClient 添加 staleTime/retry 默认值
- turbo: build 任务精细化 inputs 提升缓存命中率
- fields: id() 改为模块私有
2026-03-05 14:06:43 +08:00
cd7448c3b3 docs: 统一使用 bun run <script> 避免与 Bun 内置子命令冲突
bun build 会调用 Bun 内置 bundler 而非 package.json script,
将所有文档中的 bun <script> 改为 bun run <script> 以避免歧义。
bun test 保留不变(直接使用 Bun 内置 test runner)。
2026-03-05 12:57:26 +08:00
58d7a453b6 style: 将 biome lineWidth 从默认 80 调整为 120 2026-03-05 12:28:18 +08:00
afc3b66efa refactor: 移除根 package.json 中冗余的 --filter 参数
Turbo 会自动只在定义了对应 script 的包上执行任务,无需手动指定 filter。
2026-03-05 12:08:48 +08:00
3c97e9c3eb refactor: 移除根 turbo.json 中冗余的 compile/dist 任务定义
子包 turbo.json(extends root)已各自定义了完整配置,
根级重复注册无实际作用。
2026-03-05 12:06:11 +08:00
58620b4d4b feat: 补充 root compile/dist 脚本,通过 Turbo filter 委托到对应 app 2026-03-05 11:56:49 +08:00
04b8dedb3e fix: 修正 middleware 导入路径、清理 catalog 冗余项、同步文档 2026-03-05 11:22:49 +08:00
02bdfffe79 refactor(client): 合并 orpc.ts 和 query-client.ts 为单文件,遵循 ORPC 官方模式 2026-03-05 11:05:53 +08:00
0cd8b57d24 refactor: 优化项目结构 — 修复拼写、提取共享 interceptor、扁平化 db 目录、清理空包 2026-03-05 10:58:55 +08:00
0438b52c93 refactor(db): 移除 drizzle() 多余的 schema 参数,RQBv2 只需 relations 2026-03-05 10:37:47 +08:00
fd9478d64e docs: 同步 AGENTS.md 至 Drizzle v1 beta 并添加开发原则
- 所有 AGENTS.md 新增「开发原则」:不向后兼容、改代码必须同步文档、前向迁移
- 根 AGENTS.md: 更新 Database 段落为 Drizzle v1 beta + postgres-js + RQBv2
- server AGENTS.md: 更新 tech stack、目录结构、ORPC 示例、数据库段落
  - drizzle-zod → drizzle-orm/zod
  - bun-sql → postgres-js
  - RQBv1 回调 → RQBv2 对象语法
  - 新增 relations.ts 和 DB instance 示例
- desktop AGENTS.md: 添加开发原则和文档同步规则
2026-03-05 10:21:31 +08:00
73614204f7 chore(deps): 升级 Drizzle ORM 到 1.0 beta 并迁移至 RQBv2
- drizzle-orm/drizzle-kit 从 0.45.1/0.31.9 升级到 1.0.0-beta.15
- 移除独立的 drizzle-zod 包,改用 drizzle-orm/zod 内置导入
- DB driver 从 bun-sql 切换到 postgres-js
- 新增 defineRelations 入口 (RQBv2)
- 查询语法迁移到 RQBv2 对象风格 orderBy
2026-03-05 10:17:10 +08:00
61e7a1b621 chore(deps): 升级依赖并同步 VSCode 配置 2026-03-05 10:00:13 +08:00
5ccde0a121 fix(server): 避免 SSR 导入 *.client 模块导致构建失败 2026-02-26 12:09:45 +08:00
0553347bfe chore(deps): 升级 TanStack Start 与构建相关依赖 2026-02-26 12:09:37 +08:00
52af81b079 ci(gitea): 移除 Gitea Actions 工作流 2026-02-17 18:30:38 +08:00
527c1d1020 ci(gitea): 将 dist 工作流重命名为 release 并上传 AppImage 产物
All checks were successful
Release / release (push) Successful in 52s
2026-02-17 18:21:54 +08:00
4ed961760a ci(gitea): 升级 mise action 并补充版本输出
All checks were successful
Build Dist / dist (push) Successful in 1m5s
2026-02-17 18:00:32 +08:00
c54b7d27a6 ci(gitea): 新增 mise + turbo dist 构建工作流
Some checks failed
Build Dist / dist (push) Has been cancelled
2026-02-17 17:52:28 +08:00
d478b94c13 chore(server): 切换 Bun 运行链路并同步升级核心依赖 2026-02-17 17:43:07 +08:00
908b369732 fix(server): 使用 SubmitEventHandler 消除 React 19 弃用告警 2026-02-16 05:30:44 +08:00
51724a7936 feat(desktop): 调整启动页 logo 与加载动画视觉 2026-02-16 05:18:27 +08:00
93a2519012 feat(desktop): 迁移启动页到 React 并接入 Motion 动画 2026-02-16 05:10:15 +08:00
5edab0ba1d feat(desktop): 恢复启动加载页并在服务就绪后切换 2026-02-16 04:28:37 +08:00
a451e08209 fix(server): 移除重复的 NODE_ENV 环境变量声明 2026-02-16 04:09:14 +08:00
e76a03d0f4 feat(desktop): 拆分 sidecar 管理并接入健康检查路由 2026-02-16 04:06:41 +08:00
aa1e2c81c6 chore: remove unused fingerprint utility and stale deps 2026-02-16 03:03:33 +08:00
7e2621ae37 chore(build): 调整脚本顺序并移除多余空行 2026-02-16 00:05:22 +08:00
94a9122f34 feat(build): 统一编译命令并默认启用双架构 2026-02-15 23:48:37 +08:00
275c8e4795 docs(agents): 同步多架构构建与打包命令说明 2026-02-15 23:32:32 +08:00
8245abe217 feat(build): 支持桌面端多架构打包矩阵 2026-02-15 23:26:00 +08:00
627e6f9dd3 chore: bump dependency catalog and lockfile versions 2026-02-15 22:01:03 +08:00
e59e085217 chore(vscode): remove unused extension recommendations 2026-02-15 21:51:36 +08:00
cd9826ded3 chore(desktop): tweak electron-vite dev watch and remove redundant --config flags 2026-02-09 04:19:53 +08:00
2efc57d9ee feat(desktop): show native error dialogs on startup failures
Replace silent console.error + app.quit() with dialog.showErrorBox()
so users actually see why the app failed to start instead of it just
disappearing. Covers server spawn errors, timeout, port allocation
failure, mid-session server crashes, and window creation failures.
2026-02-09 03:35:24 +08:00
1f5940438a fix(desktop): use array format for win target in electron-builder config 2026-02-09 03:16:48 +08:00
0bab6372ac chore(desktop): reorganize electron-builder config and refine packaging targets 2026-02-09 03:15:01 +08:00
5f0c9d33cb chore 2026-02-09 02:58:43 +08:00
73982939a8 chore(desktop): add app icon and track resources directory 2026-02-09 02:51:56 +08:00
10c2d61523 fix(desktop): use CJS for preload script to fix sandbox loading error 2026-02-09 02:17:57 +08:00
18ce05854a feat(server): add NODE_ENV to shared env schema 2026-02-09 01:59:45 +08:00
7eccef5d8f chore(desktop): remove redundant config fields for KISS 2026-02-09 01:41:34 +08:00
41667cb33b refactor(desktop): simplify main process logic and improve naming
- Remove logLifecycle wrapper, inline the conditional logging
- Remove redundant shouldAbortWindowLoad check before final loadURL
- Rename getServerUrl to resolveServerUrl to reflect side effects
- Add .catch on createWindow to prevent silent async failures
2026-02-09 01:27:29 +08:00
00c944e1b5 refactor(desktop): 精简主进程启动与退出逻辑并减少打包态日志噪音 2026-02-09 01:13:08 +08:00
f9edfd0058 fix(desktop): guard shutdown race and kill sidecar process tree 2026-02-09 00:57:30 +08:00
9aea89e16d fix(desktop): force app exit on windows window close 2026-02-09 00:40:01 +08:00
26b74b25f2 fix(desktop): use stdio ignore for sidecar to prevent process hang on quit
Piped stdio handles kept the event loop alive on Windows after killing
the sidecar process, preventing the Electron app from exiting.
2026-02-09 00:23:05 +08:00
ccf220fc29 fix(desktop): ensure sidecar process stops on app shutdown 2026-02-08 23:59:54 +08:00
a585069cdc refactor: rename compile:mac/win to compile:darwin/windows to match Bun target names 2026-02-08 23:39:30 +08:00
b149cc5dc0 refactor: decentralize turbo task config — move compile/dist to workspace turbo.json
Sink package-specific tasks from root turbo.json into workspace configs:
- compile/compile:* → apps/server/turbo.json (only server compiles binaries)
- dist/dist:* → apps/desktop/turbo.json (only desktop distributes)
- Cross-package deps (desktop→server#compile) owned by desktop config
- Desktop dist scripts no longer bypass Turbo by calling bun run build

Root turbo.json reduced from 16 to 4 generic lifecycle tasks.
2026-02-08 23:26:24 +08:00
9f38636d76 refactor(server): use Bun.Build.CompileTarget and derive host target instead of manual map 2026-02-08 22:47:39 +08:00
63906ec09b refactor(server): use util.parseArgs for declarative CLI arg parsing in compile.ts 2026-02-08 22:38:49 +08:00
8c4e4ad150 refactor(server): use type guard to eliminate as Target casts in compile.ts 2026-02-08 22:33:02 +08:00
e171db8196 refactor: simplify compile.ts to single-target and add per-platform compile scripts
- Rewrite compile.ts from 112 to 66 lines: single target with auto-detect host, remove multi-target batch logic
- Add compile:linux/mac/win scripts to server, root, and turbo configs
- Wire desktop dist:* to depend on matching server compile:* (avoid unnecessary cross-platform compilation)
- Update AGENTS.md docs across root, server, and desktop
2026-02-08 22:25:30 +08:00
dac6bb1643 refactor: 统一打包命令为 dist 体系,build 仅编译不打包
- build:linux/mac/win → dist/dist:linux/dist:mac/dist:win
- Turbo 任务依赖:desktop#dist:* → server#compile → server#build
- 根目录 bun dist 一条命令完成完整打包流水线
- 更新 AGENTS.md 文档同步命令变更
2026-02-08 20:48:58 +08:00
8c0ea632d7 style(desktop): 规范 package.json 字段排序 2026-02-08 20:31:17 +08:00
db23ee42fc chore: 更新 lockfile 依赖版本 2026-02-08 20:26:16 +08:00
0784546e50 fix(desktop): 修复 artifactName 路径错误并移除 deb 构建目标
scoped package name 中的 / 导致 ${name} 作为路径出错,改用 ${productName};
移除 deb target(fpm 依赖不可用);补充 package.json 元信息字段
2026-02-08 20:03:47 +08:00
2fe3e15659 refactor: 重命名 build.ts → compile.ts,统一脚本与文件命名
- compile 脚本调用 compile.ts,消除与 build 的歧义
- desktop turbo.json 添加 build outputs 缓存配置
2026-02-08 19:46:04 +08:00
ed02993350 style(desktop): loading 页面改为白色轻盈主题 2026-02-08 19:40:20 +08:00
e4e5ff2211 chore: 移除 useSortedClasses 规则,等待 nursery 毕业后再启用 2026-02-08 19:19:18 +08:00
d69a573a33 feat: 为 desktop/server 添加 Tailwind CSS 支持和 Biome 集成
- desktop renderer 接入 @tailwindcss/vite,loading 页面改用 Tailwind 类
- 两个 app 添加 biome.json 继承配置:tailwindDirectives + useSortedClasses
2026-02-08 19:17:53 +08:00
6cc1bc6834 refactor(desktop): 用类型收窄替代 as AddressInfo 断言 2026-02-08 19:03:13 +08:00
894fd17d1a fix(desktop): 动态分配 sidecar 端口替代硬编码,避免端口冲突
使用 net.createServer().listen(0) 探测可用端口,通过 PORT 环境变量
传递给 sidecar binary(VS Code language server 同款模式)
2026-02-08 18:38:45 +08:00
888f20fdab fix(desktop): 加载 loading 页面作为启动屏,配置平台级 extraResources 对接交叉编译
- main process 启动时先加载 renderer/index.html 显示 spinner
- electron-builder 按平台引用 server/out/ 下对应架构的 sidecar binary
- 移除 mise.toml 中无关的 rust 工具配置
2026-02-08 18:21:40 +08:00
7318600e20 refactor(desktop): 替换 WebUI 为 Electron + electron-vite 桌面壳方案
- 使用 electron-vite 构建 main/preload,electron-builder 打包分发
- main process: dev 模式直连 localhost:3000,生产模式 spawn sidecar binary
- 添加 loading 页面,server 就绪前显示加载动画
- 更新 catalog 依赖: electron, electron-vite, electron-builder
- 移除 @webui-dev/bun-webui 依赖
2026-02-08 18:16:13 +08:00
e8e473b357 refactor(desktop): 替换 Electrobun 为 WebUI 作为桌面窗口方案
Electrobun 太不稳定,改用 webui-dev/webui(轻量 C 库,~300KB)通过
系统浏览器或 WebView 提供桌面窗口。已验证 bun:ffi 加载和
bun build --compile 均正常工作。

- 移除 electrobun 依赖和配置
- 添加 @webui-dev/bun-webui 依赖
- 重写桌面入口为 WebUI 窗口方案
- 移除 Conveyor 打包工具(mise.toml)
2026-02-08 04:15:34 +08:00
41d97ca312 refactor(deps): 将 @hydraulic/conveyor 从 bun 依赖迁移到 mise 全局工具 2026-02-08 03:21:38 +08:00
cfe7de2a70 chore(deps): 添加 @hydraulic/conveyor 依赖到 desktop 应用 2026-02-08 03:11:43 +08:00
b87de26e17 chore(deps): 升级 TanStack devtools 和 vite-tsconfig-paths 依赖版本 2026-02-08 02:41:22 +08:00
b8d38872ad refactor(desktop): 优化 Electrobun 构建配置
- 从 package.json 动态读取版本号替代硬编码
- 启用所有平台的 CEF 捆绑
- 将构建目标从配置文件移至 CLI 参数
- 启用 asar 打包并为 dev 脚本添加 --env=dev 标志
2026-02-08 02:38:37 +08:00
7450c685d3 chore: 移除已完成的 electrobun 生产模式计划文档 2026-02-08 00:40:43 +08:00
2048f73155 refactor(server): 按照官方推荐顺序重排 Vite 插件并清理冗余配置 2026-02-07 22:13:16 +08:00
70b5d27493 chore(desktop): 添加 win-x64 构建目标 2026-02-07 21:04:39 +08:00
5d5d3a51f6 chore(desktop): 禁用 mac 和 win 平台的 CEF 捆绑 2026-02-07 20:57:58 +08:00
3306e18395 refactor(desktop): 使用预分配端口替代 stdout 解析获取服务器端口 2026-02-07 19:32:56 +08:00
14bcdb33af chore(deps): 升级 TanStack 路由和 Start 依赖版本 2026-02-07 19:11:28 +08:00
cc81d95178 chore(desktop): 升级 electrobun 至 1.12.0-beta.1 2026-02-07 19:10:33 +08:00
55d45e6a49 docs(desktop): 更新 AGENTS.md 文档与开发计划以反映最新实现 2026-02-07 18:49:50 +08:00
b7a6a793a3 feat(desktop): 实现生产模式下的内嵌服务器子进程支持 2026-02-07 18:49:41 +08:00
6b12745e50 chore(desktop): 更新应用名称、标识符和版本号 2026-02-07 17:46:00 +08:00
989d8973f5 chore(desktop): 简化构建和开发脚本 2026-02-07 17:16:32 +08:00
41e79449ce docs: 更新 AGENTS.md 适配 Electrobun 替代 Tauri 2026-02-07 17:00:52 +08:00
4bbb0c4a16 refactor(server): simplify build script, remove Effect dependency 2026-02-07 16:44:56 +08:00
2b3026cf69 chore(turbo): simplify and optimize monorepo configuration 2026-02-07 16:35:30 +08:00
adb14cff77 chore: 重构 Turbo 构建配置并强化 Bun 专用说明
将应用特定的构建输出配置下沉至各自 turbo.json,根级 build 任务添加拓扑依赖;AGENTS.md 统一添加 Bun 专用运行时警告;桌面端启用 Linux CEF 渲染器。
2026-02-07 16:14:55 +08:00
44ca7a0f5e chore: 扩展 Turbo build 任务的输出目录配置 2026-02-07 07:06:55 +08:00
59b4edc2d2 chore(desktop): 降级 electrobun 至 0.8.0 稳定版 2026-02-07 06:59:19 +08:00
9d0e9a6aac chore(desktop): 添加 .gitignore 和多平台构建脚本 2026-02-07 06:29:08 +08:00
f758fd5947 chore: 更新 bun.lock 锁文件 2026-02-07 06:15:52 +08:00
26f9421130 chore: 统一 Node/TypeScript 配置并修复桌面端类型环境 2026-02-07 05:53:38 +08:00
29969550ed refactor(desktop): 从 Tauri 迁移到 Electrobun
- 移除 Tauri v2 代码 (src-tauri/, copy.ts)
- 添加 Electrobun 配置和入口 (electrobun.config.ts, src/bun/index.ts)
- 更新 package.json 使用 catalog 管理 electrobun 依赖
- 移除 server 中的 @tauri-apps/api 依赖
- 更新 AGENTS.md 文档
2026-02-07 05:04:53 +08:00
9aa3b46ee5 chore(desktop): 更新 Cargo 依赖 2026-02-07 03:45:08 +08:00
f3ea0f0789 chore: 更新依赖版本 2026-02-07 03:32:42 +08:00
bde325d9ae chore: 更新 biome 和 turbo 依赖版本 2026-02-07 03:31:01 +08:00
e41c4e4515 docs: 更新 AGENTS.md 文档结构和内容
- 新增根目录 AGENTS.md 作为 monorepo 总览
- 移动 desktop AGENTS.md 从 src-tauri/ 到 apps/desktop/
- 修正 server AGENTS.md 目录结构 (src/server/api/ 而非 src/orpc/)
- 明确 desktop 为纯 Tauri 壳子,无前端代码,通过 sidecar 加载 server
2026-02-07 03:29:51 +08:00
117 changed files with 6414 additions and 7933 deletions

5
.gitignore vendored
View File

@@ -9,6 +9,11 @@
# Bun build
*.bun-build
# SQLite database files
*.db
*.db-wal
*.db-shm
# Turborepo
.turbo/

View File

@@ -2,11 +2,8 @@
"recommendations": [
"biomejs.biome",
"hverlin.mise-vscode",
"mikestead.dotenv",
"oven.bun-vscode",
"redhat.vscode-yaml",
"rust-lang.rust-analyzer",
"tamasfe.even-better-toml",
"tauri-apps.tauri-vscode"
"tamasfe.even-better-toml"
]
}

43
.vscode/settings.json vendored
View File

@@ -1,49 +1,42 @@
{
// Disable the default formatter & linter, use biome instead
"prettier.enable": false,
"eslint.enable": false,
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[yaml]": {
"editor.defaultFormatter": "redhat.vscode-yaml"
},
"[toml]": {
"editor.defaultFormatter": "tamasfe.even-better-toml"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[yaml]": {
"editor.defaultFormatter": "redhat.vscode-yaml"
},
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"files.associations": {
".env": "dotenv",
".env.*": "dotenv",
"**/tsconfig.json": "jsonc",
"**/tsconfig.*.json": "jsonc",
"**/biome.json": "jsonc",
"**/opencode.json": "jsonc"
"**/opencode.json": "jsonc",
"**/tsconfig.*.json": "jsonc",
"**/tsconfig.json": "jsonc"
},
// TanStack Router
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
},

219
AGENTS.md Normal file
View File

@@ -0,0 +1,219 @@
# AGENTS.md - AI Coding Agent Guidelines
Guidelines for AI agents working in this Bun monorepo.
## Project Overview
> **This project uses [Bun](https://bun.sh) exclusively as both the JavaScript runtime and package manager. Do NOT use Node.js / npm / yarn / pnpm. All commands start with `bun` — use `bun install` for dependencies and `bun run <script>` for scripts. Always prefer `bun run <script>` over `bun <script>` to avoid conflicts with Bun built-in subcommands (e.g. `bun build` invokes Bun's bundler, NOT your package.json script). Never use `npm`, `npx`, or `node`.**
- **Monorepo**: Bun workspaces + Turborepo orchestration
- **Runtime**: Bun (see `mise.toml` for version) — **NOT Node.js**
- **Package Manager**: Bun — **NOT npm / yarn / pnpm**
- **Apps**:
- `apps/server` - TanStack Start fullstack web app (see `apps/server/AGENTS.md`)
- `apps/desktop` - Electron desktop shell, sidecar server pattern (see `apps/desktop/AGENTS.md`)
- **Packages**: `packages/tsconfig` (shared TS configs)
## Build / Lint / Test Commands
### Root Commands (via Turbo)
```bash
bun run dev # Start all apps in dev mode
bun run build # Build all apps
bun run compile # Compile server to standalone binary (current platform)
bun run compile:darwin # Compile server for macOS (arm64 + x64)
bun run compile:linux # Compile server for Linux (x64 + arm64)
bun run compile:windows # Compile server for Windows x64
bun run dist # Package desktop distributable (current platform)
bun run dist:linux # Package desktop for Linux (x64 + arm64)
bun run dist:mac # Package desktop for macOS (arm64 + x64)
bun run dist:win # Package desktop for Windows x64
bun run fix # Lint + format (Biome auto-fix)
bun run typecheck # TypeScript check across monorepo
```
### Server App (`apps/server`)
```bash
bun run dev # Vite dev server (localhost:3000)
bun run build # Production build -> .output/
bun run compile # Compile to standalone binary (current platform)
bun run compile:darwin # Compile for macOS (arm64 + x64)
bun run compile:darwin:arm64 # Compile for macOS arm64
bun run compile:darwin:x64 # Compile for macOS x64
bun run compile:linux # Compile for Linux (x64 + arm64)
bun run compile:linux:arm64 # Compile for Linux arm64
bun run compile:linux:x64 # Compile for Linux x64
bun run compile:windows # Compile for Windows (default: x64)
bun run compile:windows:x64 # Compile for Windows x64
bun run fix # Biome auto-fix
bun run typecheck # TypeScript check
# Database (Drizzle)
bun run db:generate # Generate migrations from schema
bun run db:migrate # Run migrations
bun run db:push # Push schema (dev only)
bun run db:studio # Open Drizzle Studio
```
### Desktop App (`apps/desktop`)
```bash
bun run dev # electron-vite dev mode (requires server dev running)
bun run build # electron-vite build (main + preload)
bun run dist # Build + package for current platform
bun run dist:linux # Build + package for Linux (x64 + arm64)
bun run dist:linux:x64 # Build + package for Linux x64
bun run dist:linux:arm64 # Build + package for Linux arm64
bun run dist:mac # Build + package for macOS (arm64 + x64)
bun run dist:mac:arm64 # Build + package for macOS arm64
bun run dist:mac:x64 # Build + package for macOS x64
bun run dist:win # Build + package for Windows x64
bun run fix # Biome auto-fix
bun run typecheck # TypeScript check
```
### Testing
No test framework configured yet. When adding tests:
```bash
bun test path/to/test.ts # Run single test file
bun test -t "pattern" # Run tests matching pattern
```
## Code Style (TypeScript)
### Formatting (Biome)
- **Indent**: 2 spaces | **Line endings**: LF
- **Quotes**: Single `'` | **Semicolons**: Omit (ASI)
- **Arrow parentheses**: Always `(x) => x`
### Imports
Biome auto-organizes. Order: 1) External packages → 2) Internal `@/*` aliases → 3) Type imports (`import type { ... }`)
```typescript
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { db } from '@/server/db'
import type { ReactNode } from 'react'
```
### TypeScript Strictness
- `strict: true`, `noUncheckedIndexedAccess: true`, `noImplicitOverride: true`, `verbatimModuleSyntax: true`
- Use `@/*` path aliases (maps to `src/*`)
### Naming Conventions
| Type | Convention | Example |
|------|------------|---------|
| Files (utils) | kebab-case | `auth-utils.ts` |
| Files (components) | PascalCase | `UserProfile.tsx` |
| Components | PascalCase arrow | `const Button = () => {}` |
| Functions | camelCase | `getUserById` |
| Constants | UPPER_SNAKE | `MAX_RETRIES` |
| Types/Interfaces | PascalCase | `UserProfile` |
### React Patterns
- Components: arrow functions (enforced by Biome)
- Routes: TanStack Router file conventions (`export const Route = createFileRoute(...)`)
- Data fetching: `useSuspenseQuery(orpc.feature.list.queryOptions())`
- Let React Compiler handle memoization (no manual `useMemo`/`useCallback`)
### Error Handling
- Use `try-catch` for async operations; throw descriptive errors
- ORPC: Use `ORPCError` with proper codes (`NOT_FOUND`, `INPUT_VALIDATION_FAILED`)
- Never use empty catch blocks
## Database (Drizzle ORM v1 beta + postgres-js)
- **ORM**: Drizzle ORM `1.0.0-beta` (RQBv2)
- **Driver**: `drizzle-orm/postgres-js` (NOT `bun-sql`)
- **Validation**: `drizzle-orm/zod` (built-in, NOT separate `drizzle-zod` package)
- **Relations**: Defined via `defineRelations()` in `src/server/db/relations.ts` (contains schema info, so `drizzle()` only needs `{ relations }`)
- **Query style**: RQBv2 object syntax (`orderBy: { createdAt: 'desc' }`, `where: { id: 1 }`)
```typescript
export const myTable = pgTable('my_table', {
id: uuid().primaryKey().default(sql`uuidv7()`),
name: text().notNull(),
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow().$onUpdateFn(() => new Date()),
})
```
## Environment Variables
- Use `@t3-oss/env-core` with Zod validation in `src/env.ts`
- Server vars: no prefix | Client vars: `VITE_` prefix required
- Never commit `.env` files
## Dependency Management
- All versions centralized in root `package.json` `catalog` field
- Workspace packages use `"catalog:"` — never hardcode versions
- Internal packages use `"workspace:*"` references
## Development Principles
> **These principles apply to ALL code changes. Agents MUST follow them on every task.**
1. **No backward compatibility** — This project is in rapid iteration. Always use the latest API and patterns. Never keep deprecated code paths or old API fallbacks "just in case".
2. **Always sync documentation** — When code changes, immediately update all related documentation (`AGENTS.md`, `README.md`, inline code examples). Code and docs must never drift apart. This includes updating code snippets in docs when imports, APIs, or patterns change.
3. **Forward-only migration** — When upgrading dependencies, fully adopt the new API. Don't mix old and new patterns in the same codebase.
## Critical Rules
**DO:**
- Run `bun run fix` before committing
- Use `@/*` path aliases (not relative imports)
- Include `createdAt`/`updatedAt` on all tables
- Use `catalog:` for dependency versions
- Update `AGENTS.md` and other docs whenever code patterns change
**DON'T:**
- Use `npm`, `npx`, `node`, `yarn`, `pnpm` — always use `bun` / `bunx`
- Edit `src/routeTree.gen.ts` (auto-generated)
- Use `as any`, `@ts-ignore`, `@ts-expect-error`
- Commit `.env` files
- Use empty catch blocks `catch(e) {}`
- Hardcode dependency versions in workspace packages
- Leave docs out of sync with code changes
## Git Workflow
1. Make changes following style guide
2. `bun run fix` - auto-format and lint
3. `bun run typecheck` - verify types
4. `bun run dev` - test locally
5. Commit with descriptive message
## Directory Structure
```
.
├── apps/
│ ├── server/ # TanStack Start fullstack app
│ │ ├── src/
│ │ │ ├── client/ # ORPC client + TanStack Query utils
│ │ │ ├── components/
│ │ │ ├── routes/ # File-based routing
│ │ │ └── server/ # API layer + database
│ │ │ ├── api/ # ORPC contracts, routers, middlewares
│ │ │ └── db/ # Drizzle schema
│ │ └── AGENTS.md
│ └── desktop/ # Electron desktop shell
│ ├── src/
│ │ ├── main/
│ │ │ └── index.ts # Main process entry
│ │ └── preload/
│ │ └── index.ts # Preload script
│ ├── electron.vite.config.ts
│ ├── electron-builder.yml # Packaging config
│ └── AGENTS.md
├── packages/
│ └── tsconfig/ # Shared TS configs
├── biome.json # Linting/formatting config
├── turbo.json # Turbo task orchestration
└── package.json # Workspace root + dependency catalog
```
## See Also
- `apps/server/AGENTS.md` - Detailed TanStack Start / ORPC patterns
- `apps/desktop/AGENTS.md` - Electron desktop development guide

View File

@@ -1,24 +1,3 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# electron-vite build output
out/
dist/

95
apps/desktop/AGENTS.md Normal file
View File

@@ -0,0 +1,95 @@
# AGENTS.md - Desktop App Guidelines
Thin Electron shell hosting the fullstack server app.
## Tech Stack
> **⚠️ This project uses Bun as the package manager. Runtime is Electron (Node.js). Always use `bun run <script>` (not `bun <script>`) to avoid conflicts with Bun built-in subcommands. Never use `npm`, `npx`, `yarn`, or `pnpm`.**
- **Type**: Electron desktop shell
- **Design**: Server-driven desktop (thin native window hosting web app)
- **Runtime**: Electron (Main/Renderer) + Sidecar server binary (Bun-compiled)
- **Build Tool**: electron-vite (Vite-based, handles main + preload builds)
- **Packager**: electron-builder (installers, signing, auto-update)
- **Orchestration**: Turborepo
## Architecture
- **Server-driven design**: The desktop app is a "thin" native shell. It does not contain UI or business logic; it opens a BrowserWindow pointing to the `apps/server` TanStack Start application.
- **Dev mode**: Opens a BrowserWindow pointing to `localhost:3000`. Requires `apps/server` to be running separately (Turbo handles this).
- **Production mode**: Spawns a compiled server binary (from `resources/`) as a sidecar process, waits for readiness, then loads its URL.
## Commands
```bash
bun run dev # electron-vite dev (requires server dev running)
bun run build # electron-vite build (main + preload)
bun run dist # Build + package for current platform
bun run dist:linux # Build + package for Linux (x64 + arm64)
bun run dist:linux:x64 # Build + package for Linux x64
bun run dist:linux:arm64 # Build + package for Linux arm64
bun run dist:mac # Build + package for macOS (arm64 + x64)
bun run dist:mac:arm64 # Build + package for macOS arm64
bun run dist:mac:x64 # Build + package for macOS x64
bun run dist:win # Build + package for Windows x64
bun run fix # Biome auto-fix
bun run typecheck # TypeScript check
```
## Directory Structure
```
.
├── src/
│ ├── main/
│ │ └── index.ts # Main process (server lifecycle + BrowserWindow)
│ └── preload/
│ └── index.ts # Preload script (security isolation)
├── resources/ # Sidecar binaries (gitignored, copied from server build)
├── out/ # electron-vite build output (gitignored)
├── electron.vite.config.ts
├── electron-builder.yml # Packaging configuration
├── package.json
├── turbo.json
└── AGENTS.md
```
## Development Workflow
1. **Start server**: `bun run dev` in `apps/server` (or use root `bun run dev` via Turbo).
2. **Start desktop**: `bun run dev` in `apps/desktop`.
3. **Connection**: Main process polls `localhost:3000` until responsive, then opens BrowserWindow.
## Production Build Workflow
From monorepo root, run `bun run dist` to execute the full pipeline automatically (via Turbo task dependencies):
1. **Build server**: `apps/server``vite build``.output/`
2. **Compile server**: `apps/server``bun compile.ts --target ...``out/server-{os}-{arch}`
3. **Package desktop**: `apps/desktop``electron-vite build` + `electron-builder` → distributable
The `electron-builder.yml` `extraResources` config reads binaries directly from `../server/out/`, no manual copy needed.
To build for a specific platform explicitly, use `bun run dist:linux` / `bun run dist:mac` / `bun run dist:win` in `apps/desktop`.
For single-arch output, use `bun run dist:linux:x64`, `bun run dist:linux:arm64`, `bun run dist:mac:x64`, or `bun run dist:mac:arm64`.
## Development Principles
> **These principles apply to ALL code changes. Agents MUST follow them on every task.**
1. **No backward compatibility** — This project is in rapid iteration. Always use the latest API and patterns. Never keep deprecated code paths or old API fallbacks.
2. **Always sync documentation** — When code changes, immediately update all related documentation (`AGENTS.md`, `README.md`, inline code examples). Code and docs must never drift apart.
3. **Forward-only migration** — When upgrading dependencies, fully adopt the new API. Don't mix old and new patterns.
## Critical Rules
**DO:**
- Use arrow functions for all utility functions.
- Keep the desktop app as a thin shell — no UI or business logic.
- Use `catalog:` for all dependency versions in `package.json`.
**DON'T:**
- Use `npm`, `npx`, `yarn`, or `pnpm`. Use `bun` for package management.
- Include UI components or business logic in the desktop app.
- Use `as any` or `@ts-ignore`.
- Leave docs out of sync with code changes.

9
apps/desktop/biome.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "../../node_modules/@biomejs/biome/configuration_schema.json",
"extends": "//",
"css": {
"parser": {
"tailwindDirectives": true
}
}
}

BIN
apps/desktop/build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -1,470 +0,0 @@
import * as path from 'node:path'
import { Schema } from '@effect/schema'
import { $ } from 'bun'
import { Console, Context, Data, Effect, Layer } from 'effect'
// ============================================================================
// Domain Models & Schema
// ============================================================================
/**
* Bun 构建目标后缀
*/
const BunTargetSuffixSchema = Schema.Literal(
'windows-x64',
'darwin-arm64',
'darwin-x64',
'linux-x64',
'linux-arm64',
)
/**
* Tauri sidecar 目标三元组
*/
const TauriTargetSchema = Schema.Literal(
'x86_64-pc-windows-msvc',
'aarch64-apple-darwin',
'x86_64-apple-darwin',
'x86_64-unknown-linux-gnu',
'aarch64-unknown-linux-gnu',
)
/**
* 目标映射配置
*/
const TargetMappingSchema = Schema.Struct({
bunSuffix: BunTargetSuffixSchema,
tauriTarget: TauriTargetSchema,
})
type TargetMapping = Schema.Schema.Type<typeof TargetMappingSchema>
/**
* 复制配置
*/
const CopyConfigSchema = Schema.Struct({
sourceDir: Schema.String.pipe(Schema.nonEmptyString()),
targetDir: Schema.String.pipe(Schema.nonEmptyString()),
baseName: Schema.String.pipe(Schema.nonEmptyString()),
mappings: Schema.Array(TargetMappingSchema).pipe(Schema.minItems(1)),
})
type CopyConfig = Schema.Schema.Type<typeof CopyConfigSchema>
/**
* 复制结果
*/
const CopyResultSchema = Schema.Struct({
bunSuffix: BunTargetSuffixSchema,
tauriTarget: TauriTargetSchema,
sourceFile: Schema.String,
targetFile: Schema.String,
success: Schema.Boolean,
})
type CopyResult = Schema.Schema.Type<typeof CopyResultSchema>
// ============================================================================
// Error Models
// ============================================================================
class ConfigError extends Data.TaggedError('ConfigError')<{
readonly message: string
readonly cause: unknown
}> {}
class FileSystemError extends Data.TaggedError('FileSystemError')<{
readonly operation: string
readonly path: string
readonly cause: unknown
}> {}
class CopyError extends Data.TaggedError('CopyError')<{
readonly source: string
readonly target: string
readonly cause: unknown
}> {}
// ============================================================================
// Services
// ============================================================================
/**
* 配置服务
*/
class CopyConfigService extends Context.Tag('CopyConfigService')<
CopyConfigService,
CopyConfig
>() {
/**
* 从原始数据创建并验证配置
*/
static fromRaw = (raw: unknown) =>
Effect.gen(function* () {
const decoded = yield* Schema.decodeUnknown(CopyConfigSchema)(raw)
return decoded
}).pipe(
Effect.catchAll((error) =>
Effect.fail(
new ConfigError({
message: '配置验证失败',
cause: error,
}),
),
),
)
/**
* 默认配置 Layer
*/
static readonly Live = Layer.effect(
CopyConfigService,
CopyConfigService.fromRaw({
sourceDir: path.join(__dirname, '..', 'server', 'out'),
targetDir: path.join(__dirname, 'src-tauri', 'binaries'),
baseName: 'server',
mappings: [
{
bunSuffix: 'windows-x64',
tauriTarget: 'x86_64-pc-windows-msvc',
},
{
bunSuffix: 'darwin-arm64',
tauriTarget: 'aarch64-apple-darwin',
},
{
bunSuffix: 'darwin-x64',
tauriTarget: 'x86_64-apple-darwin',
},
{
bunSuffix: 'linux-x64',
tauriTarget: 'x86_64-unknown-linux-gnu',
},
{
bunSuffix: 'linux-arm64',
tauriTarget: 'aarch64-unknown-linux-gnu',
},
],
} satisfies CopyConfig),
)
}
/**
* 文件系统服务
*/
class FileSystemService extends Context.Tag('FileSystemService')<
FileSystemService,
{
readonly ensureDir: (dir: string) => Effect.Effect<void, FileSystemError>
readonly fileExists: (
filePath: string,
) => Effect.Effect<boolean, FileSystemError>
readonly dirExists: (
dirPath: string,
) => Effect.Effect<boolean, FileSystemError>
readonly copyFile: (
source: string,
target: string,
) => Effect.Effect<void, CopyError>
}
>() {
static readonly Live = Layer.succeed(FileSystemService, {
ensureDir: (dir: string) =>
Effect.tryPromise({
try: async () => {
await $`mkdir -p ${dir}`
},
catch: (cause: unknown) =>
new FileSystemError({
operation: 'ensureDir',
path: dir,
cause,
}),
}),
fileExists: (filePath: string) =>
Effect.tryPromise({
try: async () => {
const file = Bun.file(filePath)
return await file.exists()
},
catch: (cause: unknown) =>
new FileSystemError({
operation: 'fileExists',
path: filePath,
cause,
}),
}),
dirExists: (dirPath: string) =>
Effect.tryPromise({
try: async () => {
const { default: fs } = await import('node:fs/promises')
try {
const stat = await fs.stat(dirPath)
return stat.isDirectory()
} catch {
return false
}
},
catch: (cause: unknown) =>
new FileSystemError({
operation: 'dirExists',
path: dirPath,
cause,
}),
}),
copyFile: (source: string, target: string) =>
Effect.tryPromise({
try: async () => {
await $`cp ${source} ${target}`
},
catch: (cause: unknown) =>
new CopyError({
source,
target,
cause,
}),
}),
})
}
/**
* 复制服务
*/
class CopyService extends Context.Tag('CopyService')<
CopyService,
{
readonly copyBinary: (
config: CopyConfig,
mapping: TargetMapping,
) => Effect.Effect<CopyResult, CopyError | FileSystemError>
readonly copyAllBinaries: (
config: CopyConfig,
) => Effect.Effect<ReadonlyArray<CopyResult>, CopyError | FileSystemError>
}
>() {
static readonly Live = Layer.effect(
CopyService,
Effect.gen(function* () {
const fs = yield* FileSystemService
return {
copyBinary: (config: CopyConfig, mapping: TargetMapping) =>
Effect.gen(function* () {
const { sourceDir, targetDir, baseName } = config
const { bunSuffix, tauriTarget } = mapping
// 确定文件扩展名Windows 需要 .exe
const ext = tauriTarget.includes('windows') ? '.exe' : ''
// 构建源文件和目标文件路径
const sourceFile = path.join(
sourceDir,
`${baseName}-${bunSuffix}${ext}`,
)
const targetFile = path.join(
targetDir,
`${baseName}-${tauriTarget}${ext}`,
)
// 检查源文件是否存在
const exists = yield* fs.fileExists(sourceFile)
if (!exists) {
yield* Console.log(`⚠️ 跳过 ${bunSuffix}: 源文件不存在`)
return {
bunSuffix,
tauriTarget,
sourceFile,
targetFile,
success: false,
} satisfies CopyResult
}
// 复制文件
yield* fs.copyFile(sourceFile, targetFile)
yield* Console.log(`${bunSuffix}${tauriTarget}`)
yield* Console.log(` ${sourceFile}`)
yield* Console.log(`${targetFile}\n`)
return {
bunSuffix,
tauriTarget,
sourceFile,
targetFile,
success: true,
} satisfies CopyResult
}),
copyAllBinaries: (config: CopyConfig) =>
Effect.gen(function* () {
const effects = config.mappings.map((mapping) =>
Effect.gen(function* () {
const { sourceDir, targetDir, baseName } = config
const { bunSuffix, tauriTarget } = mapping
const ext = tauriTarget.includes('windows') ? '.exe' : ''
const sourceFile = path.join(
sourceDir,
`${baseName}-${bunSuffix}${ext}`,
)
const targetFile = path.join(
targetDir,
`${baseName}-${tauriTarget}${ext}`,
)
const exists = yield* fs.fileExists(sourceFile)
if (!exists) {
yield* Console.log(`⚠️ 跳过 ${bunSuffix}: 源文件不存在`)
return {
bunSuffix,
tauriTarget,
sourceFile,
targetFile,
success: false,
} satisfies CopyResult
}
yield* fs.copyFile(sourceFile, targetFile)
yield* Console.log(`${bunSuffix}${tauriTarget}`)
yield* Console.log(` ${sourceFile}`)
yield* Console.log(`${targetFile}\n`)
return {
bunSuffix,
tauriTarget,
sourceFile,
targetFile,
success: true,
} satisfies CopyResult
}),
)
return yield* Effect.all(effects, { concurrency: 'unbounded' })
}),
}
}),
)
}
/**
* 报告服务
*/
class ReporterService extends Context.Tag('ReporterService')<
ReporterService,
{
readonly printSummary: (
results: ReadonlyArray<CopyResult>,
) => Effect.Effect<void>
}
>() {
static readonly Live = Layer.succeed(ReporterService, {
printSummary: (results: ReadonlyArray<CopyResult>) =>
Effect.gen(function* () {
const successful = results.filter((r) => r.success)
const failed = results.filter((r) => !r.success)
yield* Console.log('\n📦 复制摘要:')
yield* Console.log(` ✅ 成功: ${successful.length}`)
yield* Console.log(` ⚠️ 跳过: ${failed.length}`)
if (successful.length > 0) {
yield* Console.log('\n成功复制的文件:')
for (const result of successful) {
yield* Console.log(
`${result.bunSuffix}${result.tauriTarget}`,
)
}
}
if (failed.length > 0) {
yield* Console.log('\n跳过的文件:')
for (const result of failed) {
yield* Console.log(`${result.bunSuffix} (源文件不存在)`)
}
}
}),
})
}
// ============================================================================
// Main Program
// ============================================================================
const program = Effect.gen(function* () {
const config = yield* CopyConfigService
const fs = yield* FileSystemService
const copier = yield* CopyService
const reporter = yield* ReporterService
yield* Console.log('📦 开始复制二进制文件到 Tauri sidecar 目录...\n')
// 1. 检查源目录
const sourceExists = yield* fs.dirExists(config.sourceDir)
if (!sourceExists) {
yield* Console.error(`❌ 源目录不存在: ${config.sourceDir}`)
yield* Console.log(
'💡 提示: 请先在 apps/server 中运行 bun run compile 构建服务器二进制文件',
)
return yield* Effect.fail(
new FileSystemError({
operation: 'checkSourceDir',
path: config.sourceDir,
cause: '源目录不存在',
}),
)
}
// 2. 创建目标目录
yield* fs.ensureDir(config.targetDir)
yield* Console.log(`✓ 目标目录: ${config.targetDir}\n`)
// 3. 并行复制所有二进制文件
const results = yield* copier.copyAllBinaries(config)
// 4. 输出摘要
yield* reporter.printSummary(results)
return results
})
// ============================================================================
// Layer Composition
// ============================================================================
const MainLayer = Layer.mergeAll(
CopyConfigService.Live,
FileSystemService.Live,
CopyService.Live.pipe(Layer.provide(FileSystemService.Live)),
ReporterService.Live,
)
// ============================================================================
// Runner
// ============================================================================
const runnable = program.pipe(
Effect.provide(MainLayer),
Effect.catchTags({
ConfigError: (error) =>
Console.error(`❌ 配置错误: ${error.message}`, error.cause),
FileSystemError: (error) =>
Console.error(
`❌ 文件系统错误 [${error.operation}]: ${error.path}`,
error.cause,
),
CopyError: (error) =>
Console.error(
`❌ 复制失败: ${error.source}${error.target}`,
error.cause,
),
}),
Effect.tapErrorCause((cause) => Console.error('❌ 未预期的错误:', cause)),
)
Effect.runPromise(runnable).catch(() => {
process.exit(1)
})

View File

@@ -0,0 +1,48 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/electron-userland/electron-builder/refs/heads/master/packages/app-builder-lib/scheme.json
appId: com.furtherverse.desktop
productName: Furtherverse
executableName: furtherverse
npmRebuild: false
asarUnpack:
- resources/**
files:
- "!**/.vscode/*"
- "!src/*"
- "!electron.vite.config.{js,ts,mjs,cjs}"
- "!{.env,.env.*,bun.lock}"
- "!{tsconfig.json,tsconfig.node.json}"
- "!{AGENTS.md,README.md,CHANGELOG.md}"
# macOS
mac:
target:
- dmg
category: public.app-category.productivity
extraResources:
- from: ../server/out/server-darwin-${arch}
to: server
dmg:
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
# Windows
win:
target:
- portable
extraResources:
- from: ../server/out/server-windows-${arch}.exe
to: server.exe
portable:
artifactName: ${productName}-${version}-${os}-${arch}-Portable.${ext}
# Linux
linux:
target:
- AppImage
category: Utility
extraResources:
- from: ../server/out/server-linux-${arch}
to: server
appImage:
artifactName: ${productName}-${version}-${os}-${arch}.${ext}

View File

@@ -0,0 +1,11 @@
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'electron-vite'
export default defineConfig({
main: {},
preload: {},
renderer: {
plugins: [react(), tailwindcss()],
},
})

View File

@@ -2,18 +2,36 @@
"name": "@furtherverse/desktop",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "out/main/index.js",
"scripts": {
"build": "bun run copy && tauri build",
"copy": "rm -rf binaries && bun --bun copy.ts",
"dev": "bun run copy && tauri dev"
"build": "electron-vite build",
"dev": "electron-vite dev --watch",
"dist": "electron-builder",
"dist:linux": "bun run dist:linux:x64 && bun run dist:linux:arm64",
"dist:linux:arm64": "electron-builder --linux --arm64",
"dist:linux:x64": "electron-builder --linux --x64",
"dist:mac": "bun run dist:mac:arm64 && bun run dist:mac:x64",
"dist:mac:arm64": "electron-builder --mac --arm64",
"dist:mac:x64": "electron-builder --mac --x64",
"dist:win": "electron-builder --win --x64",
"fix": "biome check --write",
"typecheck": "tsc -b"
},
"dependencies": {
"motion": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"tree-kill": "catalog:"
},
"devDependencies": {
"@effect/schema": "catalog:",
"@furtherverse/tsconfig": "workspace:*",
"@tauri-apps/cli": "catalog:",
"@types/bun": "catalog:",
"effect": "catalog:",
"typescript": "catalog:"
"@tailwindcss/vite": "catalog:",
"@types/node": "catalog:",
"@vitejs/plugin-react": "catalog:",
"electron": "catalog:",
"electron-builder": "catalog:",
"electron-vite": "catalog:",
"tailwindcss": "catalog:",
"vite": "catalog:"
}
}

View File

View File

@@ -1,10 +0,0 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
# Tauri Sidecar
binaries/

View File

@@ -1,357 +0,0 @@
# AGENTS.md - Tauri Shell 项目开发指南
本文档为 AI 编程助手和开发者提供项目规范、构建命令和代码风格指南。
## 项目概览
- **项目类型**: Tauri v2 桌面应用(轻量级壳子)
- **后端**: Rust (Edition 2021)
- **架构**: Sidecar 模式 - Sidecar App 承载主要业务逻辑
- **设计理念**: Tauri 仅提供原生桌面能力文件对话框、系统通知等Web 逻辑全部由 Sidecar App 处理
- **开发模式**: 使用 localhost:3000需手动启动开发服务器
- **生产模式**: 自动启动 Sidecar 二进制
- **异步运行时**: Tokio
- **Rust 版本**: 1.92.0+
- **工具管理**: 使用 mise 管理 Rust 和 Tauri CLI 版本(见 `mise.toml`
## 构建、测试、运行命令
### 开发运行
```bash
# 开发模式运行 (需要先启动开发服务器)
# 终端 1: 启动前端开发服务器
bun run dev
# 终端 2: 启动 Tauri 应用
tauri dev
# 或者使用单命令并行启动(需要配置 package.json
bun run dev:tauri
```
**开发模式说明**
- 开发模式下Tauri 直接连接到 `localhost:3000`(不启动 sidecar 二进制)
- 需要手动运行 `bun run dev` 来启动开发服务器
- 支持热重载HMR无需重启 Tauri 应用
### 构建
```bash
# 开发构建 (debug mode)
cargo build
# 生产构建
cargo build --release
# Tauri 应用打包 (生成安装程序)
tauri build
```
### 代码检查
```bash
# 编译检查 (不生成二进制)
cargo check
# Clippy 代码质量检查
cargo clippy
# Clippy 严格模式 (所有警告视为错误)
cargo clippy -- -D warnings
# 代码格式化检查
cargo fmt -- --check
# 自动格式化代码
cargo fmt
```
### 测试
```bash
# 运行所有测试
cargo test
# 运行单个测试 (按名称过滤)
cargo test test_function_name
# 运行特定模块的测试
cargo test module_name::
# 显示测试输出 (包括 println!)
cargo test -- --nocapture
# 运行单个测试并显示输出
cargo test test_name -- --nocapture
```
### 清理
```bash
# 清理构建产物
cargo clean
```
## 项目结构
```
server-desktop/
├── src/
│ ├── main.rs # 入口文件 (仅调用 lib::run)
│ ├── lib.rs # 核心应用逻辑 (注册插件、命令、状态)
│ ├── commands/
│ │ └── mod.rs # 原生桌面功能命令 (文件对话框、通知等)
│ └── sidecar.rs # Sidecar 进程管理 (启动、端口扫描、清理)
├── binaries/ # Sidecar 二进制文件
│ └── app-* # Sidecar App 可执行文件 (示例: app)
├── capabilities/ # Tauri v2 权限配置
│ └── default.json
├── icons/ # 应用图标资源
├── gen/schemas/ # 自动生成的 Schema (不要手动编辑)
├── Cargo.toml # Rust 项目配置
├── tauri.conf.json # Tauri 应用配置
├── build.rs # Rust 构建脚本
└── mise.toml # 开发工具版本管理
```
## Rust 代码风格指南
### 导入 (Imports)
- 使用标准库、外部 crate、当前 crate 的顺序,用空行分隔
- 按字母顺序排列
- 优先使用具体导入而非通配符 `*`
```rust
// ✅ 推荐
use std::sync::Mutex;
use std::time::Duration;
use tauri::Manager;
use tauri_plugin_shell::ShellExt;
use tauri_plugin_shell::process::{CommandEvent, CommandChild};
// ❌ 避免
use tauri::*;
```
### 命名规范
- **函数和变量**: `snake_case`
- **类型、结构体、枚举、Trait**: `PascalCase`
- **常量和静态变量**: `SCREAMING_SNAKE_CASE`
- **生命周期参数**: 简短小写字母,如 `'a`, `'b`
```rust
// ✅ 推荐
struct SidecarProcess(Mutex<Option<CommandChild>>);
const DEFAULT_PORT: u16 = 3000;
async fn find_available_port(start: u16) -> u16 { }
// ❌ 避免
struct sidecar_process { }
const defaultPort: u16 = 3000;
```
### 类型注解
- 函数参数必须有类型注解
- 函数返回值必须明确声明 (除非返回 `()`)
- 优先使用具体类型而非 `impl Trait` (除非必要)
- 使用 `&str` 而非 `String` 作为只读字符串参数
```rust
// ✅ 推荐
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
async fn is_port_available(port: u16) -> bool {
tokio::net::TcpListener::bind(format!("127.0.0.1:{}", port))
.await
.is_ok()
}
```
### 错误处理
- 使用 `Result<T, E>` 返回可能失败的操作
- 使用 `expect()` 时提供有意义的错误消息 (中文)
- 避免 `unwrap()` 在生产代码中,除非逻辑上保证不会 panic
- 使用 `?` 操作符传播错误
- 记录关键错误信息到控制台
```rust
// ✅ 推荐
let sidecar = app_handle
.shell()
.sidecar("app")
.expect("无法找到 app sidecar");
let (mut rx, child) = sidecar.spawn().expect("启动 sidecar 失败");
// 日志记录
eprintln!("✗ Sidecar App 启动失败");
println!("✓ Sidecar App 启动成功!");
// ❌ 避免
let data = read_file().unwrap(); // 无上下文信息
```
### 异步代码
- 使用 `async/await` 而非手动创建 Future
- Tauri 内部使用 `tauri::async_runtime::spawn` 启动异步任务
- 使用 Tokio 的异步 API (如 `tokio::net::TcpListener`)
- 避免阻塞异步运行时 (使用 `tokio::task::spawn_blocking`)
```rust
// ✅ 推荐
tauri::async_runtime::spawn(async move {
let port = find_available_port(3000).await;
// ...
});
```
### 格式化
- 使用 `cargo fmt` 自动格式化
- 缩进: 4 空格
- 行宽: 100 字符 (rustfmt 默认)
- 结构体和枚举的字段每行一个 (如果超过一定长度)
- 链式调用适当换行提高可读性
### 注释
- 使用中文注释说明复杂逻辑
- 代码块前添加简短说明注释
- 避免显而易见的注释
```rust
// ✅ 推荐
// 全局状态:存储 Sidecar App 进程句柄
struct SidecarProcess(Mutex<Option<CommandChild>>);
// 检查端口是否可用
async fn is_port_available(port: u16) -> bool { }
```
## Tauri 特定规范
### 模块组织
- **`lib.rs`**: 主入口,负责注册插件、命令、状态管理
- **`commands/mod.rs`**: 所有 Tauri 命令集中定义,命令必须是 `pub fn`
- **`sidecar.rs`**: Sidecar 进程管理逻辑,导出公共 API`spawn_sidecar`, `cleanup_sidecar_process`
```rust
// lib.rs - 模块声明
mod commands;
mod sidecar;
use sidecar::SidecarProcess;
// 注册命令时使用模块路径
.invoke_handler(tauri::generate_handler![commands::greet])
```
### 命令定义
- 使用 `#[tauri::command]` 宏标记命令
- 命令函数必须是公开的或在 `invoke_handler` 中注册
- 参数类型必须实现 `serde::Deserialize`
- 返回类型必须实现 `serde::Serialize`
```rust
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
// 在 Builder 中注册
.invoke_handler(tauri::generate_handler![greet])
```
### 状态管理
- 使用 `app.manage()` 注册全局状态
- 状态必须实现 `Send + Sync`
- 使用 `Mutex``RwLock` 保证线程安全
```rust
struct SidecarProcess(Mutex<Option<CommandChild>>);
// 注册状态
app.manage(SidecarProcess(Mutex::new(None)));
// 访问状态
if let Some(state) = app_handle.try_state::<SidecarProcess>() {
*state.0.lock().unwrap() = Some(child);
}
```
### Sidecar 进程管理
- Sidecar 二进制必须在 `tauri.conf.json``bundle.externalBin` 中声明
- 使用 `app.shell().sidecar()` 启动 sidecar
- 在应用退出时清理子进程 (监听 `RunEvent::ExitRequested`)
```rust
// 启动 sidecar
let sidecar = app_handle
.shell()
.sidecar("app")
.expect("无法找到 app sidecar")
.env("PORT", port.to_string());
// 清理进程
match event {
tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => {
if let Some(child) = process.take() {
let _ = child.kill();
}
}
_ => {}
}
```
## 依赖管理
-`Cargo.toml` 中明确声明依赖版本
- 使用语义化版本 (如 `"2"` 表示兼容 2.x.x)
- 仅启用需要的 feature 以减少编译时间和二进制大小
```toml
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["net"] }
```
## 开发工具
推荐安装以下 VSCode 扩展:
- `tauri-apps.tauri-vscode` - Tauri 官方支持
- `rust-lang.rust-analyzer` - Rust 语言服务器
## 最佳实践
1. **开发环境配置**:
- 开发模式下需先启动前端开发服务器(`bun run dev`),再启动 Tauri`tauri dev`
- 生产构建自动打包 sidecar 二进制,无需额外配置
2. **进程生命周期**: 始终在应用退出时清理子进程和资源
3. **端口管理**:
- 开发模式固定使用 3000 端口(与开发服务器匹配)
- 生产模式使用端口扫描避免硬编码端口冲突
4. **超时处理**: 异步操作设置合理的超时时间 (如 5 秒)
5. **日志**: 使用表情符号 (✓/✗/🔧/🚀) 和中文消息提供清晰的状态反馈
6. **错误退出**: 关键错误时调用 `std::process::exit(1)`
7. **窗口配置**: 使用 `WebviewWindowBuilder` 动态创建窗口
## 提交代码前检查清单
- [ ] `cargo fmt` 格式化通过
- [ ] `cargo clippy` 无警告
- [ ] `cargo check` 编译通过
- [ ] `cargo test` 测试通过
- [ ] 更新相关注释和文档
- [ ] 检查是否有 `unwrap()` 需要替换为 `expect()`
- [ ] 验证 Tauri 应用正常启动和退出

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +0,0 @@
[package]
name = "server-desktop"
version = "0.1.0"
description = "A Tauri App"
authors = ["imbytecat"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "server_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["net"] }

View File

@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}

View File

@@ -1,27 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"local": true,
"remote": {
"urls": [
"http://localhost:*",
"http://127.0.0.1:*",
"http{s}?://localhost(:\\d+)?/*"
]
},
"permissions": [
"core:default",
"core:window:allow-set-title",
{
"identifier": "shell:allow-execute",
"allow": [
{
"name": "binaries/app",
"sidecar": true
}
]
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,8 +0,0 @@
// 原生桌面功能命令
// 未来可能包含: 文件对话框、系统通知、剪贴板等
// 示例命令 (可根据需要删除或替换)
#[tauri::command]
pub fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}

View File

@@ -1,33 +0,0 @@
use tauri::Manager;
// 模块声明
mod commands;
mod sidecar;
use sidecar::SidecarProcess;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.setup(|app| {
// 注册全局状态
app.manage(SidecarProcess(std::sync::Mutex::new(None)));
// 启动 Sidecar 进程
let app_handle = app.handle().clone();
sidecar::spawn_sidecar(app_handle);
Ok(())
})
.invoke_handler(tauri::generate_handler![commands::greet])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
// 监听应用退出事件,清理 Sidecar 进程
if let tauri::RunEvent::Exit = event {
// 只在 Exit 事件时清理,避免重复执行
sidecar::cleanup_sidecar_process(app_handle);
}
});
}

View File

@@ -1,6 +0,0 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
server_desktop_lib::run()
}

View File

@@ -1,166 +0,0 @@
use std::sync::Mutex;
use std::time::Duration;
use tauri::Manager;
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;
// ===== 配置常量 =====
/// Sidecar App 启动超时时间(秒)
const STARTUP_TIMEOUT_SECS: u64 = 5;
/// 默认起始端口
const DEFAULT_PORT: u16 = 3000;
/// 端口扫描范围(从起始端口开始扫描的端口数量)
const PORT_SCAN_RANGE: u16 = 100;
/// 窗口默认宽度
const DEFAULT_WINDOW_WIDTH: f64 = 1200.0;
/// 窗口默认高度
const DEFAULT_WINDOW_HEIGHT: f64 = 800.0;
/// 窗口标题
const WINDOW_TITLE: &str = "Tauri App";
// ===== 数据结构 =====
/// 全局状态:存储 Sidecar 进程句柄
pub struct SidecarProcess(pub Mutex<Option<CommandChild>>);
// 检查端口是否可用(未被占用)
async fn is_port_available(port: u16) -> bool {
tokio::net::TcpListener::bind(format!("127.0.0.1:{}", port))
.await
.is_ok()
}
// 查找可用端口
async fn find_available_port(start: u16) -> u16 {
for port in start..start + PORT_SCAN_RANGE {
if is_port_available(port).await {
return port;
}
}
start // 回退到起始端口
}
/// 启动 Sidecar 进程并创建主窗口
pub fn spawn_sidecar(app_handle: tauri::AppHandle) {
// 检测是否为开发模式
let is_dev = cfg!(debug_assertions);
if is_dev {
// 开发模式:直接创建窗口连接到 Vite 开发服务器
println!("🔧 开发模式");
match tauri::WebviewWindowBuilder::new(
&app_handle,
"main",
tauri::WebviewUrl::External("http://localhost:3000".parse().unwrap()),
)
.title(WINDOW_TITLE)
.inner_size(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT)
.center()
.build()
{
Ok(_) => println!("✓ 开发窗口创建成功"),
Err(e) => {
eprintln!("✗ 窗口创建失败: {}", e);
}
}
return;
}
// 生产模式:启动 sidecar 二进制
tauri::async_runtime::spawn(async move {
println!("🚀 生产模式");
// 查找可用端口
let port = find_available_port(DEFAULT_PORT).await;
println!("使用端口: {}", port);
// 启动 sidecar
let sidecar = app_handle
.shell()
.sidecar("server")
.expect("无法找到 app")
.env("PORT", port.to_string());
let (mut rx, child) = sidecar.spawn().expect("启动 sidecar 失败");
// 保存进程句柄到全局状态
if let Some(state) = app_handle.try_state::<SidecarProcess>() {
*state.0.lock().unwrap() = Some(child);
}
// 监听 stdout等待服务器就绪信号
let start_time = std::time::Instant::now();
let timeout = Duration::from_secs(STARTUP_TIMEOUT_SECS);
let mut app_ready = false;
while let Some(event) = rx.recv().await {
if let CommandEvent::Stdout(line) = event {
let output = String::from_utf8_lossy(&line);
println!("App: {}", output);
// 检测 App 启动成功的标志
if output.contains("Listening on:") || output.contains("localhost") {
app_ready = true;
println!("✓ App 启动成功!");
// 创建主窗口
let url = format!("http://localhost:{}", port);
tauri::WebviewWindowBuilder::new(
&app_handle,
"main",
tauri::WebviewUrl::External(url.parse().unwrap()),
)
.title(WINDOW_TITLE)
.inner_size(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT)
.center()
.build()
.expect("创建窗口失败");
break;
}
}
// 超时检查
if start_time.elapsed() > timeout {
eprintln!("✗ 启动超时: App 未能在 {} 秒内启动", STARTUP_TIMEOUT_SECS);
break;
}
}
if !app_ready {
eprintln!("✗ App 启动失败");
std::process::exit(1);
}
});
}
/// 清理 Sidecar 进程 (在应用退出时调用)
pub fn cleanup_sidecar_process(app_handle: &tauri::AppHandle) {
let is_dev = cfg!(debug_assertions);
if is_dev {
// 开发模式退出时发送异常信号exit 1让 Turbo 停止 Vite 服务器
println!("🔧 开发模式退出,终止所有依赖任务...");
std::process::exit(1);
}
// 生产模式:正常清理 sidecar 进程
println!("应用退出,正在清理 Sidecar 进程...");
if let Some(state) = app_handle.try_state::<SidecarProcess>() {
if let Ok(mut process) = state.0.lock() {
if let Some(child) = process.take() {
let _ = child.kill();
println!("✓ Sidecar 进程已终止");
}
}
}
}

View File

@@ -1,25 +0,0 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "server-desktop",
"version": "0.1.0",
"identifier": "com.imbytecat.server-desktop",
"app": {
"withGlobalTauri": true,
"windows": [],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"externalBin": ["binaries/server"]
}
}

View File

@@ -0,0 +1,198 @@
import { join } from 'node:path'
import { app, BrowserWindow, dialog, session, shell } from 'electron'
import { createSidecarRuntime } from './sidecar'
const DEV_SERVER_URL = 'http://localhost:3000'
const SAFE_EXTERNAL_PROTOCOLS = new Set(['https:', 'http:', 'mailto:'])
let mainWindow: BrowserWindow | null = null
let windowCreationPromise: Promise<void> | null = null
let isQuitting = false
const showErrorAndQuit = (title: string, detail: string) => {
if (isQuitting) {
return
}
dialog.showErrorBox(title, detail)
app.quit()
}
const sidecar = createSidecarRuntime({
devServerUrl: DEV_SERVER_URL,
isPackaged: app.isPackaged,
resourcesPath: process.resourcesPath,
isQuitting: () => isQuitting,
onUnexpectedStop: (detail) => {
showErrorAndQuit('Service Stopped', detail)
},
})
const toErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
const canOpenExternally = (url: string): boolean => {
try {
const parsed = new URL(url)
return SAFE_EXTERNAL_PROTOCOLS.has(parsed.protocol)
} catch {
return false
}
}
const loadSplash = async (windowRef: BrowserWindow) => {
if (process.env.ELECTRON_RENDERER_URL) {
await windowRef.loadURL(process.env.ELECTRON_RENDERER_URL)
return
}
await windowRef.loadFile(join(__dirname, '../renderer/index.html'))
}
const createWindow = async () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.focus()
return
}
const windowRef = new BrowserWindow({
width: 1200,
height: 800,
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
},
})
mainWindow = windowRef
windowRef.webContents.setWindowOpenHandler(({ url }) => {
if (!canOpenExternally(url)) {
if (!app.isPackaged) {
console.warn(`Blocked external URL: ${url}`)
}
return { action: 'deny' }
}
void shell.openExternal(url)
return { action: 'deny' }
})
windowRef.webContents.on('will-navigate', (event, url) => {
const allowed = [DEV_SERVER_URL, sidecar.lastResolvedUrl].filter((v): v is string => v != null)
const isAllowed = allowed.some((origin) => url.startsWith(origin))
if (!isAllowed) {
event.preventDefault()
if (canOpenExternally(url)) {
void shell.openExternal(url)
} else if (!app.isPackaged) {
console.warn(`Blocked navigation to: ${url}`)
}
}
})
windowRef.on('closed', () => {
if (mainWindow === windowRef) {
mainWindow = null
}
})
try {
await loadSplash(windowRef)
} catch (error) {
if (mainWindow === windowRef) {
mainWindow = null
}
if (!windowRef.isDestroyed()) {
windowRef.destroy()
}
throw error
}
if (!windowRef.isDestroyed()) {
windowRef.show()
}
const targetUrl = await sidecar.resolveUrl()
if (isQuitting || windowRef.isDestroyed()) {
return
}
try {
await windowRef.loadURL(targetUrl)
} catch (error) {
if (mainWindow === windowRef) {
mainWindow = null
}
if (!windowRef.isDestroyed()) {
windowRef.destroy()
}
throw error
}
}
const ensureWindow = async () => {
if (windowCreationPromise) {
return windowCreationPromise
}
windowCreationPromise = createWindow().finally(() => {
windowCreationPromise = null
})
return windowCreationPromise
}
const beginQuit = () => {
isQuitting = true
sidecar.stop()
}
const handleWindowCreationError = (error: unknown, context: string) => {
console.error(`${context}:`, error)
showErrorAndQuit(
"App Couldn't Start",
app.isPackaged
? 'A required component failed to start. Please reinstall the app.'
: `${context}: ${toErrorMessage(error)}`,
)
}
app
.whenReady()
.then(() => {
session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => {
callback(false)
})
return ensureWindow()
})
.catch((error) => {
handleWindowCreationError(error, 'Failed to create window')
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
if (isQuitting || BrowserWindow.getAllWindows().length > 0) {
return
}
ensureWindow().catch((error) => {
handleWindowCreationError(error, 'Failed to re-create window')
})
})
app.on('before-quit', beginQuit)

View File

@@ -0,0 +1,256 @@
import { type ChildProcess, spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { createServer } from 'node:net'
import { join } from 'node:path'
import killProcessTree from 'tree-kill'
const SERVER_HOST = '127.0.0.1'
const SERVER_READY_TIMEOUT_MS = 10_000
const SERVER_REQUEST_TIMEOUT_MS = 1_500
const SERVER_POLL_INTERVAL_MS = 250
const SERVER_PROBE_PATHS = ['/api/health', '/']
type SidecarState = {
process: ChildProcess | null
startup: Promise<string> | null
url: string | null
}
type SidecarRuntimeOptions = {
devServerUrl: string
isPackaged: boolean
resourcesPath: string
isQuitting: () => boolean
onUnexpectedStop: (detail: string) => void
}
type SidecarRuntime = {
resolveUrl: () => Promise<string>
stop: () => void
lastResolvedUrl: string | null
}
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
const isProcessAlive = (processToCheck: ChildProcess | null): processToCheck is ChildProcess => {
if (!processToCheck || !processToCheck.pid) {
return false
}
return processToCheck.exitCode === null && !processToCheck.killed
}
const getAvailablePort = (): Promise<number> =>
new Promise((resolve, reject) => {
const server = createServer()
server.listen(0, () => {
const addr = server.address()
if (!addr || typeof addr === 'string') {
server.close()
reject(new Error('Failed to resolve port'))
return
}
server.close(() => resolve(addr.port))
})
server.on('error', reject)
})
const isServerReady = async (url: string): Promise<boolean> => {
for (const probePath of SERVER_PROBE_PATHS) {
try {
const probeUrl = new URL(probePath, `${url}/`)
const response = await fetch(probeUrl, {
method: 'GET',
cache: 'no-store',
signal: AbortSignal.timeout(SERVER_REQUEST_TIMEOUT_MS),
})
if (response.status < 500) {
if (probePath === '/api/health' && response.status === 404) {
continue
}
return true
}
} catch {
// Expected: probe request fails while server is still starting up
}
}
return false
}
const waitForServer = async (url: string, isQuitting: () => boolean, processRef?: ChildProcess): Promise<boolean> => {
const start = Date.now()
while (Date.now() - start < SERVER_READY_TIMEOUT_MS && !isQuitting()) {
if (processRef && processRef.exitCode !== null) {
return false
}
if (await isServerReady(url)) {
return true
}
await sleep(SERVER_POLL_INTERVAL_MS)
}
return false
}
const resolveBinaryPath = (resourcesPath: string): string => {
const binaryName = process.platform === 'win32' ? 'server.exe' : 'server'
return join(resourcesPath, binaryName)
}
const formatUnexpectedStopMessage = (
isPackaged: boolean,
code: number | null,
signal: NodeJS.Signals | null,
): string => {
if (isPackaged) {
return 'The background service stopped unexpectedly. Please restart the app.'
}
return `Server process exited unexpectedly (code ${code ?? 'unknown'}, signal ${signal ?? 'none'}).`
}
export const createSidecarRuntime = (options: SidecarRuntimeOptions): SidecarRuntime => {
const state: SidecarState = {
process: null,
startup: null,
url: null,
}
const resetState = (processRef?: ChildProcess) => {
if (processRef && state.process !== processRef) {
return
}
state.process = null
state.url = null
}
const stop = () => {
const runningServer = state.process
resetState()
if (!runningServer?.pid || runningServer.exitCode !== null) {
return
}
killProcessTree(runningServer.pid, 'SIGTERM', (error?: Error) => {
if (error) {
console.error('Failed to stop server process:', error)
}
})
}
const attachLifecycleHandlers = (processRef: ChildProcess) => {
processRef.on('error', (error) => {
if (state.process !== processRef) {
return
}
const hadReadyServer = state.url !== null
resetState(processRef)
if (!options.isQuitting() && hadReadyServer) {
options.onUnexpectedStop('The background service crashed unexpectedly. Please restart the app.')
return
}
console.error('Failed to start server process:', error)
})
processRef.on('exit', (code, signal) => {
if (state.process !== processRef) {
return
}
const hadReadyServer = state.url !== null
resetState(processRef)
if (!options.isQuitting() && hadReadyServer) {
options.onUnexpectedStop(formatUnexpectedStopMessage(options.isPackaged, code, signal))
}
})
}
const startPackagedServer = async (): Promise<string> => {
if (state.url && isProcessAlive(state.process)) {
return state.url
}
if (state.startup) {
return state.startup
}
state.startup = (async () => {
const binaryPath = resolveBinaryPath(options.resourcesPath)
if (!existsSync(binaryPath)) {
throw new Error(`Sidecar server binary is missing: ${binaryPath}`)
}
if (options.isQuitting()) {
throw new Error('Application is shutting down.')
}
const port = await getAvailablePort()
const nextServerUrl = `http://${SERVER_HOST}:${port}`
const processRef = spawn(binaryPath, [], {
env: {
...process.env,
HOST: SERVER_HOST,
PORT: String(port),
},
stdio: 'ignore',
windowsHide: true,
})
processRef.unref()
state.process = processRef
attachLifecycleHandlers(processRef)
const ready = await waitForServer(nextServerUrl, options.isQuitting, processRef)
if (ready && isProcessAlive(processRef)) {
state.url = nextServerUrl
return nextServerUrl
}
const failureReason =
processRef.exitCode !== null
? `The service exited early (code ${processRef.exitCode}).`
: `The service did not respond at ${nextServerUrl} within 10 seconds.`
stop()
throw new Error(failureReason)
})().finally(() => {
state.startup = null
})
return state.startup
}
const resolveUrl = async (): Promise<string> => {
if (options.isPackaged) {
return startPackagedServer()
}
const ready = await waitForServer(options.devServerUrl, options.isQuitting)
if (!ready) {
throw new Error('Dev server not responding. Run `bun dev` in apps/server first.')
}
state.url = options.devServerUrl
return options.devServerUrl
}
return {
resolveUrl,
stop,
get lastResolvedUrl() {
return state.url
},
}
}

View File

@@ -0,0 +1 @@
export {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -0,0 +1,33 @@
import { motion } from 'motion/react'
import logoImage from '../assets/logo.png'
export const SplashApp = () => {
return (
<main className="m-0 flex h-screen w-screen cursor-default select-none items-center justify-center overflow-hidden bg-white font-sans antialiased">
<motion.section
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center gap-8"
initial={{ opacity: 0, y: 4 }}
transition={{
duration: 1,
ease: [0.16, 1, 0.3, 1],
}}
>
<img alt="Logo" className="h-20 w-auto object-contain" draggable={false} src={logoImage} />
<div className="relative h-[4px] w-36 overflow-hidden rounded-full bg-zinc-100">
<motion.div
animate={{ x: '100%' }}
className="h-full w-full bg-zinc-800"
initial={{ x: '-100%' }}
transition={{
duration: 2,
ease: [0.4, 0, 0.2, 1],
repeat: Infinity,
}}
/>
</div>
</motion.section>
</main>
)
}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Furtherverse</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { SplashApp } from './components/SplashApp'
import './styles.css'
// biome-ignore lint/style/noNonNullAssertion: 一定存在
createRoot(document.getElementById('root')!).render(
<StrictMode>
<SplashApp />
</StrictMode>,
)

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,8 @@
{
"extends": "@furtherverse/tsconfig/react.json",
"compilerOptions": {
"composite": true,
"types": ["vite/client"]
},
"include": ["src/renderer/**/*"]
}

View File

@@ -1,4 +1,11 @@
{
"extends": "@furtherverse/tsconfig/bun.json",
"exclude": ["node_modules", "src-tauri"]
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@furtherverse/tsconfig/base.json",
"compilerOptions": {
"composite": true,
"types": ["node"]
},
"include": ["src/main/**/*", "src/preload/**/*", "electron.vite.config.ts"]
}

View File

@@ -3,12 +3,39 @@
"extends": ["//"],
"tasks": {
"build": {
"dependsOn": ["@furtherverse/server#compile"],
"outputs": ["src-tauri/target/release/**"]
"outputs": ["out/**"]
},
"dev": {
"dependsOn": ["@furtherverse/server#compile"],
"with": ["@furtherverse/server#dev"]
"dist": {
"dependsOn": ["build", "@furtherverse/server#compile"],
"outputs": ["dist/**"]
},
"dist:linux": {
"dependsOn": ["build", "@furtherverse/server#compile:linux:arm64", "@furtherverse/server#compile:linux:x64"],
"outputs": ["dist/**"]
},
"dist:linux:arm64": {
"dependsOn": ["build", "@furtherverse/server#compile:linux:arm64"],
"outputs": ["dist/**"]
},
"dist:linux:x64": {
"dependsOn": ["build", "@furtherverse/server#compile:linux:x64"],
"outputs": ["dist/**"]
},
"dist:mac": {
"dependsOn": ["build", "@furtherverse/server#compile:darwin:arm64", "@furtherverse/server#compile:darwin:x64"],
"outputs": ["dist/**"]
},
"dist:mac:arm64": {
"dependsOn": ["build", "@furtherverse/server#compile:darwin:arm64"],
"outputs": ["dist/**"]
},
"dist:mac:x64": {
"dependsOn": ["build", "@furtherverse/server#compile:darwin:x64"],
"outputs": ["dist/**"]
},
"dist:win": {
"dependsOn": ["build", "@furtherverse/server#compile:windows:x64"],
"outputs": ["dist/**"]
}
}
}

View File

@@ -1 +1 @@
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
DATABASE_PATH=data.db

View File

@@ -1,155 +1,144 @@
# AGENTS.md - AI Coding Agent Guidelines
# AGENTS.md - Server App Guidelines
本文档为 AI 编程助手提供此 TanStack Start 全栈项目的开发规范和指南。
TanStack Start fullstack web app with ORPC (contract-first RPC).
## 项目概览
## Tech Stack
- **框架**: TanStack Start (React SSR 框架,文件路由)
- **运行时**: Bun
- **语言**: TypeScript (strict mode, ESNext)
- **样式**: Tailwind CSS v4
- **数据库**: PostgreSQL + Drizzle ORM
- **状态管理**: TanStack Query
- **路由**: TanStack Router (文件路由)
- **RPC**: ORPC (类型安全 RPC契约优先)
- **构建工具**: Vite + Turbo
- **代码质量**: Biome (格式化 + Lint)
- **桌面壳** (可选): Tauri v2 (详见 `src-tauri/AGENTS.md`)
> **⚠️ This project uses Bun — NOT Node.js / npm. All commands use `bun`. Always use `bun run <script>` (not `bun <script>`) to avoid conflicts with Bun built-in subcommands. Never use `npm`, `npx`, or `node`.**
## 构建、Lint 和测试命令
- **Framework**: TanStack Start (React 19 SSR, file-based routing)
- **Runtime**: Bun — **NOT Node.js**
- **Package Manager**: Bun — **NOT npm / yarn / pnpm**
- **Language**: TypeScript (strict mode)
- **Styling**: Tailwind CSS v4
- **Database**: PostgreSQL + Drizzle ORM v1 beta (`drizzle-orm/postgres-js`, RQBv2)
- **State**: TanStack Query v5
- **RPC**: ORPC (contract-first, type-safe)
- **Build**: Vite + Nitro
## Commands
### 开发
```bash
bun dev # 使用 Turbo 并行启动 Tauri + Vite 开发服务器
bun dev:vite # 仅启动 Vite 开发服务器 (localhost:3000)
bun dev:tauri # 启动 Tauri 桌面应用
bun db:studio # 打开 Drizzle Studio 数据库管理界面
# Development
bun run dev # Vite dev server (localhost:3000)
bun run db:studio # Drizzle Studio GUI
# Build
bun run build # Production build → .output/
bun run compile # Compile to standalone binary (current platform, depends on build)
bun run compile:darwin # Compile for macOS (arm64 + x64)
bun run compile:darwin:arm64 # Compile for macOS arm64
bun run compile:darwin:x64 # Compile for macOS x64
bun run compile:linux # Compile for Linux (x64 + arm64)
bun run compile:linux:arm64 # Compile for Linux arm64
bun run compile:linux:x64 # Compile for Linux x64
bun run compile:windows # Compile for Windows (default: x64)
bun run compile:windows:x64 # Compile for Windows x64
# Code Quality
bun run fix # Biome auto-fix
bun run typecheck # TypeScript check
# Database
bun run db:generate # Generate migrations from schema
bun run db:migrate # Run migrations
bun run db:push # Push schema directly (dev only)
# Testing (not yet configured)
bun test path/to/test.ts # Run single test
bun test -t "pattern" # Run tests matching pattern
```
### 构建
```bash
bun build # 完整构建 (Vite → 编译 → Tauri 打包)
bun build:vite # 仅构建 Vite (输出到 .output/)
bun build:compile # 编译为独立可执行文件 (使用 build.ts)
bun build:tauri # 构建 Tauri 桌面安装包
## Directory Structure
```
src/
├── client/ # Client-side code
│ └── orpc.ts # ORPC client + TanStack Query utils (single entry point)
├── components/ # React components
├── routes/ # TanStack Router file routes
│ ├── __root.tsx # Root layout
│ ├── index.tsx # Home page
│ └── api/
│ ├── $.ts # OpenAPI handler + Scalar docs
│ ├── health.ts # Health check endpoint
│ └── rpc.$.ts # ORPC RPC handler
├── server/ # Server-side code
│ ├── api/ # ORPC layer
│ │ ├── contracts/ # Input/output schemas (Zod)
│ │ ├── middlewares/ # Middleware (db provider, auth)
│ │ ├── routers/ # Handler implementations
│ │ ├── interceptors.ts # Shared error interceptors
│ │ ├── context.ts # Request context
│ │ ├── server.ts # ORPC server instance
│ │ └── types.ts # Type exports
│ └── db/
│ ├── schema/ # Drizzle table definitions
│ ├── fields.ts # Shared field builders (id, createdAt, updatedAt)
│ ├── relations.ts # Drizzle relations (defineRelations, RQBv2)
│ └── index.ts # Database instance (postgres-js driver)
├── env.ts # Environment variable validation
├── router.tsx # Router configuration
├── routeTree.gen.ts # Auto-generated (DO NOT EDIT)
└── styles.css # Tailwind entry
```
### 代码质量
```bash
bun typecheck # 运行 TypeScript 编译器检查 (tsc -b)
bun fix # 运行 Biome 自动修复格式和 Lint 问题
biome check . # 检查但不自动修复
biome format --write . # 仅格式化代码
```
## ORPC Pattern
### 数据库
```bash
bun db:generate # 从 schema 生成迁移文件
bun db:migrate # 执行数据库迁移
bun db:push # 直接推送 schema 变更 (仅开发环境)
```
### 测试
**注意**: 当前未配置测试框架。添加测试时:
- 使用 Vitest 或 Bun 内置测试运行器
- 运行单个测试文件: `bun test path/to/test.ts`
- 运行特定测试: `bun test -t "测试名称模式"`
## 代码风格指南
### 格式化 (Biome)
**缩进**: 2 空格 (不使用 tab)
**换行符**: LF (Unix 风格)
**引号**: 单引号 `'string'`
**分号**: 按需 (ASI - 自动分号插入)
**箭头函数括号**: 始终使用 `(x) => x`
示例:
### 1. Define Contract (`src/server/api/contracts/feature.contract.ts`)
```typescript
const myFunc = (value: string) => {
return value.toUpperCase()
}
```
### 导入组织
Biome 自动组织导入。顺序:
1. 外部依赖
2. 内部导入 (使用 `@/*` 别名)
3. 类型导入 (仅导入类型时使用 `type` 关键字)
示例:
```typescript
import { createFileRoute } from '@tanstack/react-router'
import { oc } from '@orpc/contract'
import { createSelectSchema } from 'drizzle-orm/zod'
import { z } from 'zod'
import { db } from '@/db'
import { todoTable } from '@/db/schema'
import type { ReactNode } from 'react'
import { featureTable } from '@/server/db/schema'
const selectSchema = createSelectSchema(featureTable)
export const list = oc.input(z.void()).output(z.array(selectSchema))
export const create = oc.input(insertSchema).output(selectSchema)
```
### TypeScript
**严格模式**: 启用了额外的严格检查
- `strict: true`
- `noUncheckedIndexedAccess: true` - 数组/对象索引返回 `T | undefined`
- `noImplicitOverride: true`
- `noFallthroughCasesInSwitch: true`
**模块解析**: `bundler` 模式 + `verbatimModuleSyntax`
- 导入时始终使用 `.ts`/`.tsx` 扩展名
- 使用 `@/*` 路径别名指向 `src/*`
**类型注解**:
- 公共 API 的函数参数和返回类型必须注解
- 优先使用显式类型而非 `any`
- 对象形状用 `type`,可扩展契约用 `interface`
- 不可变 props 使用 `Readonly<T>`
### 命名规范
- **文件**: 工具函数用 kebab-case组件用 PascalCase
- `utils.ts`, `todo.tsx`, `NotFound.tsx`
- **路由**: 遵循 TanStack Router 约定
- `routes/index.tsx``/`
- `routes/__root.tsx` → 根布局
- **组件**: PascalCase 箭头函数 (Biome 规则 `useArrowFunction` 强制)
- **函数**: camelCase
- **常量**: 真常量用 UPPER_SNAKE_CASE配置对象用 camelCase
- **类型/接口**: PascalCase
### React 模式
**组件**: 使用箭头函数
### 2. Implement Router (`src/server/api/routers/feature.router.ts`)
```typescript
const MyComponent = ({ title }: { title: string }) => {
return <div>{title}</div>
}
```
import { ORPCError } from '@orpc/server'
import { db } from '../middlewares'
import { os } from '../server'
**路由**: 使用 `createFileRoute` 定义路由
```typescript
export const Route = createFileRoute('/')({
component: Home,
export const list = os.feature.list.use(db).handler(async ({ context }) => {
return await context.db.query.featureTable.findMany({
orderBy: { createdAt: 'desc' },
})
})
```
**数据获取**: 使用 TanStack Query hooks
- `useSuspenseQuery` - 保证有数据
- `useQuery` - 数据可能为空
### 3. Register in Index Files
```typescript
// src/server/api/contracts/index.ts
import * as feature from './feature.contract'
export const contract = { feature }
**Props**: 禁止直接修改 props (Biome 规则 `noReactPropAssignments`)
// src/server/api/routers/index.ts
import * as feature from './feature.router'
export const router = os.router({ feature })
```
### 数据库 Schema (Drizzle)
### 4. Use in Components
```typescript
import { useSuspenseQuery, useMutation } from '@tanstack/react-query'
import { orpc } from '@/client/orpc'
-`src/db/schema/*.ts` 定义 schema
-`src/db/schema/index.ts` 导出
- 使用 `drizzle-orm/pg-core` 的 PostgreSQL 类型
- 主键使用 `uuidv7()` (需要 PostgreSQL 扩展)
- 始终包含 `createdAt``updatedAt` 时间戳
const { data } = useSuspenseQuery(orpc.feature.list.queryOptions())
const mutation = useMutation(orpc.feature.create.mutationOptions())
```
示例:
## Database (Drizzle ORM v1 beta)
- **Driver**: `drizzle-orm/postgres-js` (NOT `bun-sql`)
- **Validation**: `drizzle-orm/zod` (built-in, NOT separate `drizzle-zod` package)
- **Relations**: Defined via `defineRelations()` in `src/server/db/relations.ts`
- **Query**: RQBv2 — use `db.query.tableName.findMany()` with object-style `orderBy` and `where`
### Schema Definition
```typescript
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
import { sql } from 'drizzle-orm'
@@ -162,116 +151,129 @@ export const myTable = pgTable('my_table', {
})
```
### 环境变量
- 使用 `@t3-oss/env-core` 进行类型安全的环境变量验证
-`src/env.ts` 定义 schema
- 服务端变量: 无前缀
- 客户端变量: 必须有 `VITE_` 前缀
- 使用 Zod schema 验证
### 错误处理
- 异步操作使用 try-catch
- 抛出带有描述性消息的错误
- 用户界面错误优先使用 Result 类型或错误边界
- 适当记录错误 (避免记录敏感数据)
### 样式 (Tailwind CSS)
- 使用 Tailwind v4 工具类
- 通过 `@/styles.css?url` 导入样式
- 优先使用组合而非自定义 CSS
- 响应式修饰符: `sm:`, `md:`, `lg:`
- UI 文本适当使用中文
## 目录结构
```
src/
├── components/ # 可复用 React 组件
├── db/
│ ├── schema/ # Drizzle schema 定义
│ └── index.ts # 数据库实例
├── integrations/ # 第三方集成 (TanStack Query/Router)
├── lib/ # 工具函数
├── orpc/ # ORPC (RPC 层)
│ ├── contracts/ # 契约定义 (input/output schemas)
│ ├── handlers/ # 服务端过程实现
│ ├── middlewares/ # 中间件 (如 DB provider)
│ ├── contract.ts # 契约聚合
│ ├── router.ts # 路由组合
│ ├── server.ts # 服务端实例
│ └── client.ts # 同构客户端
├── routes/ # TanStack Router 文件路由
│ ├── __root.tsx # 根布局
│ ├── index.tsx # 首页
│ └── api/rpc.$.ts # ORPC HTTP 端点
├── env.ts # 环境变量验证
└── router.tsx # 路由配置
```
## 重要提示
- **禁止** 编辑 `src/routeTree.gen.ts` - 自动生成
- **禁止** 提交 `.env` 文件 - 使用 `.env.example` 作为模板
- **必须** 在提交前运行 `bun fix`
- **必须** 使用 `@/*` 路径别名而非相对导入
- **必须** 利用 React Compiler (babel-plugin-react-compiler) - 避免手动 memoization
## Git 工作流
1. 按照上述风格指南进行修改
2. 运行 `bun fix` 自动格式化和 lint
3. 运行 `bun typecheck` 确保类型安全
4. 使用 `bun dev` 本地测试变更
5. 使用清晰的描述性消息提交
## 常见模式
### 创建 ORPC 过程
**步骤 1: 定义契约** (`src/orpc/contracts/my-feature.ts`)
### Relations (RQBv2)
```typescript
import { oc } from '@orpc/contract'
import { z } from 'zod'
// src/server/db/relations.ts
import { defineRelations } from 'drizzle-orm'
import * as schema from './schema'
export const myContract = {
get: oc.input(z.object({ id: z.uuid() })).output(mySchema),
create: oc.input(createSchema).output(mySchema),
}
export const relations = defineRelations(schema, (r) => ({
// Define relations here using r.one / r.many / r.through
}))
```
**步骤 2: 实现处理器** (`src/orpc/handlers/my-feature.ts`)
### DB Instance
```typescript
import { os } from '@/orpc/server'
import { dbProvider } from '@/orpc/middlewares'
// src/server/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import { relations } from '@/server/db/relations'
// In RQBv2, relations already contain schema info — no separate schema import needed
export const get = os.myFeature.get
.use(dbProvider)
.handler(async ({ context, input }) => {
return await context.db.query.myTable.findFirst(...)
const db = drizzle({
connection: env.DATABASE_URL,
relations,
})
```
**步骤 3: 注册到契约和路由**
### RQBv2 Query Examples
```typescript
// src/orpc/contract.ts
export const contract = { myFeature: myContract }
// Object-style orderBy (NOT callback style)
const todos = await db.query.todoTable.findMany({
orderBy: { createdAt: 'desc' },
})
// src/orpc/router.ts
import * as myFeature from './handlers/my-feature'
export const router = os.router({ myFeature })
// Object-style where
const todo = await db.query.todoTable.findFirst({
where: { id: someId },
})
```
**步骤 4: 在组件中使用**
## Code Style
### Formatting (Biome)
- **Indent**: 2 spaces
- **Quotes**: Single `'`
- **Semicolons**: Omit (ASI)
- **Arrow parens**: Always `(x) => x`
### Imports
Biome auto-organizes:
1. External packages
2. Internal `@/*` aliases
3. Type imports (`import type { ... }`)
```typescript
import { orpc } from '@/orpc'
const query = useSuspenseQuery(orpc.myFeature.get.queryOptions({ id }))
const mutation = useMutation(orpc.myFeature.create.mutationOptions())
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { db } from '@/server/db'
import type { ReactNode } from 'react'
```
---
### TypeScript
- `strict: true`
- `noUncheckedIndexedAccess: true` - array access returns `T | undefined`
- Use `@/*` path aliases (maps to `src/*`)
**最后更新**: 2026-01-18
**项目版本**: 基于 package.json 依赖版本
### Naming
| Type | Convention | Example |
|------|------------|---------|
| Files (utils) | kebab-case | `auth-utils.ts` |
| Files (components) | PascalCase | `UserProfile.tsx` |
| Components | PascalCase arrow | `const Button = () => {}` |
| Functions | camelCase | `getUserById` |
| Types | PascalCase | `UserProfile` |
### React
- Use arrow functions for components (Biome enforced)
- Use `useSuspenseQuery` for guaranteed data
- Let React Compiler handle memoization (no manual `useMemo`/`useCallback`)
## Environment Variables
```typescript
// src/env.ts - using @t3-oss/env-core
import { createEnv } from '@t3-oss/env-core'
import { z } from 'zod'
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
},
clientPrefix: 'VITE_',
client: {
VITE_API_URL: z.string().optional(),
},
})
```
## Development Principles
> **These principles apply to ALL code changes. Agents MUST follow them on every task.**
1. **No backward compatibility** — This project is in rapid iteration. Always use the latest API and patterns. Never keep deprecated code paths or old API fallbacks.
2. **Always sync documentation** — When code changes, immediately update all related documentation (`AGENTS.md`, `README.md`, inline code examples). Code and docs must never drift apart.
3. **Forward-only migration** — When upgrading dependencies, fully adopt the new API. Don't mix old and new patterns.
## Critical Rules
**DO:**
- Run `bun run fix` before committing
- Use `@/*` path aliases
- Include `createdAt`/`updatedAt` on all tables
- Use `ORPCError` with proper codes
- Use `drizzle-orm/zod` (NOT `drizzle-zod`) for schema validation
- Use RQBv2 object syntax for `orderBy` and `where`
- Update `AGENTS.md` and other docs whenever code patterns change
**DON'T:**
- Use `npm`, `npx`, `node`, `yarn`, `pnpm` — always use `bun` / `bunx`
- Edit `src/routeTree.gen.ts` (auto-generated)
- Use `as any`, `@ts-ignore`, `@ts-expect-error`
- Commit `.env` files
- Use empty catch blocks
- Import from `drizzle-zod` (use `drizzle-orm/zod` instead)
- Use RQBv1 callback-style `orderBy` / old `relations()` API
- Use `drizzle-orm/bun-sql` driver (use `drizzle-orm/postgres-js`)
- Pass `schema` to `drizzle()` constructor (only `relations` is needed in RQBv2)
- Import `os` from `@orpc/server` in middleware — use `@/server/api/server` (the local typed instance)
- Leave docs out of sync with code changes

View File

@@ -3,5 +3,10 @@
"extends": "//",
"files": {
"includes": ["**", "!**/routeTree.gen.ts"]
},
"css": {
"parser": {
"tailwindDirectives": true
}
}
}

View File

@@ -1,289 +0,0 @@
import { Schema } from '@effect/schema'
import { $ } from 'bun'
import { Console, Context, Data, Effect, Layer } from 'effect'
// ============================================================================
// Domain Models & Schema
// ============================================================================
const BunTargetSchema = Schema.Literal(
'bun-windows-x64',
'bun-darwin-arm64',
'bun-darwin-x64',
'bun-linux-x64',
'bun-linux-arm64',
)
/**
* 将 bun target 转换为文件后缀 (去掉 'bun-' 前缀)
*/
const getTargetSuffix = (target: BunTarget): string => {
return target.replace('bun-', '')
}
type BunTarget = Schema.Schema.Type<typeof BunTargetSchema>
const BuildConfigSchema = Schema.Struct({
entrypoint: Schema.String.pipe(Schema.nonEmptyString()),
outputDir: Schema.String.pipe(Schema.nonEmptyString()),
outfile: Schema.String.pipe(Schema.nonEmptyString()),
targets: Schema.Array(BunTargetSchema).pipe(Schema.minItems(1)),
})
type BuildConfig = Schema.Schema.Type<typeof BuildConfigSchema>
const BuildResultSchema = Schema.Struct({
target: BunTargetSchema,
outputs: Schema.Array(Schema.String),
})
type BuildResult = Schema.Schema.Type<typeof BuildResultSchema>
// ============================================================================
// Error Models (使用 Data.TaggedError)
// ============================================================================
class CleanError extends Data.TaggedError('CleanError')<{
readonly dir: string
readonly cause: unknown
}> {}
class BuildError extends Data.TaggedError('BuildError')<{
readonly target: BunTarget
readonly cause: unknown
}> {}
class ConfigError extends Data.TaggedError('ConfigError')<{
readonly message: string
readonly cause: unknown
}> {}
// ============================================================================
// Services
// ============================================================================
/**
* 配置服务
*/
class BuildConfigService extends Context.Tag('BuildConfigService')<
BuildConfigService,
BuildConfig
>() {
/**
* 从原始数据创建并验证配置
*/
static fromRaw = (raw: unknown) =>
Effect.gen(function* () {
const decoded = yield* Schema.decodeUnknown(BuildConfigSchema)(raw)
return decoded
}).pipe(
Effect.catchAll((error) =>
Effect.fail(
new ConfigError({
message: '配置验证失败',
cause: error,
}),
),
),
)
/**
* 默认配置 Layer
*/
static readonly Live = Layer.effect(
BuildConfigService,
BuildConfigService.fromRaw({
entrypoint: '.output/server/index.mjs',
outputDir: 'out',
outfile: 'server',
targets: ['bun-windows-x64', 'bun-darwin-arm64', 'bun-linux-x64'],
} satisfies BuildConfig),
)
}
/**
* 文件系统服务
*/
class FileSystemService extends Context.Tag('FileSystemService')<
FileSystemService,
{
readonly cleanDir: (dir: string) => Effect.Effect<void, CleanError>
}
>() {
static readonly Live = Layer.succeed(FileSystemService, {
cleanDir: (dir: string) =>
Effect.tryPromise({
try: async () => {
await $`rm -rf ${dir}`
},
catch: (cause: unknown) =>
new CleanError({
dir,
cause,
}),
}),
})
}
/**
* 构建服务
*/
class BuildService extends Context.Tag('BuildService')<
BuildService,
{
readonly buildForTarget: (
config: BuildConfig,
target: BunTarget,
) => Effect.Effect<BuildResult, BuildError>
readonly buildAll: (
config: BuildConfig,
) => Effect.Effect<ReadonlyArray<BuildResult>, BuildError>
}
>() {
static readonly Live = Layer.succeed(BuildService, {
buildForTarget: (config: BuildConfig, target: BunTarget) =>
Effect.gen(function* () {
yield* Console.log(`🔨 开始构建: ${target}`)
const output = yield* Effect.tryPromise({
try: () =>
Bun.build({
entrypoints: [config.entrypoint],
compile: {
outfile: `${config.outfile}-${getTargetSuffix(target)}`,
target: target,
},
outdir: config.outputDir,
}),
catch: (cause: unknown) =>
new BuildError({
target,
cause,
}),
})
const paths = output.outputs.map((item: { path: string }) => item.path)
return {
target,
outputs: paths,
} satisfies BuildResult
}),
buildAll: (config: BuildConfig) =>
Effect.gen(function* () {
const effects = config.targets.map((target) =>
Effect.gen(function* () {
yield* Console.log(`🔨 开始构建: ${target}`)
const output = yield* Effect.tryPromise({
try: () =>
Bun.build({
entrypoints: [config.entrypoint],
compile: {
outfile: `${config.outfile}-${getTargetSuffix(target)}`,
target: target,
},
outdir: config.outputDir,
}),
catch: (cause: unknown) =>
new BuildError({
target,
cause,
}),
})
const paths = output.outputs.map(
(item: { path: string }) => item.path,
)
return {
target,
outputs: paths,
} satisfies BuildResult
}),
)
return yield* Effect.all(effects, { concurrency: 'unbounded' })
}),
})
}
/**
* 报告服务
*/
class ReporterService extends Context.Tag('ReporterService')<
ReporterService,
{
readonly printSummary: (
results: ReadonlyArray<BuildResult>,
) => Effect.Effect<void>
}
>() {
static readonly Live = Layer.succeed(ReporterService, {
printSummary: (results: ReadonlyArray<BuildResult>) =>
Effect.gen(function* () {
yield* Console.log('\n📦 构建完成:')
for (const result of results) {
yield* Console.log(` ${result.target}:`)
for (const path of result.outputs) {
yield* Console.log(` - ${path}`)
}
}
}),
})
}
// ============================================================================
// Main Program
// ============================================================================
const program = Effect.gen(function* () {
const config = yield* BuildConfigService
const fs = yield* FileSystemService
const builder = yield* BuildService
const reporter = yield* ReporterService
// 1. 清理输出目录
yield* fs.cleanDir(config.outputDir)
yield* Console.log(`✓ 已清理输出目录: ${config.outputDir}`)
// 2. 并行构建所有目标
const results = yield* builder.buildAll(config)
// 3. 输出构建摘要
yield* reporter.printSummary(results)
return results
})
// ============================================================================
// Layer Composition
// ============================================================================
const MainLayer = Layer.mergeAll(
BuildConfigService.Live,
FileSystemService.Live,
BuildService.Live,
ReporterService.Live,
)
// ============================================================================
// Runner
// ============================================================================
const runnable = program.pipe(
Effect.provide(MainLayer),
Effect.catchTags({
CleanError: (error) =>
Console.error(`❌ 清理目录失败: ${error.dir}`, error.cause),
BuildError: (error) =>
Console.error(`❌ 构建失败 [${error.target}]:`, error.cause),
ConfigError: (error) =>
Console.error(`❌ 配置错误: ${error.message}`, error.cause),
}),
Effect.tapErrorCause((cause) => Console.error('❌ 未预期的错误:', cause)),
)
Effect.runPromise(runnable).catch(() => {
process.exit(1)
})

64
apps/server/compile.ts Normal file
View File

@@ -0,0 +1,64 @@
import { mkdir, rm } from 'node:fs/promises'
import { parseArgs } from 'node:util'
const ENTRYPOINT = '.output/server/index.mjs'
const OUTDIR = 'out'
const SUPPORTED_TARGETS: readonly Bun.Build.CompileTarget[] = [
'bun-windows-x64',
'bun-darwin-arm64',
'bun-darwin-x64',
'bun-linux-x64',
'bun-linux-arm64',
]
const isSupportedTarget = (value: string): value is Bun.Build.CompileTarget =>
(SUPPORTED_TARGETS as readonly string[]).includes(value)
const { values } = parseArgs({
options: { target: { type: 'string' } },
strict: true,
allowPositionals: false,
})
const resolveTarget = (): Bun.Build.CompileTarget => {
if (values.target !== undefined) {
if (!isSupportedTarget(values.target)) {
throw new Error(`Invalid target: ${values.target}\nAllowed: ${SUPPORTED_TARGETS.join(', ')}`)
}
return values.target
}
const os = process.platform === 'win32' ? 'windows' : process.platform
const candidate = `bun-${os}-${process.arch}`
if (!isSupportedTarget(candidate)) {
throw new Error(`Unsupported host: ${process.platform}-${process.arch}`)
}
return candidate
}
const main = async () => {
const target = resolveTarget()
const suffix = target.replace('bun-', '')
const outfile = `server-${suffix}`
await mkdir(OUTDIR, { recursive: true })
await Promise.all([rm(`${OUTDIR}/${outfile}`, { force: true }), rm(`${OUTDIR}/${outfile}.exe`, { force: true })])
const result = await Bun.build({
entrypoints: [ENTRYPOINT],
outdir: OUTDIR,
compile: { outfile, target },
})
if (!result.success) {
throw new Error(result.logs.map(String).join('\n'))
}
console.log(`${target}${OUTDIR}/${outfile}`)
}
main().catch((err) => {
console.error('❌', err instanceof Error ? err.message : err)
process.exit(1)
})

View File

@@ -1,11 +1,12 @@
import { defineConfig } from 'drizzle-kit'
import { env } from '@/env'
const databasePath = process.env.DATABASE_PATH ?? 'data.db'
export default defineConfig({
out: './drizzle',
schema: './src/server/db/schema/index.ts',
dialect: 'postgresql',
dialect: 'sqlite',
dbCredentials: {
url: env.DATABASE_URL,
url: databasePath,
},
})

View File

@@ -4,18 +4,26 @@
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"compile": "bun build.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"dev": "vite dev",
"build": "bunx --bun vite build",
"compile": "bun compile.ts",
"compile:darwin": "bun run compile:darwin:arm64 && bun run compile:darwin:x64",
"compile:darwin:arm64": "bun compile.ts --target bun-darwin-arm64",
"compile:darwin:x64": "bun compile.ts --target bun-darwin-x64",
"compile:linux": "bun run compile:linux:x64 && bun run compile:linux:arm64",
"compile:linux:arm64": "bun compile.ts --target bun-linux-arm64",
"compile:linux:x64": "bun compile.ts --target bun-linux-x64",
"compile:windows": "bun run compile:windows:x64",
"compile:windows:x64": "bun compile.ts --target bun-windows-x64",
"db:generate": "bun --bun drizzle-kit generate",
"db:migrate": "bun --bun drizzle-kit migrate",
"db:push": "bun --bun drizzle-kit push",
"db:studio": "bun --bun drizzle-kit studio",
"dev": "bunx --bun vite dev",
"fix": "biome check --write",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@furtherverse/utils": "workspace:*",
"@furtherverse/crypto": "workspace:*",
"@orpc/client": "catalog:",
"@orpc/contract": "catalog:",
"@orpc/openapi": "catalog:",
@@ -27,18 +35,14 @@
"@tanstack/react-router": "catalog:",
"@tanstack/react-router-ssr-query": "catalog:",
"@tanstack/react-start": "catalog:",
"@tauri-apps/api": "catalog:",
"drizzle-orm": "catalog:",
"drizzle-zod": "catalog:",
"postgres": "catalog:",
"jszip": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"uuid": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@effect/platform": "catalog:",
"@effect/schema": "catalog:",
"@furtherverse/tsconfig": "workspace:*",
"@tailwindcss/vite": "catalog:",
"@tanstack/devtools-vite": "catalog:",
@@ -49,10 +53,8 @@
"@vitejs/plugin-react": "catalog:",
"babel-plugin-react-compiler": "catalog:",
"drizzle-kit": "catalog:",
"effect": "catalog:",
"nitro": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-tsconfig-paths": "catalog:"
}

View File

@@ -1,6 +1,7 @@
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
import { createRouterClient } from '@orpc/server'
import { createTanstackQueryUtils } from '@orpc/tanstack-query'
import { createIsomorphicFn } from '@tanstack/react-start'
import { getRequestHeaders } from '@tanstack/react-start/server'
import { router } from '@/server/api/routers'
@@ -21,4 +22,6 @@ const getORPCClient = createIsomorphicFn()
return createORPCClient<RouterClient>(link)
})
export const orpc: RouterClient = getORPCClient()
const client: RouterClient = getORPCClient()
export const orpc = createTanstackQueryUtils(client)

View File

@@ -1,30 +0,0 @@
import { createTanstackQueryUtils } from '@orpc/tanstack-query'
import { orpc as orpcClient } from './orpc.client'
export const orpc = createTanstackQueryUtils(orpcClient, {
experimental_defaults: {
todo: {
create: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
update: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
remove: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
},
},
})

View File

@@ -3,7 +3,7 @@ import { z } from 'zod'
export const env = createEnv({
server: {
DATABASE_URL: z.url(),
DATABASE_PATH: z.string().min(1).default('data.db'),
},
clientPrefix: 'VITE_',
client: {

View File

@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ApiHealthRouteImport } from './routes/api/health'
import { Route as ApiSplatRouteImport } from './routes/api/$'
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
@@ -18,6 +19,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiHealthRoute = ApiHealthRouteImport.update({
id: '/api/health',
path: '/api/health',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSplatRoute = ApiSplatRouteImport.update({
id: '/api/$',
path: '/api/$',
@@ -32,30 +38,34 @@ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/api/$' | '/api/rpc/$'
fullPaths: '/' | '/api/$' | '/api/health' | '/api/rpc/$'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/api/$' | '/api/rpc/$'
id: '__root__' | '/' | '/api/$' | '/api/rpc/$'
to: '/' | '/api/$' | '/api/health' | '/api/rpc/$'
id: '__root__' | '/' | '/api/$' | '/api/health' | '/api/rpc/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ApiSplatRoute: typeof ApiSplatRoute
ApiHealthRoute: typeof ApiHealthRoute
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
}
@@ -68,6 +78,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/health': {
id: '/api/health'
path: '/api/health'
fullPath: '/api/health'
preLoaderRoute: typeof ApiHealthRouteImport
parentRoute: typeof rootRouteImport
}
'/api/$': {
id: '/api/$'
path: '/api/$'
@@ -88,6 +105,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ApiSplatRoute: ApiSplatRoute,
ApiHealthRoute: ApiHealthRoute,
ApiRpcSplatRoute: ApiRpcSplatRoute,
}
export const routeTree = rootRouteImport

View File

@@ -5,7 +5,14 @@ import type { RouterContext } from './routes/__root'
import { routeTree } from './routeTree.gen'
export const getRouter = () => {
const queryClient = new QueryClient()
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
retry: 1,
},
},
})
const router = createRouter({
routeTree,

View File

@@ -1,15 +1,11 @@
import { TanStackDevtools } from '@tanstack/react-devtools'
import type { QueryClient } from '@tanstack/react-query'
import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
import {
createRootRouteWithContext,
HeadContent,
Scripts,
} from '@tanstack/react-router'
import { createRootRouteWithContext, HeadContent, Scripts } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import type { ReactNode } from 'react'
import { ErrorComponent } from '@/components/Error'
import { NotFoundComponent } from '@/components/NotFount'
import { NotFoundComponent } from '@/components/NotFound'
import appCss from '@/styles.css?url'
export interface RouterContext {
@@ -50,6 +46,7 @@ function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
</head>
<body>
{children}
{import.meta.env.DEV && (
<TanStackDevtools
config={{
position: 'bottom-right',
@@ -65,6 +62,7 @@ function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
},
]}
/>
)}
<Scripts />
</body>
</html>

View File

@@ -1,10 +1,10 @@
import { OpenAPIHandler } from '@orpc/openapi/fetch'
import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'
import { ORPCError, onError, ValidationError } from '@orpc/server'
import { onError } from '@orpc/server'
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { name, version } from '@/../package.json'
import { handleValidationError, logError } from '@/server/api/interceptors'
import { router } from '@/server/api/routers'
const handler = new OpenAPIHandler(router, {
@@ -16,56 +16,15 @@ const handler = new OpenAPIHandler(router, {
info: {
title: name,
version,
description: 'UX 授权服务 OpenAPI 文档:设备授权、任务解密、摘要加密与报告签名打包接口。',
},
// components: {
// securitySchemes: {
// bearerAuth: {
// type: 'http',
// scheme: 'bearer',
// },
// },
// },
},
docsPath: '/docs',
specPath: '/spec.json',
}),
],
interceptors: [
onError((error) => {
console.error(error)
}),
],
clientInterceptors: [
onError((error) => {
if (
error instanceof ORPCError &&
error.code === 'BAD_REQUEST' &&
error.cause instanceof ValidationError
) {
// If you only use Zod you can safely cast to ZodIssue[]
const zodError = new z.ZodError(
error.cause.issues as z.core.$ZodIssue[],
)
throw new ORPCError('INPUT_VALIDATION_FAILED', {
status: 422,
message: z.prettifyError(zodError),
data: z.flattenError(zodError),
cause: error.cause,
})
}
if (
error instanceof ORPCError &&
error.code === 'INTERNAL_SERVER_ERROR' &&
error.cause instanceof ValidationError
) {
throw new ORPCError('OUTPUT_VALIDATION_FAILED', {
cause: error.cause,
})
}
}),
],
interceptors: [onError(logError)],
clientInterceptors: [onError(handleValidationError)],
})
export const Route = createFileRoute('/api/$')({

View File

@@ -0,0 +1,27 @@
import { createFileRoute } from '@tanstack/react-router'
import { name, version } from '@/../package.json'
const createHealthResponse = (): Response =>
Response.json(
{
status: 'ok',
service: name,
version,
timestamp: new Date().toISOString(),
},
{
status: 200,
headers: {
'cache-control': 'no-store',
},
},
)
export const Route = createFileRoute('/api/health')({
server: {
handlers: {
GET: async () => createHealthResponse(),
HEAD: async () => new Response(null, { status: 200 }),
},
},
})

View File

@@ -1,46 +1,12 @@
import { ORPCError, onError, ValidationError } from '@orpc/server'
import { onError } from '@orpc/server'
import { RPCHandler } from '@orpc/server/fetch'
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { handleValidationError, logError } from '@/server/api/interceptors'
import { router } from '@/server/api/routers'
const handler = new RPCHandler(router, {
interceptors: [
onError((error) => {
console.error(error)
}),
],
clientInterceptors: [
onError((error) => {
if (
error instanceof ORPCError &&
error.code === 'BAD_REQUEST' &&
error.cause instanceof ValidationError
) {
// If you only use Zod you can safely cast to ZodIssue[]
const zodError = new z.ZodError(
error.cause.issues as z.core.$ZodIssue[],
)
throw new ORPCError('INPUT_VALIDATION_FAILED', {
status: 422,
message: z.prettifyError(zodError),
data: z.flattenError(zodError),
cause: error.cause,
})
}
if (
error instanceof ORPCError &&
error.code === 'INTERNAL_SERVER_ERROR' &&
error.cause instanceof ValidationError
) {
throw new ORPCError('OUTPUT_VALIDATION_FAILED', {
cause: error.cause,
})
}
}),
],
interceptors: [onError(logError)],
clientInterceptors: [onError(handleValidationError)],
})
export const Route = createFileRoute('/api/rpc/$')({

View File

@@ -1,215 +1,21 @@
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { isTauri } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import type { ChangeEventHandler, FormEventHandler } from 'react'
import { useEffect, useState } from 'react'
import { orpc } from '@/client/query-client'
export const Route = createFileRoute('/')({
component: Todos,
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(orpc.todo.list.queryOptions())
},
component: Home,
})
function Todos() {
const [newTodoTitle, setNewTodoTitle] = useState('')
const listQuery = useSuspenseQuery(orpc.todo.list.queryOptions())
const createMutation = useMutation(orpc.todo.create.mutationOptions())
const updateMutation = useMutation(orpc.todo.update.mutationOptions())
const deleteMutation = useMutation(orpc.todo.remove.mutationOptions())
useEffect(() => {
if (!isTauri()) return
getCurrentWindow().setTitle('待办事项')
}, [])
const handleCreateTodo: FormEventHandler<HTMLFormElement> = (e) => {
e.preventDefault()
if (newTodoTitle.trim()) {
createMutation.mutate({ title: newTodoTitle.trim() })
setNewTodoTitle('')
}
}
const handleInputChange: ChangeEventHandler<HTMLInputElement> = (e) => {
setNewTodoTitle(e.target.value)
}
const handleToggleTodo = (id: string, currentCompleted: boolean) => {
updateMutation.mutate({
id,
data: { completed: !currentCompleted },
})
}
const handleDeleteTodo = (id: string) => {
deleteMutation.mutate({ id })
}
const todos = listQuery.data
const completedCount = todos.filter((todo) => todo.completed).length
const totalCount = todos.length
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
function Home() {
return (
<div className="min-h-screen bg-slate-50 py-12 px-4 sm:px-6 font-sans">
<div className="max-w-2xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-end justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">
</h1>
<p className="text-slate-500 mt-1"></p>
</div>
<div className="text-right">
<div className="text-2xl font-semibold text-slate-900">
{completedCount}
<span className="text-slate-400 text-lg">/{totalCount}</span>
</div>
<div className="text-xs font-medium text-slate-400 uppercase tracking-wider">
</div>
</div>
</div>
{/* Add Todo Form */}
<form onSubmit={handleCreateTodo} className="relative group z-10">
<div className="relative transform transition-all duration-200 focus-within:-translate-y-1">
<input
type="text"
value={newTodoTitle}
onChange={handleInputChange}
placeholder="添加新任务..."
className="w-full pl-6 pr-32 py-5 bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] border-0 ring-1 ring-slate-100 focus:ring-2 focus:ring-indigo-500/50 outline-none transition-all placeholder:text-slate-400 text-lg text-slate-700"
disabled={createMutation.isPending}
/>
<button
type="submit"
disabled={createMutation.isPending || !newTodoTitle.trim()}
className="absolute right-3 top-3 bottom-3 px-6 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-all shadow-md shadow-indigo-200 disabled:opacity-50 disabled:shadow-none hover:shadow-lg hover:shadow-indigo-300 active:scale-95"
>
{createMutation.isPending ? '添加中' : '添加'}
</button>
</div>
</form>
{/* Progress Bar (Only visible when there are tasks) */}
{totalCount > 0 && (
<div className="h-1.5 w-full bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 transition-all duration-500 ease-out rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
)}
{/* Todo List */}
<div className="space-y-3">
{todos.length === 0 ? (
<div className="py-20 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100 mb-4">
<svg
className="w-8 h-8 text-slate-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
</div>
<p className="text-slate-500 text-lg font-medium"></p>
<p className="text-slate-400 text-sm mt-1">
<div className="min-h-screen bg-slate-50 flex items-center justify-center font-sans">
<div className="text-center space-y-4">
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">UX Server</h1>
<p className="text-slate-500">
API:&nbsp;
<a href="/api" className="text-indigo-600 hover:text-indigo-700 underline">
/api
</a>
</p>
</div>
) : (
todos.map((todo) => (
<div
key={todo.id}
className={`group relative flex items-center p-4 bg-white rounded-xl border border-slate-100 shadow-sm transition-all duration-200 hover:shadow-md hover:border-slate-200 ${
todo.completed ? 'bg-slate-50/50' : ''
}`}
>
<button
type="button"
onClick={() => handleToggleTodo(todo.id, todo.completed)}
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 transition-all duration-200 flex items-center justify-center mr-4 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${
todo.completed
? 'bg-indigo-500 border-indigo-500'
: 'border-slate-300 hover:border-indigo-500 bg-white'
}`}
>
{todo.completed && (
<svg
className="w-3.5 h-3.5 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
)}
</button>
<div className="flex-1 min-w-0">
<p
className={`text-lg transition-all duration-200 truncate ${
todo.completed
? 'text-slate-400 line-through decoration-slate-300 decoration-2'
: 'text-slate-700'
}`}
>
{todo.title}
</p>
</div>
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 absolute right-4 pl-4 bg-gradient-to-l from-white via-white to-transparent sm:static sm:bg-none">
<span className="text-xs text-slate-400 mr-3 hidden sm:inline-block">
{new Date(todo.createdAt).toLocaleDateString('zh-CN')}
</span>
<button
type="button"
onClick={() => handleDeleteTodo(todo.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors focus:outline-none"
title="删除"
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,99 @@
import { oc } from '@orpc/contract'
import { z } from 'zod'
export const encryptDeviceInfo = oc
.route({
method: 'POST',
path: '/crypto/encrypt-device-info',
operationId: 'encryptDeviceInfo',
summary: '生成设备授权密文',
description:
'按 deviceId 查询已注册设备,使用设备记录中的 platformPublicKey 对 {licence, fingerprint} 做 RSA-OAEP 加密,返回 Base64 密文。',
tags: ['Crypto'],
})
.input(
z.object({
deviceId: z.string().min(1).describe('设备 ID'),
}),
)
.output(
z.object({
encrypted: z.string().describe('Base64 密文(用于设备授权二维码)'),
}),
)
export const decryptTask = oc
.route({
method: 'POST',
path: '/crypto/decrypt-task',
operationId: 'decryptTask',
summary: '解密任务二维码',
description:
'按 deviceId 查询已注册设备,使用 UTF-8 编码下的 licence 与 fingerprint 直接拼接(无分隔符)后取 SHA256 作为 AES-256-GCM 密钥解密任务密文。',
tags: ['Crypto'],
})
.input(
z.object({
deviceId: z.string().min(1).describe('设备 ID'),
encryptedData: z.string().min(1).describe('任务二维码中的 Base64 密文'),
}),
)
.output(
z.object({
taskId: z.string().describe('任务 ID'),
enterpriseId: z.string().describe('企业 ID'),
orgName: z.string().describe('单位名称'),
inspectionId: z.string().describe('检查 ID'),
inspectionPerson: z.string().describe('检查人'),
issuedAt: z.number().describe('任务发布时间戳(毫秒)'),
}),
)
export const encryptSummary = oc
.route({
method: 'POST',
path: '/crypto/encrypt-summary',
operationId: 'encryptSummary',
summary: '加密摘要二维码内容',
description: '按 deviceId 查询已注册设备,使用 HKDF-SHA256 + AES-256-GCM 加密摘要信息,返回二维码 JSON 字符串。',
tags: ['Crypto'],
})
.input(
z.object({
deviceId: z.string().min(1).describe('设备 ID'),
taskId: z.string().min(1).describe('任务 ID'),
enterpriseId: z.string().min(1).describe('企业 ID'),
inspectionId: z.string().min(1).describe('检查 ID'),
summary: z.string().min(1).describe('摘要明文'),
}),
)
.output(
z.object({
qrContent: z.string().describe('二维码内容 JSON{"taskId":"...","encrypted":"..."}'),
}),
)
export const signAndPackReport = oc
.route({
method: 'POST',
path: '/crypto/sign-and-pack-report',
operationId: 'signAndPackReport',
summary: '签名并打包报告 ZIP',
description:
'接收原始 ZIPmultipart/form-data 文件字段 rawZip由 UX 生成 summary.json、manifest.json、signature.asc并直接返回签名后 ZIP 二进制文件。',
tags: ['Crypto', 'Report'],
})
.input(
z.object({
deviceId: z.string().min(1).describe('设备 ID'),
taskId: z.string().min(1).describe('任务 ID'),
enterpriseId: z.string().min(1).describe('企业 ID'),
inspectionId: z.string().min(1).describe('检查 ID'),
summary: z.string().min(1).describe('检查摘要明文'),
rawZip: z
.file()
.mime(['application/zip', 'application/x-zip-compressed'])
.describe('原始报告 ZIP 文件multipart/form-data 字段)'),
}),
)
.output(z.file().describe('签名后报告 ZIP 文件(二进制响应)'))

View File

@@ -0,0 +1,46 @@
import { oc } from '@orpc/contract'
import { z } from 'zod'
const deviceOutput = z.object({
id: z.string().describe('设备主键 ID'),
licence: z.string().describe('设备授权码 licence'),
fingerprint: z.string().describe('UX 计算并持久化的设备指纹'),
platformPublicKey: z.string().describe('平台公钥Base64SPKI DER'),
pgpPublicKey: z.string().nullable().describe('设备 OpenPGP 公钥ASCII armored'),
createdAt: z.date().describe('记录创建时间'),
updatedAt: z.date().describe('记录更新时间'),
})
export const register = oc
.route({
method: 'POST',
path: '/device/register',
operationId: 'deviceRegister',
summary: '注册设备',
description: '注册 licence 与平台公钥,指纹由 UX 本机计算,返回设备信息。',
tags: ['Device'],
})
.input(
z.object({
licence: z.string().min(1).describe('设备授权码 licence'),
platformPublicKey: z.string().min(1).describe('平台公钥Base64SPKI DER'),
}),
)
.output(deviceOutput)
export const get = oc
.route({
method: 'POST',
path: '/device/get',
operationId: 'deviceGet',
summary: '查询设备',
description: '按 id 或 licence 查询设备信息。',
tags: ['Device'],
})
.input(
z.object({
id: z.string().optional().describe('设备 ID与 licence 二选一'),
licence: z.string().optional().describe('设备授权码,与 id 二选一'),
}),
)
.output(deviceOutput)

View File

@@ -1,7 +1,11 @@
import * as todo from './todo.contract'
import * as crypto from './crypto.contract'
import * as device from './device.contract'
import * as task from './task.contract'
export const contract = {
todo,
device,
crypto,
task,
}
export type Contract = typeof contract

View File

@@ -0,0 +1,71 @@
import { oc } from '@orpc/contract'
import { z } from 'zod'
const taskOutput = z.object({
id: z.string().describe('任务记录 ID'),
deviceId: z.string().describe('设备 ID'),
taskId: z.string().describe('任务业务 ID'),
enterpriseId: z.string().nullable().describe('企业 ID'),
orgName: z.string().nullable().describe('单位名称'),
inspectionId: z.string().nullable().describe('检查 ID'),
inspectionPerson: z.string().nullable().describe('检查人'),
issuedAt: z.date().nullable().describe('任务发布时间ISO date-time由毫秒时间戳转换后存储'),
status: z.enum(['pending', 'in_progress', 'done']).describe('任务状态'),
createdAt: z.date().describe('记录创建时间'),
updatedAt: z.date().describe('记录更新时间'),
})
export const save = oc
.route({
method: 'POST',
path: '/task/save',
operationId: 'taskSave',
summary: '保存任务',
description: '保存解密后的任务信息到 UX 数据库。',
tags: ['Task'],
})
.input(
z.object({
deviceId: z.string().min(1).describe('设备 ID'),
taskId: z.string().min(1).describe('任务 ID'),
enterpriseId: z.string().optional().describe('企业 ID'),
orgName: z.string().optional().describe('单位名称'),
inspectionId: z.string().optional().describe('检查 ID'),
inspectionPerson: z.string().optional().describe('检查人'),
issuedAt: z.number().optional().describe('任务发布时间戳(毫秒)'),
}),
)
.output(taskOutput)
export const list = oc
.route({
method: 'POST',
path: '/task/list',
operationId: 'taskList',
summary: '查询任务列表',
description: '按设备 ID 查询任务列表。',
tags: ['Task'],
})
.input(
z.object({
deviceId: z.string().min(1).describe('设备 ID'),
}),
)
.output(z.array(taskOutput))
export const updateStatus = oc
.route({
method: 'POST',
path: '/task/update-status',
operationId: 'taskUpdateStatus',
summary: '更新任务状态',
description: '按记录 ID 更新任务状态。',
tags: ['Task'],
})
.input(
z.object({
id: z.string().min(1).describe('任务记录 ID'),
status: z.enum(['pending', 'in_progress', 'done']).describe('目标状态'),
}),
)
.output(taskOutput)

View File

@@ -1,43 +0,0 @@
import { oc } from '@orpc/contract'
import {
createInsertSchema,
createSelectSchema,
createUpdateSchema,
} from 'drizzle-zod'
import { z } from 'zod'
import { todoTable } from '@/server/db/schema'
const selectSchema = createSelectSchema(todoTable)
const insertSchema = createInsertSchema(todoTable).omit({
id: true,
createdAt: true,
updatedAt: true,
})
const updateSchema = createUpdateSchema(todoTable).omit({
id: true,
createdAt: true,
updatedAt: true,
})
export const list = oc.input(z.void()).output(z.array(selectSchema))
export const create = oc.input(insertSchema).output(selectSchema)
export const update = oc
.input(
z.object({
id: z.uuid(),
data: updateSchema,
}),
)
.output(selectSchema)
export const remove = oc
.input(
z.object({
id: z.uuid(),
}),
)
.output(z.void())

View File

@@ -0,0 +1,26 @@
import { ORPCError, ValidationError } from '@orpc/server'
import { z } from 'zod'
export const logError = (error: unknown) => {
console.error(error)
}
export const handleValidationError = (error: unknown) => {
if (error instanceof ORPCError && error.code === 'BAD_REQUEST' && error.cause instanceof ValidationError) {
// If you only use Zod you can safely cast to ZodIssue[] (per ORPC official docs)
const zodError = new z.ZodError(error.cause.issues as z.core.$ZodIssue[])
throw new ORPCError('INPUT_VALIDATION_FAILED', {
status: 422,
message: z.prettifyError(zodError),
data: z.flattenError(zodError),
cause: error.cause,
})
}
if (error instanceof ORPCError && error.code === 'INTERNAL_SERVER_ERROR' && error.cause instanceof ValidationError) {
throw new ORPCError('OUTPUT_VALIDATION_FAILED', {
cause: error.cause,
})
}
}

View File

@@ -1,4 +1,4 @@
import { os } from '@orpc/server'
import { os } from '@/server/api/server'
import { getDB } from '@/server/db'
export const db = os.middleware(async ({ context, next }) => {

View File

@@ -0,0 +1,307 @@
import {
aesGcmDecrypt,
aesGcmEncrypt,
hkdfSha256,
hmacSha256Base64,
pgpSignDetached,
rsaOaepEncrypt,
sha256,
sha256Hex,
} from '@furtherverse/crypto'
import { ORPCError } from '@orpc/server'
import type { JSZipObject } from 'jszip'
import JSZip from 'jszip'
import { z } from 'zod'
import { db } from '../middlewares'
import { os } from '../server'
interface DeviceRow {
id: string
licence: string
fingerprint: string
platformPublicKey: string
pgpPrivateKey: string | null
pgpPublicKey: string | null
}
interface ReportFiles {
assets: Uint8Array
vulnerabilities: Uint8Array
weakPasswords: Uint8Array
reportHtml: Uint8Array
reportHtmlName: string
}
const MAX_RAW_ZIP_BYTES = 50 * 1024 * 1024
const MAX_SINGLE_FILE_BYTES = 20 * 1024 * 1024
const MAX_TOTAL_UNCOMPRESSED_BYTES = 60 * 1024 * 1024
const MAX_ZIP_ENTRIES = 32
const taskPayloadSchema = z.object({
taskId: z.string().min(1),
enterpriseId: z.string().min(1),
orgName: z.string().min(1),
inspectionId: z.string().min(1),
inspectionPerson: z.string().min(1),
issuedAt: z.number(),
})
const normalizePath = (name: string): string => name.replaceAll('\\', '/')
const isUnsafePath = (name: string): boolean => {
const normalized = normalizePath(name)
const segments = normalized.split('/')
return (
normalized.startsWith('/') ||
normalized.includes('\u0000') ||
segments.some((segment) => segment === '..' || segment.trim().length === 0)
)
}
const getBaseName = (name: string): string => {
const normalized = normalizePath(name)
const parts = normalized.split('/')
return parts.at(-1) ?? normalized
}
const getRequiredReportFiles = async (rawZip: JSZip): Promise<ReportFiles> => {
let assets: Uint8Array | null = null
let vulnerabilities: Uint8Array | null = null
let weakPasswords: Uint8Array | null = null
let reportHtml: Uint8Array | null = null
let reportHtmlName: string | null = null
const entries = Object.values(rawZip.files) as JSZipObject[]
if (entries.length > MAX_ZIP_ENTRIES) {
throw new ORPCError('BAD_REQUEST', {
message: `Zip contains too many entries: ${entries.length}`,
})
}
let totalUncompressedBytes = 0
for (const entry of entries) {
if (entry.dir) {
continue
}
if (isUnsafePath(entry.name)) {
throw new ORPCError('BAD_REQUEST', {
message: `Zip contains unsafe entry path: ${entry.name}`,
})
}
const content = await entry.async('uint8array')
if (content.byteLength > MAX_SINGLE_FILE_BYTES) {
throw new ORPCError('BAD_REQUEST', {
message: `Zip entry too large: ${entry.name}`,
})
}
totalUncompressedBytes += content.byteLength
if (totalUncompressedBytes > MAX_TOTAL_UNCOMPRESSED_BYTES) {
throw new ORPCError('BAD_REQUEST', {
message: 'Zip total uncompressed content exceeds max size limit',
})
}
const fileName = getBaseName(entry.name)
const lowerFileName = fileName.toLowerCase()
if (lowerFileName === 'assets.json') {
if (assets) {
throw new ORPCError('BAD_REQUEST', { message: 'Zip contains duplicate assets.json' })
}
assets = content
continue
}
if (lowerFileName === 'vulnerabilities.json') {
if (vulnerabilities) {
throw new ORPCError('BAD_REQUEST', { message: 'Zip contains duplicate vulnerabilities.json' })
}
vulnerabilities = content
continue
}
if (lowerFileName === 'weakpasswords.json') {
if (weakPasswords) {
throw new ORPCError('BAD_REQUEST', { message: 'Zip contains duplicate weakPasswords.json' })
}
weakPasswords = content
continue
}
if (fileName.includes('漏洞评估报告') && lowerFileName.endsWith('.html')) {
if (reportHtml) {
throw new ORPCError('BAD_REQUEST', {
message: 'Zip contains multiple 漏洞评估报告*.html files',
})
}
reportHtml = content
reportHtmlName = fileName
}
}
if (!assets || !vulnerabilities || !weakPasswords || !reportHtml || !reportHtmlName) {
throw new ORPCError('BAD_REQUEST', {
message:
'Zip missing required files. Required: assets.json, vulnerabilities.json, weakPasswords.json, and 漏洞评估报告*.html',
})
}
return {
assets,
vulnerabilities,
weakPasswords,
reportHtml,
reportHtmlName,
}
}
const getDevice = async (
context: {
db: { query: { deviceTable: { findFirst: (args: { where: { id: string } }) => Promise<DeviceRow | undefined> } } }
},
deviceId: string,
): Promise<DeviceRow> => {
const device = await context.db.query.deviceTable.findFirst({
where: { id: deviceId },
})
if (!device) {
throw new ORPCError('NOT_FOUND', { message: 'Device not found' })
}
return device
}
export const encryptDeviceInfo = os.crypto.encryptDeviceInfo.use(db).handler(async ({ context, input }) => {
const device = await getDevice(context, input.deviceId)
const deviceInfoJson = JSON.stringify({
licence: device.licence,
fingerprint: device.fingerprint,
})
const encrypted = rsaOaepEncrypt(deviceInfoJson, device.platformPublicKey)
return { encrypted }
})
export const decryptTask = os.crypto.decryptTask.use(db).handler(async ({ context, input }) => {
const device = await getDevice(context, input.deviceId)
const key = sha256(device.licence + device.fingerprint)
const decryptedJson = aesGcmDecrypt(input.encryptedData, key)
const taskData = taskPayloadSchema.parse(JSON.parse(decryptedJson))
return taskData
})
export const encryptSummary = os.crypto.encryptSummary.use(db).handler(async ({ context, input }) => {
const device = await getDevice(context, input.deviceId)
const ikm = device.licence + device.fingerprint
const aesKey = hkdfSha256(ikm, input.taskId, 'inspection_report_encryption')
const timestamp = Date.now()
const plaintextJson = JSON.stringify({
enterpriseId: input.enterpriseId,
inspectionId: input.inspectionId,
summary: input.summary,
timestamp,
})
const encrypted = aesGcmEncrypt(plaintextJson, aesKey)
const qrContent = JSON.stringify({
taskId: input.taskId,
encrypted,
})
return { qrContent }
})
export const signAndPackReport = os.crypto.signAndPackReport.use(db).handler(async ({ context, input }) => {
const device = await getDevice(context, input.deviceId)
const rawZipArrayBuffer = await input.rawZip.arrayBuffer()
const rawZipBytes = Buffer.from(rawZipArrayBuffer)
if (rawZipBytes.byteLength === 0 || rawZipBytes.byteLength > MAX_RAW_ZIP_BYTES) {
throw new ORPCError('BAD_REQUEST', {
message: 'rawZip is empty or exceeds max size limit',
})
}
const rawZip = await JSZip.loadAsync(rawZipBytes, {
checkCRC32: true,
}).catch(() => {
throw new ORPCError('BAD_REQUEST', {
message: 'rawZip is not a valid zip file',
})
})
const reportFiles = await getRequiredReportFiles(rawZip)
const ikm = device.licence + device.fingerprint
const signingKey = hkdfSha256(ikm, 'AUTH_V3_SALT', 'device_report_signature')
const assetsHash = sha256Hex(Buffer.from(reportFiles.assets))
const vulnerabilitiesHash = sha256Hex(Buffer.from(reportFiles.vulnerabilities))
const weakPasswordsHash = sha256Hex(Buffer.from(reportFiles.weakPasswords))
const reportHtmlHash = sha256Hex(Buffer.from(reportFiles.reportHtml))
const signPayload =
input.taskId + input.inspectionId + assetsHash + vulnerabilitiesHash + weakPasswordsHash + reportHtmlHash
const deviceSignature = hmacSha256Base64(signingKey, signPayload)
if (!device.pgpPrivateKey) {
throw new ORPCError('PRECONDITION_FAILED', {
message: 'Device does not have a PGP key pair. Re-register the device.',
})
}
const summaryObject = {
enterpriseId: input.enterpriseId,
inspectionId: input.inspectionId,
taskId: input.taskId,
licence: device.licence,
fingerprint: device.fingerprint,
deviceSignature,
summary: input.summary,
timestamp: Date.now(),
}
const summaryBytes = Buffer.from(JSON.stringify(summaryObject), 'utf-8')
const manifestObject = {
files: {
'summary.json': sha256Hex(summaryBytes),
'assets.json': assetsHash,
'vulnerabilities.json': vulnerabilitiesHash,
'weakPasswords.json': weakPasswordsHash,
[reportFiles.reportHtmlName]: reportHtmlHash,
},
}
const manifestBytes = Buffer.from(JSON.stringify(manifestObject, null, 2), 'utf-8')
const signatureAsc = await pgpSignDetached(manifestBytes, device.pgpPrivateKey)
const signedZip = new JSZip()
signedZip.file('summary.json', summaryBytes)
signedZip.file('assets.json', reportFiles.assets)
signedZip.file('vulnerabilities.json', reportFiles.vulnerabilities)
signedZip.file('weakPasswords.json', reportFiles.weakPasswords)
signedZip.file(reportFiles.reportHtmlName, reportFiles.reportHtml)
signedZip.file('META-INF/manifest.json', manifestBytes)
signedZip.file('META-INF/signature.asc', signatureAsc)
const signedZipBytes = await signedZip.generateAsync({
type: 'uint8array',
compression: 'DEFLATE',
compressionOptions: { level: 9 },
})
return new File([Buffer.from(signedZipBytes)], `${input.taskId}-signed-report.zip`, {
type: 'application/zip',
})
})

View File

@@ -0,0 +1,54 @@
import { generatePgpKeyPair } from '@furtherverse/crypto'
import { ORPCError } from '@orpc/server'
import { deviceTable } from '@/server/db/schema'
import { computeDeviceFingerprint } from '@/server/device-fingerprint'
import { db } from '../middlewares'
import { os } from '../server'
export const register = os.device.register.use(db).handler(async ({ context, input }) => {
const existing = await context.db.query.deviceTable.findFirst({
where: { licence: input.licence },
})
if (existing) {
throw new ORPCError('CONFLICT', {
message: `Device with licence "${input.licence}" already registered`,
})
}
const pgpKeys = await generatePgpKeyPair(input.licence, `${input.licence}@ux.local`)
const fingerprint = computeDeviceFingerprint()
const rows = await context.db
.insert(deviceTable)
.values({
licence: input.licence,
fingerprint,
platformPublicKey: input.platformPublicKey,
pgpPrivateKey: pgpKeys.privateKey,
pgpPublicKey: pgpKeys.publicKey,
})
.returning()
return rows[0] as (typeof rows)[number]
})
export const get = os.device.get.use(db).handler(async ({ context, input }) => {
if (!input.id && !input.licence) {
throw new ORPCError('BAD_REQUEST', {
message: 'Either id or licence must be provided',
})
}
const device = input.id
? await context.db.query.deviceTable.findFirst({ where: { id: input.id } })
: await context.db.query.deviceTable.findFirst({ where: { licence: input.licence } })
if (!device) {
throw new ORPCError('NOT_FOUND', {
message: 'Device not found',
})
}
return device
})

View File

@@ -1,6 +1,10 @@
import { os } from '../server'
import * as todo from './todo.router'
import * as crypto from './crypto.router'
import * as device from './device.router'
import * as task from './task.router'
export const router = os.router({
todo,
device,
crypto,
task,
})

View File

@@ -0,0 +1,44 @@
import { ORPCError } from '@orpc/server'
import { eq } from 'drizzle-orm'
import { taskTable } from '@/server/db/schema'
import { db } from '../middlewares'
import { os } from '../server'
export const save = os.task.save.use(db).handler(async ({ context, input }) => {
const rows = await context.db
.insert(taskTable)
.values({
deviceId: input.deviceId,
taskId: input.taskId,
enterpriseId: input.enterpriseId,
orgName: input.orgName,
inspectionId: input.inspectionId,
inspectionPerson: input.inspectionPerson,
issuedAt: input.issuedAt ? new Date(input.issuedAt) : null,
})
.returning()
return rows[0] as (typeof rows)[number]
})
export const list = os.task.list.use(db).handler(async ({ context, input }) => {
return await context.db.query.taskTable.findMany({
where: { deviceId: input.deviceId },
orderBy: { createdAt: 'desc' },
})
})
export const updateStatus = os.task.updateStatus.use(db).handler(async ({ context, input }) => {
const rows = await context.db
.update(taskTable)
.set({ status: input.status })
.where(eq(taskTable.id, input.id))
.returning()
const updated = rows[0]
if (!updated) {
throw new ORPCError('NOT_FOUND', { message: 'Task not found' })
}
return updated
})

View File

@@ -1,49 +0,0 @@
import { ORPCError } from '@orpc/server'
import { eq } from 'drizzle-orm'
import { todoTable } from '@/server/db/schema'
import { db } from '../middlewares'
import { os } from '../server'
export const list = os.todo.list.use(db).handler(async ({ context }) => {
const todos = await context.db.query.todoTable.findMany({
orderBy: (todos, { desc }) => [desc(todos.createdAt)],
})
return todos
})
export const create = os.todo.create
.use(db)
.handler(async ({ context, input }) => {
const [newTodo] = await context.db
.insert(todoTable)
.values(input)
.returning()
if (!newTodo) {
throw new ORPCError('NOT_FOUND')
}
return newTodo
})
export const update = os.todo.update
.use(db)
.handler(async ({ context, input }) => {
const [updatedTodo] = await context.db
.update(todoTable)
.set(input.data)
.where(eq(todoTable.id, input.id))
.returning()
if (!updatedTodo) {
throw new ORPCError('NOT_FOUND')
}
return updatedTodo
})
export const remove = os.todo.remove
.use(db)
.handler(async ({ context, input }) => {
await context.db.delete(todoTable).where(eq(todoTable.id, input.id))
})

View File

@@ -1,8 +1,4 @@
import type {
ContractRouterClient,
InferContractRouterInputs,
InferContractRouterOutputs,
} from '@orpc/contract'
import type { ContractRouterClient, InferContractRouterInputs, InferContractRouterOutputs } from '@orpc/contract'
import type { Contract } from './contracts'
export type RouterClient = ContractRouterClient<Contract>

View File

@@ -0,0 +1,36 @@
import { integer, text } from 'drizzle-orm/sqlite-core'
import { v7 as uuidv7 } from 'uuid'
export const pk = (name = 'id') =>
text(name)
.primaryKey()
.$defaultFn(() => uuidv7())
export const createdAt = (name = 'created_at') =>
integer(name, { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date())
export const updatedAt = (name = 'updated_at') =>
integer(name, { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date())
.$onUpdateFn(() => new Date())
export const generatedFields = {
id: pk('id'),
createdAt: createdAt('created_at'),
updatedAt: updatedAt('updated_at'),
}
const createGeneratedFieldKeys = <T extends Record<string, unknown>>(fields: T): Record<keyof T, true> => {
return Object.keys(fields).reduce(
(acc, key) => {
acc[key as keyof T] = true
return acc
},
{} as Record<keyof T, true>,
)
}
export const generatedFieldKeys = createGeneratedFieldKeys(generatedFields)

View File

@@ -1,15 +1,14 @@
import { drizzle } from 'drizzle-orm/postgres-js'
import { Database } from 'bun:sqlite'
import { drizzle } from 'drizzle-orm/bun-sqlite'
import { env } from '@/env'
import * as schema from '@/server/db/schema'
import { relations } from '@/server/db/relations'
export const createDB = () =>
drizzle({
connection: {
url: env.DATABASE_URL,
prepare: true,
},
schema,
})
export const createDB = () => {
const sqlite = new Database(env.DATABASE_PATH)
sqlite.exec('PRAGMA journal_mode = WAL')
sqlite.exec('PRAGMA foreign_keys = ON')
return drizzle({ client: sqlite, relations })
}
export type DB = ReturnType<typeof createDB>

View File

@@ -0,0 +1,14 @@
import { defineRelations } from 'drizzle-orm'
import * as schema from './schema'
export const relations = defineRelations(schema, (r) => ({
deviceTable: {
tasks: r.many.taskTable(),
},
taskTable: {
device: r.one.deviceTable({
from: r.taskTable.deviceId,
to: r.deviceTable.id,
}),
},
}))

View File

@@ -0,0 +1,11 @@
import { sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { generatedFields } from '../fields'
export const deviceTable = sqliteTable('device', {
...generatedFields,
licence: text('licence').notNull().unique(),
fingerprint: text('fingerprint').notNull(),
platformPublicKey: text('platform_public_key').notNull(),
pgpPrivateKey: text('pgp_private_key'),
pgpPublicKey: text('pgp_public_key'),
})

View File

@@ -1 +1,2 @@
export * from './todo'
export * from './device'
export * from './task'

View File

@@ -0,0 +1,16 @@
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { generatedFields } from '../fields'
export const taskTable = sqliteTable('task', {
...generatedFields,
deviceId: text('device_id').notNull(),
taskId: text('task_id').notNull(),
enterpriseId: text('enterprise_id'),
orgName: text('org_name'),
inspectionId: text('inspection_id'),
inspectionPerson: text('inspection_person'),
issuedAt: integer('issued_at', { mode: 'timestamp_ms' }),
status: text('status', { enum: ['pending', 'in_progress', 'done'] })
.notNull()
.default('pending'),
})

View File

@@ -1,8 +0,0 @@
import { boolean, pgTable, text } from 'drizzle-orm/pg-core'
import { generatedFields } from './utils/field'
export const todoTable = pgTable('todo', {
...generatedFields,
title: text('title').notNull(),
completed: boolean('completed').notNull().default(false),
})

View File

@@ -1,58 +0,0 @@
import { sql } from 'drizzle-orm'
import { timestamp, uuid } from 'drizzle-orm/pg-core'
import { v7 as uuidv7 } from 'uuid'
// id
export const id = (name: string) => uuid(name)
export const pk = (name: string, strategy?: 'native' | 'extension') => {
switch (strategy) {
// PG 18+
case 'native':
return id(name).primaryKey().default(sql`uuidv7()`)
// PG 13+ with extension
case 'extension':
return id(name).primaryKey().default(sql`uuid_generate_v7()`)
// Any PG version
default:
return id(name)
.primaryKey()
.$defaultFn(() => uuidv7())
}
}
// timestamp
export const createdAt = (name = 'created_at') =>
timestamp(name, { withTimezone: true }).notNull().defaultNow()
export const updatedAt = (name = 'updated_at') =>
timestamp(name, { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date())
// generated fields
export const generatedFields = {
id: pk('id'),
createdAt: createdAt('created_at'),
updatedAt: updatedAt('updated_at'),
}
// Helper to create omit keys from generatedFields
const createGeneratedFieldKeys = <T extends Record<string, unknown>>(
fields: T,
): Record<keyof T, true> => {
return Object.keys(fields).reduce(
(acc, key) => {
acc[key as keyof T] = true
return acc
},
{} as Record<keyof T, true>,
)
}
export const generatedFieldKeys = createGeneratedFieldKeys(generatedFields)

View File

@@ -0,0 +1,39 @@
import { readFileSync } from 'node:fs'
import { arch, cpus, networkInterfaces, platform, release, totalmem } from 'node:os'
import { sha256Hex } from '@furtherverse/crypto'
const readMachineId = (): string => {
const candidates = ['/etc/machine-id', '/var/lib/dbus/machine-id']
for (const path of candidates) {
try {
const value = readFileSync(path, 'utf-8').trim()
if (value.length > 0) {
return value
}
} catch {}
}
return ''
}
const collectMacAddresses = (): string[] => {
const interfaces = networkInterfaces()
return Object.values(interfaces)
.flatMap((group) => group ?? [])
.filter((item) => item.mac && item.mac !== '00:00:00:00:00:00' && !item.internal)
.map((item) => item.mac)
.sort()
}
export const computeDeviceFingerprint = (): string => {
const machineId = readMachineId()
const firstCpuModel = cpus()[0]?.model ?? 'unknown'
const macs = collectMacAddresses().join(',')
const source = [machineId, platform(), release(), arch(), String(totalmem()), firstCpuModel, macs].join('|')
const hash = sha256Hex(source)
return `FP-${hash.slice(0, 16)}`
}

View File

@@ -2,9 +2,46 @@
"$schema": "../../node_modules/turbo/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"env": ["NODE_ENV", "VITE_*"],
"inputs": ["src/**", "public/**", "package.json", "tsconfig.json", "vite.config.ts"],
"outputs": [".output/**"]
},
"compile": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:darwin": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:darwin:arm64": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:darwin:x64": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:linux": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:linux:arm64": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:linux:x64": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:windows": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:windows:x64": {
"dependsOn": ["build"],
"outputs": ["out/**"]
}
}
}

View File

@@ -10,24 +10,21 @@ export default defineConfig({
clearScreen: false,
plugins: [
tanstackDevtools(),
nitro({
preset: 'bun',
serveStatic: 'inline',
}),
tsconfigPaths(),
tailwindcss(),
tsconfigPaths(),
tanstackStart(),
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
},
}),
nitro({
preset: 'bun',
serveStatic: 'inline',
}),
],
server: {
port: 3000,
strictPort: true,
watch: {
ignored: ['**/src-tauri/**'],
},
},
})

View File

@@ -11,7 +11,8 @@
"formatter": {
"enabled": true,
"indentStyle": "space",
"lineEnding": "lf"
"lineEnding": "lf",
"lineWidth": 120
},
"linter": {
"enabled": true,

1824
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
# UX 授权端接口说明
本文档描述当前 UX 服务端实现的授权对接接口与职责边界。
## 1. 职责边界
- UX **只与工具箱交互**HTTP RPC不直接与手机 App 交互。
- 手机 App 仅承担扫码和与管理平台联网通信。
- 报告签名流程由工具箱上传原始 ZIP 到 UXUX 返回已签名 ZIP。
## 2. 设备注册
`device.register`
- 输入:`licence``platformPublicKey`
- UX 在本机采集设备特征并计算 `fingerprint`
- UX 将 `licence + fingerprint + 公钥 + PGP 密钥对` 持久化到数据库
## 3. 核心加密接口
### 3.1 设备授权二维码密文
`crypto.encryptDeviceInfo`
- 使用平台公钥 RSA-OAEP 加密:`{ licence, fingerprint }`
- 返回 Base64 密文(工具箱用于生成二维码)
### 3.2 任务二维码解密
`crypto.decryptTask`
- 密钥:`SHA256(licence + fingerprint)`
- 算法AES-256-GCM
- 输入:任务二维码中的 Base64 密文
- 输出:任务 JSON
### 3.3 摘要二维码加密
`crypto.encryptSummary`
- 密钥派生HKDF-SHA256
- `ikm = licence + fingerprint`
- `salt = taskId`
- `info = "inspection_report_encryption"`
- 算法AES-256-GCM
- 输出:`{ taskId, encrypted }` JSON工具箱用于生成二维码
### 3.4 原始 ZIP 签名打包(最终报告)
`crypto.signAndPackReport`
- 输入:`rawZip``multipart/form-data` 文件字段) + `taskId` + `inspectionId` + `enterpriseId` + `summary`
- UX 在服务端完成:
1. 校验并解包原始 ZIP
2. 计算文件 SHA-256
3. HKDF + HMAC 生成 `deviceSignature`
4. 生成 `summary.json`
5. 生成 `META-INF/manifest.json`
6. OpenPGP 分离签名生成 `META-INF/signature.asc`
7. 重新打包为 signed ZIP
- 输出:签名后 ZIP 文件(二进制响应,`application/zip`
## 4. 安全约束(签名打包)
- 拒绝危险 ZIP 路径(防 Zip Slip
- 限制原始 ZIP 和单文件大小
- 强制存在以下文件:
- `assets.json`
- `vulnerabilities.json`
- `weakPasswords.json`
- `漏洞评估报告*.html`

View File

@@ -0,0 +1,644 @@
# 工具箱端 - 任务二维码解密指南
## 概述
本文档说明工具箱端如何解密任务二维码数据。App 创建任务后,平台会生成加密的任务数据并返回给 AppApp 将其生成二维码。工具箱扫描二维码后,需要使用自己的 `licence``fingerprint` 解密任务数据。
> ### UX 集成模式补充(当前项目实现)
>
> 在当前集成模式中,工具箱扫描二维码后将密文提交给 UX 的 `crypto.decryptTask`
> 由 UX 使用设备绑定的 `licence + fingerprint` 执行 AES-256-GCM 解密并返回任务明文。
## 一、业务流程
```
App创建任务 → 平台加密任务数据 → 返回加密数据 → App生成二维码
工具箱扫描二维码 → 提取加密数据 → AES-256-GCM解密 → 获取任务信息
```
## 二、任务数据结构
### 2.1 任务数据 JSON 格式
解密后的任务数据为 JSON 格式,包含以下字段:
```json
{
"taskId": "TASK-20260115-4875",
"enterpriseId": "1173040813421105152",
"orgName": "超艺科技有限公司",
"inspectionId": "702286470691215417",
"inspectionPerson": "警务通",
"issuedAt": 1734571234567
}
```
### 2.2 字段说明
| 字段名 | 类型 | 说明 | 示例 |
|--------|------|------|------|
| `taskId` | String | 任务唯一ID格式TASK-YYYYMMDD-XXXX | `"TASK-20260115-4875"` |
| `enterpriseId` | String | 企业ID | `"1173040813421105152"` |
| `orgName` | String | 单位名称 | `"超艺科技有限公司"` |
| `inspectionId` | String | 检查ID | `"702286470691215417"` |
| `inspectionPerson` | String | 检查人 | `"警务通"` |
| `issuedAt` | Number | 任务发布时间戳(毫秒) | `1734571234567` |
## 三、加密算法说明
### 3.1 加密方式
- **算法**AES-256-GCMGalois/Counter Mode
- **密钥长度**256 位32 字节)
- **IV 长度**12 字节96 位)
- **认证标签长度**16 字节128 位)
### 3.2 密钥生成
密钥由工具箱的 `licence``fingerprint` 生成:
```
密钥 = SHA-256(licence + fingerprint)
```
**重要说明**
- `licence``fingerprint` 直接字符串拼接(无分隔符)
- 使用 SHA-256 哈希算法的全部 32 字节作为 AES-256 密钥
- 工具箱必须使用与平台绑定时相同的 `licence``fingerprint`
### 3.3 加密数据格式
加密后的数据格式Base64 编码前):
```
[IV(12字节)] + [加密数据] + [认证标签(16字节)]
```
**数据布局**
```
+------------------+------------------+------------------+
| IV (12字节) | 加密数据 | 认证标签(16字节)|
+------------------+------------------+------------------+
```
## 四、解密步骤
### 4.1 解密流程
1. **扫描二维码**:获取 Base64 编码的加密数据
2. **Base64 解码**:将 Base64 字符串解码为字节数组
3. **分离数据**:从字节数组中分离 IV、加密数据和认证标签
4. **生成密钥**:使用 `licence + fingerprint` 生成 AES-256 密钥
5. **解密数据**:使用 AES-256-GCM 解密(自动验证认证标签)
6. **解析 JSON**:将解密后的字符串解析为 JSON 对象
### 4.2 Python 实现示例
```python
import base64
import json
import hashlib
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.backends import default_backend
def decrypt_task_data(
encrypted_data_base64: str,
licence: str,
fingerprint: str
) -> dict:
"""
解密任务二维码数据
Args:
encrypted_data_base64: Base64编码的加密数据
licence: 设备授权码
fingerprint: 设备硬件指纹
Returns:
解密后的任务数据(字典)
"""
# 1. Base64 解码
encrypted_bytes = base64.b64decode(encrypted_data_base64)
# 2. 分离 IV 和加密数据(包含认证标签)
if len(encrypted_bytes) < 12:
raise ValueError("加密数据格式错误:数据长度不足")
iv = encrypted_bytes[:12] # IV: 前12字节
ciphertext_with_tag = encrypted_bytes[12:] # 加密数据 + 认证标签
# 3. 生成密钥SHA-256(licence + fingerprint)
combined = licence + fingerprint
key = hashlib.sha256(combined.encode('utf-8')).digest()
# 4. 使用 AES-256-GCM 解密
aesgcm = AESGCM(key)
decrypted_bytes = aesgcm.decrypt(iv, ciphertext_with_tag, None)
# 5. 解析 JSON
decrypted_json = decrypted_bytes.decode('utf-8')
task_data = json.loads(decrypted_json)
return task_data
# 使用示例
if __name__ == "__main__":
# 从二维码扫描获取的加密数据
encrypted_data = "Base64编码的加密数据..."
# 工具箱的授权信息(必须与平台绑定时一致)
licence = "LIC-8F2A-XXXX"
fingerprint = "FP-2c91e9f3"
# 解密任务数据
task_data = decrypt_task_data(encrypted_data, licence, fingerprint)
print("任务ID:", task_data["taskId"])
print("企业ID:", task_data["enterpriseId"])
print("单位名称:", task_data["orgName"])
print("检查ID:", task_data["inspectionId"])
print("检查人:", task_data["inspectionPerson"])
print("发布时间:", task_data["issuedAt"])
```
### 4.3 Java/Kotlin 实现示例
```kotlin
import com.fasterxml.jackson.databind.ObjectMapper
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
object TaskDecryptionUtil {
private const val ALGORITHM = "AES"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val GCM_IV_LENGTH = 12 // GCM 推荐使用 12 字节 IV
private const val GCM_TAG_LENGTH = 16 // GCM 认证标签长度128位
private const val KEY_LENGTH = 32 // AES-256 密钥长度256位 = 32字节
private val objectMapper = ObjectMapper()
/**
* 解密任务二维码数据
*
* @param encryptedDataBase64 Base64编码的加密数据
* @param licence 设备授权码
* @param fingerprint 设备硬件指纹
* @return 解密后的任务数据Map
*/
fun decryptTaskData(
encryptedDataBase64: String,
licence: String,
fingerprint: String
): Map<String, Any> {
// 1. Base64 解码
val encryptedBytes = Base64.getDecoder().decode(encryptedDataBase64)
// 2. 分离 IV 和加密数据(包含认证标签)
if (encryptedBytes.size < GCM_IV_LENGTH) {
throw IllegalArgumentException("加密数据格式错误:数据长度不足")
}
val iv = encryptedBytes.sliceArray(0 until GCM_IV_LENGTH)
val ciphertextWithTag = encryptedBytes.sliceArray(GCM_IV_LENGTH until encryptedBytes.size)
// 3. 生成密钥SHA-256(licence + fingerprint)
val combined = "$licence$fingerprint"
val digest = MessageDigest.getInstance("SHA-256")
val keyBytes = digest.digest(combined.toByteArray(StandardCharsets.UTF_8))
val key = SecretKeySpec(keyBytes, ALGORITHM)
// 4. 使用 AES-256-GCM 解密
val cipher = Cipher.getInstance(TRANSFORMATION)
val parameterSpec = GCMParameterSpec(GCM_TAG_LENGTH * 8, iv) // 标签长度以位为单位
cipher.init(Cipher.DECRYPT_MODE, key, parameterSpec)
// 解密数据GCM 会自动验证认证标签)
val decryptedBytes = cipher.doFinal(ciphertextWithTag)
// 5. 解析 JSON
val decryptedJson = String(decryptedBytes, StandardCharsets.UTF_8)
@Suppress("UNCHECKED_CAST")
return objectMapper.readValue(decryptedJson, Map::class.java) as Map<String, Any>
}
}
// 使用示例
fun main() {
// 从二维码扫描获取的加密数据
val encryptedData = "Base64编码的加密数据..."
// 工具箱的授权信息(必须与平台绑定时一致)
val licence = "LIC-8F2A-XXXX"
val fingerprint = "FP-2c91e9f3"
// 解密任务数据
val taskData = TaskDecryptionUtil.decryptTaskData(encryptedData, licence, fingerprint)
println("任务ID: ${taskData["taskId"]}")
println("企业ID: ${taskData["enterpriseId"]}")
println("单位名称: ${taskData["orgName"]}")
println("检查ID: ${taskData["inspectionId"]}")
println("检查人: ${taskData["inspectionPerson"]}")
println("发布时间: ${taskData["issuedAt"]}")
}
```
### 4.4 C# 实现示例
```csharp
using System;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
public class TaskDecryptionUtil
{
private const int GcmIvLength = 12; // GCM 推荐使用 12 字节 IV
private const int GcmTagLength = 16; // GCM 认证标签长度128位
/// <summary>
/// 解密任务二维码数据
/// </summary>
public static Dictionary<string, object> DecryptTaskData(
string encryptedDataBase64,
string licence,
string fingerprint
)
{
// 1. Base64 解码
byte[] encryptedBytes = Convert.FromBase64String(encryptedDataBase64);
// 2. 分离 IV 和加密数据(包含认证标签)
if (encryptedBytes.Length < GcmIvLength)
{
throw new ArgumentException("加密数据格式错误:数据长度不足");
}
byte[] iv = new byte[GcmIvLength];
Array.Copy(encryptedBytes, 0, iv, 0, GcmIvLength);
byte[] ciphertextWithTag = new byte[encryptedBytes.Length - GcmIvLength];
Array.Copy(encryptedBytes, GcmIvLength, ciphertextWithTag, 0, ciphertextWithTag.Length);
// 3. 生成密钥SHA-256(licence + fingerprint)
string combined = licence + fingerprint;
byte[] keyBytes = SHA256.Create().ComputeHash(Encoding.UTF8.GetBytes(combined));
// 4. 使用 AES-256-GCM 解密
using (AesGcm aesGcm = new AesGcm(keyBytes))
{
byte[] decryptedBytes = new byte[ciphertextWithTag.Length - GcmTagLength];
byte[] tag = new byte[GcmTagLength];
Array.Copy(ciphertextWithTag, ciphertextWithTag.Length - GcmTagLength, tag, 0, GcmTagLength);
Array.Copy(ciphertextWithTag, 0, decryptedBytes, 0, decryptedBytes.Length);
aesGcm.Decrypt(iv, decryptedBytes, tag, null, decryptedBytes);
// 5. 解析 JSON
string decryptedJson = Encoding.UTF8.GetString(decryptedBytes);
return JsonSerializer.Deserialize<Dictionary<string, object>>(decryptedJson);
}
}
}
// 使用示例
class Program
{
static void Main()
{
// 从二维码扫描获取的加密数据
string encryptedData = "Base64编码的加密数据...";
// 工具箱的授权信息(必须与平台绑定时一致)
string licence = "LIC-8F2A-XXXX";
string fingerprint = "FP-2c91e9f3";
// 解密任务数据
var taskData = TaskDecryptionUtil.DecryptTaskData(encryptedData, licence, fingerprint);
Console.WriteLine($"任务ID: {taskData["taskId"]}");
Console.WriteLine($"企业ID: {taskData["enterpriseId"]}");
Console.WriteLine($"单位名称: {taskData["orgName"]}");
Console.WriteLine($"检查ID: {taskData["inspectionId"]}");
Console.WriteLine($"检查人: {taskData["inspectionPerson"]}");
Console.WriteLine($"发布时间: {taskData["issuedAt"]}");
}
}
```
## 五、完整流程示例
### 5.1 Python 完整示例(包含二维码扫描)
```python
import base64
import json
import hashlib
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from pyzbar import pyzbar
from PIL import Image
class TaskQRCodeDecoder:
"""任务二维码解码器"""
def __init__(self, licence: str, fingerprint: str):
"""
初始化解码器
Args:
licence: 设备授权码
fingerprint: 设备硬件指纹
"""
self.licence = licence
self.fingerprint = fingerprint
self._key = self._generate_key()
def _generate_key(self) -> bytes:
"""生成 AES-256 密钥"""
combined = self.licence + self.fingerprint
return hashlib.sha256(combined.encode('utf-8')).digest()
def scan_qr_code(self, qr_image_path: str) -> dict:
"""
扫描二维码并解密任务数据
Args:
qr_image_path: 二维码图片路径
Returns:
解密后的任务数据(字典)
"""
# 1. 扫描二维码
image = Image.open(qr_image_path)
qr_codes = pyzbar.decode(image)
if not qr_codes:
raise ValueError("未找到二维码")
# 获取二维码内容Base64编码的加密数据
encrypted_data_base64 = qr_codes[0].data.decode('utf-8')
print(f"扫描到二维码内容: {encrypted_data_base64[:50]}...")
# 2. 解密任务数据
return self.decrypt_task_data(encrypted_data_base64)
def decrypt_task_data(self, encrypted_data_base64: str) -> dict:
"""
解密任务数据
Args:
encrypted_data_base64: Base64编码的加密数据
Returns:
解密后的任务数据(字典)
"""
# 1. Base64 解码
encrypted_bytes = base64.b64decode(encrypted_data_base64)
# 2. 分离 IV 和加密数据(包含认证标签)
if len(encrypted_bytes) < 12:
raise ValueError("加密数据格式错误:数据长度不足")
iv = encrypted_bytes[:12] # IV: 前12字节
ciphertext_with_tag = encrypted_bytes[12:] # 加密数据 + 认证标签
# 3. 使用 AES-256-GCM 解密
aesgcm = AESGCM(self._key)
decrypted_bytes = aesgcm.decrypt(iv, ciphertext_with_tag, None)
# 4. 解析 JSON
decrypted_json = decrypted_bytes.decode('utf-8')
task_data = json.loads(decrypted_json)
return task_data
# 使用示例
if __name__ == "__main__":
# 工具箱的授权信息(必须与平台绑定时一致)
licence = "LIC-8F2A-XXXX"
fingerprint = "FP-2c91e9f3"
# 创建解码器
decoder = TaskQRCodeDecoder(licence, fingerprint)
# 扫描二维码并解密
try:
task_data = decoder.scan_qr_code("task_qr_code.png")
print("\n=== 任务信息 ===")
print(f"任务ID: {task_data['taskId']}")
print(f"企业ID: {task_data['enterpriseId']}")
print(f"单位名称: {task_data['orgName']}")
print(f"检查ID: {task_data['inspectionId']}")
print(f"检查人: {task_data['inspectionPerson']}")
print(f"发布时间: {task_data['issuedAt']}")
# 可以使用任务信息执行检查任务
# execute_inspection_task(task_data)
except Exception as e:
print(f"解密失败: {e}")
```
### 5.2 Java/Kotlin 完整示例(包含二维码扫描)
```kotlin
import com.fasterxml.jackson.databind.ObjectMapper
import com.google.zxing.BinaryBitmap
import com.google.zxing.MultiFormatReader
import com.google.zxing.Result
import com.google.zxing.client.j2se.BufferedImageLuminanceSource
import com.google.zxing.common.HybridBinarizer
import java.awt.image.BufferedImage
import java.io.File
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
import javax.imageio.ImageIO
class TaskQRCodeDecoder(
private val licence: String,
private val fingerprint: String
) {
private val key: SecretKeySpec by lazy {
val combined = "$licence$fingerprint"
val digest = MessageDigest.getInstance("SHA-256")
val keyBytes = digest.digest(combined.toByteArray(StandardCharsets.UTF_8))
SecretKeySpec(keyBytes, "AES")
}
private val objectMapper = ObjectMapper()
/**
* 扫描二维码并解密任务数据
*/
fun scanAndDecrypt(qrImagePath: String): Map<String, Any> {
// 1. 扫描二维码
val image: BufferedImage = ImageIO.read(File(qrImagePath))
val source = BufferedImageLuminanceSource(image)
val bitmap = BinaryBitmap(HybridBinarizer(source))
val reader = MultiFormatReader()
val result: Result = reader.decode(bitmap)
// 获取二维码内容Base64编码的加密数据
val encryptedDataBase64 = result.text
println("扫描到二维码内容: ${encryptedDataBase64.take(50)}...")
// 2. 解密任务数据
return decryptTaskData(encryptedDataBase64)
}
/**
* 解密任务数据
*/
fun decryptTaskData(encryptedDataBase64: String): Map<String, Any> {
// 1. Base64 解码
val encryptedBytes = Base64.getDecoder().decode(encryptedDataBase64)
// 2. 分离 IV 和加密数据(包含认证标签)
if (encryptedBytes.size < 12) {
throw IllegalArgumentException("加密数据格式错误:数据长度不足")
}
val iv = encryptedBytes.sliceArray(0 until 12)
val ciphertextWithTag = encryptedBytes.sliceArray(12 until encryptedBytes.size)
// 3. 使用 AES-256-GCM 解密
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val parameterSpec = GCMParameterSpec(16 * 8, iv) // 标签长度以位为单位
cipher.init(Cipher.DECRYPT_MODE, key, parameterSpec)
// 解密数据GCM 会自动验证认证标签)
val decryptedBytes = cipher.doFinal(ciphertextWithTag)
// 4. 解析 JSON
val decryptedJson = String(decryptedBytes, StandardCharsets.UTF_8)
@Suppress("UNCHECKED_CAST")
return objectMapper.readValue(decryptedJson, Map::class.java) as Map<String, Any>
}
}
// 使用示例
fun main() {
// 工具箱的授权信息(必须与平台绑定时一致)
val licence = "LIC-8F2A-XXXX"
val fingerprint = "FP-2c91e9f3"
// 创建解码器
val decoder = TaskQRCodeDecoder(licence, fingerprint)
// 扫描二维码并解密
try {
val taskData = decoder.scanAndDecrypt("task_qr_code.png")
println("\n=== 任务信息 ===")
println("任务ID: ${taskData["taskId"]}")
println("企业ID: ${taskData["enterpriseId"]}")
println("单位名称: ${taskData["orgName"]}")
println("检查ID: ${taskData["inspectionId"]}")
println("检查人: ${taskData["inspectionPerson"]}")
println("发布时间: ${taskData["issuedAt"]}")
// 可以使用任务信息执行检查任务
// executeInspectionTask(taskData)
} catch (e: Exception) {
println("解密失败: ${e.message}")
}
}
```
## 六、常见错误和注意事项
### 6.1 解密失败
**可能原因**
1. **密钥不匹配**`licence``fingerprint` 与平台绑定时不一致
- 确保使用与设备授权时相同的 `licence``fingerprint`
- 检查字符串拼接是否正确(无分隔符)
2. **数据格式错误**Base64 编码或数据布局错误
- 确保 Base64 解码正确
- 确保 IV 长度正确12 字节)
3. **认证标签验证失败**:数据被篡改或损坏
- GCM 模式会自动验证认证标签
- 如果验证失败,说明数据被篡改或密钥错误
4. **算法不匹配**:必须使用 `AES/GCM/NoPadding`
- 确保使用正确的加密算法
- 确保认证标签长度为 128 位16 字节)
### 6.2 二维码扫描失败
**可能原因**
1. **二维码图片质量差**:确保图片清晰,有足够的对比度
2. **二维码内容过长**:如果加密数据过长,可能需要更高版本的二维码
3. **扫描库不支持**:确保使用支持 Base64 字符串的二维码扫描库
### 6.3 JSON 解析失败
**可能原因**
1. **字符编码错误**:确保使用 UTF-8 编码
2. **JSON 格式错误**:确保解密后的字符串是有效的 JSON
3. **字段缺失**:确保所有必需字段都存在
## 七、安全设计说明
### 7.1 为什么使用 AES-256-GCM
1. **认证加密AEAD**GCM 模式提供加密和认证,防止数据被篡改
2. **强安全性**AES-256 提供 256 位密钥强度
3. **自动验证**GCM 模式会自动验证认证标签,任何篡改都会导致解密失败
### 7.2 为什么第三方无法解密
1. **密钥绑定**:只有拥有正确 `licence + fingerprint` 的工具箱才能生成正确的密钥
2. **认证标签**GCM 模式会验证认证标签,任何篡改都会导致解密失败
3. **密钥唯一性**:每个设备的 `licence + fingerprint` 组合是唯一的
### 7.3 密钥生成的安全性
1. **SHA-256 哈希**:使用强哈希算法生成密钥
2. **密钥长度**:使用全部 32 字节作为 AES-256 密钥
3. **密钥隔离**:每个设备的密钥是独立的,互不影响
## 八、测试建议
1. **单元测试**
- 测试密钥生成是否正确
- 测试解密功能是否正常
- 测试 JSON 解析是否正确
2. **集成测试**
- 使用真实平台生成的二维码进行测试
- 测试不同长度的任务数据
- 测试错误的密钥是否会导致解密失败
3. **边界测试**
- 测试超长的任务数据
- 测试特殊字符的处理
- 测试错误的 Base64 格式
## 九、参考实现
- **Python**`cryptography`AES-GCM 加密)、`pyzbar` 库(二维码扫描)
- **Java/Kotlin**JDK `javax.crypto`AES-GCM 加密、ZXing 库(二维码扫描)
- **C#**`System.Security.Cryptography`AES-GCM 加密、ZXing.Net 库(二维码扫描)
## 十、联系支持
如有问题,请联系平台技术支持团队获取:
- 测试环境地址
- 技术支持

View File

@@ -0,0 +1,646 @@
# 工具箱端 - 报告加密与签名生成指南
## 概述
本文档说明工具箱端如何生成加密和签名的检查报告 ZIP 文件,以确保:
1. **授权校验**:只有合法授权的工具箱才能生成有效的报告
2. **防篡改校验**:确保报告内容在传输过程中未被篡改
> ### UX 集成模式补充(当前项目实现)
>
> 在当前集成模式中,工具箱可将原始报告 ZIP 直接上传到 UX 的 `crypto.signAndPackReport`
>
> 1. UX 校验 ZIP 并提取必需文件;
> 2. UX 生成 `deviceSignature`、`summary.json`、`META-INF/manifest.json`、`META-INF/signature.asc`
> 3. UX 重新打包并返回签名后的 ZIP二进制文件响应工具箱再用于离线介质回传平台。
## 一、ZIP 文件结构要求
工具箱生成的 ZIP 文件必须包含以下文件:
```
report.zip
├── summary.json # 摘要信息(必须包含授权和签名字段)
├── assets.json # 资产信息(用于签名校验)
├── vulnerabilities.json # 漏洞信息(用于签名校验)
├── weakPasswords.json # 弱密码信息(用于签名校验)
├── 漏洞评估报告.html # 漏洞评估报告(用于签名校验)
└── META-INF/
├── manifest.json # 文件清单(用于 OpenPGP 签名)
└── signature.asc # OpenPGP 签名文件(防篡改)
```
## 二、授权校验 - 设备签名device_signature
### 2.1 目的
设备签名用于验证报告是由合法授权的工具箱生成的,防止第三方伪造扫描结果。
### 2.2 密钥派生
使用 **HKDF-SHA256** 从设备的 `licence``fingerprint` 派生签名密钥:
```
K = HKDF(
input = licence + fingerprint, # 输入密钥材料(字符串拼接)
salt = "AUTH_V3_SALT", # 固定盐值
info = "device_report_signature", # 固定信息参数
hash = SHA-256, # 哈希算法
length = 32 # 输出密钥长度32字节 = 256位
)
```
**伪代码示例**
```python
import hkdf
# 输入密钥材料
ikm = licence + fingerprint # 字符串直接拼接
# HKDF 参数
salt = "AUTH_V3_SALT"
info = "device_report_signature"
key_length = 32 # 32字节 = 256位
# 派生密钥
derived_key = hkdf.HKDF(
algorithm=hashlib.sha256,
length=key_length,
salt=salt.encode('utf-8'),
info=info.encode('utf-8'),
ikm=ikm.encode('utf-8')
).derive()
```
### 2.3 签名数据组装(严格顺序)
签名数据必须按照以下**严格顺序**组装:
```
sign_payload =
taskId + # 任务ID字符串
inspectionId + # 检查ID数字转字符串
SHA256(assets.json) + # assets.json 的 SHA256hex字符串小写
SHA256(vulnerabilities.json) + # vulnerabilities.json 的 SHA256hex字符串小写
SHA256(weakPasswords.json) + # weakPasswords.json 的 SHA256hex字符串小写
SHA256(漏洞评估报告.html) # 漏洞评估报告.html 的 SHA256hex字符串小写
```
**重要说明**
- 所有字符串直接拼接,**不添加任何分隔符**
- SHA256 哈希值必须是 **hex 字符串(小写)**,例如:`a1b2c3d4...`
- 文件内容必须是**原始字节**,不能进行任何编码转换
- 顺序必须严格一致,任何顺序错误都会导致签名验证失败
**伪代码示例**
```python
import hashlib
# 1. 读取文件内容(原始字节)
assets_content = read_file("assets.json")
vulnerabilities_content = read_file("vulnerabilities.json")
weak_passwords_content = read_file("weakPasswords.json")
report_html_content = read_file("漏洞评估报告.html")
# 2. 计算 SHA256hex字符串小写
def sha256_hex(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
assets_sha256 = sha256_hex(assets_content)
vulnerabilities_sha256 = sha256_hex(vulnerabilities_content)
weak_passwords_sha256 = sha256_hex(weak_passwords_content)
report_html_sha256 = sha256_hex(report_html_content)
# 3. 组装签名数据(严格顺序,直接拼接)
sign_payload = (
str(task_id) +
str(inspection_id) +
assets_sha256 +
vulnerabilities_sha256 +
weak_passwords_sha256 +
report_html_sha256
)
```
### 2.4 生成设备签名
使用 **HMAC-SHA256** 计算签名:
```
device_signature = Base64(HMAC-SHA256(key=K, data=sign_payload))
```
**伪代码示例**
```python
import hmac
import base64
# 使用派生密钥计算 HMAC-SHA256
mac = hmac.new(
key=derived_key, # 派生密钥32字节
msg=sign_payload.encode('utf-8'), # 签名数据UTF-8编码
digestmod=hashlib.sha256
)
# 计算签名
signature_bytes = mac.digest()
# Base64 编码
device_signature = base64.b64encode(signature_bytes).decode('utf-8')
```
### 2.5 写入 summary.json
`device_signature` 写入 `summary.json`
```json
{
"orgId": 1173040813421105152,
"checkId": 702286470691215417,
"taskId": "TASK-20260115-4875",
"licence": "LIC-8F2A-XXXX",
"fingerprint": "FP-2c91e9f3",
"deviceSignature": "Base64编码的签名值",
"summary": "检查摘要信息",
......
}
```
**必需字段**
- `licence`:设备授权码(字符串)
- `fingerprint`:设备硬件指纹(字符串)
- `taskId`任务ID字符串
- `deviceSignature`设备签名Base64字符串
- `checkId``inspectionId`检查ID数字
## 三、防篡改校验 - OpenPGP 签名
### 3.1 目的
OpenPGP 签名用于验证 ZIP 文件在传输过程中未被篡改,确保文件完整性。
### 3.2 生成 manifest.json
创建 `META-INF/manifest.json` 文件,包含所有文件的 SHA-256 哈希值:
```json
{
"files": {
"summary.json": "a1b2c3d4e5f6...",
"assets.json": "b2c3d4e5f6a1...",
"vulnerabilities.json": "c3d4e5f6a1b2...",
"weakPasswords.json": "d4e5f6a1b2c3...",
"漏洞评估报告.html": "e5f6a1b2c3d4..."
}
}
```
**伪代码示例**
```python
import hashlib
import json
def calculate_sha256_hex(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
# 计算所有文件的 SHA256
files_hashes = {
"summary.json": calculate_sha256_hex(summary_content),
"assets.json": calculate_sha256_hex(assets_content),
"vulnerabilities.json": calculate_sha256_hex(vulnerabilities_content),
"weakPasswords.json": calculate_sha256_hex(weak_passwords_content),
"漏洞评估报告.html": calculate_sha256_hex(report_html_content)
}
# 生成 manifest.json
manifest = {
"files": files_hashes
}
manifest_json = json.dumps(manifest, ensure_ascii=False, indent=2)
```
### 3.3 生成 OpenPGP 签名
使用工具箱的**私钥**对 `manifest.json` 进行 OpenPGP 签名,生成 `META-INF/signature.asc`
**伪代码示例(使用 Python gnupg**
```python
import gnupg
# 初始化 GPG
gpg = gnupg.GPG()
# 导入私钥(或使用已配置的密钥)
# gpg.import_keys(private_key_data)
# 对 manifest.json 进行签名
with open('META-INF/manifest.json', 'rb') as f:
signed_data = gpg.sign_file(
f,
detach=True, # 分离式签名
clearsign=False, # 不使用明文签名
output='META-INF/signature.asc'
)
```
**伪代码示例(使用 BouncyCastle - Java/Kotlin**
```kotlin
import org.bouncycastle.openpgp.*
import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder
import org.bouncycastle.openpgp.operator.jcajce.JcaPGPPrivateKey
import java.io.ByteArrayOutputStream
import java.io.FileOutputStream
fun generatePGPSignature(
manifestContent: ByteArray,
privateKey: PGPPrivateKey,
publicKey: PGPPublicKey
): ByteArray {
val signatureGenerator = PGPSignatureGenerator(
JcaPGPContentSignerBuilder(publicKey.algorithm, PGPUtil.SHA256)
)
signatureGenerator.init(PGPSignature.BINARY_DOCUMENT, privateKey)
signatureGenerator.update(manifestContent)
val signature = signatureGenerator.generate()
val signatureList = PGPSignatureList(signature)
val out = ByteArrayOutputStream()
val pgpOut = PGPObjectFactory(PGPUtil.getEncoderStream(out))
signatureList.encode(out)
return out.toByteArray()
}
```
### 3.4 打包 ZIP 文件
将所有文件打包成 ZIP 文件,确保包含:
- 所有报告文件summary.json、assets.json 等)
- `META-INF/manifest.json`
- `META-INF/signature.asc`
**伪代码示例**
```python
import zipfile
def create_signed_zip(output_path: str):
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
# 添加报告文件
zipf.write('summary.json', 'summary.json')
zipf.write('assets.json', 'assets.json')
zipf.write('vulnerabilities.json', 'vulnerabilities.json')
zipf.write('weakPasswords.json', 'weakPasswords.json')
zipf.write('漏洞评估报告.html', '漏洞评估报告.html')
# 添加签名文件
zipf.write('META-INF/manifest.json', 'META-INF/manifest.json')
zipf.write('META-INF/signature.asc', 'META-INF/signature.asc')
```
## 四、完整流程示例
### 4.1 Python 完整示例
```python
import hashlib
import hmac
import base64
import json
import zipfile
import hkdf
import gnupg
def generate_report_zip(
licence: str,
fingerprint: str,
task_id: str,
inspection_id: int,
output_path: str
):
"""
生成带签名和加密的检查报告 ZIP 文件
"""
# ========== 1. 读取报告文件 ==========
assets_content = read_file("assets.json")
vulnerabilities_content = read_file("vulnerabilities.json")
weak_passwords_content = read_file("weakPasswords.json")
report_html_content = read_file("漏洞评估报告.html")
# ========== 2. 生成设备签名 ==========
# 2.1 密钥派生
ikm = licence + fingerprint
salt = "AUTH_V3_SALT"
info = "device_report_signature"
key_length = 32
derived_key = hkdf.HKDF(
algorithm=hashlib.sha256,
length=key_length,
salt=salt.encode('utf-8'),
info=info.encode('utf-8'),
ikm=ikm.encode('utf-8')
).derive()
# 2.2 计算文件 SHA256
def sha256_hex(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
assets_sha256 = sha256_hex(assets_content)
vulnerabilities_sha256 = sha256_hex(vulnerabilities_content)
weak_passwords_sha256 = sha256_hex(weak_passwords_content)
report_html_sha256 = sha256_hex(report_html_content)
# 2.3 组装签名数据(严格顺序)
sign_payload = (
str(task_id) +
str(inspection_id) +
assets_sha256 +
vulnerabilities_sha256 +
weak_passwords_sha256 +
report_html_sha256
)
# 2.4 计算 HMAC-SHA256
mac = hmac.new(
key=derived_key,
msg=sign_payload.encode('utf-8'),
digestmod=hashlib.sha256
)
device_signature = base64.b64encode(mac.digest()).decode('utf-8')
# 2.5 生成 summary.json
summary = {
"orgId": 1173040813421105152,
"checkId": inspection_id,
"taskId": task_id,
"licence": licence,
"fingerprint": fingerprint,
"deviceSignature": device_signature,
"summary": "检查摘要信息"
}
summary_content = json.dumps(summary, ensure_ascii=False).encode('utf-8')
# ========== 3. 生成 OpenPGP 签名 ==========
# 3.1 生成 manifest.json
files_hashes = {
"summary.json": sha256_hex(summary_content),
"assets.json": assets_sha256,
"vulnerabilities.json": vulnerabilities_sha256,
"weakPasswords.json": weak_passwords_sha256,
"漏洞评估报告.html": report_html_sha256
}
manifest = {"files": files_hashes}
manifest_content = json.dumps(manifest, ensure_ascii=False, indent=2).encode('utf-8')
# 3.2 生成 OpenPGP 签名
gpg = gnupg.GPG()
with open('META-INF/manifest.json', 'wb') as f:
f.write(manifest_content)
with open('META-INF/manifest.json', 'rb') as f:
signed_data = gpg.sign_file(
f,
detach=True,
output='META-INF/signature.asc'
)
# ========== 4. 打包 ZIP 文件 ==========
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
zipf.writestr('summary.json', summary_content)
zipf.writestr('assets.json', assets_content)
zipf.writestr('vulnerabilities.json', vulnerabilities_content)
zipf.writestr('weakPasswords.json', weak_passwords_content)
zipf.writestr('漏洞评估报告.html', report_html_content)
zipf.writestr('META-INF/manifest.json', manifest_content)
zipf.write('META-INF/signature.asc', 'META-INF/signature.asc')
print(f"报告 ZIP 文件生成成功: {output_path}")
```
### 4.2 Java/Kotlin 完整示例
```kotlin
import org.bouncycastle.crypto.digests.SHA256Digest
import org.bouncycastle.crypto.generators.HKDFBytesGenerator
import org.bouncycastle.crypto.params.HKDFParameters
import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.util.Base64
import java.util.zip.ZipOutputStream
import java.io.FileOutputStream
fun generateReportZip(
licence: String,
fingerprint: String,
taskId: String,
inspectionId: Long,
outputPath: String
) {
// ========== 1. 读取报告文件 ==========
val assetsContent = readFile("assets.json")
val vulnerabilitiesContent = readFile("vulnerabilities.json")
val weakPasswordsContent = readFile("weakPasswords.json")
val reportHtmlContent = readFile("漏洞评估报告.html")
// ========== 2. 生成设备签名 ==========
// 2.1 密钥派生
val ikm = (licence + fingerprint).toByteArray(Charsets.UTF_8)
val salt = "AUTH_V3_SALT".toByteArray(Charsets.UTF_8)
val info = "device_report_signature".toByteArray(Charsets.UTF_8)
val keyLength = 32
val hkdf = HKDFBytesGenerator(SHA256Digest())
hkdf.init(HKDFParameters(ikm, salt, info))
val derivedKey = ByteArray(keyLength)
hkdf.generateBytes(derivedKey, 0, keyLength)
// 2.2 计算文件 SHA256
fun sha256Hex(content: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(content)
return hashBytes.joinToString("") { "%02x".format(it) }
}
val assetsSha256 = sha256Hex(assetsContent)
val vulnerabilitiesSha256 = sha256Hex(vulnerabilitiesContent)
val weakPasswordsSha256 = sha256Hex(weakPasswordsContent)
val reportHtmlSha256 = sha256Hex(reportHtmlContent)
// 2.3 组装签名数据(严格顺序)
val signPayload = buildString {
append(taskId)
append(inspectionId)
append(assetsSha256)
append(vulnerabilitiesSha256)
append(weakPasswordsSha256)
append(reportHtmlSha256)
}
// 2.4 计算 HMAC-SHA256
val mac = Mac.getInstance("HmacSHA256")
val secretKey = SecretKeySpec(derivedKey, "HmacSHA256")
mac.init(secretKey)
val signatureBytes = mac.doFinal(signPayload.toByteArray(Charsets.UTF_8))
val deviceSignature = Base64.getEncoder().encodeToString(signatureBytes)
// 2.5 生成 summary.json
val summary = mapOf(
"orgId" to 1173040813421105152L,
"checkId" to inspectionId,
"taskId" to taskId,
"licence" to licence,
"fingerprint" to fingerprint,
"deviceSignature" to deviceSignature,
"summary" to "检查摘要信息"
)
val summaryContent = objectMapper.writeValueAsString(summary).toByteArray(Charsets.UTF_8)
// ========== 3. 生成 OpenPGP 签名 ==========
// 3.1 生成 manifest.json
val filesHashes = mapOf(
"summary.json" to sha256Hex(summaryContent),
"assets.json" to assetsSha256,
"vulnerabilities.json" to vulnerabilitiesSha256,
"weakPasswords.json" to weakPasswordsSha256,
"漏洞评估报告.html" to reportHtmlSha256
)
val manifest = mapOf("files" to filesHashes)
val manifestContent = objectMapper.writeValueAsString(manifest).toByteArray(Charsets.UTF_8)
// 3.2 生成 OpenPGP 签名(使用 BouncyCastle
val signatureAsc = generatePGPSignature(manifestContent, privateKey, publicKey)
// ========== 4. 打包 ZIP 文件 ==========
ZipOutputStream(FileOutputStream(outputPath)).use { zipOut ->
zipOut.putNextEntry(ZipEntry("summary.json"))
zipOut.write(summaryContent)
zipOut.closeEntry()
zipOut.putNextEntry(ZipEntry("assets.json"))
zipOut.write(assetsContent)
zipOut.closeEntry()
zipOut.putNextEntry(ZipEntry("vulnerabilities.json"))
zipOut.write(vulnerabilitiesContent)
zipOut.closeEntry()
zipOut.putNextEntry(ZipEntry("weakPasswords.json"))
zipOut.write(weakPasswordsContent)
zipOut.closeEntry()
zipOut.putNextEntry(ZipEntry("漏洞评估报告.html"))
zipOut.write(reportHtmlContent)
zipOut.closeEntry()
zipOut.putNextEntry(ZipEntry("META-INF/manifest.json"))
zipOut.write(manifestContent)
zipOut.closeEntry()
zipOut.putNextEntry(ZipEntry("META-INF/signature.asc"))
zipOut.write(signatureAsc)
zipOut.closeEntry()
}
println("报告 ZIP 文件生成成功: $outputPath")
}
```
## 五、平台端验证流程
平台端会按以下顺序验证:
1. **OpenPGP 签名验证**(防篡改)
- 读取 `META-INF/manifest.json``META-INF/signature.asc`
- 使用平台公钥验证签名
- 验证所有文件的 SHA256 是否与 manifest.json 中的哈希值匹配
2. **设备签名验证**(授权)
-`summary.json` 提取 `licence``fingerprint``taskId``deviceSignature`
- 验证 `licence + fingerprint` 是否已绑定
- 验证 `taskId` 是否存在且属于该设备
- 使用相同的 HKDF 派生密钥
- 重新计算签名并与 `deviceSignature` 比较
## 六、常见错误和注意事项
### 6.1 设备签名验证失败
**可能原因**
1. **密钥派生错误**:确保使用正确的 `salt``info` 参数
2. **签名数据顺序错误**:必须严格按照 `taskId + inspectionId + SHA256(...)` 的顺序
3. **SHA256 格式错误**:必须是 hex 字符串(小写),不能包含分隔符
4. **文件内容错误**:确保使用原始文件内容,不能进行编码转换
5. **licence 或 fingerprint 不匹配**:确保与平台绑定的值一致
### 6.2 OpenPGP 签名验证失败
**可能原因**
1. **私钥不匹配**:确保使用与平台公钥对应的私钥
2. **manifest.json 格式错误**:确保 JSON 格式正确
3. **文件哈希值错误**:确保 manifest.json 中的哈希值与实际文件匹配
### 6.3 文件缺失
**必需文件**
- `summary.json`(必须包含授权字段)
- `assets.json`
- `vulnerabilities.json`
- `weakPasswords.json`(文件名大小写不敏感)
- `漏洞评估报告.html`(文件名包含"漏洞评估报告"且以".html"结尾)
- `META-INF/manifest.json`
- `META-INF/signature.asc`
## 七、安全设计说明
### 7.1 为什么第三方无法伪造
1. **设备签名**
- 只有拥有正确 `licence + fingerprint` 的设备才能派生正确的签名密钥
- 即使第三方获取了某个设备的签名,也无法用于其他任务(`taskId` 绑定)
- 即使第三方修改了报告内容,签名也会失效(多个文件的 SHA256 绑定)
2. **OpenPGP 签名**
- 只有拥有私钥的工具箱才能生成有效签名
- 任何文件修改都会导致哈希值不匹配
### 7.2 密钥分离
使用 HKDF 的 `info` 参数区分不同用途的密钥:
- `device_report_signature`:用于设备签名
- 其他用途可以使用不同的 `info` 值,确保密钥隔离
## 八、测试建议
1. **单元测试**
- 测试密钥派生是否正确
- 测试签名生成和验证是否匹配
- 测试文件 SHA256 计算是否正确
2. **集成测试**
- 使用真实数据生成 ZIP 文件
- 上传到平台验证是否通过
- 测试篡改文件后验证是否失败
3. **边界测试**
- 测试文件缺失的情况
- 测试签名数据顺序错误的情况
- 测试错误的 `licence``fingerprint` 的情况
## 九、参考实现
- **HKDF 实现**BouncyCastleJava/Kotlin`hkdf`Python
- **HMAC-SHA256**Java `javax.crypto.Mac`、Python `hmac`
- **OpenPGP**BouncyCastleJava/Kotlin`gnupg`Python
## 十、联系支持
如有问题,请联系平台技术支持团队。

View File

@@ -0,0 +1,756 @@
# 工具箱端 - 摘要信息二维码生成指南
## 概述
本文档说明工具箱端如何生成摘要信息二维码。工具箱完成检查任务后,需要将摘要信息加密并生成二维码,供 App 扫描后上传到平台。
> ### UX 集成模式补充(当前项目实现)
>
> 在当前集成模式中,工具箱将摘要明文传给 UX 的 `crypto.encryptSummary`
> 由 UX 执行 HKDF + AES-256-GCM 加密并返回二维码内容 JSON`taskId + encrypted`)。
## 一、业务流程
```
工具箱完成检查 → 准备摘要信息 → HKDF派生密钥 → AES-256-GCM加密 → 组装二维码内容 → 生成二维码
App扫描二维码 → 提取taskId和encrypted → 提交到平台 → 平台解密验证 → 保存摘要信息
```
## 二、二维码内容格式
二维码内容为 JSON 格式,包含以下字段:
```json
{
"taskId": "TASK-20260115-4875",
"encrypted": "uWUcAmp6UQd0w3G3crdsd4613QCxGLoEgslgXJ4G2hQhpQdjtghtQjCBUZwB/JO+NRgH1vSTr8dqBJRq7Qh4nugESrB2jUSGASTf4+5E7cLlDOmtDw7QlqS+6Hb7sn3daMSOovcna07huchHeesrJCiHV8ntEDXdCCdQOEHfkZAvy5gS8jQY41x5Qcnmqbz3qqHTmceIihTj4uqRVyKOE8jxzY6ko76jx0gW239gyFysJUTrqSPiFAr+gToi2l9SWP8ISViBmYmCY2cQtKvPfTKXwxGMid0zE/nDmb9n38X1oR05nAP0v1KaVY7iPcjsWySDGqO2iIbPzV8tQzq5TNuYqn9gvxIX/oRTFECP+aosfmOD5I8H8rVFTebyTHw+ONV3KoN2IMRqnG+a2lucbhzwQk7/cX1hs9lYm+yapmp+0MbLCtf2KMWqJPdeZqTVZgi3R181BCxo3OIwcCFTnZ/b9pdw+q8ai6SJpso5mA0TpUCvqYlGlKMZde0nj07kmLpdAm3AOg3GtPezfJu8iHmsc4PTa8RDsPgTIxcdyxNSMqo1Ws3VLQXm6DHK/kma/vbvSA/N7upPzi7wLvboig=="
}
```
### 2.1 字段说明
| 字段名 | 类型 | 说明 | 示例 |
|--------|------|------|------|
| `taskId` | String | 任务ID从任务二维码中获取 | `"TASK-20260115-4875"` |
| `encrypted` | String | Base64编码的加密数据 | `"uWUcAmp6UQd0w3G3..."` |
## 三、摘要信息数据结构
### 3.1 明文数据 JSON 格式
加密前的摘要信息为 JSON 格式,包含以下字段:
```json
{
"enterpriseId": "1173040813421105152",
"inspectionId": "702286470691215417",
"summary": "检查摘要信息",
"timestamp": 1734571234567
}
```
### 3.2 字段说明
| 字段名 | 类型 | 说明 | 示例 |
|--------|------|------|------|
| `enterpriseId` | String | 企业ID从任务数据中获取 | `"1173040813421105152"` |
| `inspectionId` | String | 检查ID从任务数据中获取 | `"702286470691215417"` |
| `summary` | String | 检查摘要信息 | `"检查摘要信息"` |
| `timestamp` | Number | 时间戳(毫秒) | `1734571234567` |
## 四、密钥派生HKDF-SHA256
### 4.1 密钥派生参数
使用 **HKDF-SHA256**`licence + fingerprint` 派生 AES 密钥:
```
AES Key = HKDF(
input = licence + fingerprint, # 输入密钥材料(字符串拼接)
salt = taskId, # Salt值任务ID
info = "inspection_report_encryption", # Info值固定值
hash = SHA-256, # 哈希算法
length = 32 # 输出密钥长度32字节 = 256位
)
```
**重要说明**
- `ikm`(输入密钥材料)= `licence + fingerprint`(直接字符串拼接,无分隔符)
- `salt` = `taskId`从任务二维码中获取的任务ID
- `info` = `"inspection_report_encryption"`(固定值,区分不同用途的密钥)
- `length` = `32` 字节AES-256 密钥长度)
### 4.2 Python 实现示例
```python
import hashlib
import hkdf
def derive_aes_key(licence: str, fingerprint: str, task_id: str) -> bytes:
"""
使用 HKDF-SHA256 派生 AES-256 密钥
Args:
licence: 设备授权码
fingerprint: 设备硬件指纹
task_id: 任务ID
Returns:
派生出的密钥32字节
"""
# 输入密钥材料
ikm = licence + fingerprint # 直接字符串拼接
# HKDF 参数
salt = task_id
info = "inspection_report_encryption"
key_length = 32 # 32字节 = 256位
# 派生密钥
derived_key = hkdf.HKDF(
algorithm=hashlib.sha256,
length=key_length,
salt=salt.encode('utf-8'),
info=info.encode('utf-8'),
ikm=ikm.encode('utf-8')
).derive()
return derived_key
```
### 4.3 Java/Kotlin 实现示例
```kotlin
import org.bouncycastle.crypto.digests.SHA256Digest
import org.bouncycastle.crypto.generators.HKDFBytesGenerator
import org.bouncycastle.crypto.params.HKDFParameters
import java.nio.charset.StandardCharsets
fun deriveAesKey(licence: String, fingerprint: String, taskId: String): ByteArray {
// 输入密钥材料
val ikm = (licence + fingerprint).toByteArray(StandardCharsets.UTF_8)
// HKDF 参数
val salt = taskId.toByteArray(StandardCharsets.UTF_8)
val info = "inspection_report_encryption".toByteArray(StandardCharsets.UTF_8)
val keyLength = 32 // 32字节 = 256位
// 派生密钥
val hkdf = HKDFBytesGenerator(SHA256Digest())
val params = HKDFParameters(ikm, salt, info)
hkdf.init(params)
val derivedKey = ByteArray(keyLength)
hkdf.generateBytes(derivedKey, 0, keyLength)
return derivedKey
}
```
## 五、AES-256-GCM 加密
### 5.1 加密算法
- **算法**AES-256-GCMGalois/Counter Mode
- **密钥长度**256 位32 字节)
- **IV 长度**12 字节96 位)
- **认证标签长度**16 字节128 位)
### 5.2 加密数据格式
加密后的数据格式Base64 编码前):
```
[IV(12字节)] + [加密数据] + [认证标签(16字节)]
```
**数据布局**
```
+------------------+------------------+------------------+
| IV (12字节) | 加密数据 | 认证标签(16字节)|
+------------------+------------------+------------------+
```
### 5.3 Python 实现示例
```python
import base64
import hashlib
import hkdf
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.backends import default_backend
import json
import time
def encrypt_summary_data(
enterprise_id: str,
inspection_id: str,
summary: str,
licence: str,
fingerprint: str,
task_id: str
) -> str:
"""
加密摘要信息数据
Args:
enterprise_id: 企业ID
inspection_id: 检查ID
summary: 摘要信息
licence: 设备授权码
fingerprint: 设备硬件指纹
task_id: 任务ID
Returns:
Base64编码的加密数据
"""
# 1. 组装明文数据JSON格式
timestamp = int(time.time() * 1000) # 毫秒时间戳
plaintext_map = {
"enterpriseId": str(enterprise_id),
"inspectionId": str(inspection_id),
"summary": summary,
"timestamp": timestamp
}
plaintext = json.dumps(plaintext_map, ensure_ascii=False)
# 2. 使用 HKDF-SHA256 派生 AES 密钥
ikm = licence + fingerprint
salt = task_id
info = "inspection_report_encryption"
key_length = 32
aes_key = hkdf.HKDF(
algorithm=hashlib.sha256,
length=key_length,
salt=salt.encode('utf-8'),
info=info.encode('utf-8'),
ikm=ikm.encode('utf-8')
).derive()
# 3. 使用 AES-256-GCM 加密数据
aesgcm = AESGCM(aes_key)
iv = os.urandom(12) # 生成12字节随机IV
encrypted_bytes = aesgcm.encrypt(iv, plaintext.encode('utf-8'), None)
# 4. 组装IV + 加密数据(包含认证标签)
# AESGCM.encrypt 返回的格式已经是:加密数据 + 认证标签
combined = iv + encrypted_bytes
# 5. Base64 编码
encrypted_base64 = base64.b64encode(combined).decode('utf-8')
return encrypted_base64
```
### 5.4 Java/Kotlin 实现示例
```kotlin
import com.fasterxml.jackson.databind.ObjectMapper
import org.bouncycastle.crypto.digests.SHA256Digest
import org.bouncycastle.crypto.generators.HKDFBytesGenerator
import org.bouncycastle.crypto.params.HKDFParameters
import java.nio.charset.StandardCharsets
import java.security.SecureRandom
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
object SummaryEncryptionUtil {
private const val ALGORITHM = "AES"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val GCM_IV_LENGTH = 12 // 12 bytes = 96 bits
private const val GCM_TAG_LENGTH = 16 // 16 bytes = 128 bits
private const val GCM_TAG_LENGTH_BITS = GCM_TAG_LENGTH * 8 // 128 bits
private val objectMapper = ObjectMapper()
private val secureRandom = SecureRandom()
/**
* 加密摘要信息数据
*/
fun encryptSummaryData(
enterpriseId: String,
inspectionId: String,
summary: String,
licence: String,
fingerprint: String,
taskId: String
): String {
// 1. 组装明文数据JSON格式
val timestamp = System.currentTimeMillis()
val plaintextMap = mapOf(
"enterpriseId" to enterpriseId,
"inspectionId" to inspectionId,
"summary" to summary,
"timestamp" to timestamp
)
val plaintext = objectMapper.writeValueAsString(plaintextMap)
// 2. 使用 HKDF-SHA256 派生 AES 密钥
val ikm = (licence + fingerprint).toByteArray(StandardCharsets.UTF_8)
val salt = taskId.toByteArray(StandardCharsets.UTF_8)
val info = "inspection_report_encryption".toByteArray(StandardCharsets.UTF_8)
val keyLength = 32
val hkdf = HKDFBytesGenerator(SHA256Digest())
val params = HKDFParameters(ikm, salt, info)
hkdf.init(params)
val aesKey = ByteArray(keyLength)
hkdf.generateBytes(aesKey, 0, keyLength)
// 3. 使用 AES-256-GCM 加密数据
val iv = ByteArray(GCM_IV_LENGTH)
secureRandom.nextBytes(iv)
val secretKey = SecretKeySpec(aesKey, ALGORITHM)
val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmSpec)
val plaintextBytes = plaintext.toByteArray(StandardCharsets.UTF_8)
val encryptedBytes = cipher.doFinal(plaintextBytes)
// 4. 组装IV + 加密数据(包含认证标签)
// GCM 模式会将认证标签附加到密文末尾
val ciphertext = encryptedBytes.sliceArray(0 until encryptedBytes.size - GCM_TAG_LENGTH)
val tag = encryptedBytes.sliceArray(encryptedBytes.size - GCM_TAG_LENGTH until encryptedBytes.size)
val combined = iv + ciphertext + tag
// 5. Base64 编码
return Base64.getEncoder().encodeToString(combined)
}
}
```
## 六、组装二维码内容
### 6.1 二维码内容 JSON
`taskId` 和加密后的 `encrypted` 组装成 JSON 格式:
```json
{
"taskId": "TASK-20260115-4875",
"encrypted": "Base64编码的加密数据"
}
```
### 6.2 Python 实现示例
```python
import json
def generate_qr_code_content(task_id: str, encrypted: str) -> str:
"""
生成二维码内容JSON格式
Args:
task_id: 任务ID
encrypted: Base64编码的加密数据
Returns:
JSON格式的字符串
"""
qr_content = {
"taskId": task_id,
"encrypted": encrypted
}
return json.dumps(qr_content, ensure_ascii=False)
```
## 七、完整流程示例
### 7.1 Python 完整示例
```python
import base64
import json
import time
import hashlib
import hkdf
import qrcode
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os
class SummaryQRCodeGenerator:
"""摘要信息二维码生成器"""
def __init__(self, licence: str, fingerprint: str):
"""
初始化生成器
Args:
licence: 设备授权码
fingerprint: 设备硬件指纹
"""
self.licence = licence
self.fingerprint = fingerprint
def generate_summary_qr_code(
self,
task_id: str,
enterprise_id: str,
inspection_id: str,
summary: str,
output_path: str = "summary_qr.png"
) -> str:
"""
生成摘要信息二维码
Args:
task_id: 任务ID从任务二维码中获取
enterprise_id: 企业ID从任务数据中获取
inspection_id: 检查ID从任务数据中获取
summary: 摘要信息
output_path: 二维码图片保存路径
Returns:
二维码内容JSON字符串
"""
# 1. 组装明文数据JSON格式
timestamp = int(time.time() * 1000) # 毫秒时间戳
plaintext_map = {
"enterpriseId": str(enterprise_id),
"inspectionId": str(inspection_id),
"summary": summary,
"timestamp": timestamp
}
plaintext = json.dumps(plaintext_map, ensure_ascii=False)
print(f"明文数据: {plaintext}")
# 2. 使用 HKDF-SHA256 派生 AES 密钥
ikm = self.licence + self.fingerprint
salt = task_id
info = "inspection_report_encryption"
key_length = 32
aes_key = hkdf.HKDF(
algorithm=hashlib.sha256,
length=key_length,
salt=salt.encode('utf-8'),
info=info.encode('utf-8'),
ikm=ikm.encode('utf-8')
).derive()
print(f"密钥派生成功: {len(aes_key)} 字节")
# 3. 使用 AES-256-GCM 加密数据
aesgcm = AESGCM(aes_key)
iv = os.urandom(12) # 生成12字节随机IV
encrypted_bytes = aesgcm.encrypt(iv, plaintext.encode('utf-8'), None)
# 组装IV + 加密数据(包含认证标签)
combined = iv + encrypted_bytes
# Base64 编码
encrypted_base64 = base64.b64encode(combined).decode('utf-8')
print(f"加密成功: {encrypted_base64[:50]}...")
# 4. 组装二维码内容JSON格式
qr_content = {
"taskId": task_id,
"encrypted": encrypted_base64
}
qr_content_json = json.dumps(qr_content, ensure_ascii=False)
print(f"二维码内容: {qr_content_json[:100]}...")
# 5. 生成二维码
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=10,
border=4,
)
qr.add_data(qr_content_json)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
img.save(output_path)
print(f"二维码已生成: {output_path}")
return qr_content_json
# 使用示例
if __name__ == "__main__":
# 工具箱的授权信息(必须与平台绑定时一致)
licence = "LIC-8F2A-XXXX"
fingerprint = "FP-2c91e9f3"
# 创建生成器
generator = SummaryQRCodeGenerator(licence, fingerprint)
# 从任务二维码中获取的信息
task_id = "TASK-20260115-4875"
enterprise_id = "1173040813421105152"
inspection_id = "702286470691215417"
summary = "检查摘要信息发现3个高危漏洞5个中危漏洞"
# 生成二维码
qr_content = generator.generate_summary_qr_code(
task_id=task_id,
enterprise_id=enterprise_id,
inspection_id=inspection_id,
summary=summary,
output_path="summary_qr_code.png"
)
print(f"\n二维码内容:\n{qr_content}")
```
### 7.2 Java/Kotlin 完整示例
```kotlin
import com.fasterxml.jackson.databind.ObjectMapper
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import org.bouncycastle.crypto.digests.SHA256Digest
import org.bouncycastle.crypto.generators.HKDFBytesGenerator
import org.bouncycastle.crypto.params.HKDFParameters
import java.awt.image.BufferedImage
import java.nio.charset.StandardCharsets
import java.security.SecureRandom
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
import javax.imageio.ImageIO
import java.io.File
class SummaryQRCodeGenerator(
private val licence: String,
private val fingerprint: String
) {
private const val ALGORITHM = "AES"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val GCM_IV_LENGTH = 12
private const val GCM_TAG_LENGTH = 16
private const val GCM_TAG_LENGTH_BITS = GCM_TAG_LENGTH * 8
private val objectMapper = ObjectMapper()
private val secureRandom = SecureRandom()
/**
* 生成摘要信息二维码
*/
fun generateSummaryQRCode(
taskId: String,
enterpriseId: String,
inspectionId: String,
summary: String,
outputPath: String = "summary_qr.png"
): String {
// 1. 组装明文数据JSON格式
val timestamp = System.currentTimeMillis()
val plaintextMap = mapOf(
"enterpriseId" to enterpriseId,
"inspectionId" to inspectionId,
"summary" to summary,
"timestamp" to timestamp
)
val plaintext = objectMapper.writeValueAsString(plaintextMap)
println("明文数据: $plaintext")
// 2. 使用 HKDF-SHA256 派生 AES 密钥
val ikm = (licence + fingerprint).toByteArray(StandardCharsets.UTF_8)
val salt = taskId.toByteArray(StandardCharsets.UTF_8)
val info = "inspection_report_encryption".toByteArray(StandardCharsets.UTF_8)
val keyLength = 32
val hkdf = HKDFBytesGenerator(SHA256Digest())
val params = HKDFParameters(ikm, salt, info)
hkdf.init(params)
val aesKey = ByteArray(keyLength)
hkdf.generateBytes(aesKey, 0, keyLength)
println("密钥派生成功: ${aesKey.size} 字节")
// 3. 使用 AES-256-GCM 加密数据
val iv = ByteArray(GCM_IV_LENGTH)
secureRandom.nextBytes(iv)
val secretKey = SecretKeySpec(aesKey, ALGORITHM)
val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmSpec)
val plaintextBytes = plaintext.toByteArray(StandardCharsets.UTF_8)
val encryptedBytes = cipher.doFinal(plaintextBytes)
// 组装IV + 加密数据(包含认证标签)
val ciphertext = encryptedBytes.sliceArray(0 until encryptedBytes.size - GCM_TAG_LENGTH)
val tag = encryptedBytes.sliceArray(encryptedBytes.size - GCM_TAG_LENGTH until encryptedBytes.size)
val combined = iv + ciphertext + tag
// Base64 编码
val encryptedBase64 = Base64.getEncoder().encodeToString(combined)
println("加密成功: ${encryptedBase64.take(50)}...")
// 4. 组装二维码内容JSON格式
val qrContent = mapOf(
"taskId" to taskId,
"encrypted" to encryptedBase64
)
val qrContentJson = objectMapper.writeValueAsString(qrContent)
println("二维码内容: ${qrContentJson.take(100)}...")
// 5. 生成二维码
val hints = hashMapOf<EncodeHintType, Any>().apply {
put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M)
put(EncodeHintType.CHARACTER_SET, "UTF-8")
put(EncodeHintType.MARGIN, 1)
}
val writer = QRCodeWriter()
val bitMatrix = writer.encode(qrContentJson, BarcodeFormat.QR_CODE, 300, 300, hints)
val width = bitMatrix.width
val height = bitMatrix.height
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB)
for (x in 0 until width) {
for (y in 0 until height) {
image.setRGB(x, y, if (bitMatrix[x, y]) 0x000000 else 0xFFFFFF)
}
}
ImageIO.write(image, "PNG", File(outputPath))
println("二维码已生成: $outputPath")
return qrContentJson
}
}
// 使用示例
fun main() {
// 工具箱的授权信息(必须与平台绑定时一致)
val licence = "LIC-8F2A-XXXX"
val fingerprint = "FP-2c91e9f3"
// 创建生成器
val generator = SummaryQRCodeGenerator(licence, fingerprint)
// 从任务二维码中获取的信息
val taskId = "TASK-20260115-4875"
val enterpriseId = "1173040813421105152"
val inspectionId = "702286470691215417"
val summary = "检查摘要信息发现3个高危漏洞5个中危漏洞"
// 生成二维码
val qrContent = generator.generateSummaryQRCode(
taskId = taskId,
enterpriseId = enterpriseId,
inspectionId = inspectionId,
summary = summary,
outputPath = "summary_qr_code.png"
)
println("\n二维码内容:\n$qrContent")
}
```
## 八、平台端验证流程
平台端会按以下流程验证:
1. **接收请求**App 扫描二维码后,将 `taskId``encrypted` 提交到平台
2. **查询任务**:根据 `taskId` 查询任务记录,获取 `deviceLicenceId`
3. **获取设备信息**:根据 `deviceLicenceId` 查询设备授权记录,获取 `licence``fingerprint`
4. **密钥派生**:使用相同的 HKDF 参数派生 AES 密钥
5. **解密数据**:使用 AES-256-GCM 解密(自动验证认证标签)
6. **时间戳校验**:验证 `timestamp` 是否在合理范围内(防止重放攻击)
7. **保存摘要**:将摘要信息保存到数据库
## 九、常见错误和注意事项
### 9.1 加密失败
**可能原因**
1. **密钥派生错误**:确保使用正确的 HKDF 参数
- `ikm` = `licence + fingerprint`(直接字符串拼接)
- `salt` = `taskId`(必须与任务二维码中的 taskId 一致)
- `info` = `"inspection_report_encryption"`(固定值)
- `length` = `32` 字节
2. **数据格式错误**:确保 JSON 格式正确
- 字段名和类型必须匹配
- 时间戳必须是数字类型(毫秒)
3. **IV 生成错误**:确保使用安全的随机数生成器生成 12 字节 IV
### 9.2 平台验证失败
**可能原因**
1. **taskId 不匹配**:确保二维码中的 `taskId` 与任务二维码中的 `taskId` 一致
2. **密钥不匹配**:确保 `licence``fingerprint` 与平台绑定时一致
3. **时间戳过期**:平台会验证时间戳,确保时间戳在合理范围内
4. **认证标签验证失败**:数据被篡改或密钥错误
### 9.3 二维码生成失败
**可能原因**
1. **内容过长**:如果加密数据过长,可能需要更高版本的二维码
2. **JSON 格式错误**:确保 JSON 格式正确
3. **字符编码错误**:确保使用 UTF-8 编码
## 十、安全设计说明
### 10.1 为什么使用 HKDF
1. **密钥分离**:使用 `info` 参数区分不同用途的密钥
2. **Salt 随机性**:使用 `taskId` 作为 salt确保每个任务的密钥不同
3. **密钥扩展**HKDF 提供更好的密钥扩展性
### 10.2 为什么第三方无法伪造
1. **密钥绑定**:只有拥有正确 `licence + fingerprint` 的工具箱才能生成正确的密钥
2. **任务绑定**:使用 `taskId` 作为 salt确保密钥与特定任务绑定
3. **认证加密**GCM 模式提供认证加密,任何篡改都会导致解密失败
4. **时间戳校验**:平台会验证时间戳,防止重放攻击
### 10.3 密钥派生参数的重要性
- **ikm**`licence + fingerprint` 是设备唯一标识
- **salt**`taskId` 确保每个任务使用不同的密钥
- **info**`"inspection_report_encryption"` 区分不同用途的密钥
- **length**`32` 字节提供 256 位密钥强度
## 十一、测试建议
1. **单元测试**
- 测试密钥派生是否正确
- 测试加密和解密是否匹配
- 测试 JSON 格式是否正确
2. **集成测试**
- 使用真实任务数据生成二维码
- App 扫描二维码并提交到平台
- 验证平台是否能正确解密和验证
3. **边界测试**
- 测试超长的摘要信息
- 测试特殊字符的处理
- 测试错误的 taskId 是否会导致解密失败
## 十二、参考实现
- **Python**`hkdf`HKDF`cryptography`AES-GCM`qrcode` 库(二维码生成)
- **Java/Kotlin**BouncyCastleHKDF、JDK `javax.crypto`AES-GCM、ZXing 库(二维码生成)
- **C#**BouncyCastle.NetHKDF`System.Security.Cryptography`AES-GCM、ZXing.Net 库(二维码生成)
## 十三、联系支持
如有问题,请联系平台技术支持团队获取:
- 测试环境地址
- 技术支持

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