commit 8ae2838f78065827d2ac0eb4e67378470f1eb2a4 Author: leon_mac Date: Sun Oct 18 15:11:44 2026 +0800 chore(工程组): 初始化根仓库并排除独立工程 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..87d5af3 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# 用独立随机值替换这些示例;建议仅使用十六进制字符以避免连接串转义问题 +POSTGRES_PASSWORD=replace-with-random-hex +REDIS_PASSWORD=replace-with-another-random-hex +ADMIN_USERNAME=admin +ADMIN_PASSWORD=replace-with-at-least-12-characters +# 本地联调显式开启;真实上线改 production 并配置微信凭据 +APP_ENV=development +WECHAT_APP_ID= +WECHAT_APP_SECRET= +SEED_DEMO=true +CORS_ORIGINS=http://127.0.0.1:5173,http://127.0.0.1:5174 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c42a8fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.DS_Store +node_modules/ +dist/ +.env +.env.local +.env.*.local +!.env.example +*.log +*.tsbuildinfo +coverage/ +perler-beads-server/server +deploy/certs/ + +# Independently managed project repositories +/perler-beads-applet/ +/perler-beads-server/ +/perler-beads-webui/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f704e29 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# 豆间 · 拼豆工坊 + +首期单店私域小程序:图纸工具、个人作品、到店服务与店员工作台。业务数据带门店归属,预留未来扩店;目前管理员与小程序默认绑定 `store-001`,尚未实现跨店调度、总部权限和分布式同步。 + +## 工程与功能 + +| 工程 | 技术 | 已实现 | +| --- | --- | --- | +| `perler-beads-applet` | uni-app / Vue 3 / TypeScript / Pinia | 图纸浏览、搜索、收藏、图片转色号网格、文字图纸、手绘改色、本地草稿、作品保存、跟做高亮与进度、桌码到店、呼叫员工/补豆/熨烫/打印 | +| `perler-beads-server` | Go 1.25 / Gin / PostgreSQL / Redis | 微信登录、管理员鉴权、持久化、门店权限、幂等呼叫、任务状态机、实际尺寸 SVG、后台接口 | +| `perler-beads-webui` | Vue 3 / Element Plus / Pinia / Vue Router | 服务队列、接单完成、图纸导入与发布、色板与取料位置、桌位、门店设置 | + +图片仅在顾客设备处理,上传保存的是色号网格。支付与储值只提供禁用的能力接口,没有交易、充值或自动计费。打印由员工下载 SVG 后操作打印机。参考 `example` 页面结构重新实现,演示图案为原创像素网格。 + +## 小程序页面与交互 + +主包保留图纸、制作、门店、我的四个 tab。创作设置/编辑、图纸详情/用料/打印、到店/申请/记录、个人图纸列表分别放入四个分包。选项采用分组设置行,点击后通过编辑对话或选择弹层确认,不使用 form 提交。分包页提供返回上页与所属主 tab 的入口。 + +路由、共享组件和本轮验收见 [UI 拆分记录](docs/applet-ui-refactor.md)。 + +## 本地启动 + +### 当前本地演示管理员 + +| 项目 | 值 | +| --- | --- | +| 后台地址 | http://127.0.0.1:5174 | +| 用户名 | `admin` | +| 密码 | `local-integration-only` | + +以上为此前本机联调使用的账号,角色为管理员,所属门店为 `store-001`。这是本地演示配置,不是程序内置的默认密码,也不会自动应用到自行部署的环境。预览依赖开发服务持续运行。2026-10-14 已重新启动三端,继续使用下述独立 PostgreSQL 数据库。 + +自行部署时,账号由服务端环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 决定。Docker Compose 从根目录 `.env` 读取;独立运行 Go 服务需自行导出这两个变量,应用不会自动加载 `.env`。示例用户名为 `admin`,密码占位符必须替换为自设密码(至少 12 个字符),不要将上述演示密码用于正式门店。 + +### 当前数据库连接 + +PostgreSQL 复用 `events-server` 配置中的实例 `192.168.31.98:5433` 和用户 `postgres`,拼豆独立使用 `perler_beads` 数据库。连接配置保存在被 Git 忽略的 `perler-beads-server/.env.local`(文件权限 600);密码不写入本文。`events` 库未修改。旧临时 `perler_test` 库的测试记录未迁入新库,新库初始化演示门店、色板和图纸。 + +Redis 当前仍为本机演示实例 `127.0.0.1:56379`。此处只切换 PostgreSQL,未复用 events 的 Redis 键空间。Go 不自动加载 `.env.local`,启动时须将其作为进程环境传入;当前后台进程已经使用该配置。 + +需要 Node.js 22.12+、npm;独立运行后端需要 Go 1.25+、PostgreSQL、Redis。容器路径需要 Docker Compose 插件。 + +### 容器启动服务端和后台 + +在根目录复制 `.env.example` 为 `.env`,将三个密码替换为独立随机值。数据库和 Redis 密码建议使用十六进制,避免连接串转义。示例显式设置 `APP_ENV=development`,仅供本地联调。 + +```sh +cp .env.example .env +# 编辑 .env 后执行 +docker compose up --build -d +``` + +后台 `http://127.0.0.1:8088`,API `http://127.0.0.1:8080/api/health`。使用 `.env` 的管理员账号登录。数据库与 Redis 不映射宿主端口;业务数据保存在命名卷,停止时使用 `docker compose down`,不要随意删除卷。 + +当前开发机器没有 Docker,Compose 尚未实际启动验收;本次真实联调使用了独立临时 PostgreSQL/Redis 进程,见 [验收记录](docs/verification.md)。 + +### 顾客端 H5 预览 + +```sh +cd perler-beads-applet +npm ci +VITE_DEV_LOGIN=true npm run dev:h5 +``` + +打开 `http://127.0.0.1:5173`。开发构建未配置域名时默认请求本机 8080。只有开发构建且显式启用 `VITE_DEV_LOGIN=true` 才显示演示登录;后端也必须为 development。演示用户是共享身份,不能用于真实顾客数据。初始化的桌位码为 `T01`、`T02`。 + +### 后台开发与后端独立运行 + +```sh +cd perler-beads-webui +npm ci +npm run dev +``` + +后台开发地址 `http://127.0.0.1:5174`,`/api` 代理本机 8080,不需要前端环境变量。生产构建 `npm run build` 输出 `dist`,可由提供的 Nginx 容器托管,或使用后端 `WEBUI_DIR` 指向其绝对路径。 + +后端按 `perler-beads-server/.env.example` 配置并导出环境变量,然后在该目录执行 `go run ./cmd/server`。Go 进程不自动加载 `.env`。启动会执行内嵌幂等数据库迁移;`SEED_DEMO=true` 仅在没有门店时初始化演示数据。 + +## 微信小程序构建 + +1. 复制 applet 内 `.env.example` 为 `.env.production`,填写公网、内网 HTTPS 域名和门店 ID;域名不包含 `/api`、路径或尾斜杠。生产不允许 HTTP、本机地址或开发登录。 +2. 在 `src/manifest.json` 的 `mp-weixin.appid` 填入真实微信 AppID;服务端配置对应 AppID 与 AppSecret 并使用 `APP_ENV=production`。密钥仅进入服务端环境。 +3. 在微信后台配置两个请求域名,按实际下载能力配置下载域名。保持 `urlCheck=true`,不要以关闭校验作为发布条件。 +4. 执行 `npm run build:mp-weixin`,用微信开发者工具导入 `dist/build/mp-weixin`。先在合法域名校验开启的情况下完成体验版真机测试,再提交审核。 + +`npm run build:h5` 输出 `dist/build/h5`;H5 是工具与界面预览,正式顾客微信身份通过小程序登录。未实现生产 H5 微信网页授权。 + +## 独立域名与网络路径 + +```mermaid +flowchart TD + A[获取网络类型] --> B{是否 Wi-Fi} + B -->|否 / unknown| C[公网 HTTPS 域名] + B -->|是| D[探测指定内网域名 health,1秒超时] + D -->|服务标识与门店匹配| E[内网 HTTPS 直连] + D -->|失败 / 门店不匹配| C + C --> F[公网 frps → 门店 frpc → Nginx → Gin] + E --> G[门店 Nginx → Gin] +``` + +`none` 直接提示离线。未读取 SSID、BSSID 或扫描网段。线路选择使用 `uni.getNetworkType` 和网络变化监听,参考 [uni-app 官方文档](https://uniapp.dcloud.net.cn/api/system/network.html)。失败探测有 30 秒冷却,网络变化使旧探测结果失效。GET 可以回退重试;服务呼叫使用同一 UUID 和请求内容回退,后端持久幂等;业务错误不触发线路回退,其他写操作不会盲目自动重放。 + +部署模板在 `deploy/`:内网域名解析到门店局域网地址,公网域名解析到 frps 主机。门店 Nginx 在 443 提供两个域名都有效的 HTTPS 证书;frp HTTPS 代理按公网域名透传到门店 443。frpc/frps 间还配置了证书校验与 token,参考 [frp TLS 官方文档](https://gofrp.org/zh-cn/docs/features/common/network/network-tls/)。替换全部示例域名、证书路径与密钥后再部署。 + +店铺域名字段供后台记录维护;小程序实际路由目前以构建时 `VITE_*` 配置为准。修改域名后需要重新构建发布,不会从未经信任的响应自动切换地址。两条路径访问同一 Gin、数据库与 Redis,无需同步两份数据。门店设备停机时公网隧道也不可用。 + +到店状态由有效桌位码创建的会话定义,与走内网还是公网分离。它不是物理位置认证:知道桌码的已登录用户也能建立到店会话。若后续需要更严格的防远程呼叫,可增加短效入座码或员工确认。员工权限始终由后端鉴权,不由网络类型授予。 + +## 实时呼叫与语音提醒 + +后台通过同源 `/api/ws` 建立 WebSocket,登录后在所有工作台页面持续接收门店任务变更。首次连接和重连都会查询任务补齐数据,每 60 秒另做一次兜底同步;无需一直停在服务队列页。生产 HTTPS 页面自动使用 WSS,Vite 和 Nginx 示例已配置 Upgrade 转发。 + +点击工作台上方「开启语音」完成一次试听后,新待接单呼叫会播报桌位与服务类型,例如「T01 桌,呼叫员工」。使用浏览器提供的本机中文 TTS,无第三方密钥;无中文音色或播放失败会提示。首次打开不补播历史任务,同一任务不重复播报,试听期间的新任务继续排队,已被接单或取消的待播任务移出队列。 + +语音开启仅对当前页面会话生效,刷新后需重新点击;可随时关闭。多台设备或多个标签同时开启会分别播报,门店可指定一台接单设备。浏览器冻结、休眠或页面关闭后不能保证后台持续发声。首期 Hub 为单 Gin 实例,未来多副本需增加 Redis Pub/Sub 分发。协议、验证与边界见 [语音与 WS 说明](docs/voice-notifications.md)。 + +## 数据、色板与打印 + +- 初始十色 `DEMO*` 色板和四张 7×7 图纸仅用于演示,不代表任何品牌真实色号。上线前由后台维护品牌色号、颜色、库存可用状态与取料位置。 +- 图纸最大 200×200,色板最多 512 色;保存时后端按门店权威色板校验,个人作品默认为私有。后台支持符合接口结构的 JSON 图纸导入。 +- 跟做进度和未保存草稿在当前设备本地存储;保存的作品、收藏、到店会话与服务任务在服务端。跨设备草稿/逐格进度同步尚未实现。 +- SVG 用毫米描述,默认珠距 5 mm,可传 2–10 mm;附色号、坐标、色量与 50 mm 标尺。打印选 100% 实际大小,测量标尺后使用;大图使用打印软件的海报分页。小程序内由门店任务交给员工下载打印,不直接控制打印设备。 +- 数据库是业务真源;Redis 保存会话与登录限流。备份需覆盖 PostgreSQL;Redis 数据丢失会导致重新登录。当前使用 JSONB 文档存储加关系表,规模增长后再按查询负载拆分与索引。 + +## 验证与交付边界 + +测试命令、真实运行结果、尚待真机验收的项目见 [验收记录](docs/verification.md)。API 契约见 [实施计划](docs/implementation-plan.md),具体审查结论见 [服务端报告](docs/server-report.md) 和 [后台报告](docs/webui-report.md)。 + +uni-app 当前稳定工具链仍有 24 项 npm 审计记录(9 high / 15 moderate);已做兼容补丁升级,剩余集中于工具链,详见 [依赖报告](docs/dependency-report.md)。不要把 Vite 开发服务暴露公网或用于生产。后台 npm 审计为零。依赖审计、构建成功与真机验收是不同检查,当前没有宣称已生产上线。 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..d9893a9 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,55 @@ +services: + postgres: + image: postgres:18-alpine + environment: + POSTGRES_DB: perler + POSTGRES_USER: perler + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql + healthcheck: + test: [CMD-SHELL, 'pg_isready -U perler -d perler'] + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped + redis: + image: redis:8-alpine + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:?Set REDIS_PASSWORD} + command: [sh, -c, 'exec redis-server --appendonly yes --requirepass "$$REDIS_PASSWORD"'] + volumes: + - redis-data:/data + healthcheck: + test: [CMD-SHELL, 'REDISCLI_AUTH="$$REDIS_PASSWORD" redis-cli ping'] + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped + server: + build: ./perler-beads-server + environment: + APP_ENV: ${APP_ENV:-production} + GIN_MODE: release + LISTEN_ADDR: :8080 + DATABASE_URL: postgres://perler:${POSTGRES_PASSWORD}@postgres:5432/perler?sslmode=disable + REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0 + ADMIN_USERNAME: ${ADMIN_USERNAME:?Set ADMIN_USERNAME} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD} + WECHAT_APP_ID: ${WECHAT_APP_ID:-} + WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:-} + CORS_ORIGINS: ${CORS_ORIGINS:-} + SEED_DEMO: ${SEED_DEMO:-false} + ports: ['127.0.0.1:8080:8080'] + depends_on: + postgres: {condition: service_healthy} + redis: {condition: service_healthy} + restart: unless-stopped + webui: + build: ./perler-beads-webui + ports: ['127.0.0.1:8088:80'] + depends_on: [server] + restart: unless-stopped +volumes: + postgres-data: + redis-data: diff --git a/deploy/frpc.example.toml b/deploy/frpc.example.toml new file mode 100644 index 0000000..c021e4b --- /dev/null +++ b/deploy/frpc.example.toml @@ -0,0 +1,14 @@ +# 门店节点:frps 证书必须能被下面的 CA 校验,名称匹配 serverName。 +serverAddr = "tunnel.your-domain.cn" +serverPort = 7000 +auth.method = "token" +auth.token = "REPLACE_WITH_RANDOM_SHARED_SECRET" +transport.tls.enable = true +transport.tls.serverName = "tunnel.your-domain.cn" +transport.tls.trustedCaFile = "/etc/frp/certs/ca.crt" +[[proxies]] +name = "perler-store-001" +type = "https" +localIP = "127.0.0.1" +localPort = 443 +customDomains = ["api.your-domain.cn"] diff --git a/deploy/frps.example.toml b/deploy/frps.example.toml new file mode 100644 index 0000000..a49fe17 --- /dev/null +++ b/deploy/frps.example.toml @@ -0,0 +1,8 @@ +# 公网节点:只提供受认证的隧道入口与 HTTPS SNI 转发。 +bindPort = 7000 +vhostHTTPSPort = 443 +auth.method = "token" +auth.token = "REPLACE_WITH_RANDOM_SHARED_SECRET" +transport.tls.force = true +transport.tls.certFile = "/etc/frp/certs/server.crt" +transport.tls.keyFile = "/etc/frp/certs/server.key" diff --git a/deploy/nginx-https.example.conf b/deploy/nginx-https.example.conf new file mode 100644 index 0000000..70408b4 --- /dev/null +++ b/deploy/nginx-https.example.conf @@ -0,0 +1,28 @@ +# 门店主机上的 Nginx 示例;证书同时覆盖两个域名(或拆成两个 server)。 +# 店内域名解析到门店局域网地址,公网域名解析到 frps 公网节点。 +server { + listen 443 ssl; + server_name store-001.lan.your-domain.cn api.your-domain.cn; + ssl_certificate /etc/nginx/certs/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + client_max_body_size 4m; + # WS protocol upgrade; heartbeat every 20s keeps the tunnel live. + location = /api/ws { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto https; + proxy_read_timeout 75s; + proxy_send_timeout 10s; + proxy_buffering off; + } + location /api/ { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + } + location / { proxy_pass http://127.0.0.1:8088; } +} diff --git a/docs/applet-ui-refactor.md b/docs/applet-ui-refactor.md new file mode 100644 index 0000000..9907299 --- /dev/null +++ b/docs/applet-ui-refactor.md @@ -0,0 +1,51 @@ +# 小程序分包与交互整理 · 2026-10-14 + +目标:交互选项采用「标签 / 当前值 / 展开入口」的分组设置行,点击后通过编辑对话或选择弹层修改。不是 HTML/uni form 表单提交。沿用米白与森林绿,减少首页操作密度,主包只保留四个 tab 入口和浏览所需内容。 + +## 路由 + +主包 pages/gallery/index、pages/make/index、pages/store/index、pages/mine/index。 + +- packages/creation/config:mode=image|text|blank,设置生成参数;packages/creation/editor:调整画布,恢复与保存草稿。 +- packages/patterns/detail:id,跟做图纸;packages/patterns/materials:id,用料清单;packages/patterns/print:id,打印参数与申请。 +- packages/store/visit:桌位确认;packages/store/request:kind?/patternId?,服务申请;packages/store/records:记录与取消。 +- packages/account/library:type=works|favorites,个人图纸。 + +每个分包页显示返回上页与返回所属主 tab 的入口。主 tab 用 switchTab,普通分包页用 navigateTo;无历史页栈时返回所属主 tab。不使用独立分包;共享基础 UI、Pinia 与 API 放主包可引用目录。旧 /pages/detail/index 不再注册,所有调用更新。 + +## 共用接口 + +- components/ui/SettingRow.vue:props label,value?,description?,disabled?,danger?;事件 activate。 +- components/ui/EditDialog.vue:props open,title,value?,placeholder?,type?(text/number/digit/textarea),maxlength?;事件 close、confirm(value:string)。临时修改在确认后才提交。 +- components/ui/ChoiceSheet.vue:props open,title,options:Array<{value:string,label:string,description?:string,color?:string}>,selected?:string[],multiple?:boolean;事件 close、confirm(values:string[])。 +- components/ui/PageReturn.vue:props tab(gallery/make/store/mine);上页/主 tab 两个入口。 +- core/navigation.ts:route(path,query?) 生成经过编码的分包 URL;goPage(path,query?);goMain(tab);backOrMain(tab)。主包不得静态导入分包代码。 +- 通用样式 .page .page-heading .eyebrow .title .muted .settings-group .section-title .panel .row .spread .mt .primary .empty .error .hint .action-footer。 + +## 实施与验证 + +- [x] 共享设置行、对话框、选择面板与导航辅助;路由编码/回退测试。 +- [x] 制作入口 + 生成参数页 + 编辑页,保留图片/文字/手绘、草稿与保存。 +- [x] 门店入口 + 到店/申请/记录,保留幂等请求与状态反馈。 +- [x] 图纸/我的主包页,以及详情/用料/打印/个人列表分包。 +- [x] 核心逻辑测试、类型检查、H5 与 mp-weixin 构建;浏览器验证入口→分包→对话→返回主 tab,检查分包产物。 + +不改变后端接口与数据库。微信真机分包加载、原生对话与设备字体仍需最终真机验收。没有 Git 仓库,不创建虚构提交或分支。 + +## 验收结果 + +2026-10-14:`npm run typecheck`、`npm test`(23 项)、`npm run build:h5`、`npm run build:mp-weixin` 通过。微信构建产物 `app.json` 保留 4 个主包页面,并注册 4 个分包、9 个分包页面;每个注册页均生成 WXML。源代码没有 `
` 或旧 `/pages/detail/index` 路由。 + +浏览器检查覆盖 390px 手机与 320px 窄屏: +- 制作入口 → 参数页 → 编辑画布;取消修改保留 29 格,确认后变为 32 格;空白画布显示完整网格。 +- 设置行支持 Enter,编辑对话支持 Escape 关闭并恢复焦点;320px 对话无横向溢出。 +- 直接刷新编辑分包后,返回按钮在无历史栈时切回制作主 tab。 +- 图纸 → 用料 → 返回详情;打印方式选择后只显示相关参数;到店打印将 `kind=print` 和图纸 ID 带入服务申请。 +- 服务备注确认后,到店页往返保留服务类型、图纸与备注;分包的门店首页入口回到主 tab。 +- 我的收藏迁入独立列表页,未登录状态和返回我的入口正常。 + +状态回归修复:离店/换桌位清除旧申请成功结果;换账号清除旧草稿、记录及幂等上下文,旧账号在途读写响应不再回写。首次未登录填写的申请在确认到店流程中保留。 + +本轮浏览器没有提交员工呼叫或创建服务器图纸;申请及创作工作流使用隔离测试验证。真实微信小程序的软键盘、相机、读屏与分包下载仍需开发者工具/真机验收。本轮没有完整 Lighthouse 或 Core Web Vitals 测量,不宣称达到评分或 WCAG 认证。 + +截图:[门店首页](screenshots/ui-store-2026-10-14.jpg)、[320px 编辑对话](screenshots/ui-dialog-320-2026-10-14.jpg)。 diff --git a/docs/dependency-report.md b/docs/dependency-report.md new file mode 100644 index 0000000..7a7a754 --- /dev/null +++ b/docs/dependency-report.md @@ -0,0 +1,39 @@ +# 小程序依赖审计与修复 + +2026-10-03:仅修改 `perler-beads-applet/package.json` / `package-lock.json`,没有修改业务源码或配置。 + +## 官方版本核查 + +npm 官方注册表的 `@dcloudio/vite-plugin-uni` 最新稳定匹配套件为 `3.0.0-5020620260917001`(版本日期 2026-09-17),已统一更新 uni-app、uni-components、uni-h5、uni-mp-weixin、vite-plugin-uni、uni-cli-shared 六个直接依赖。npm `vue3` 标签实际是 `3.0.0-alpha-5030120260930001`,未采用 alpha;`latest` 标签也是旧 alpha,不能直接作为稳定选型依据。 + +当前稳定 uni 插件仍声明精确 peer Vite 5.2.8。通过 package overrides 显式采用同主版本 Vite 5.4.21,未使用忽略 peer 的强制安装,也未跨主版本替换 uni/Vite。保持 Vue 3.4.38 与现有工具链一致。 + +## 已修复的依赖链 + +覆盖依赖固定版本:Vite 5.4.21、ws 8.22.0、jpeg-js 0.4.4、PostCSS 8.5.28、postcss-selector-parser 6.1.4、Express 4.22.3、adm-zip 0.6.1、phin 3.7.1、Babel core 7.29.7,以及 intlify 9.14.5 的 core-base/shared/message-compiler/message-resolver/vue-devtools。同主版本升级;adm-zip/jpeg-js 属 0.x 补丁套件,其使用由生产构建验证。没有接受 `npm audit fix --force` 建议的 DCloud 0.x 降级。 + +清除了旧 i18n 原型污染/XSS、ws 内存泄漏/DoS、JPEG 解码无限循环、PostCSS 源映射读文件/输出 XSS、ZIP 解压相关问题以及 Express 路由链问题;Vite 5 的历史漏洞大部分修复。 + +最后一次 `npm audit --json`:24 项(9 high、15 moderate、0 critical、0 low)。审计统计包含传播至 DCloud 上层包的重复节点,不等于 24 个独立攻击入口。安装时统计曾为 22 项,最终以上述显式重新审计结果为准。原始报告约 45 项 / 20 high,明显降低但没有达到零风险。 + +## 残留与实际可达性 + +- **braces 3.0.3(high)**:npm 官方已发布最新仍为 3.0.3,审计范围 `<=3.0.3`,没有可选修复版本。传播经过 micromatch→fast-glob、chokidar、unimport→DCloud。实际使用用于源码/目录 glob 和本地文件监听;项目没有把顾客输入交给这些 Node glob API。构建开发工具在不可信源码或极深 glob 输入下仍可能被耗尽栈。不能把它简单报告成“已修复”;待上游发布补丁。 +- **Vite 5.4.21(high)**:残留优化依赖 `.map` 路径穿越,以及 Windows UNC/替代路径相关开发服务器漏洞。DCloud CLI `dist/cli/server.js` 明确调用 Vite createServer,因此 dev:h5 使用时实际可达。监听 127.0.0.1 限制远程直接访问,但不是对恶意页面/开发请求的完整免疫。生产交付是静态 H5 文件/微信构建,不运行 Vite 开发服务;不应将 dev:h5 暴露公网或用作生产服务。真正彻底修复需要上游支持新版 Vite,未经确认不强制跨主版本覆盖。 +- **esbuild 0.20.2(moderate)**:残留为 esbuild 内置开发服务器跨来源请求问题。DCloud `uni-cli-shared/dist/esbuild.js` 使用 esbuild.build;检查未发现项目调用 esbuild.serve。Vite 工具链也持有 esbuild,但本次生产构建不运行其内置服务。保留上游版本,避免未经兼容验证的 0.x 次版本强制升级。 + +这些依赖仍由 uni 包传递,`npm audit --omit=dev` 不能简单证明产物安全;应按具体 Node API 与生产打包内容判断。未声称实际扫描微信运行时或完整源到汇的数据流。 + +## 验证 + +依赖安装成功。实际执行通过: + +```sh +npm test # 当前 12 项测试 +npm run typecheck +npm run build:h5 # Compiler 5.26 +npm run build:mp-weixin # Compiler 5.26 +npm audit --json --cache /tmp/perler-npm-cache +``` + +审计仍返回非零结果为上述残留;测试与构建通过不等价于清除这些风险。业务源代码当时由根任务继续修改,最终交付前根任务应在最终源码快照重跑。 diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..9a1f0eb --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,76 @@ +# 拼豆私域小程序首期实施计划 + +**目标:** 建立 uni-app 顾客端、Gin 服务端和 Vue 3 店员后台,完成图纸制作/收藏/保存、店内服务及管理员处理流程。 + +**已确认:** 用户已授权创建三个工程并编码。首期单店,数据预留多店隔离。支付与储值仅保留禁用的能力接口。内外网独立 HTTPS 域名;非 Wi-Fi 不探测内网。Wi-Fi 下短超时验证内网门店服务,失败回退公网。不读取 SSID。 + +## 全局约束 + +- 三个工程:perler-beads-applet、perler-beads-server、perler-beads-webui。 +- 小程序 uni-app + Vue 3 + TypeScript;后台 Vue 3 + Element Plus + Pinia + Vue Router;禁止引入 Tailwind。 +- 服务端 Gin + PostgreSQL + Redis,业务持久化以 PostgreSQL 为准,不以 Redis 为持久化真源。 +- 所有 JSON 接口采用 HTTP 200 与 `{code,msg,data}`,code=200 成功、403 未登录、401 未授权;平台登录密钥不交给前端。 +- 商户配置、图纸数据和权限必须在服务端校验;网络连通性不授予员工权限。 +- 无微信凭据时提供显式开发模式,生产模式禁止开发登录。 +- 首期支付/储值不可操作,返回 enabled=false;不建立假成功支付或余额。 +- 首期打印为可下载 SVG 实际尺寸文件 + 店员手动打印任务;不宣称已自动驱动硬件。 +- 原始图片在小程序本地处理;保存到服务端的是色号网格和元数据。演示色板与图纸明确标记,正式色板由后台维护。 + +## 接口契约 + +基础路径 `/api`。所有 ID 均 string。JSON 字段 camelCase。数组接口直接返回数组,不包 items。 + +- GET /health:data={service:"perler-beads",storeId:"store-001",version:"0.1.0"},不含个人信息。 +- POST /auth/login:{code:string} 微信登录;开发模式允许 {code:"dev-guest"},data={token,user:{id,name,role,storeId}}。 +- POST /auth/admin:{username,password},返回同登录形状;管理员凭据环境配置。 +- GET /me:当前用户;GET /capabilities:{payment:{enabled:false},storedValue:{enabled:false}}。 +- GET /stores:Store[];Store={id,name,address,hours,notice,localBaseUrl,publicBaseUrl}。 +- GET /palettes?storeId=:Color[];Color={code,name,hex,location,available}。 +- GET /patterns?storeId=&q=:Pattern[];GET /patterns/:id:Pattern。 +- Pattern={id,title,category,width,height,cells:(string|null)[],colors:Color[],beadCount,ownerId?,visibility:"public"|"private",createdAt}。颜色和 cells 使用同一 code。透明为 null。 +- POST /patterns(登录):{title,category,width,height,cells,colors,visibility:"private",storeId};DELETE /patterns/:id(所有者);GET /my/patterns。 +- GET /favorites:string[];PUT /favorites/:id:{active:boolean}。 +- POST /visits:{storeId,tableCode},data=Visit;GET /visits/current:Visit|null;DELETE /visits/current 退出;Visit={id,storeId,tableCode,status:"active"|"closed"}。tableCode 来自后台已启用桌位。 +- GET /tables?storeId=(员工):Table[];Table={code,label,enabled}。 +- GET /service-requests:顾客自己的任务;员工当前门店任务。ServiceRequest={id,storeId,userId,tableCode,kind:"help"|"refill"|"ironing"|"print",note,status:"pending"|"accepted"|"completed"|"cancelled",patternId?,createdAt,updatedAt}。 +- POST /service-requests:{kind,note,patternId?,requestId};要求有效到店 session,store/table 取 session。requestId UUID 持久幂等;重复呼叫返回原任务或业务冲突。重复键不同内容不能误判为同次请求。 +- PATCH /service-requests/:id:{status};员工可 pending->accepted->completed;顾客仅取消自己的 pending。校验门店。 +- GET /admin/patterns;POST /admin/patterns;PATCH /admin/patterns/:id(发布/下架、名称分类);仅管理员。 +- PUT /admin/palette:{storeId,colors:Color[]};PUT /admin/store:Store;PUT /admin/tables:{storeId,tables:Table[]};仅管理员。 +- GET /patterns/:id/print?pitchMm=5:鉴权访问对应图纸,返回实际尺寸 SVG 文件,含色号/坐标/色量与校准标尺,超大图说明分页;不接受未校验 ID 文件路径。 + +## Task 1:服务端与数据持久化 + +- [x] 为图纸校验、服务状态变更、权限、幂等建立失败测试,再实现。 +- [x] 建立 Gin 模块、迁移、配置、鉴权、PostgreSQL 仓储、Redis 会话/限流适配及演示数据。 +- [x] 实现以上契约,保证用户私有图纸隔离和门店任务隔离。 +- [x] 提供 go test ./...、go vet ./...、构建验证;数据库集成测试使用独立测试库,缺环境明确报告。 + +## Task 2:小程序与图纸工具 + +- [x] uni-app Vue 3 工程,图纸/制作/门店/我的四个页面,详情/跟做页。 +- [x] 先用 Node 测试网络路由:蜂窝零探测、Wi-Fi 成功、失败回退、none、unknown、网络切换取消过期结果、幂等重试。 +- [x] 图片本地裁剪采样、色板量化、透明网格、文字生成、局部改色、计数、逐色高亮、草稿和进度保存。 +- [x] 图纸列表搜索收藏、账号保存、桌码到店、呼叫/补豆/熨烫/打印、取消与状态反馈。 +- [x] 本地/公网连接统一封装,配置合法 HTTPS 端点,开发 localhost 仅开发构建。 +- [x] 执行核心逻辑测试、类型检查、H5 与微信小程序构建。 + +## Task 3:店员管理后台 + +- [x] 登录、路由权限、门店切换边界、待处理服务列表、接单完成操作。 +- [x] 图纸管理与导入、色板与物料位置编辑、桌位、门店配置、禁用支付能力展示。 +- [x] Element Plus 控件与表格、侧栏、抽屉编辑,响应式、空/错/载入状态、无障碍标签。 +- [x] 类型检查、构建、桌面/移动浏览器交互核查。 + +## Task 4:交付与整体验证 + +- [x] Docker Compose(PostgreSQL、Redis、服务端),双 HTTPS 域名与 frp 示例、环境变量模板。 +- [x] README:本地启动、微信配置、示例色板限制、打印方式、真实部署验证清单。 +- [x] 复查 API 联通、鉴权、重复呼叫与网络回退;独立代码审查,修复重要问题。 +- [x] 记录真实执行结果;微信真机、实际 DNS/TLS、打印硬件未验证则明确列出。 + +## 进度 + +- 2026-10-03:用户确认开始编码。当前仅有 example 截图,无 Git 仓库、无已有代码;直接在指定工程目录创建,不创建额外 worktree。 + +- 2026-10-03:三端首期实现完成。最终类型检查、构建与测试通过,真实 PostgreSQL/Redis 联调及独立审查通过。部署模板已交付但未在 Docker 或门店网络运行;真机与打印验收仍待实际环境,详见 verification.md。 diff --git a/docs/screenshots/admin-completed.jpg b/docs/screenshots/admin-completed.jpg new file mode 100644 index 0000000..7e2270c Binary files /dev/null and b/docs/screenshots/admin-completed.jpg differ diff --git a/docs/screenshots/admin-mobile.jpg b/docs/screenshots/admin-mobile.jpg new file mode 100644 index 0000000..1a11f7a Binary files /dev/null and b/docs/screenshots/admin-mobile.jpg differ diff --git a/docs/screenshots/applet-gallery.jpg b/docs/screenshots/applet-gallery.jpg new file mode 100644 index 0000000..583d2d2 Binary files /dev/null and b/docs/screenshots/applet-gallery.jpg differ diff --git a/docs/screenshots/applet-mobile.jpg b/docs/screenshots/applet-mobile.jpg new file mode 100644 index 0000000..b312c9d Binary files /dev/null and b/docs/screenshots/applet-mobile.jpg differ diff --git a/docs/screenshots/applet-progress.jpg b/docs/screenshots/applet-progress.jpg new file mode 100644 index 0000000..e5d3178 Binary files /dev/null and b/docs/screenshots/applet-progress.jpg differ diff --git a/docs/screenshots/ui-dialog-320-2026-10-14.jpg b/docs/screenshots/ui-dialog-320-2026-10-14.jpg new file mode 100644 index 0000000..0a712aa Binary files /dev/null and b/docs/screenshots/ui-dialog-320-2026-10-14.jpg differ diff --git a/docs/screenshots/ui-store-2026-10-14.jpg b/docs/screenshots/ui-store-2026-10-14.jpg new file mode 100644 index 0000000..e1cdff4 Binary files /dev/null and b/docs/screenshots/ui-store-2026-10-14.jpg differ diff --git a/docs/screenshots/ws-tts-call.jpg b/docs/screenshots/ws-tts-call.jpg new file mode 100644 index 0000000..f3ed309 Binary files /dev/null and b/docs/screenshots/ws-tts-call.jpg differ diff --git a/docs/screenshots/ws-tts-cross-page.jpg b/docs/screenshots/ws-tts-cross-page.jpg new file mode 100644 index 0000000..c239a90 Binary files /dev/null and b/docs/screenshots/ws-tts-cross-page.jpg differ diff --git a/docs/server-report.md b/docs/server-report.md new file mode 100644 index 0000000..0f11896 --- /dev/null +++ b/docs/server-report.md @@ -0,0 +1,48 @@ +# 服务端交付报告 + +实现目录:`perler-beads-server`,Go 1.25 + Gin + pgx PostgreSQL + Redis。全部 JSON 接口为 HTTP 200 与 `{code,msg,data}`;code=403 未登录、401 未授权。所有计划接口已经注册。打印成功为 SVG 下载。 + +## 启动 + +先启动 PostgreSQL 和 Redis,使用 `.env.example` 配置 `DATABASE_URL`、`REDIS_URL`、`ADMIN_USERNAME`、至少 12 字符的 `ADMIN_PASSWORD`、`APP_ENV`,再执行 `go run ./cmd/server`。应用不会自动读取 `.env`,请通过容器环境或 shell export 注入。生产环境必须提供 `WECHAT_APP_ID` 和 `WECHAT_APP_SECRET`;仅 `APP_ENV=development` 接受 `dev-guest`。开发登录是共享演示身份,不应用于真实顾客。 + +启动时执行内嵌幂等迁移 `internal/server/migrations/001_initial.sql`。`SEED_DEMO=true` 在没有门店时写入 store-001、两个启用桌位、四张原创 7×7 像素图纸和十种 DEMO 色号。演示地址、营业时间和色号必须在上线前替换。生产环境不提供内存数据库降级。 + +## 数据与权限 + +业务数据持久化 PostgreSQL;Redis 只保存随机令牌会话与登录限流。令牌 SHA-256 哈希作为 Redis 键,有效期七天。微信 code2Session 请求只在服务端执行,openid 派生稳定用户 ID,平台密钥不返回。管理员账号从环境获取。默认不信任 X-Forwarded-For,以免伪造 IP 绕过限流;部署在代理后登录限流按代理源 IP 聚合,请在真实网络压力验证中评估并配置可信代理支持。 + +图纸大小限制 200×200,色板最多 512 色;色号必须唯一、hex 必须六位十六进制,cells 数量必须匹配尺寸,所有非透明格必须引用已定义颜色。保存图纸必须匹配当前门店维护色板,物料位置由服务端权威数据回填。顾客保存只能 private,所有者才能删除;私有图纸访问验证身份,工作人员只能访问所属门店。后台账号当前属于 store-001,无跨店授权。 + +桌位必须存在且已启用。服务请求从有效到店会话读取门店与桌位,提交时再次校验桌位;打印请求必须引用有访问权限且属于当前门店的图纸。requestId UUID 由数据库主键持久保证幂等,同用户并发请求通过事务锁串行;相同键不同 kind/note/patternId 返回 code=409。重试返回原始创建结果。员工状态只能 pending→accepted→completed,顾客仅取消自己的 pending。状态更新使用行锁,避免并发覆盖。 + +SVG 标注毫米尺寸、网格色号、坐标、色量、50 mm 校准标尺,pitchMm 为 2–10。文字转义,图纸 ID 不拼接文件路径。临时文件下载后一分钟清理。超大图注明打印机海报/分页模式并保持 100% 缩放。服务请求是人工打印任务,不驱动硬件。支付与储值 capability 恒为 enabled=false。 + +## 验证 + +先写图纸校验、授权状态机、幂等内容散列测试,执行时出现 undefined Pattern/Transition/Fingerprint 的预期失败,再实施。随后增加匿名 API 权限、私有图纸门店隔离、SVG 尺寸与转义检查。 + +实际执行通过: + +```sh +GOPATH=/tmp/perler-go GOCACHE=/tmp/perler-go-cache GOMODCACHE=/tmp/perler-go-mod go test ./... +GOPATH=/tmp/perler-go GOCACHE=/tmp/perler-go-cache GOMODCACHE=/tmp/perler-go-mod go vet ./... +GOPATH=/tmp/perler-go GOCACHE=/tmp/perler-go-cache GOMODCACHE=/tmp/perler-go-mod go build -o /tmp/perler-beads-server ./cmd/server +``` + +`TestPostgresPersistentIdempotency` 无 TEST_DATABASE_URL 时跳过。执行该测试必须使用数据库名包含 test 的独立数据库;它检查数据库唯一键且回滚数据。初期系统没有 Docker/PostgreSQL/Redis,随后主任务使用临时 PostgreSQL/Redis 启动真实联调环境;独立 perler_test 数据库的集成测试实际通过。微信真实凭据、生产 DNS/TLS 和打印设备仍未验证。 + +## 审查修复 + +- 服务请求事务内读取图纸与桌位改用同一 tx,避免单连接池等待自己占用的连接;收藏列表采用单次 JOIN 查询,避免 rows 未释放时嵌套借连接。 +- 启用 Gin ContextWithFallback,统一给请求 context 设置 15 秒业务操作期限;数据库、Redis 与微信请求继承客户端取消。事务回滚使用独立 3 秒清理 context。 +- SVG 页面宽度至少 100 mm,50 mm 标尺完全位于页内;长标题按字符宽度换行,打印提示分行。图纸网格仍精确保持 pitchMm。新增最小图纸在 2/5/10 mm 的尺寸与文本边界测试。 +- 演示数据扩为十色及四个原创像素图案(心、菱形、小花、星),全部 DEMO 标识。已初始化的库不会被 seed 覆盖,需要独立新库查看新演示数据。 +- 新增 TestSingleConnectionHandlers:独立 TEST_DATABASE_URL 下将 pool MaxConns=1,有限时地验证收藏、打印服务创建与幂等重试;无数据库时明确跳过。 +- 修复后 go test ./...、go vet ./...、go build 全部再次通过;随后主任务已使用独立 perler_test 数据库实际执行并通过 PostgreSQL 集成测试。 + +## 最终补充 + +`WEBUI_DIR` 可配置为后台构建输出目录(需包含 index.html),后端直接提供静态文件及非 API GET/HEAD 深层路由 SPA 回退。未知 /api 与 /api/* 仍返回 HTTP200/code404 JSON。路径验证限制在输出目录内,拒绝目录穿越与指向目录外的符号链接;新增临时目录静态文件、SPA、API 和符号链接测试。 + +自定义异常恢复统一返回 HTTP200/code500 JSON。SVG 标题、尺寸说明与坐标分开预留行,色号字体按长度缩放以限制在格内。上述修改的 go test/vet/build 已通过;主任务随后已切换到更新后的二进制,并再次通过 23 项 API 联调断言及最终真实数据库回归。 diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 0000000..acacddb --- /dev/null +++ b/docs/verification.md @@ -0,0 +1,79 @@ +# 首期验收记录 + +日期:2026-10-03。此记录区分本机已验证内容与真实部署待验内容,不代表已发布微信小程序。 + +## 最终源码验证 + +| 检查 | 结果 | +| --- | --- | +| applet `npm test` | 12/12,通过;网络选择、过期探测、量化/透明像素/裁剪、画布偏移、幂等回退、提交后刷新失败 | +| applet `npm run typecheck` | 通过 | +| applet `npm run build:h5` | 通过,uni Compiler 5.26 | +| applet `npm run build:mp-weixin` | 通过,输出 dist/build/mp-weixin;未真机运行 | +| webui `npm test` | 3/3,通过;业务错误、鉴权和任务状态机 | +| webui `npm run build` | 类型检查与 Vite 生产构建通过 | +| server `go test -count=1 ./...` | 通过,设置 TEST_DATABASE_URL,真实执行数据库回归,未跳过数据库项 | +| server `go vet ./...` / `go build ./cmd/server` | 通过 | +| `scripts/integration-smoke.mjs` | 23 项 API 断言通过,4 个并发同键呼叫只创建一个任务 | +| frp 示例 TOML | Python tomllib 语法解析通过;未建立实际公网隧道 | +| Docker Compose / Nginx | 已提供配置,当前环境没有 Docker,未做容器与 HTTPS 启动验证 | +| 依赖审计 | webui 0;applet 24(9 high / 15 moderate),详见 dependency-report.md | + +最终检查包含画布颜色从八位 hex 改为 uni legacy canvas 可识别 rgba 后的构建。开发服务重启以消除旧模块缓存,浏览器确认完成标记不再显示黑色块。 + +## 真实联调环境 + +临时 PostgreSQL 18(127.0.0.1:55432 / perler_test)、Redis(127.0.0.1:56379)与 Gin(127.0.0.1:8080);业务经过真实 PostgreSQL/Redis,没有前端 mock 或内存仓储替代。联调资源在 `/tmp`,不作为持久部署方案。测试密码仅用于这些本机隔离服务。 + +数据库测试要求 TEST_DATABASE_URL 指向名称包含 test 的独立数据库。单连接池回归验证收藏查询、图纸打印呼叫和重复提交不会嵌套借连接死锁;幂等测试验证持久唯一键。API 脚本需要显式测试地址及管理员密码: + +```sh +PERLER_TEST_API_URL=http://127.0.0.1:8080 \ +PERLER_TEST_ADMIN_PASSWORD='<隔离测试环境密码>' \ +node scripts/integration-smoke.mjs +``` + +脚本仅针对 development 环境;它创建私有临时图纸、收藏、到店记录和服务任务,完成后移除收藏并删除测试图纸,保留到店会话与已完成任务作为验证记录。不要对真实门店数据执行该脚本。 + +## 浏览器验收 + +实际完成 H5 演示登录 → 文字「豆」生成 29×29 图纸(143 颗)→ 保存 → 收藏 → 详情跟做标记。滚动/偏移画布点击正确定位行 6 列 7。 + +顾客输入 T01 到店 → 呼叫员工并附备注「联调验证:请协助检查图纸」→ 后台真实管理员登录 → 接单 → 完成 → 顾客记录显示服务已完成。后台状态筛选可显示完成任务。浏览器还检查了桌面页面与 390×844 iframe 中的顾客首页、后台任务布局;iframe 检查是窄屏布局验证,不冒充手机微信真机测试。 + +截图: + +- [顾客首页](screenshots/applet-gallery.jpg) +- [图纸跟做](screenshots/applet-progress.jpg) +- [后台完成记录](screenshots/admin-completed.jpg) +- [后台窄屏](screenshots/admin-mobile.jpg) +- [顾客窄屏](screenshots/applet-mobile.jpg) + +未执行完整 Lighthouse / Core Web Vitals 实测、读屏器验收或所有设备组合。无障碍已做表单标签、导航/错误/空状态与原生控件基础处理;不能视为 WCAG 完整认证。 + +## 独立代码审查 + +独立只读审查后修复:事务中嵌套借数据库连接、请求 context 取消传播、打印页边界和标尺裁切、画布点击坐标偏移、呼叫成功后的刷新失败误报。修复后复核通过,无剩余重大问题。新增静态托管路径与符号链接隔离、业务异常统一响应的检查;未做独立渗透测试。 + +## 门店部署前验收 + +尚缺真实微信 AppID/密钥、域名和证书、门店网络与打印设备,以下无法在本次本机环境代验: + +- Android/iOS 微信体验版真实登录,合法域名校验开启;图片选取、文字渲染、跟做点击与扫码。 +- 蜂窝直走公网且零内网探测;其他 Wi-Fi 内网失败回退;门店 Wi-Fi 内网 HTTPS 成功;禁用局域网权限、DNS 不可达、证书错误、网络切换等场景。 +- 内网域名证书可信且匹配域名,路由器 DNS/隔离策略允许访问;公网隧道与门店服务的连通、断线恢复。 +- frp/Nginx/Compose 模板实际部署,生产密钥与管理员密码替换,APP_ENV=production,演示数据替换、备份恢复演练。 +- 5 mm 与实际珠板尺寸匹配、50 mm 标尺实测、大图分页与真实打印设备输出。 +- 多桌并发与员工轮询容量;反向代理下登录限流按代理源地址聚合,需按实际规模评估可信代理与限流策略。 + +当前首期为单店运行;数据归属与 API 预留门店边界,未来多店账号、选店、总部权限与多节点部署需另行实现。 + +## 后续增量:WebSocket + TTS(2026-10-03) + +后台原 10 秒页面轮询已升级为工作台级 WebSocket 实时通知,60 秒兜底补查;增加本机中文语音提醒。后台测试现为 14 项,构建通过;服务端真实 PostgreSQL/Redis 测试及 race/vet/build 通过。完成顾客呼叫、实时列表与跨页面 TTS 的浏览器联调。详细范围、协议、证据与限制见 [语音提醒说明](voice-notifications.md)。 + +## 当前检查环境(2026-10-03) + +按用户要求复用 events-server 的 PostgreSQL 实例 192.168.31.98:5433 与 postgres 用户,创建独立 perler_beads 数据库。后端已连接新库,验证 current_database/current_user 正确,4 张业务表与1个演示门店已初始化。events 库未修改,旧临时库测试数据未迁移。凭据保存在 Git 忽略的 perler-beads-server/.env.local,权限600。Redis 仍使用本机56379。 + +三端地址5173、5174、8080均返回HTTP200,管理员登录业务码200,通过5174代理的WS鉴权收到ready。三端以后台进程运行,日志位于 /tmp/perler-preview/。 diff --git a/docs/voice-notifications.md b/docs/voice-notifications.md new file mode 100644 index 0000000..02da8c9 --- /dev/null +++ b/docs/voice-notifications.md @@ -0,0 +1,40 @@ +# 后台呼叫语音提醒 + +## 设计与实施计划(2026-10-03) + +目标:已登录的后台在任意工作台页面接收新呼叫,启用语音后使用浏览器本机中文 TTS 播报桌位及服务类型。 + +选择浏览器 SpeechSynthesis,前端无新依赖、无第三方密钥。云 TTS 需费用与联网,本地服务端 TTS 需额外模型服务;本期不接入。仅选择 localService=true 的中文语音,避免自动把业务内容交给远程语音服务。 + +- [x] 先编写新增任务识别、去重、排队、关闭和失败行为测试,观察失败后实施。 +- [x] Pinia 统一后台任务数据与 WebSocket 推送监听;退出登录停止请求并清空语音。切换业务页面不中断监听。连接就绪与事件推送后补查任务,每 60 秒低频兜底同步。 +- [x] 首次快照仅建立基线;后续出现的新 pending 任务按时间排序播报,已接单/完成的不播报。关闭语音期间持续建立已见基线,不在重新启用时重放。 +- [x] 全局可见开启/关闭、试听、状态和错误提示;语音通过用户点击启用,刷新页面重新启用。播报不包含顾客备注。 +- [x] 串行播放并限制队列;等待播报期间任务被接单或取消则移除。失败停止语音并提示重新开启,队列卡住有超时处理。 +- [x] 执行自动化测试、类型检查、生产构建与浏览器检查,记录真实语音能力限制。 + +当前每个页面独立开启语音;同一店铺建议只在指定接单电脑开启,多个设备/标签都开启时各自播报。WebSocket 推送成功后立即查询并更新列表;断线按 1–30 秒退避重连,45 秒无心跳主动重连;浏览器后台冻结、设备睡眠或页面关闭时无法保证播报。恢复网络后的下一次成功同步播报仍待接单的新任务。首次进入的历史未接单任务继续在列表展示,不补播。 + +依据:[SpeechSynthesis](https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesis)、[localService](https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesisVoice/localService)、[语音错误事件](https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesisUtterance/error_event)。 + +WebSocket 协议:`GET /api/ws`,首帧 `{type:"auth",token}`,就绪 `ready`、通知 `tasks.changed`、每 20 秒 `heartbeat`;登录失效关闭 4403。token 不放 URL,Origin 校验且按门店隔离。服务端事务提交后通知,幂等重放不再次通知。首期单实例 Hub,未来多实例需 Redis Pub/Sub 分发。 + +## 接入位置 + +- `perler-beads-server/internal/server/websocket.go`:鉴权、按门店分组的连接、心跳与失效通知;`requests.go` 在创建/变更事务提交后发通知。 +- `perler-beads-webui/src/notifications/store.ts`:Pinia 工作台级任务状态与同步;`realtime.ts`:连接状态、认证与退避重连。 +- `core.ts`:快照去重与语音队列;`speech.ts`:本机中文 TTS 适配;`components/CallNotifications.vue`:全局控制与状态。 +- `vite.config.ts` 与两份 Nginx 配置:WebSocket Upgrade 转发;frp HTTPS 透传无需增加独立隧道。 + +测试音与任务使用同一队列;不会因为试听而清空已有任务。服务端通知只带事件类型,不带顾客备注、图纸或用户资料,前端仍通过鉴权 API 获取当前数据。网络断线不重置任务已见集合,页面刷新则重新建立基线。 + +## 本次验收 + +- 后台 `npm test`:14 项通过;`npm run build`(包含 vue-tsc)通过。测试覆盖初始基线、重复/已处理任务、静音、中文本机音色、队列取消/过载/错误、试听期间的新任务、重连退避、鉴权失效与心跳超时。 +- 服务端真实 PostgreSQL + Redis 的 `go test ./... -count=1`、`go test -race ./... -count=1`、`go vet ./...`、构建通过,详情见 [WS 服务端报告](ws-server-report.md)。 +- 浏览器真实联调:已有登录工作台建立 WS,显示「实时连接正常」;选择系统本机音色「婷婷」,试听触发 onstart/onend;顾客 T01 桌提交呼叫后列表即时更新,最近播报变为「T01 桌,呼叫员工」。切换到图纸管理页后,再次提交补豆,最近播报变为「T01 桌,需要补充豆料」。这些是浏览器语音事件与界面验证,实际扬声器音量仍取决于设备设置。 +- 两个联调呼叫已取消,保留验证记录;后台保持实时连接和语音开启,便于继续体验。页面刷新需要重新开启语音。 +- 独立审查发现并修复了试听期间漏播/清空队列,以及 Nginx `$host` 丢失非标准端口导致 WS 同源校验失败。WS 路由使用 `$http_host` 保留端口。 +- 未在 Docker/Nginx 实例、门店公网 TLS/frp、其他浏览器或睡眠唤醒设备上验收,部署前仍需测试。 + +截图:[新呼叫播报](screenshots/ws-tts-call.jpg)、[跨页面播报](screenshots/ws-tts-cross-page.jpg)。 diff --git a/docs/webui-report.md b/docs/webui-report.md new file mode 100644 index 0000000..1d4293d --- /dev/null +++ b/docs/webui-report.md @@ -0,0 +1,36 @@ +# 店员后台交付报告 + +实现目录:`perler-beads-webui`。Vue 3 + TypeScript + Element Plus / icons + Pinia + Vue Router + Vite,CSS 原生样式,不含 Tailwind 或外部素材。森林绿与奶油色工作台,桌面侧栏与移动端折叠菜单。 + +## 已实现 + +- 管理员账号密码登录,不内置任何凭据。令牌存在 sessionStorage;每次受保护路由导航调用 `/api/me` 确認身份、员工角色与 store-001 门店,禁止仅凭令牌存在放行。401 未授权、403 未登录从业务 envelope 识别。403 清除会话并退出。 +- 店内服务列表、数量摘要、状态/服务类型/桌码备注筛选、明确空/载入/错误状态、pending→accepted→completed 操作。10 秒轮询可暂停,隐藏页面不轮询,路由卸载停止计时并 AbortController 取消未完成请求。 +- 图纸列表与检索、JSON 导入、名称分类编辑、发布/下架。仅管理员访问;私有图纸仍由服务端鉴权。导入尺寸、格子、色板、门店匹配由服务端权威校验。 +- 色板与物料位置编辑抽屉,添加色号、颜色名称、HEX、可用状态;更新整门店色板。桌位新增/编辑、启停;门店信息与双 HTTPS 服务地址编辑。所有配置以当前授权门店提交,无跨店选择假授权。 +- 图纸库与有 patternId 的服务任务可下载实际尺寸 SVG,带 Bearer 鉴权;识别下载接口 JSON 错误,释放 Object URL。服务端限制最多 200×200,SVG 是有界文件,客户端采用 Blob 下载;实际打印由店员手动完成。 +- 支付与储值始终禁用,没有假支付、假充值或余额。 +- 中文页面语言、内部后台 noindex、语义 heading/main/nav、跳转主要内容链接、控件标签、文字状态、键盘焦点、减少动画偏好、移动端抽屉与可横向滚动表格。 +- 页面按路由懒加载,Element Plus 按实际组件注册,减少首屏 JS;无第三方字体/图片网络请求。 + +## 启动 + +```sh +cd perler-beads-webui +npm ci --cache /tmp/perler-npm-cache +npm run dev +``` + +开发服务监听 `127.0.0.1:5174`,`/api` 相对地址代理 `127.0.0.1:8080`。生产构建 `dist/`,部署需将 `/api` 代理服务端,并将前端路由回退至 index.html。管理员用户名与密码配置于服务端环境,后台手动输入。 + +本次本机联调账号:用户名 `admin`,密码 `local-integration-only`,门店 `store-001`。仅适用于本次启动的演示服务;自行部署按 `ADMIN_USERNAME` / `ADMIN_PASSWORD` 配置。完整说明见 [README 的本地管理员说明](../README.md#当前本地演示管理员)。 + +## 验证 + +先写 envelope 权限错误与任务状态序列测试,执行出现 contract.ts 尚不存在的预期失败,再实现。 + +已执行通过:`npm test`(3 项,含真实 client 模拟 Fetch / Bearer / 过期会话校验)、`npm run build`(vue-tsc 类型检查 + Vite 生产构建)。安装审计 0 vulnerabilities。官方 npm 元数据确认框架版本。TypeScript latest(7)与当前 vue-tsc 不兼容,固定使用 TypeScript 5.9.3。依赖版本及 package-lock 保存。 + +最终首屏 JS gzip 146.84 kB,CSS gzip 50.13 kB,额外路由 chunk 每份约 1.3–3.2 kB gzip。构建无超大 chunk 警告。 + +浏览器桌面/移动交互与真实 API 联调由根任务统一验证;此报告当前不声称已完成该核查。没有 PostgreSQL/Redis 启动时,后台正确呈现连接失败。微信凭据、生产 DNS/TLS 和打印机硬件未验证。 diff --git a/docs/ws-server-report.md b/docs/ws-server-report.md new file mode 100644 index 0000000..9766f74 --- /dev/null +++ b/docs/ws-server-report.md @@ -0,0 +1,53 @@ +# WebSocket 服务端交付报告 + +## 协议 + +新增 `GET /api/ws`。浏览器使用当前后台同源的 `wss://.../api/ws`;令牌不出现在 URL,任何 query string 均拒绝。Origin 必须精确匹配请求 Host(含端口),或来自 CORS_ORIGINS 明确配置的源;拒绝缺失、null、路径、userinfo 等异常 Origin。跨域后台必须把实际后台 HTTPS Origin 配入 CORS_ORIGINS。WebSocket 协议升级返回 101;Origin/升级错误允许真实 HTTP 错误状态,既有 JSON 契约保持原样。 + +客户端连接后 5 秒内发送首个文本 JSON: + +```json +{"type":"auth","token":"后台登录返回的token"} +``` + +首帧与后续应用消息均限制 4096 字节。服务端直接查询 Redis 会话,必须有合法 user ID、storeId 与 admin/staff 角色;访客无法订阅。鉴权成功发送 `{"type":"ready"}`。服务请求新增和状态变更在 PostgreSQL 事务成功提交后,向对应 storeId 的员工连接发送 `{"type":"tasks.changed"}`。消息仅提示重新 GET /api/service-requests,没有任务正文、顾客信息、桌码或图纸内容。相同 requestId 重试、内容冲突和失败的状态变更均不广播。 + +每 20 秒服务端重验 Redis 会话,并发送 WebSocket ping 控制帧与 `{"type":"heartbeat"}`。浏览器协议栈自动回应 pong;前端无需发送应用层消息,也允许 `{"type":"pong"}`。heartbeat 用于前端判活,tasks.changed 用于刷新列表,再由前端选择播报。JSON 通知不是可靠事件队列;就绪与重连后必须主动拉取最新任务。 + +关闭码:4403 鉴权失败、5 秒首帧超时、会话过期/撤销或身份/门店变更;4400 非约定应用消息;1001 服务关闭;消息过大也可能由协议库以 1009 关闭。Redis 故障按失效会话关闭,客户端应重新验证登录并采用重连退避。 + +## 实现约束 + +- 连接读期限 45 秒,由协议 pong 延长;写操作最多 5 秒,Redis 查询为独立最多 3 秒 context。WS 升级请求不套用常规 HTTP 的 15 秒业务 context,因此连接可长期存活。 +- 每个连接一个 reader 和一个 writer。应用 JSON、ping/pong 与正常关闭均通过 writer 发出;鉴权阶段尚未启动 writer,由握手处理同步完成。 +- 单实例、按门店分组的轻量 hub。每连接容量 1 的变更通道合并失效通知,广播不等待浏览器写入,慢连接不会阻塞业务事务。控制 pong 使用容量 4 的独立小通道。 +- 服务关闭显式通知已鉴权的 hijacked WS 连接,HTTP graceful shutdown 不依赖隐式关闭 WS。 +- 当前广播是进程内的。未来多实例需要 Redis PubSub(门店主题)或可靠消息设施,否则其他实例上的连接不会收到更新。业务真源仍为 PostgreSQL。 + +Gorilla WebSocket 锁定 `v1.5.3`,使用前查询了[官方发布页](https://github.com/gorilla/websocket/releases)。该页当前标记 v1.5.3 为 latest;Go 官方数据库列出的弱 mask key 报告影响 v1.5.3 之前的版本。[Go 漏洞报告](https://pkg.go.dev/vuln/GO-2026-6278) + +## 代理与构建 + +仅修改指定的 `deploy/nginx-https.example.conf` 和 `perler-beads-webui/nginx.conf`:新增精确 `/api/ws` 代理,HTTP/1.1 + Upgrade/Connection,75 秒读期限,10 秒发送期限,禁用代理缓冲。WS 路由使用 `Host $http_host` 保留原始 Host 与非标准端口(例如 Compose :8088),以便正确校验同源。生产 DNS、TLS、frp 和 Nginx 二进制未在本次 WS 测试运行,需在真实部署验证隧道。 + +新二进制:`/tmp/perler-ws-server`,主任务已接管启动本机 8080,并完成真实浏览器 WebSocket 与 TTS 联调。 + +## 真实执行验证 + +先写实际 Redis 鉴权测试,缺少端点时观察到预期失败:`websocket: bad handshake (status 200)`。新增/更新推送测试在接入提交钩子前观察到预期失败:`committed task did not push notification`,然后接入提交后广播。 + +使用真实本地 PostgreSQL 独立 `perler_test` 库(127.0.0.1:55432)与 Redis 专用测试 DB15(127.0.0.1:56379)。Redis session key 和 PostgreSQL fixture 使用随机 ID,仅删除测试自己创建的数据,不清空共享库。 + +实际执行通过: + +```sh +export GOPATH=/tmp/perler-go GOCACHE=/tmp/perler-go-cache GOMODCACHE=/tmp/perler-go-mod +export TEST_DATABASE_URL='独立测试 PostgreSQL 连接串,数据库名需包含 test' +export TEST_REDIS_URL='redis://127.0.0.1:56379/15' +GIN_MODE=release go test ./... -count=1 +GIN_MODE=release go test -race ./... -count=1 +go vet ./... +go build -o /tmp/perler-ws-server ./cmd/server +``` + +普通测试约 22 秒,race 测试约 23 秒,均通过。覆盖管理员/员工认证、访客/无效令牌/空门店拒绝、同源/白名单/恶意 Origin、消息上限、未知协议、5 秒首帧超时、20 秒 heartbeat(证明超过 15 秒 HTTP context)、会话撤销关闭、真实事务提交后通知、跨门店不通知、幂等重放及冲突静默、越权状态变更静默、失效消息无私有字段,以及慢订阅者通知合并和广播不阻塞。原有 PostgreSQL 单连接池回归测试同时通过。 diff --git a/example/QQ_1791014601658.png b/example/QQ_1791014601658.png new file mode 100644 index 0000000..2c4be87 Binary files /dev/null and b/example/QQ_1791014601658.png differ diff --git a/example/QQ_1791014614932.png b/example/QQ_1791014614932.png new file mode 100644 index 0000000..cc7bdca Binary files /dev/null and b/example/QQ_1791014614932.png differ diff --git a/example/QQ_1791014627569.png b/example/QQ_1791014627569.png new file mode 100644 index 0000000..dfa7ad7 Binary files /dev/null and b/example/QQ_1791014627569.png differ diff --git a/example/QQ_1791014638525.png b/example/QQ_1791014638525.png new file mode 100644 index 0000000..8e6b5cc Binary files /dev/null and b/example/QQ_1791014638525.png differ diff --git a/example/QQ_1791014649164.png b/example/QQ_1791014649164.png new file mode 100644 index 0000000..a399d6d Binary files /dev/null and b/example/QQ_1791014649164.png differ diff --git a/example/QQ_1791014659920.png b/example/QQ_1791014659920.png new file mode 100644 index 0000000..331566d Binary files /dev/null and b/example/QQ_1791014659920.png differ diff --git a/example/QQ_1791014672532.png b/example/QQ_1791014672532.png new file mode 100644 index 0000000..3ece187 Binary files /dev/null and b/example/QQ_1791014672532.png differ diff --git a/scripts/integration-smoke.mjs b/scripts/integration-smoke.mjs new file mode 100644 index 0000000..31f0ecc --- /dev/null +++ b/scripts/integration-smoke.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict' +import {randomUUID} from 'node:crypto' +let base=process.env.PERLER_TEST_API_URL +if(!base||!process.env.PERLER_TEST_ADMIN_PASSWORD)throw new Error('必须显式指定隔离测试服务 PERLER_TEST_API_URL 与 PERLER_TEST_ADMIN_PASSWORD') +let checks=0 +async function call(path,method='GET',data,token='',expected=200){let r=await fetch(base+'/api'+path,{method,headers:{'Content-Type':'application/json',...(token?{Authorization:'Bearer '+token}:{})},body:data===undefined?undefined:JSON.stringify(data)});assert.equal(r.status,200);let b=await r.json();assert.equal(b.code,expected,`${path}: ${b.msg}`);checks++;return b.data} +let health=await call('/health');assert.equal(health.service,'perler-beads') +let guest=await call('/auth/login','POST',{code:'dev-guest'}) +let admin=await call('/auth/admin','POST',{username:process.env.PERLER_TEST_ADMIN_USERNAME||'admin',password:process.env.PERLER_TEST_ADMIN_PASSWORD}) +let colors=await call('/palettes?storeId=store-001') +let capabilities=await call('/capabilities');assert.equal(capabilities.payment.enabled,false);assert.equal(capabilities.storedValue.enabled,false) +await call('/admin/patterns','GET',undefined,guest.token,401) +let p=await call('/patterns','POST',{title:'自动联调临时作品',category:'测试',width:2,height:2,cells:[colors[0].code,null,colors[1].code,colors[0].code],colors:colors.slice(0,2),visibility:'private',storeId:'store-001'},guest.token) +await call('/patterns/'+p.id,'GET',undefined,'',403) +await call('/favorites/'+p.id,'PUT',{active:true},guest.token) +assert.ok((await call('/favorites','GET',undefined,guest.token)).includes(p.id)) +await call('/visits','POST',{storeId:'store-001',tableCode:'T01'},guest.token) +let body={kind:'print',note:'自动联调:幂等与状态变更',patternId:p.id,requestId:randomUUID()} +let tasks=await Promise.all(Array.from({length:4},()=>call('/service-requests','POST',body,guest.token))) +assert.equal(new Set(tasks.map(t=>t.id)).size,1) +await call('/service-requests','POST',{...body,note:'不同内容'},guest.token,409) +let task=tasks[0] +await call('/service-requests/'+task.id,'PATCH',{status:'completed'},guest.token,401) +await call('/service-requests/'+task.id,'PATCH',{status:'accepted'},admin.token) +await call('/service-requests/'+task.id,'PATCH',{status:'completed'},admin.token) +let saved=(await call('/service-requests','GET',undefined,guest.token)).find(t=>t.id===task.id);assert.equal(saved.status,'completed') +let print=await fetch(base+'/api/patterns/'+p.id+'/print?pitchMm=5',{headers:{Authorization:'Bearer '+admin.token}});assert.ok(print.headers.get('content-type').includes('image/svg'));let svg=await print.text();assert.match(svg,/50 mm calibration/);assert.match(svg,/width="5.00" height="5.00"/);checks++ +await call('/favorites/'+p.id,'PUT',{active:false},guest.token) +await call('/patterns/'+p.id,'DELETE',undefined,guest.token) +console.log(JSON.stringify({checks,concurrentReplayCount:4,createdTaskId:task.id,status:'passed'},null,2))