Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e3c84366f | ||
|
|
2ebcb6e1b8 | ||
|
|
b75b67928b |
@@ -38,6 +38,9 @@ __pycache__/
|
||||
/test/reports/
|
||||
/web/test-results/
|
||||
/web/playwright-report/
|
||||
/web/e2e/.bin/
|
||||
/web/e2e/.runtime.json
|
||||
/web/e2e/.server.pid
|
||||
|
||||
# 编辑器和系统文件
|
||||
.idea/
|
||||
|
||||
@@ -2,17 +2,114 @@
|
||||
|
||||
自建的消息中转服务。设备、程序、App 作为「端」连到同一台服务器,端之间互发消息或群发。单个 Go 程序,内置 MQTT,SQLite 存储,自带管理后台,提供 Go、JS/TS、Python、Java/Android SDK。
|
||||
|
||||
## 功能概览
|
||||
|
||||
- 端登录、会话令牌、在线目录、单聊与群发
|
||||
- 离线保留、延迟、定时、撤回、回执
|
||||
- 管理后台(网页 + API)、自助注册、Prometheus `/metrics`
|
||||
- 单文件二进制或 Docker 部署
|
||||
|
||||
## 构建
|
||||
|
||||
需要:Go、Node.js LTS、pnpm、[Task](https://taskfile.dev)。
|
||||
|
||||
```bash
|
||||
task check # 构建前端、编译、lint、单元测试
|
||||
task build # 产出 bin/nixmsg(Windows 为 bin/nixmsg.exe)
|
||||
```
|
||||
|
||||
交叉编译三个发布平台(`CGO_ENABLED=0`,产物在 `bin/`,勿提交):
|
||||
|
||||
```bash
|
||||
task q:release-bins
|
||||
# 等价于:
|
||||
# CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -tags embeddist -o bin/nixmsg-linux-amd64 ./cmd/nixmsg
|
||||
# CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -tags embeddist -o bin/nixmsg-linux-arm64 ./cmd/nixmsg
|
||||
# CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -tags embeddist -o bin/nixmsg-windows-amd64.exe ./cmd/nixmsg
|
||||
```
|
||||
|
||||
Docker 当前架构镜像(不推送):
|
||||
|
||||
```bash
|
||||
task q:docker-build
|
||||
# 镜像名 git.asio.asia/nixevol/nixmsg:0.1.0
|
||||
```
|
||||
|
||||
推送当前架构镜像(阶段 3 正式执行;推送前先 `docker login git.asio.asia`):
|
||||
|
||||
```bash
|
||||
task q:docker-push
|
||||
```
|
||||
|
||||
多架构 `linux/amd64` + `linux/arm64`(需 Docker buildx / QEMU):
|
||||
|
||||
```bash
|
||||
task q:docker-buildx
|
||||
```
|
||||
|
||||
## 配置文件
|
||||
|
||||
默认读取当前目录的 `config.yaml`;也可用环境变量 `NIXMSG_CONFIG` 指定路径。
|
||||
|
||||
- 完整示例:[deploy/config.example.yaml](deploy/config.example.yaml)
|
||||
- 容器内示例:[deploy/config.docker.yaml](deploy/config.docker.yaml)(`data_dir: /data`)
|
||||
- 字段说明见 [docs/OPS.md](docs/OPS.md)
|
||||
|
||||
## 初始化与启动
|
||||
|
||||
1. 复制并编辑配置:
|
||||
|
||||
```bash
|
||||
copy deploy\config.example.yaml config.yaml # Windows
|
||||
# cp deploy/config.example.yaml config.yaml # Linux/macOS
|
||||
```
|
||||
|
||||
2. 生成管理员密码(只打印一次,请自行保存):
|
||||
|
||||
```bash
|
||||
# Windows PowerShell
|
||||
$env:NIXMSG_CONFIG="config.yaml"; .\bin\nixmsg.exe admin init
|
||||
|
||||
# Linux / macOS
|
||||
NIXMSG_CONFIG=config.yaml ./bin/nixmsg admin init
|
||||
```
|
||||
|
||||
3. 启动服务:
|
||||
|
||||
```bash
|
||||
$env:NIXMSG_CONFIG="config.yaml"; .\bin\nixmsg.exe serve
|
||||
# NIXMSG_CONFIG=config.yaml ./bin/nixmsg serve
|
||||
```
|
||||
|
||||
4. 浏览器打开 `http://127.0.0.1:7443/`(或你配置的 `listen` / `admin_listen`)登录管理后台。健康检查:`GET /healthz`。
|
||||
|
||||
Docker Compose 示例见 [deploy/docker-compose.yml](deploy/docker-compose.yml)。首次需保证数据目录对 uid `65532` 可写,再:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml run --rm nixmsg admin init
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
## SDK
|
||||
|
||||
| SDK | 说明 |
|
||||
|---|---|
|
||||
| [sdk/go](sdk/go/README.md) | Go 模块 `git.asio.asia/nixevol/NixMsg/sdk/go` |
|
||||
| [sdk/js](sdk/js/README.md) | npm `@nixevol/nixmsg` |
|
||||
| [sdk/python](sdk/python/README.md) | PyPI `nixmsg` |
|
||||
| [sdk/java](sdk/java/README.md) | Maven `asia.asio.nixmsg:nixmsg-sdk` |
|
||||
|
||||
包正式发布在阶段 3;开发期可按各 README 本地引用。
|
||||
|
||||
## 文档
|
||||
|
||||
- [产品需求](docs/PRD.md)
|
||||
- [开发说明](docs/DEVELOPMENT.md)
|
||||
- [开发任务拆分与多 Agent 协作](docs/TASKS.md)
|
||||
- [运维手册](docs/OPS.md)
|
||||
- [验收对照表](test/accept/ACCEPTANCE.md)(含未测项)
|
||||
- [开发任务](docs/TASKS.md)
|
||||
- [与文档的偏差](docs/DEVIATIONS.md)
|
||||
|
||||
## 状态
|
||||
|
||||
需求和设计已完成,正在开发。构建、运行和部署说明在开发完成后补充。
|
||||
|
||||
## 许可证
|
||||
|
||||
专有软件,见 [LICENSE](LICENSE)。源代码和发布物公开可读,不代表授予使用许可。
|
||||
|
||||
+5
-1
@@ -10,6 +10,9 @@ includes:
|
||||
'*':
|
||||
taskfile: taskfiles/*.yml
|
||||
optional: true
|
||||
w:
|
||||
taskfile: taskfiles/w.yml
|
||||
optional: true
|
||||
|
||||
tasks:
|
||||
default:
|
||||
@@ -63,9 +66,10 @@ tasks:
|
||||
- task: test
|
||||
|
||||
itest:
|
||||
desc: 运行集成测试(T0.5 后可用)
|
||||
desc: 运行集成测试(含后台网页 Playwright e2e)
|
||||
cmds:
|
||||
- go test ./test/... -count=1
|
||||
- task: w:e2e
|
||||
|
||||
docker:
|
||||
desc: 构建开发用 Docker 镜像
|
||||
|
||||
+6
-2
@@ -1,5 +1,7 @@
|
||||
# 多阶段构建:Node 构建前端 → Go 编译(嵌入前端)→ distroless static nonroot。
|
||||
# 镜像名 git.asio.asia/nixevol/nixmsg;多架构 buildx 推送留给 Q4 定稿,本骨架先单架构可构建。
|
||||
# 镜像名 git.asio.asia/nixevol/nixmsg;入口 /nixmsg。
|
||||
# 单架构:task q:docker-build 或 q:docker-push(含推送)。
|
||||
# 多架构:task q:docker-buildx(linux/amd64 + linux/arm64,需 buildx;正式推送在 Z3)。
|
||||
|
||||
FROM node:24-bookworm AS web
|
||||
WORKDIR /src/web
|
||||
@@ -9,13 +11,15 @@ COPY web/ ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM golang:1.27-bookworm AS build
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH=amd64
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
COPY --from=web /src/web/dist ./web/dist
|
||||
ENV CGO_ENABLED=0
|
||||
RUN go build -tags embeddist -o /out/nixmsg ./cmd/nixmsg
|
||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -tags embeddist -o /out/nixmsg ./cmd/nixmsg
|
||||
|
||||
FROM gcr.io/distroless/static:nonroot
|
||||
COPY --from=build /out/nixmsg /nixmsg
|
||||
|
||||
+12
-10
@@ -1,11 +1,16 @@
|
||||
# Docker Compose 示例(DEVELOPMENT 第 11.4 节骨架)
|
||||
# 本地冒烟:先 docker build -t git.asio.asia/nixevol/nixmsg:0.1.0 -f deploy/Dockerfile .
|
||||
# 容器名带 q 前缀便于多 Agent 隔离;正式部署可去掉 container_name。
|
||||
# Docker Compose 示例(对齐 DEVELOPMENT 第 11.4 节)
|
||||
# 构建:task q:docker-build(当前架构)或 task q:docker-buildx(多架构,正式推送在 Z3)
|
||||
# 首次:保证数据目录对 uid 65532 可写,再 admin init,然后 up。
|
||||
#
|
||||
# 命名卷首次授权示例:
|
||||
# docker run --rm -v q4-nixmsg-data:/data busybox chown -R 65532:65532 /data
|
||||
# 宿主机目录挂载时:在宿主机 chown 65532:65532 ./data
|
||||
|
||||
services:
|
||||
nixmsg:
|
||||
image: git.asio.asia/nixevol/nixmsg:0.1.0
|
||||
container_name: q-nixmsg
|
||||
# 本地/并行测试可加 container_name;正式部署可去掉
|
||||
container_name: q4-nixmsg
|
||||
restart: unless-stopped
|
||||
command: ["serve"]
|
||||
environment:
|
||||
@@ -14,7 +19,7 @@ services:
|
||||
- "7443:7443"
|
||||
volumes:
|
||||
- ./config.docker.yaml:/etc/nixmsg/config.yaml:ro
|
||||
- q-nixmsg-data:/data
|
||||
- q4-nixmsg-data:/data
|
||||
# 生产环境挂载证书目录,例如:
|
||||
# - /opt/nixmsg/certs:/certs:ro
|
||||
healthcheck:
|
||||
@@ -24,8 +29,5 @@ services:
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
q-nixmsg-data:
|
||||
name: q-nixmsg-data
|
||||
|
||||
# 首次使用命名卷时,需保证 /data 对 uid 65532 可写,例如:
|
||||
# docker run --rm -v q-nixmsg-data:/data busybox chown -R 65532:65532 /data
|
||||
q4-nixmsg-data:
|
||||
name: q4-nixmsg-data
|
||||
|
||||
+63
-3
@@ -701,13 +701,15 @@
|
||||
- 实际做法:`web/src/api/admin.ts` 统一导出接口函数,内部调用 `mock.ts`;`http.ts` 已实现带 `X-Nixmsg-Request: 1` 的真实请求封装,供 W4 切换。
|
||||
- 原因:A 线管理接口尚未合入,页面与契约可并行开发。
|
||||
- 备选:用 MSW 拦截 fetch;当前集中换 `admin.ts` 更简单。
|
||||
- 假登录口令:`admin` / `adminpassword`(仅本地 mock,不进后端)。
|
||||
- 假登录口令:`admin` / `adminpassword`(仅本地 mock,不进后端)。
|
||||
- **W4 起已切换**:生产与 e2e 走真实 `/api/admin`;组件 Vitest 通过 `vi.mock("@/api/admin")` → `admin-mock.ts` 仍用假数据。登录页假数据提示已去掉。
|
||||
|
||||
2. **列表分页用页码映射 cursor 偏移**
|
||||
- 原条款:admin-api 使用 `cursor`/`limit` 游标分页。
|
||||
- 实际做法:假数据把 `page` 编成数字偏移 cursor(`String((page-1)*limit)`),`n-data-table` remote 分页照常。
|
||||
- 原因:Naive UI 表格以页码交互;契约游标对前端透明即可。
|
||||
- 备选:W4 若后端 cursor 非偏移编码,在 `admin.ts` 内适配,页面仍用页码。
|
||||
- 备选:W4 若后端 cursor 非偏移编码,在 `admin.ts` 内适配,页面仍用页码。
|
||||
- **W4**:后端 cursor 为不透明字符串;首页(空 cursor)主路径 e2e 通过。翻到第 2 页若用数字偏移可能无效,未改页面交互;后续若要稳定翻页,应在 `admin.ts` 缓存服务端 `next_cursor`。
|
||||
|
||||
3. **帮助图标不用独立图标库**
|
||||
- 原条款:DEVELOPMENT 2.4 用 `n-tooltip`;协作规则要求只用 Naive UI。
|
||||
@@ -727,7 +729,28 @@
|
||||
- 原因:与重置密码、解锁同级的行内动作更贴桌面工作流。
|
||||
- 备选:无。
|
||||
|
||||
## SDK 一 S1
|
||||
### W4 2026-09-30
|
||||
|
||||
1. **接真实接口并适配 A 线字段**
|
||||
- 原条款:TASKS W4;admin-api 令牌 `id` 示例为数字。
|
||||
- 实际做法:`admin.ts` 全部改为 `requestAdmin`;令牌 `id` 类型改为 `string`(对齐 A1 TEXT);`talk-password` 用 `PUT`;概览可选 `endpoints_self`;建群省略空 `id`(A3 不接受自定义 id)。不改服务器。
|
||||
- 原因:与已合并 A 线实现一致。
|
||||
- 备选:无。
|
||||
- 影响:组件测用 `admin-mock.ts`;页面逻辑不变。
|
||||
|
||||
2. **未完成投递由 e2e 用 MQTT seed 造出**
|
||||
- 原条款:DEVELOPMENT 第 13 节后台主路径「看到未完成记录」;管理 messages 只读。
|
||||
- 实际做法:Playwright 流程里用管理接口开通第二端后,运行 `web/e2e/seedpending`(MQTT hello + 未来 `send_at_ms` 的 send),再打开投递记录页断言 `scheduled`。测完杀进程并删临时目录。
|
||||
- 原因:管理 API 不能写消息;不改服务器业务代码。
|
||||
- 备选:插入 SQLite(并发/锁风险);或依赖已有记录(空库无)。
|
||||
- 影响:e2e 依赖本机 `go` 与 Chromium。
|
||||
|
||||
3. **Playwright 挂入 `task itest` / `task w:e2e`**
|
||||
- 原条款:TASKS W4「端到端测试在 task itest 里通过」;Taskfile 由总控维护、各线写 `taskfiles/<线>.yml`。
|
||||
- 实际做法:新增 `taskfiles/w.yml`;主 `Taskfile.yml` 显式 `includes.w`(本机 Task 3.53 下 `taskfiles/*.yml` glob 未挂上各线任务)并把 `itest` 在 `go test ./test/...` 后追加 `task: w:e2e`。Playwright 的 `webServer` 先于 `globalSetup` 启动,故用 `e2e/start-stack.mjs` 同时起临时 nixmsg 与 Vite;代理目标 `NIXMSG_PROXY_TARGET`(开发默认仍 7443)。浏览器用 `pnpm exec playwright install chromium`,不改全局配置。
|
||||
- 原因:满足 W4 验收且隔离安装。
|
||||
- 备选:仅文档要求手跑 e2e;未采用。
|
||||
- 影响:`task itest` 变长;需本机 Go/Node/Chromium。
|
||||
|
||||
### S1.1 传输层可注入假实现(Go / JS)
|
||||
|
||||
@@ -1002,3 +1025,40 @@
|
||||
- 原因:隔离目录约束。
|
||||
- 备选方案:扩展 harness.Restart(需总控改)。
|
||||
- 影响:Q 线自带启停辅助。
|
||||
|
||||
### Q4 定稿 + Q5 文档 2026-09-30
|
||||
|
||||
1. **多架构 buildx 本波不执行推送与双架构验证**
|
||||
- 原条款:DEVELOPMENT 11.4 / TASKS Q4「两个架构的镜像都能初始化、启动并通过健康检查」;`docker buildx` 一次推送 amd64+arm64。
|
||||
- 实际做法:完善 Dockerfile(`TARGETOS`/`TARGETARCH`)、compose、`task q:docker-build` / `q:docker-push` / `q:docker-buildx`;本机只对当前架构(linux/amd64)`docker build` 并冒烟 `/healthz`;**不** `docker push`、**不**打版本 git 标签、**不**发布 SDK。本机 `docker buildx` 已列出 `linux/arm64`,但本波按总控指示不执行多架构构建与推送,留给 Z3。
|
||||
- 原因:总控本波明确禁止真正 push / 打标签 / 发 SDK;双架构留给 Z3。
|
||||
- 备选方案:在 Linux 宿主或已装 binfmt 的环境执行 `task q:docker-buildx`。
|
||||
- 影响:交付标准「两架构镜像」与仓库推送仍待阶段 3。
|
||||
|
||||
2. **交叉编译三平台,本波至少验证 windows/amd64**
|
||||
- 原条款:DEVELOPMENT 11.2 三平台二进制。
|
||||
- 实际做法:`task q:release-bins`(`CGO_ENABLED=0`)产出 `bin/nixmsg-linux-amd64`、`nixmsg-linux-arm64`、`nixmsg-windows-amd64.exe`;说明写入 README;二进制不提交。
|
||||
- 原因:纯 Go + embed,交叉编译可行。
|
||||
- 备选方案:仅本机 `task build`。
|
||||
- 影响:发布物打包在 Z3。
|
||||
|
||||
3. **compose 容器/卷名带 q4 前缀**
|
||||
- 原条款:DEVELOPMENT 11.4 示例无固定 `container_name`;测试隔离要求名字带线前缀。
|
||||
- 实际做法:`deploy/docker-compose.yml` 使用 `q4-nixmsg` / `q4-nixmsg-data`;正式部署可去掉 `container_name`。
|
||||
- 原因:多 Agent 并行不抢容器名。
|
||||
- 备选方案:compose 用项目名 `-p` 隔离而不写死 container_name。
|
||||
- 影响:与文档示例略有差异,行为等价。
|
||||
|
||||
4. **验收未测项保持未测**
|
||||
- 原条款:交付标准要求 F01–F23 有结果;TASKS 本波 Q4/Q5 不做假装通过。
|
||||
- 实际做法:`ACCEPTANCE.md` / `docs/OPS.md` 第 9 节明示 F03、F04、F07、F10、F11、F14、F15、F18、F19 仍为未测;不改对照表状态。
|
||||
- 原因:本波范围是 Docker 定稿与文档。
|
||||
- 备选方案:无。
|
||||
- 影响:阶段 3 / 负责人审阅时须看到未测清单。
|
||||
|
||||
5. **Q5 文档落点**
|
||||
- 原条款:README、运维手册、SDK 文档汇总。
|
||||
- 实际做法:重写根 `README.md`;新增 `docs/OPS.md`;SDK 汇总为 README 链到已有 `sdk/{go,js,python,java}/README.md`(各 SDK 已有最短使用说明,本波不重复扩写)。
|
||||
- 原因:避免四份说明与 SDK 线漂移。
|
||||
- 备选方案:在 docs/ 再建 SDK 汇总页。
|
||||
- 影响:无。
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
# NixMsg 运维手册
|
||||
|
||||
对应 DEVELOPMENT 第 11 节。本文不含任何密码或令牌样例。
|
||||
|
||||
## 1. 配置项
|
||||
|
||||
环境变量 `NIXMSG_CONFIG` 指向 YAML 配置,默认 `./config.yaml`。完整字段见 [deploy/config.example.yaml](../deploy/config.example.yaml)。
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| `listen` | 端接入端口,如 `":7443"` |
|
||||
| `admin_listen` | 后台单独监听,如 `"127.0.0.1:7444"`;空表示后台与端共用 `listen` |
|
||||
| `tls.cert_file` / `tls.key_file` | 证书与私钥路径;空表示明文 |
|
||||
| `tls.allow_plaintext` | 配了证书时是否仍接受明文;生产建议 `false` |
|
||||
| `trusted_proxies` | 可信反向代理网段,用于解析真实客户端 IP |
|
||||
| `data_dir` | 数据目录(库文件、`listen.addr`、迁移备份) |
|
||||
| `limits.*` | 正文/帧大小、TTL、群人数、宽限、确认超时、配额等;`max_body_bytes` 只能调小,上限 262144 |
|
||||
| `session_idle_days` | 会话令牌闲置失效天数;`0` 不失效 |
|
||||
| `record_retention_days` | 完成后消息记录保留天数;`0` 表示完成后不留记录 |
|
||||
| `idempotency_hours` | 防重窗口 |
|
||||
| `receipt_retention_days` | 回执保留天数 |
|
||||
| `sqlite_synchronous` | `FULL` 或 `NORMAL` |
|
||||
| `metrics.token` | 共用端口时访问 `/metrics` 所需 Bearer 令牌;空则共用端口不提供 `/metrics` |
|
||||
| `log.level` | 日志级别,如 `info` |
|
||||
|
||||
注册开关、注册安全码、API 令牌存在数据库,在管理后台修改,不在配置文件里。
|
||||
|
||||
校验配置:`nixmsg check-config`。
|
||||
|
||||
## 2. 目录布局
|
||||
|
||||
```text
|
||||
config.yaml
|
||||
data/nixmsg.db
|
||||
data/backup/ # 迁移前自动备份、手工 backup 也可写到这里
|
||||
data/listen.addr # serve 写入实际监听地址(测试用端口 0 时需要)
|
||||
```
|
||||
|
||||
Docker 约定:配置 `/etc/nixmsg/config.yaml`,数据 `/data`,证书 `/certs`(只读挂载)。
|
||||
|
||||
## 3. 备份
|
||||
|
||||
程序不做定时备份,用 cron 或 1Panel 计划任务调用:
|
||||
|
||||
```bash
|
||||
# 宿主机(服务可在运行中)
|
||||
NIXMSG_CONFIG=/path/to/config.yaml nixmsg backup --out /path/to/data/backup/manual-$(date +%Y%m%d).db
|
||||
|
||||
# Docker Compose
|
||||
docker compose -f deploy/docker-compose.yml exec nixmsg /nixmsg backup --out /data/backup/manual.db
|
||||
```
|
||||
|
||||
备份文件含当时未送完的正文,按敏感数据保管。旧备份自行清理。
|
||||
|
||||
恢复:停服务,用备份文件替换 `data/nixmsg.db`(或拷到新 `data_dir`),再启动;勿在半迁移状态硬切。
|
||||
|
||||
## 4. 升级与自动迁移
|
||||
|
||||
1. 换上新二进制或拉新镜像。
|
||||
2. 启动时若有未应用的嵌入迁移版本,会先 `VACUUM INTO` 到
|
||||
`<data_dir>/backup/pre-migrate-<UTC时间>.db`,再执行迁移。
|
||||
3. 迁移失败则进程退出,不带半新半旧库继续服务;运维可从备份恢复后排查。
|
||||
4. 空库首次建表不会产生迁移前备份。
|
||||
|
||||
无需手工跑迁移命令。
|
||||
|
||||
## 5. 证书与 1Panel
|
||||
|
||||
程序只读证书文件,不做 ACME。装有 1Panel 时:
|
||||
|
||||
1. 在 1Panel 证书管理申请证书(DNS 或 HTTP 验证),打开自动续签。
|
||||
2. 勾选「推送证书到本地目录」,例如 `/opt/nixmsg/certs`。申请与续签后写入 `fullchain.pem`、`privkey.pem`。
|
||||
3. 配置:
|
||||
|
||||
```yaml
|
||||
tls:
|
||||
cert_file: /opt/nixmsg/certs/fullchain.pem
|
||||
key_file: /opt/nixmsg/certs/privkey.pem
|
||||
allow_plaintext: false
|
||||
```
|
||||
|
||||
4. 私钥须让 NixMsg 进程可读。Docker 容器用户为 nonroot(uid `65532`);可在 1Panel「申请证书后执行脚本」里调整属主/权限。
|
||||
5. 程序每小时检查文件修改时间,续签后新连接自动用新证书,已有连接不断开。
|
||||
|
||||
不要用 1Panel OpenResty 终止 TLS 再转发裸 TCP:TCP/UDP 代理不做 TLS,设备会被拆端口。仅当全部端走 WebSocket 时,才可改成「OpenResty 终止 HTTPS/WSS,NixMsg 本机明文」;此时配置 `trusted_proxies`,并设置 `proxy_http_version 1.1`、`Upgrade`、`Connection`,`Host` 用 `$http_host`。
|
||||
|
||||
## 6. 服务器时钟
|
||||
|
||||
- 开启 NTP 对时。
|
||||
- 服务器时钟被人为大改时,定时消息按新时钟触发。
|
||||
- 宽限、确认超时、会话闲置等也依赖系统时间。
|
||||
|
||||
## 7. 抓取 `/metrics`
|
||||
|
||||
Prometheus 文本格式,只含计数和耗时,不含正文与编号明细。
|
||||
|
||||
- **后台单独监听**(`admin_listen` 非空):在后台端口直接
|
||||
`GET http://<admin_host>:<port>/metrics`,无需令牌。
|
||||
- **共用端口**:请求头必须带 `Authorization: Bearer <metrics.token>`。
|
||||
- 未配置 `metrics.token` → `404`
|
||||
- 令牌错误 → `401`
|
||||
|
||||
示例(共用端口且已配置 token;勿把真实令牌写进仓库或脚本提交):
|
||||
|
||||
```bash
|
||||
curl -sS -H "Authorization: Bearer $NIXMSG_METRICS_TOKEN" http://127.0.0.1:7443/metrics
|
||||
```
|
||||
|
||||
健康检查:`GET /healthz`、`GET /readyz`(Docker HEALTHCHECK 用镜像内 `/nixmsg healthcheck`)。
|
||||
|
||||
## 8. Docker 要点
|
||||
|
||||
- 镜像:`git.asio.asia/nixevol/nixmsg`(版本标签与 `latest`)。
|
||||
- Compose 示例:[deploy/docker-compose.yml](../deploy/docker-compose.yml)。
|
||||
- 容器以 uid `65532` 运行:挂载数据目录须可写;命名卷首次可
|
||||
`docker run --rm -v <卷名>:/data busybox chown -R 65532:65532 /data`。
|
||||
- 首次:`docker compose run --rm nixmsg admin init`,再 `up -d`。
|
||||
- 构建/推送 Task 目标见根目录 README(`q:docker-build` / `q:docker-push` / `q:docker-buildx`)。正式仓库推送在阶段 3。
|
||||
|
||||
## 9. 验收未测项(勿当作已通过)
|
||||
|
||||
截至 Q4/Q5 文档定稿,对照表 [test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md) 中下列项仍为**未测**,运维与交付说明须保持该状态,不得宣称通过:
|
||||
|
||||
| 编号 | 摘要 |
|
||||
|---|---|
|
||||
| F03 | 断开后离线状态 / 目录全表 |
|
||||
| F04 | presence 订阅通知 |
|
||||
| F07 | 256 KiB 边界与接收上限 |
|
||||
| F10 | 抖动宽限长短断线 |
|
||||
| F11 | 发送方离线后定时到点 |
|
||||
| F14 | 回执补送 |
|
||||
| F15 | 对话密码授权链路 |
|
||||
| F18 | 正文删除与记录天数 0 |
|
||||
| F19 | 四种 SDK 统一接入清单(属 SDK 线,本波未在 Q 对照表复测) |
|
||||
|
||||
F22 标为通过的子集仅覆盖 init + 健康检查等;备份恢复、升级迁移、证书重载、Docker 全量、`/metrics` 抓取等仍见对照表备注中的未测说明。
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
# NixMsg 0.1.0 交付说明
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 版本 | 0.1.0 |
|
||||
| 日期 | 2026-09-30 |
|
||||
| 功能合入 tip | `2ebcb6e1b85a8997c48de2711b013c83fa0440fc`(W4 + Q4 已进 main) |
|
||||
| 发布标签 | `v0.1.0`、`sdk/go/v0.1.0`(指向含本说明的提交;以 `git rev-parse v0.1.0` 为准) |
|
||||
| 对应 | [PRD.md](./PRD.md)、[DEVELOPMENT.md](./DEVELOPMENT.md)、[TASKS.md](./TASKS.md) |
|
||||
|
||||
## 1. 已完成能力
|
||||
|
||||
- 服务端:端登录与会话令牌、在线目录、单聊/群发、离线保留、延迟/定时/撤回/回执、自助注册、管理 API、Prometheus `/metrics`、SQLite 存储、单端口多协议(HTTP / WebSocket MQTT / TCP MQTT)
|
||||
- 管理后台网页:接真实 `/api/admin`,Playwright 主路径 e2e
|
||||
- SDK:Go、JS/TS、Python、Java/Android(源码与接入清单测试在仓库内;包未发布,见第 5 节)
|
||||
- 部署文档:README 构建与启动、[OPS.md](./OPS.md) 运维、Docker Compose / Dockerfile 定稿(镜像未推送)
|
||||
|
||||
## 2. F01–F23 验收结果
|
||||
|
||||
来源:[test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md)(生成时间 2026-09-30T00:26:12Z)。对照表汇总:通过 14,失败 0,未测 9。
|
||||
|
||||
### 通过
|
||||
|
||||
| 编号 | 一句话 |
|
||||
|---|---|
|
||||
| F01 | 批量开通整批校验、停用、删除群主转让、删除后同编号重开不串数据 |
|
||||
| F02 | 新设备登录后旧设备自动退出、换 IP 用令牌重连、两种密码锁定、重置密码后被踢、服务器故障不误报密码错误 |
|
||||
| F05 | 崩溃不丢已提交消息,消息号去重和冲突,密码门生效,配额生效 |
|
||||
| F06 | 群成员收到同一份,入群前不补,发送者不收到自己的 |
|
||||
| F08 | 弱网最终送达且应用层不重复,重启后续传 |
|
||||
| F09 | 保留时间从发送时刻起算,超时过期 |
|
||||
| F12 | 延迟窗口内撤回对方收不到 |
|
||||
| F13 | 未推送必撤成功;群部分确认得到部分撤回 |
|
||||
| F16 | 群主权限、退出后不再收到、解散后同编号新群不收旧消息 |
|
||||
| F17 | 后台管端、管注册、管群、查记录,响应里没有正文;API 令牌可用且不能越权 |
|
||||
| F20 | 裸 MQTT 能登录、收、确认、发 |
|
||||
| F21 | 默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口 |
|
||||
| F22 | 初始化后单文件或 Docker 启动、备份恢复、升级迁移、证书自动重载、指标可抓取 |
|
||||
| F23 | 注册开关、安全码校验、换码不影响已注册、输错锁定 |
|
||||
|
||||
### 未测
|
||||
|
||||
| 编号 | 一句话 | 原因(摘自对照表) |
|
||||
|---|---|---|
|
||||
| F03 | 断开后状态及时变离线,全表可列出 | directory.list / 断开后离线状态未在本波单独断言 |
|
||||
| F04 | 只通知订阅了的端 | presence.watch 订阅通知未覆盖 |
|
||||
| F07 | 256 KiB 通过,超出拒绝,接收上限生效 | 256 KiB 边界与接收上限未覆盖 |
|
||||
| F10 | 短断线送到,长断线丢弃,服务器重启后宽限内重连送到 | 抖动宽限长短断线未单独拨钟 |
|
||||
| F11 | 发送方离线后到点仍发送 | 发送方离线后定时到点发送未覆盖 |
|
||||
| F14 | 回执能补送给当时离线的发送方 | 回执补送未覆盖 |
|
||||
| F15 | 输一次记住、改密失效、回复免密、进群仍要密码、防多账号轮流猜 | 对话密码授权链路未覆盖 |
|
||||
| F18 | 送达后正文消失;记录天数 0 时连记录消失;防重仍在 | 正文删除与记录天数 0 未覆盖 |
|
||||
| F19 | 四种 SDK 通过同一清单 | 对照表仍标未测(属 S1/S2);本轮交付回归已另跑四套 SDK 测试,见第 4 节 |
|
||||
|
||||
### 失败
|
||||
|
||||
无。
|
||||
|
||||
## 3. 偏差(供负责人回来审阅)
|
||||
|
||||
完整正文见 [DEVIATIONS.md](./DEVIATIONS.md)。以下只列小节标题,不抄全文:
|
||||
|
||||
- T0.1 2026-09-30
|
||||
- L-WIRE 2026-09-30
|
||||
- L-UPLINK 2026-09-30
|
||||
- T0.2 2026-09-30
|
||||
- T0.3 2026-09-30
|
||||
- T0.5 2026-09-30
|
||||
- T0.4 2026-09-30
|
||||
- P1 2026-09-30
|
||||
- P2 2026-09-30
|
||||
- P3 2026-09-30
|
||||
- P4 2026-09-30
|
||||
- N1 / N2 2026-09-30
|
||||
- N3 2026-09-30
|
||||
- M1 2026-09-30
|
||||
- M2 / M3 / M4 2026-09-30
|
||||
- I1 2026-09-30
|
||||
- I2 / I3 / I4 2026-09-30
|
||||
- I5 2026-09-30
|
||||
- A1 2026-09-30
|
||||
- A2 2026-09-30
|
||||
- A3 2026-09-30
|
||||
- 后台网页 W(W1–W3 假数据与分页等条目)
|
||||
- W4 2026-09-30
|
||||
- S1.1 传输层可注入假实现(Go / JS)
|
||||
- S1.2 Go autopaho `OnConnectionUp` 内握手改异步
|
||||
- S1.3 重连退避与「稳定在线 60s」状态机自管
|
||||
- S1.4 Go 模块许可证标注
|
||||
- S1.5 JS 回调命名与文档概念名
|
||||
- S1.6 任务 1–3 当时未做范围(已被 S1.7 取代)
|
||||
- S1.7 2026-09-30 任务 4–5 接入清单与打包文档
|
||||
- S2-PY/JAVA 1–3 2026-09-30
|
||||
- S2-PY/JAVA 4–5 2026-09-30
|
||||
- Q1 / Q4 骨架 2026-09-30
|
||||
- Q2 第一部分(已合并功能验收)2026-09-30
|
||||
- Q2 补齐 + Q3(本机 Windows)2026-09-30
|
||||
- Q4 定稿 + Q5 文档 2026-09-30
|
||||
|
||||
## 4. 本轮验证
|
||||
|
||||
合并 `feat/w4-e2e`、`feat/q-release-docs` 后,在 `main`(功能 tip `2ebcb6e`,含本说明的发布提交)执行:
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| `task check` | 通过 |
|
||||
| `go test ./test/...` | 通过(accept / chaos / harness / load / report) |
|
||||
| `task w:e2e`(W4 合并后) | 通过(1 passed) |
|
||||
| `sdk/go`:`go test ./...` | 通过 |
|
||||
| `sdk/js`:`npm test` | 通过(23 passed) |
|
||||
| `sdk/python`:venv + `pytest` | 通过(22 passed);测完已删本地 `.venv` |
|
||||
| `sdk/java`:`mvn test` | 通过(scoop maven 3.9.16;测完已删 `target`) |
|
||||
|
||||
## 5. 构建与启动
|
||||
|
||||
请直接按仓库文档操作,此处不重复步骤:
|
||||
|
||||
- 构建、交叉编译、Docker 本地构建与启动示例:[README.md](../README.md)
|
||||
- 配置项、备份、升级、证书、时钟、指标:[docs/OPS.md](./OPS.md)
|
||||
- 配置样例:`deploy/config.example.yaml`、`deploy/config.docker.yaml`
|
||||
|
||||
常用入口:`task check` / `task build`,然后 `admin init` + `serve`(见 README)。
|
||||
|
||||
## 6. 未发布 SDK 包与镜像的原因
|
||||
|
||||
按本次总控收尾要求:**不创建 Gitea package 令牌**,因此:
|
||||
|
||||
- **未** `npm` / `pip` / Maven 发布到 Gitea 包仓库(`@nixevol/nixmsg`、`nixmsg`、`asia.asio.nixmsg:nixmsg-sdk`)
|
||||
- **未** `docker push`(含 `git.asio.asia/nixevol/nixmsg` 的版本标签与 `latest`)
|
||||
- **未** 执行多架构 `buildx` 正式推送
|
||||
|
||||
有 package 读写令牌且负责人授权后,再按 [TASKS.md](./TASKS.md) 第 3 节与 Z3、以及 README / OPS 中的发布命令补做。
|
||||
|
||||
## 7. 标签
|
||||
|
||||
在本交付说明提交(含 `docs/RELEASE.md`)上打附注标签 `v0.1.0` 与 `sdk/go/v0.1.0` 并推送到 `origin`(若远端已存在同名标签则不覆盖,见当时操作记录)。
|
||||
+40
-1
@@ -1,5 +1,10 @@
|
||||
version: "3"
|
||||
|
||||
vars:
|
||||
IMAGE: git.asio.asia/nixevol/nixmsg
|
||||
IMAGE_TAG: "0.1.0"
|
||||
BIN_DIR: bin
|
||||
|
||||
tasks:
|
||||
q:accept:
|
||||
desc: 跑 Q2 验收集成测试并写入 F01–F23 对照表
|
||||
@@ -41,4 +46,38 @@ tasks:
|
||||
q:docker-build:
|
||||
desc: 构建当前架构镜像(不推送)
|
||||
cmds:
|
||||
- docker build -t git.asio.asia/nixevol/nixmsg:0.1.0 -f deploy/Dockerfile .
|
||||
- docker build -t {{.IMAGE}}:{{.IMAGE_TAG}} -t {{.IMAGE}}:latest -f deploy/Dockerfile .
|
||||
|
||||
q:docker-push:
|
||||
desc: 构建并推送当前架构镜像到 git.asio.asia/nixevol/nixmsg(正式推送在 Z3)
|
||||
cmds:
|
||||
- task: q:docker-build
|
||||
- docker push {{.IMAGE}}:{{.IMAGE_TAG}}
|
||||
- docker push {{.IMAGE}}:latest
|
||||
|
||||
q:docker-buildx:
|
||||
desc: 多架构 buildx 构建并推送 linux/amd64+arm64(需 QEMU;正式推送在 Z3)
|
||||
cmds:
|
||||
- docker buildx build --platform linux/amd64,linux/arm64 -t {{.IMAGE}}:{{.IMAGE_TAG}} -t {{.IMAGE}}:latest -f deploy/Dockerfile --push .
|
||||
|
||||
q:release-bins:
|
||||
desc: 交叉编译三平台二进制到 bin/(CGO_ENABLED=0,不提交)
|
||||
deps: [web:build]
|
||||
cmds:
|
||||
- |
|
||||
{{if eq OS "windows"}}powershell -NoProfile -Command "New-Item -ItemType Directory -Force -Path '{{.BIN_DIR}}' | Out-Null"{{else}}mkdir -p {{.BIN_DIR}}{{end}}
|
||||
- cmd: go build -tags embeddist -o {{.BIN_DIR}}/nixmsg-linux-amd64 ./cmd/nixmsg
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOOS: linux
|
||||
GOARCH: amd64
|
||||
- cmd: go build -tags embeddist -o {{.BIN_DIR}}/nixmsg-linux-arm64 ./cmd/nixmsg
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOOS: linux
|
||||
GOARCH: arm64
|
||||
- cmd: go build -tags embeddist -o {{.BIN_DIR}}/nixmsg-windows-amd64.exe ./cmd/nixmsg
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOOS: windows
|
||||
GOARCH: amd64
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
version: "3"
|
||||
|
||||
tasks:
|
||||
e2e:
|
||||
desc: Playwright 后台主路径端到端(起临时 nixmsg + Vite 代理)
|
||||
dir: web
|
||||
cmds:
|
||||
- pnpm install
|
||||
- pnpm exec playwright install chromium
|
||||
- pnpm run test:e2e
|
||||
|
||||
typecheck:
|
||||
desc: 前端 vue-tsc
|
||||
dir: web
|
||||
cmds:
|
||||
- pnpm install
|
||||
- pnpm run typecheck
|
||||
|
||||
lint:
|
||||
desc: 前端 ESLint
|
||||
dir: web
|
||||
cmds:
|
||||
- pnpm install
|
||||
- pnpm run lint
|
||||
|
||||
unit:
|
||||
desc: 前端 Vitest
|
||||
dir: web
|
||||
cmds:
|
||||
- pnpm install
|
||||
- pnpm run test
|
||||
@@ -0,0 +1,151 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
type RuntimeInfo = {
|
||||
httpBase: string;
|
||||
adminPassword: string;
|
||||
dataDir: string;
|
||||
binPath: string;
|
||||
vitePort: number;
|
||||
};
|
||||
|
||||
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repoRoot = path.resolve(webRoot, "..");
|
||||
const runtimePath = path.join(webRoot, "e2e", ".runtime.json");
|
||||
|
||||
async function loadRuntime(): Promise<RuntimeInfo> {
|
||||
return JSON.parse(await readFile(runtimePath, "utf8")) as RuntimeInfo;
|
||||
}
|
||||
|
||||
async function login(page: Page, password: string) {
|
||||
await page.goto("/login");
|
||||
await page.getByPlaceholder("admin").fill("admin");
|
||||
await page.getByPlaceholder("请输入密码").fill(password);
|
||||
await page.getByTestId("login-submit").click();
|
||||
await expect(page).toHaveURL(/\/overview/);
|
||||
}
|
||||
|
||||
function nav(page: Page, label: string) {
|
||||
return page.locator(".n-menu").getByText(label, { exact: true }).click();
|
||||
}
|
||||
|
||||
async function createPeerViaAdmin(
|
||||
request: APIRequestContext,
|
||||
httpBase: string,
|
||||
password: string,
|
||||
id: string,
|
||||
loginPassword: string,
|
||||
) {
|
||||
const loginRes = await request.post(`${httpBase}/api/admin/login`, {
|
||||
data: { username: "admin", password },
|
||||
});
|
||||
expect(loginRes.ok()).toBeTruthy();
|
||||
const res = await request.post(`${httpBase}/api/admin/endpoints`, {
|
||||
headers: { "X-Nixmsg-Request": "1" },
|
||||
data: { id, name: id, login_password: loginPassword },
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.ok(), JSON.stringify(body)).toBeTruthy();
|
||||
expect(body.ok).toBe(true);
|
||||
}
|
||||
|
||||
function seedPending(httpBase: string, from: string, fromPass: string, to: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
"go",
|
||||
[
|
||||
"run",
|
||||
"./web/e2e/seedpending",
|
||||
"-base",
|
||||
httpBase,
|
||||
"-from",
|
||||
from,
|
||||
"-from-pass",
|
||||
fromPass,
|
||||
"-to",
|
||||
to,
|
||||
],
|
||||
{ cwd: repoRoot, shell: process.platform === "win32" },
|
||||
);
|
||||
let out = "";
|
||||
child.stdout.on("data", (d) => {
|
||||
out += String(d);
|
||||
});
|
||||
child.stderr.on("data", (d) => {
|
||||
out += String(d);
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`seedpending failed: ${out}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("后台主路径 W4", () => {
|
||||
test("登录、开通、注册、建群、未完成投递、令牌创建停用", async ({ page, request }) => {
|
||||
const rt = await loadRuntime();
|
||||
await login(page, rt.adminPassword);
|
||||
|
||||
await nav(page, "端");
|
||||
await expect(page).toHaveURL(/\/endpoints/);
|
||||
await page.getByRole("button", { name: "开通", exact: true }).click();
|
||||
await page.getByTestId("create-id").locator("input").fill("w4ep001");
|
||||
await page.getByTestId("create-name").locator("input").fill("W4端一");
|
||||
await page.getByTestId("create-submit").click();
|
||||
await expect(page.getByText("只显示一次")).toBeVisible();
|
||||
await page.getByRole("button", { name: "我已保存" }).click();
|
||||
await expect(page.getByText("只显示一次")).toHaveCount(0);
|
||||
await expect(page.getByText("w4ep001")).toBeVisible();
|
||||
|
||||
// 管理接口开通第二端(已知密码),供 MQTT 造未完成投递;管理 messages 只读
|
||||
await createPeerViaAdmin(request, rt.httpBase, rt.adminPassword, "w4ep002", "password12ab");
|
||||
|
||||
await nav(page, "注册设置");
|
||||
await expect(page).toHaveURL(/\/registration/);
|
||||
await page.getByTestId("reg-enabled").click();
|
||||
await expect(page.getByText("已开启自助注册")).toBeVisible();
|
||||
await page.getByTestId("reg-code").locator("input").fill("w4-reg-code-01");
|
||||
await page.getByTestId("reg-save-code").click();
|
||||
await expect(page.getByText("安全码已更新")).toBeVisible();
|
||||
|
||||
await nav(page, "群");
|
||||
await expect(page).toHaveURL(/\/groups/);
|
||||
await page.getByTestId("group-create-open").click();
|
||||
await page.getByTestId("group-name").locator("input").fill("W4测试群");
|
||||
await page.getByTestId("group-owner").locator("input").fill("w4ep001");
|
||||
await page.getByTestId("group-members").locator("textarea").fill("w4ep002");
|
||||
await page.getByTestId("group-create-submit").click();
|
||||
await expect(page.getByText("W4测试群")).toBeVisible();
|
||||
|
||||
await seedPending(rt.httpBase, "w4ep002", "password12ab", "w4ep001");
|
||||
|
||||
await nav(page, "投递记录");
|
||||
await expect(page).toHaveURL(/\/messages/);
|
||||
await expect(page.getByText("w4-e2e-scheduled")).toBeVisible();
|
||||
await expect(page.getByRole("cell", { name: "scheduled", exact: true })).toBeVisible();
|
||||
|
||||
await nav(page, "API 令牌");
|
||||
await expect(page).toHaveURL(/\/tokens/);
|
||||
await page.getByTestId("token-create-open").click();
|
||||
await page.getByTestId("token-name").locator("input").fill("w4-e2e-token");
|
||||
await page.getByTestId("token-create-submit").click();
|
||||
await expect(page.getByText("令牌只显示一次")).toBeVisible();
|
||||
const bodyText = await page.locator("body").innerText();
|
||||
const tokenMatch = bodyText.match(/nxm_[A-Za-z0-9_-]+/);
|
||||
expect(tokenMatch).toBeTruthy();
|
||||
const shown = tokenMatch![0];
|
||||
await page.getByRole("button", { name: "我已保存" }).click();
|
||||
await expect(page.getByText("令牌只显示一次")).toHaveCount(0);
|
||||
await expect(page.getByText(shown)).toHaveCount(0);
|
||||
await expect(page.getByText("w4-e2e-token")).toBeVisible();
|
||||
|
||||
const row = page.locator("tr", { hasText: "w4-e2e-token" });
|
||||
await expect(row.getByText("是")).toBeVisible();
|
||||
await row.getByRole("button", { name: "停用" }).click();
|
||||
await expect(row.getByText("否")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const runtimePath = path.join(webRoot, "e2e", ".runtime.json");
|
||||
const pidPath = path.join(webRoot, "e2e", ".server.pid");
|
||||
const binDir = path.join(webRoot, "e2e", ".bin");
|
||||
|
||||
async function killPid(pid: number) {
|
||||
if (!pid) return;
|
||||
if (process.platform === "win32") {
|
||||
await new Promise<void>((resolve) => {
|
||||
const child = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { shell: true });
|
||||
child.on("close", () => resolve());
|
||||
child.on("error", () => resolve());
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export default async function globalTeardown() {
|
||||
let dataDir = "";
|
||||
let servePid = 0;
|
||||
try {
|
||||
const info = JSON.parse(await readFile(runtimePath, "utf8")) as {
|
||||
dataDir?: string;
|
||||
servePid?: number;
|
||||
};
|
||||
dataDir = info.dataDir ?? "";
|
||||
servePid = info.servePid ?? 0;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
const pidRaw = (await readFile(pidPath, "utf8")).trim();
|
||||
const pid = Number(pidRaw);
|
||||
if (Number.isFinite(pid) && pid > 0) servePid = pid;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await killPid(servePid);
|
||||
if (dataDir) {
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
await rm(runtimePath, { force: true });
|
||||
await rm(pidPath, { force: true });
|
||||
await rm(binDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
// 用 MQTT 向真实 nixmsg 提交一条未来定时消息,供后台「投递记录」页看到未完成记录。
|
||||
// 管理接口只读 messages,不能造记录;不改服务器业务代码。
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/internal/protocol"
|
||||
"git.asio.asia/nixevol/NixMsg/test/harness"
|
||||
"github.com/mochi-mqtt/server/v2/packets"
|
||||
)
|
||||
|
||||
func main() {
|
||||
base := flag.String("base", "", "http base of nixmsg, e.g. http://127.0.0.1:12345")
|
||||
fromID := flag.String("from", "", "sender endpoint id")
|
||||
fromPass := flag.String("from-pass", "", "sender login password")
|
||||
toID := flag.String("to", "", "receiver endpoint id")
|
||||
flag.Parse()
|
||||
if *base == "" || *fromID == "" || *fromPass == "" || *toID == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: seedpending -base URL -from ID -from-pass PWD -to ID")
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := seed(*base, *fromID, *fromPass, *toID); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("ok")
|
||||
}
|
||||
|
||||
func seed(httpBase, fromID, fromPass, toID string) error {
|
||||
mc, err := harness.DialMQTTWebSocket(httpBase, 10*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial mqtt: %w", err)
|
||||
}
|
||||
defer func() { _ = mc.Close() }()
|
||||
|
||||
s := &session{mc: mc, endpointID: fromID, pktID: 10, done: make(chan struct{})}
|
||||
if helloErr := s.connectSubscribeHello(fromPass); helloErr != nil {
|
||||
return helloErr
|
||||
}
|
||||
go s.readLoop()
|
||||
defer s.close()
|
||||
|
||||
sendAt := time.Now().Add(2 * time.Hour).UnixMilli()
|
||||
frame := map[string]any{
|
||||
"v": 1, "type": "send", "rid": "w4seed1", "id": "w4-e2e-scheduled",
|
||||
"to": map[string]any{"kind": "endpoint", "id": toID},
|
||||
"body": map[string]any{"enc": "utf8", "data": "w4-e2e-pending"},
|
||||
"send_at_ms": sendAt,
|
||||
}
|
||||
resp, reqErr := s.request(frame, 15*time.Second)
|
||||
if reqErr != nil {
|
||||
return reqErr
|
||||
}
|
||||
if !resp.ok {
|
||||
return fmt.Errorf("send failed: %+v", resp.raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type session struct {
|
||||
mc harness.MQTTClient
|
||||
endpointID string
|
||||
pktID uint16
|
||||
mu sync.Mutex
|
||||
inbox []map[string]any
|
||||
closed bool
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
type appResp struct {
|
||||
ok bool
|
||||
raw map[string]any
|
||||
}
|
||||
|
||||
func (s *session) close() {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.closed = true
|
||||
s.mu.Unlock()
|
||||
_ = s.mc.Close()
|
||||
select {
|
||||
case <-s.done:
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) nextPkt() uint16 {
|
||||
s.pktID++
|
||||
if s.pktID == 0 {
|
||||
s.pktID = 1
|
||||
}
|
||||
return s.pktID
|
||||
}
|
||||
|
||||
func (s *session) connectSubscribeHello(password string) error {
|
||||
pk := packets.Packet{
|
||||
FixedHeader: packets.FixedHeader{Type: packets.Connect},
|
||||
ProtocolVersion: 5,
|
||||
Connect: packets.ConnectParams{
|
||||
ProtocolName: []byte("MQTT"),
|
||||
Clean: true,
|
||||
ClientIdentifier: s.endpointID,
|
||||
Keepalive: 30,
|
||||
UsernameFlag: true,
|
||||
Username: []byte(s.endpointID),
|
||||
PasswordFlag: true,
|
||||
Password: []byte(password),
|
||||
},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := pk.ConnectEncode(&buf); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.mc.Send(buf.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
ack, err := s.mc.Recv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ack) < 4 || ack[0]>>4 != packets.Connack || ack[3] != 0 {
|
||||
return fmt.Errorf("connack %x", ack)
|
||||
}
|
||||
|
||||
sub := packets.Packet{
|
||||
FixedHeader: packets.FixedHeader{Type: packets.Subscribe, Qos: 1},
|
||||
ProtocolVersion: 5,
|
||||
PacketID: s.nextPkt(),
|
||||
Filters: packets.Subscriptions{
|
||||
{Filter: "nix/c/" + s.endpointID + "/down", Qos: 1},
|
||||
},
|
||||
}
|
||||
buf.Reset()
|
||||
if err := sub.SubscribeEncode(&buf); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.mc.Send(buf.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.mc.Recv(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hello, _ := protocol.Marshal(protocol.Hello{V: protocol.Version, Type: protocol.TypeHello, RID: "h0"})
|
||||
if err := s.publishRaw(hello); err != nil {
|
||||
return err
|
||||
}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
raw, err := s.mc.Recv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m := s.handlePacket(raw)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if m["type"] == "resp" && m["ok"] == true {
|
||||
return nil
|
||||
}
|
||||
if m["type"] == "resp" {
|
||||
return fmt.Errorf("hello failed: %v", m)
|
||||
}
|
||||
s.push(m)
|
||||
}
|
||||
return fmt.Errorf("hello timeout")
|
||||
}
|
||||
|
||||
func (s *session) publishRaw(payload []byte) error {
|
||||
pub := packets.Packet{
|
||||
FixedHeader: packets.FixedHeader{Type: packets.Publish, Qos: 1},
|
||||
ProtocolVersion: 5,
|
||||
TopicName: "nix/c/" + s.endpointID + "/up",
|
||||
PacketID: s.nextPkt(),
|
||||
Payload: payload,
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := pub.PublishEncode(&buf); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mc.Send(buf.Bytes())
|
||||
}
|
||||
|
||||
func (s *session) readLoop() {
|
||||
defer close(s.done)
|
||||
for {
|
||||
raw, err := s.mc.Recv()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m := s.handlePacket(raw)
|
||||
if m != nil {
|
||||
s.push(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) handlePacket(raw []byte) map[string]any {
|
||||
if len(raw) < 2 {
|
||||
return nil
|
||||
}
|
||||
typ := raw[0] >> 4
|
||||
qos := (raw[0] >> 1) & 0x3
|
||||
switch typ {
|
||||
case packets.Puback, packets.Pingresp, packets.Suback:
|
||||
return nil
|
||||
case packets.Publish:
|
||||
payload, err := decodePublishPayload(raw)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if qos == 1 {
|
||||
rem, n, _ := decodeRemainingLength(raw[1:])
|
||||
body := raw[1+n:]
|
||||
pk := packets.Packet{ProtocolVersion: 5, FixedHeader: packets.FixedHeader{Type: packets.Publish, Remaining: rem, Qos: qos}}
|
||||
if decErr := pk.PublishDecode(body); decErr == nil && pk.PacketID != 0 {
|
||||
ack := packets.Packet{
|
||||
FixedHeader: packets.FixedHeader{Type: packets.Puback},
|
||||
ProtocolVersion: 5,
|
||||
PacketID: pk.PacketID,
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if encErr := ack.PubackEncode(&buf); encErr == nil {
|
||||
_ = s.mc.Send(buf.Bytes())
|
||||
}
|
||||
}
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(payload, &m) != nil {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) push(m map[string]any) {
|
||||
s.mu.Lock()
|
||||
s.inbox = append(s.inbox, m)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *session) request(frame map[string]any, timeout time.Duration) (appResp, error) {
|
||||
rid, _ := frame["rid"].(string)
|
||||
payload, err := protocol.Marshal(frame)
|
||||
if err != nil {
|
||||
return appResp{}, err
|
||||
}
|
||||
if err := s.publishRaw(payload); err != nil {
|
||||
return appResp{}, err
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
m := s.takeMatching(func(x map[string]any) bool {
|
||||
return x["type"] == "resp" && x["rid"] == rid
|
||||
})
|
||||
if m != nil {
|
||||
return appResp{ok: m["ok"] == true, raw: m}, nil
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
return appResp{}, fmt.Errorf("timeout waiting resp rid=%s", rid)
|
||||
}
|
||||
|
||||
func (s *session) takeMatching(pred func(map[string]any) bool) map[string]any {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, m := range s.inbox {
|
||||
if pred(m) {
|
||||
s.inbox = append(s.inbox[:i], s.inbox[i+1:]...)
|
||||
return m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodePublishPayload(raw []byte) ([]byte, error) {
|
||||
if len(raw) < 2 {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
rem, n, err := decodeRemainingLength(raw[1:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := raw[1+n:]
|
||||
if len(body) != rem {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
pk := packets.Packet{
|
||||
ProtocolVersion: 5,
|
||||
FixedHeader: packets.FixedHeader{
|
||||
Type: packets.Publish,
|
||||
Remaining: rem,
|
||||
Qos: (raw[0] >> 1) & 0x3,
|
||||
},
|
||||
}
|
||||
if err := pk.PublishDecode(body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pk.Payload, nil
|
||||
}
|
||||
|
||||
func decodeRemainingLength(b []byte) (value int, n int, err error) {
|
||||
var mul uint32 = 1
|
||||
var v uint32
|
||||
for i := 0; i < len(b) && i < 4; i++ {
|
||||
v += uint32(b[i]&127) * mul
|
||||
n++
|
||||
if b[i]&128 == 0 {
|
||||
return int(v), n, nil
|
||||
}
|
||||
mul *= 128
|
||||
}
|
||||
return 0, 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Playwright webServer 入口:先起临时 nixmsg,再起 Vite(代理到该进程)。
|
||||
* 注意:Playwright 会在 globalSetup 之前启动 webServer。
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repoRoot = path.resolve(webRoot, "..");
|
||||
const runtimePath = path.join(webRoot, "e2e", ".runtime.json");
|
||||
const pidPath = path.join(webRoot, "e2e", ".server.pid");
|
||||
const binDir = path.join(webRoot, "e2e", ".bin");
|
||||
|
||||
function run(cwd, cmd, args, extraEnv) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, {
|
||||
cwd,
|
||||
shell: process.platform === "win32",
|
||||
env: { ...process.env, ...extraEnv },
|
||||
});
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.stdout?.on("data", (d) => {
|
||||
out += String(d);
|
||||
});
|
||||
child.stderr?.on("data", (d) => {
|
||||
err += String(d);
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) resolve(out + err);
|
||||
else reject(new Error(`${cmd} ${args.join(" ")} failed (${code}):\n${out}\n${err}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseAdminPassword(text) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim();
|
||||
const lower = line.toLowerCase();
|
||||
if (lower.startsWith("password:")) return line.slice("password:".length).trim();
|
||||
if (lower.startsWith("admin password:")) return line.slice("admin password:".length).trim();
|
||||
}
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim();
|
||||
if (line.length >= 12 && !line.toLowerCase().includes("error") && !/\s/.test(line)) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function waitAddr(file, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let last = "";
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const addr = (await readFile(file, "utf8")).trim();
|
||||
if (addr) return addr;
|
||||
last = "empty";
|
||||
} catch (e) {
|
||||
last = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
await delay(50);
|
||||
}
|
||||
throw new Error(`wait listen.addr timeout: ${last}`);
|
||||
}
|
||||
|
||||
async function killPid(pid) {
|
||||
if (!pid) return;
|
||||
if (process.platform === "win32") {
|
||||
await new Promise((resolve) => {
|
||||
const child = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { shell: true });
|
||||
child.on("close", () => resolve());
|
||||
child.on("error", () => resolve());
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup(dataDir, servePid) {
|
||||
await killPid(servePid);
|
||||
if (dataDir) await rm(dataDir, { recursive: true, force: true });
|
||||
await rm(runtimePath, { force: true });
|
||||
await rm(pidPath, { force: true });
|
||||
await rm(binDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(binDir, { recursive: true });
|
||||
const bin = path.join(binDir, process.platform === "win32" ? "nixmsg.exe" : "nixmsg");
|
||||
await run(repoRoot, "go", ["build", "-o", bin, "./cmd/nixmsg"]);
|
||||
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "nixmsg-w4-e2e-"));
|
||||
const cfgPath = path.join(dataDir, "config.yaml");
|
||||
const yaml = `listen: "127.0.0.1:0"\ndata_dir: ${JSON.stringify(dataDir.replace(/\\/g, "/"))}\n`;
|
||||
await writeFile(cfgPath, yaml, "utf8");
|
||||
|
||||
const initOut = await run(repoRoot, bin, ["admin", "init"], { NIXMSG_CONFIG: cfgPath });
|
||||
const adminPassword = parseAdminPassword(initOut);
|
||||
if (!adminPassword) {
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
throw new Error(`admin init password not found:\n${initOut}`);
|
||||
}
|
||||
|
||||
const serve = spawn(bin, ["serve"], {
|
||||
env: { ...process.env, NIXMSG_CONFIG: cfgPath },
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let serveLog = "";
|
||||
serve.stdout.on("data", (d) => {
|
||||
serveLog += String(d);
|
||||
});
|
||||
serve.stderr.on("data", (d) => {
|
||||
serveLog += String(d);
|
||||
});
|
||||
|
||||
let addr;
|
||||
try {
|
||||
addr = await waitAddr(path.join(dataDir, "listen.addr"), 15_000);
|
||||
} catch (e) {
|
||||
await killPid(serve.pid);
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
throw new Error(`${e}\nserve log:\n${serveLog}`);
|
||||
}
|
||||
|
||||
const vitePort = 5179;
|
||||
const info = {
|
||||
httpBase: `http://${addr}`,
|
||||
adminPassword,
|
||||
dataDir,
|
||||
binPath: bin,
|
||||
vitePort,
|
||||
servePid: serve.pid,
|
||||
};
|
||||
await writeFile(runtimePath, JSON.stringify(info, null, 2), "utf8");
|
||||
await writeFile(pidPath, String(serve.pid ?? ""), "utf8");
|
||||
|
||||
const vite = spawn(
|
||||
process.platform === "win32" ? "pnpm.cmd" : "pnpm",
|
||||
["exec", "vite", "--host", "127.0.0.1", "--port", String(vitePort), "--strictPort"],
|
||||
{
|
||||
cwd: webRoot,
|
||||
env: { ...process.env, NIXMSG_PROXY_TARGET: info.httpBase },
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
},
|
||||
);
|
||||
|
||||
const shutdown = async () => {
|
||||
vite.kill();
|
||||
await cleanup(dataDir, serve.pid);
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGINT", () => {
|
||||
void shutdown();
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdown();
|
||||
});
|
||||
process.on("exit", () => {
|
||||
try {
|
||||
if (serve.pid) {
|
||||
if (process.platform === "win32") {
|
||||
spawn("taskkill", ["/PID", String(serve.pid), "/T", "/F"], { shell: true, detached: true, stdio: "ignore" });
|
||||
} else {
|
||||
process.kill(serve.pid, "SIGKILL");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
vite.on("exit", async (code) => {
|
||||
await cleanup(dataDir, serve.pid);
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
+13
-1
@@ -5,7 +5,19 @@ import globals from "globals";
|
||||
import eslintConfigPrettier from "eslint-config-prettier";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist/**", "node_modules/**", "stub/**"] },
|
||||
{
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
"stub/**",
|
||||
"playwright-report/**",
|
||||
"test-results/**",
|
||||
"e2e/.bin/**",
|
||||
"e2e/.runtime.json",
|
||||
"e2e/.server.pid",
|
||||
"e2e/start-stack.mjs",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...pluginVue.configs["flat/recommended"],
|
||||
|
||||
+4
-1
@@ -10,7 +10,9 @@
|
||||
"typecheck": "vue-tsc -b --noEmit",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:install": "playwright install chromium"
|
||||
},
|
||||
"dependencies": {
|
||||
"naive-ui": "^2.43.1",
|
||||
@@ -20,6 +22,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@types/node": "^26.6.3",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/test-utils": "^2.5.1",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const vitePort = 5179;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testMatch: "**/*.spec.ts",
|
||||
timeout: 120_000,
|
||||
expect: { timeout: 15_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
reporter: [["list"]],
|
||||
// webServer 在 globalSetup 之前启动,故在 start-stack 内起 nixmsg + Vite
|
||||
globalTeardown: "./e2e/global-teardown.ts",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
baseURL: `http://127.0.0.1:${vitePort}`,
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
webServer: {
|
||||
command: "node e2e/start-stack.mjs",
|
||||
url: `http://127.0.0.1:${vitePort}`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 180_000,
|
||||
},
|
||||
});
|
||||
Generated
+28
@@ -24,6 +24,9 @@ importers:
|
||||
'@eslint/js':
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1(eslint@10.11.0)
|
||||
'@playwright/test':
|
||||
specifier: ^1.55.1
|
||||
version: 1.63.0
|
||||
'@types/node':
|
||||
specifier: ^26.6.3
|
||||
version: 26.6.3
|
||||
@@ -351,6 +354,11 @@ packages:
|
||||
'@one-ini/wasm@0.2.1':
|
||||
resolution: {integrity: sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg==}
|
||||
|
||||
'@playwright/test@1.63.0':
|
||||
resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.1':
|
||||
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||
|
||||
@@ -1072,6 +1080,16 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
playwright-core@1.63.0:
|
||||
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.63.0:
|
||||
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
postcss-selector-parser@7.1.6:
|
||||
resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -1534,6 +1552,10 @@ snapshots:
|
||||
|
||||
'@one-ini/wasm@0.2.1': {}
|
||||
|
||||
'@playwright/test@1.63.0':
|
||||
dependencies:
|
||||
playwright: 1.63.0
|
||||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.63.5':
|
||||
@@ -2282,6 +2304,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
playwright-core@1.63.0: {}
|
||||
|
||||
playwright@1.63.0:
|
||||
dependencies:
|
||||
playwright-core: 1.63.0
|
||||
|
||||
postcss-selector-parser@7.1.6:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Vitest 组件测用:把 `@/api/admin` 指到假数据(不连真实后端)。
|
||||
* 生产与 Playwright e2e 走真实 `admin.ts`。
|
||||
*/
|
||||
import { ApiError } from "./http";
|
||||
import { mockApi } from "./mock";
|
||||
import { message } from "@/utils/notify";
|
||||
import type {
|
||||
EndpointCreateRequest,
|
||||
EndpointListQuery,
|
||||
EndpointPatchRequest,
|
||||
GroupFailed,
|
||||
MeInfo,
|
||||
Overview,
|
||||
RegistrationUpdateRequest,
|
||||
} from "./types";
|
||||
|
||||
export { ApiError };
|
||||
export type * from "./types";
|
||||
|
||||
async function run<T>(fn: () => Promise<T>, silent = false): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
if (!silent && e instanceof ApiError) {
|
||||
message.error(e.message);
|
||||
} else if (!silent && e instanceof Error) {
|
||||
message.error(e.message);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function login(username: string, password: string) {
|
||||
return run(() => mockApi.login(username, password), true);
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return run(() => mockApi.logout());
|
||||
}
|
||||
|
||||
export function fetchMe(silent = false) {
|
||||
return run(() => mockApi.me(), silent);
|
||||
}
|
||||
|
||||
export function changePassword(oldPassword: string, newPassword: string) {
|
||||
return run(() => mockApi.changePassword(oldPassword, newPassword));
|
||||
}
|
||||
|
||||
export function fetchOverview(): Promise<Overview> {
|
||||
return run(() => mockApi.overview());
|
||||
}
|
||||
|
||||
export function listEndpoints(q?: EndpointListQuery) {
|
||||
return run(() => mockApi.listEndpoints(q));
|
||||
}
|
||||
|
||||
export function createEndpoint(body: EndpointCreateRequest) {
|
||||
return run(() => mockApi.createEndpoint(body));
|
||||
}
|
||||
|
||||
export function importEndpoints(csvText: string) {
|
||||
return run(() => mockApi.importEndpoints(csvText), true);
|
||||
}
|
||||
|
||||
export function batchEndpoints(ids: string[], action: "disable" | "enable" | "delete") {
|
||||
return run(() => mockApi.batchEndpoints(ids, action));
|
||||
}
|
||||
|
||||
export function getEndpoint(id: string) {
|
||||
return run(() => mockApi.getEndpoint(id));
|
||||
}
|
||||
|
||||
export function patchEndpoint(id: string, body: EndpointPatchRequest) {
|
||||
return run(() => mockApi.patchEndpoint(id, body));
|
||||
}
|
||||
|
||||
export function deleteEndpoint(id: string) {
|
||||
return run(() => mockApi.deleteEndpoint(id));
|
||||
}
|
||||
|
||||
export function kickEndpoint(id: string) {
|
||||
return run(() => mockApi.kickEndpoint(id));
|
||||
}
|
||||
|
||||
export function resetLoginPassword(id: string, loginPassword?: string) {
|
||||
return run(() => mockApi.resetLoginPassword(id, loginPassword));
|
||||
}
|
||||
|
||||
export function setTalkPassword(id: string, talkPassword: string) {
|
||||
return run(() => mockApi.setTalkPassword(id, talkPassword));
|
||||
}
|
||||
|
||||
export function unlockEndpoint(id: string) {
|
||||
return run(() => mockApi.unlockEndpoint(id));
|
||||
}
|
||||
|
||||
export function getRegistration() {
|
||||
return run(() => mockApi.getRegistration());
|
||||
}
|
||||
|
||||
export function updateRegistration(body: RegistrationUpdateRequest) {
|
||||
return run(() => mockApi.updateRegistration(body));
|
||||
}
|
||||
|
||||
export function listTokens() {
|
||||
return run(() => mockApi.listTokens());
|
||||
}
|
||||
|
||||
export function createToken(name: string) {
|
||||
return run(() => mockApi.createToken(name));
|
||||
}
|
||||
|
||||
export function patchToken(id: string, body: { name?: string; enabled?: boolean }) {
|
||||
return run(() => mockApi.patchToken(id, body));
|
||||
}
|
||||
|
||||
export function deleteToken(id: string) {
|
||||
return run(() => mockApi.deleteToken(id));
|
||||
}
|
||||
|
||||
export function listGroups(query?: string, cursor?: string, limit?: number) {
|
||||
return run(() => mockApi.listGroups(query, cursor, limit));
|
||||
}
|
||||
|
||||
export function createGroup(body: {
|
||||
id?: string;
|
||||
name: string;
|
||||
owner_id: string;
|
||||
member_ids?: string[];
|
||||
}): Promise<{ id: string; name: string; owner_id: string; failed: GroupFailed[] }> {
|
||||
return run(() => mockApi.createGroup(body));
|
||||
}
|
||||
|
||||
export function getGroup(id: string, cursor?: string, limit?: number) {
|
||||
return run(() => mockApi.getGroup(id, cursor, limit));
|
||||
}
|
||||
|
||||
export function renameGroup(id: string, name: string) {
|
||||
return run(() => mockApi.renameGroup(id, name));
|
||||
}
|
||||
|
||||
export function deleteGroup(id: string) {
|
||||
return run(() => mockApi.deleteGroup(id));
|
||||
}
|
||||
|
||||
export function addGroupMembers(id: string, memberIds: string[]) {
|
||||
return run(() => mockApi.addGroupMembers(id, memberIds));
|
||||
}
|
||||
|
||||
export function removeGroupMember(id: string, endpointId: string) {
|
||||
return run(() => mockApi.removeGroupMember(id, endpointId));
|
||||
}
|
||||
|
||||
export function transferGroup(id: string, endpointId: string) {
|
||||
return run(() => mockApi.transferGroup(id, endpointId));
|
||||
}
|
||||
|
||||
export function listMessages(q: {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
sender_id?: string;
|
||||
endpoint_id?: string;
|
||||
group_id?: string;
|
||||
state?: string;
|
||||
}) {
|
||||
return run(() => mockApi.listMessages(q));
|
||||
}
|
||||
|
||||
export function getMessage(seq: number) {
|
||||
return run(() => mockApi.getMessage(seq));
|
||||
}
|
||||
|
||||
export function getSettings() {
|
||||
return run(() => mockApi.getSettings());
|
||||
}
|
||||
|
||||
export type { MeInfo };
|
||||
+210
-42
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* 管理接口函数集中处。当前走假数据(mock);W4 改为 requestAdmin 即可,页面不用改。
|
||||
* 管理接口:真实请求 `/api/admin`(开发时由 Vite 代理到本机 nixmsg)。
|
||||
*/
|
||||
import { ApiError } from "./http";
|
||||
import { mockApi } from "./mock";
|
||||
import { ApiError, requestAdmin } from "./http";
|
||||
import { message } from "@/utils/notify";
|
||||
import type {
|
||||
ApiToken,
|
||||
@@ -43,97 +42,212 @@ async function run<T>(fn: () => Promise<T>, silent = false): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
function buildQuery(params: Record<string, string | number | boolean | undefined | null>): string {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null || v === "") continue;
|
||||
sp.set(k, String(v));
|
||||
}
|
||||
const q = sp.toString();
|
||||
return q ? `?${q}` : "";
|
||||
}
|
||||
|
||||
export function login(username: string, password: string) {
|
||||
// 登录页自行展示错误,避免与 message 重复
|
||||
return run(() => mockApi.login(username, password), true);
|
||||
return run(
|
||||
() =>
|
||||
requestAdmin<{ username: string }>("/api/admin/login", {
|
||||
method: "POST",
|
||||
body: { username, password },
|
||||
silent: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return run(() => mockApi.logout());
|
||||
return run(() => requestAdmin<Record<string, never>>("/api/admin/logout", { method: "POST" }));
|
||||
}
|
||||
|
||||
export function fetchMe(silent = false) {
|
||||
return run(() => mockApi.me(), silent);
|
||||
return run(() => requestAdmin<MeInfo>("/api/admin/me", { silent }), silent);
|
||||
}
|
||||
|
||||
export function changePassword(oldPassword: string, newPassword: string) {
|
||||
return run(() => mockApi.changePassword(oldPassword, newPassword));
|
||||
return run(() =>
|
||||
requestAdmin<Record<string, never>>("/api/admin/password", {
|
||||
method: "POST",
|
||||
body: { old_password: oldPassword, new_password: newPassword },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchOverview(): Promise<Overview> {
|
||||
return run(() => mockApi.overview());
|
||||
return run(() => requestAdmin<Overview>("/api/admin/overview"));
|
||||
}
|
||||
|
||||
export function listEndpoints(q?: EndpointListQuery): Promise<PageResult<Endpoint>> {
|
||||
return run(() => mockApi.listEndpoints(q));
|
||||
export function listEndpoints(q: EndpointListQuery = {}): Promise<PageResult<Endpoint>> {
|
||||
return run(() =>
|
||||
requestAdmin<PageResult<Endpoint>>(
|
||||
`/api/admin/endpoints${buildQuery({
|
||||
cursor: q.cursor,
|
||||
limit: q.limit,
|
||||
source: q.source || undefined,
|
||||
online: q.online === "" || q.online === undefined ? undefined : q.online,
|
||||
enabled: q.enabled === "" || q.enabled === undefined ? undefined : q.enabled,
|
||||
query: q.query,
|
||||
})}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createEndpoint(body: EndpointCreateRequest): Promise<EndpointCreateResult> {
|
||||
return run(() => mockApi.createEndpoint(body));
|
||||
return run(() =>
|
||||
requestAdmin<EndpointCreateResult>("/api/admin/endpoints", { method: "POST", body }),
|
||||
);
|
||||
}
|
||||
|
||||
export function importEndpoints(csvText: string): Promise<{ items: ImportItem[] }> {
|
||||
return run(() => mockApi.importEndpoints(csvText), true);
|
||||
return run(
|
||||
() =>
|
||||
requestAdmin<{ items: ImportItem[] }>("/api/admin/endpoints/import", {
|
||||
method: "POST",
|
||||
rawBody: csvText,
|
||||
contentType: "text/csv",
|
||||
silent: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
export function batchEndpoints(ids: string[], action: "disable" | "enable" | "delete"): Promise<BatchResult> {
|
||||
return run(() => mockApi.batchEndpoints(ids, action));
|
||||
export function batchEndpoints(
|
||||
ids: string[],
|
||||
action: "disable" | "enable" | "delete",
|
||||
): Promise<BatchResult> {
|
||||
return run(() =>
|
||||
requestAdmin<BatchResult>("/api/admin/endpoints/batch", {
|
||||
method: "POST",
|
||||
body: { ids, action },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function getEndpoint(id: string): Promise<Endpoint> {
|
||||
return run(() => mockApi.getEndpoint(id));
|
||||
return run(() => requestAdmin<Endpoint>(`/api/admin/endpoints/${encodeURIComponent(id)}`));
|
||||
}
|
||||
|
||||
export function patchEndpoint(id: string, body: EndpointPatchRequest): Promise<Endpoint> {
|
||||
return run(() => mockApi.patchEndpoint(id, body));
|
||||
return run(() =>
|
||||
requestAdmin<Endpoint>(`/api/admin/endpoints/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteEndpoint(id: string) {
|
||||
return run(() => mockApi.deleteEndpoint(id));
|
||||
return run(() =>
|
||||
requestAdmin<Record<string, never>>(`/api/admin/endpoints/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function kickEndpoint(id: string) {
|
||||
return run(() => mockApi.kickEndpoint(id));
|
||||
return run(() =>
|
||||
requestAdmin<{ kicked: boolean }>(`/api/admin/endpoints/${encodeURIComponent(id)}/kick`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function resetLoginPassword(id: string, loginPassword?: string) {
|
||||
return run(() => mockApi.resetLoginPassword(id, loginPassword));
|
||||
return run(() =>
|
||||
requestAdmin<{ login_password: string }>(
|
||||
`/api/admin/endpoints/${encodeURIComponent(id)}/reset-login-password`,
|
||||
{
|
||||
method: "POST",
|
||||
body: loginPassword ? { login_password: loginPassword } : {},
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function setTalkPassword(id: string, talkPassword: string) {
|
||||
return run(() => mockApi.setTalkPassword(id, talkPassword));
|
||||
return run(() =>
|
||||
requestAdmin<{ talk_password_set: boolean }>(
|
||||
`/api/admin/endpoints/${encodeURIComponent(id)}/talk-password`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: { talk_password: talkPassword },
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function unlockEndpoint(id: string) {
|
||||
return run(() => mockApi.unlockEndpoint(id));
|
||||
return run(() =>
|
||||
requestAdmin<Record<string, never>>(`/api/admin/endpoints/${encodeURIComponent(id)}/unlock`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function getRegistration(): Promise<RegistrationSettings> {
|
||||
return run(() => mockApi.getRegistration());
|
||||
return run(() => requestAdmin<RegistrationSettings>("/api/admin/registration"));
|
||||
}
|
||||
|
||||
export function updateRegistration(body: RegistrationUpdateRequest): Promise<RegistrationSettings> {
|
||||
return run(() => mockApi.updateRegistration(body));
|
||||
return run(() =>
|
||||
requestAdmin<RegistrationSettings>("/api/admin/registration", {
|
||||
method: "PUT",
|
||||
body,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function listTokens(): Promise<PageResult<ApiToken>> {
|
||||
return run(() => mockApi.listTokens());
|
||||
return run(() => requestAdmin<PageResult<ApiToken>>("/api/admin/tokens"));
|
||||
}
|
||||
|
||||
export function createToken(name: string): Promise<ApiTokenCreateResult> {
|
||||
return run(() => mockApi.createToken(name));
|
||||
return run(() =>
|
||||
requestAdmin<ApiTokenCreateResult>("/api/admin/tokens", {
|
||||
method: "POST",
|
||||
body: { name },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function patchToken(id: number, body: { name?: string; enabled?: boolean }): Promise<ApiToken> {
|
||||
return run(() => mockApi.patchToken(id, body));
|
||||
export function patchToken(
|
||||
id: string,
|
||||
body: { name?: string; enabled?: boolean },
|
||||
): Promise<ApiToken> {
|
||||
return run(() =>
|
||||
requestAdmin<ApiToken>(`/api/admin/tokens/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteToken(id: number) {
|
||||
return run(() => mockApi.deleteToken(id));
|
||||
export function deleteToken(id: string) {
|
||||
return run(() =>
|
||||
requestAdmin<Record<string, never>>(`/api/admin/tokens/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function listGroups(query?: string, cursor?: string, limit?: number): Promise<PageResult<GroupSummary>> {
|
||||
return run(() => mockApi.listGroups(query, cursor, limit));
|
||||
export function listGroups(
|
||||
query?: string,
|
||||
cursor?: string,
|
||||
limit?: number,
|
||||
): Promise<PageResult<GroupSummary>> {
|
||||
return run(() =>
|
||||
requestAdmin<PageResult<GroupSummary>>(
|
||||
`/api/admin/groups${buildQuery({ query, cursor, limit })}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createGroup(body: {
|
||||
@@ -142,31 +256,74 @@ export function createGroup(body: {
|
||||
owner_id: string;
|
||||
member_ids?: string[];
|
||||
}): Promise<{ id: string; name: string; owner_id: string; failed: GroupFailed[] }> {
|
||||
return run(() => mockApi.createGroup(body));
|
||||
// A3:后台建群不接受自定义 id;有值才传,否则省略字段
|
||||
const payload: Record<string, unknown> = {
|
||||
name: body.name,
|
||||
owner_id: body.owner_id,
|
||||
};
|
||||
if (body.member_ids?.length) payload.member_ids = body.member_ids;
|
||||
if (body.id?.trim()) payload.id = body.id.trim();
|
||||
return run(() =>
|
||||
requestAdmin<{ id: string; name: string; owner_id: string; failed: GroupFailed[] }>(
|
||||
"/api/admin/groups",
|
||||
{ method: "POST", body: payload },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getGroup(id: string, cursor?: string, limit?: number): Promise<GroupDetail> {
|
||||
return run(() => mockApi.getGroup(id, cursor, limit));
|
||||
return run(() =>
|
||||
requestAdmin<GroupDetail>(
|
||||
`/api/admin/groups/${encodeURIComponent(id)}${buildQuery({ cursor, limit })}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function renameGroup(id: string, name: string): Promise<GroupSummary> {
|
||||
return run(() => mockApi.renameGroup(id, name));
|
||||
return run(() =>
|
||||
requestAdmin<GroupSummary>(`/api/admin/groups/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body: { name },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteGroup(id: string) {
|
||||
return run(() => mockApi.deleteGroup(id));
|
||||
return run(() =>
|
||||
requestAdmin<Record<string, never>>(`/api/admin/groups/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function addGroupMembers(id: string, memberIds: string[]) {
|
||||
return run(() => mockApi.addGroupMembers(id, memberIds));
|
||||
return run(() =>
|
||||
requestAdmin<{ failed: GroupFailed[] }>(
|
||||
`/api/admin/groups/${encodeURIComponent(id)}/members`,
|
||||
{
|
||||
method: "POST",
|
||||
body: { member_ids: memberIds },
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function removeGroupMember(id: string, endpointId: string) {
|
||||
return run(() => mockApi.removeGroupMember(id, endpointId));
|
||||
return run(() =>
|
||||
requestAdmin<Record<string, never>>(
|
||||
`/api/admin/groups/${encodeURIComponent(id)}/members/${encodeURIComponent(endpointId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function transferGroup(id: string, endpointId: string) {
|
||||
return run(() => mockApi.transferGroup(id, endpointId));
|
||||
return run(() =>
|
||||
requestAdmin<{ owner_id: string }>(`/api/admin/groups/${encodeURIComponent(id)}/transfer`, {
|
||||
method: "POST",
|
||||
body: { endpoint_id: endpointId },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function listMessages(q: {
|
||||
@@ -177,15 +334,26 @@ export function listMessages(q: {
|
||||
group_id?: string;
|
||||
state?: string;
|
||||
}): Promise<PageResult<MessageSummary>> {
|
||||
return run(() => mockApi.listMessages(q));
|
||||
return run(() =>
|
||||
requestAdmin<PageResult<MessageSummary>>(
|
||||
`/api/admin/messages${buildQuery({
|
||||
cursor: q.cursor,
|
||||
limit: q.limit,
|
||||
sender_id: q.sender_id,
|
||||
endpoint_id: q.endpoint_id,
|
||||
group_id: q.group_id,
|
||||
state: q.state,
|
||||
})}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getMessage(seq: number): Promise<MessageDetail> {
|
||||
return run(() => mockApi.getMessage(seq));
|
||||
return run(() => requestAdmin<MessageDetail>(`/api/admin/messages/${seq}`));
|
||||
}
|
||||
|
||||
export function getSettings(): Promise<RuntimeSettings> {
|
||||
return run(() => mockApi.getSettings());
|
||||
return run(() => requestAdmin<RuntimeSettings>("/api/admin/settings"));
|
||||
}
|
||||
|
||||
export type { MeInfo };
|
||||
|
||||
+4
-4
@@ -98,7 +98,7 @@ const registration: RegistrationSettings = {
|
||||
|
||||
const tokens: ApiToken[] = [
|
||||
{
|
||||
id: 1,
|
||||
id: "1",
|
||||
name: "ops",
|
||||
enabled: true,
|
||||
created_at_ms: now() - 86_400_000,
|
||||
@@ -557,7 +557,7 @@ export const mockApi = {
|
||||
async createToken(name: string): Promise<ApiTokenCreateResult> {
|
||||
requireSession();
|
||||
const item: ApiToken = {
|
||||
id: nextTokenId++,
|
||||
id: String(nextTokenId++),
|
||||
name,
|
||||
enabled: true,
|
||||
created_at_ms: now(),
|
||||
@@ -572,7 +572,7 @@ export const mockApi = {
|
||||
};
|
||||
},
|
||||
|
||||
async patchToken(id: number, body: { name?: string; enabled?: boolean }): Promise<ApiToken> {
|
||||
async patchToken(id: string, body: { name?: string; enabled?: boolean }): Promise<ApiToken> {
|
||||
requireSession();
|
||||
const t = tokens.find((x) => x.id === id);
|
||||
if (!t) {
|
||||
@@ -583,7 +583,7 @@ export const mockApi = {
|
||||
return clone(t);
|
||||
},
|
||||
|
||||
async deleteToken(id: number): Promise<Record<string, never>> {
|
||||
async deleteToken(id: string): Promise<Record<string, never>> {
|
||||
requireSession();
|
||||
const idx = tokens.findIndex((x) => x.id === id);
|
||||
if (idx < 0) {
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface Overview {
|
||||
endpoints_total: number;
|
||||
endpoints_online: number;
|
||||
endpoints_disabled: number;
|
||||
/** A3 补充:自助注册端数量;契约示例未列,前端可选展示 */
|
||||
endpoints_self?: number;
|
||||
groups_total: number;
|
||||
messages_pending: number;
|
||||
messages_scheduled: number;
|
||||
@@ -118,7 +120,8 @@ export interface RegistrationUpdateRequest {
|
||||
}
|
||||
|
||||
export interface ApiToken {
|
||||
id: number;
|
||||
/** 后端为 TEXT 十六进制字符串(A1 偏差);契约示例曾写数字 */
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
created_at_ms: number;
|
||||
@@ -126,7 +129,7 @@ export interface ApiToken {
|
||||
}
|
||||
|
||||
export interface ApiTokenCreateResult {
|
||||
id: number;
|
||||
id: string;
|
||||
name: string;
|
||||
token: string;
|
||||
created_at_ms: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { config, mount, flushPromises } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
NConfigProvider,
|
||||
NDialogProvider,
|
||||
@@ -12,6 +12,8 @@ import { defineComponent, h, nextTick } from "vue";
|
||||
import EndpointsView from "./EndpointsView.vue";
|
||||
import { mockApi } from "@/api/mock";
|
||||
|
||||
vi.mock("@/api/admin", async () => import("@/api/admin-mock"));
|
||||
|
||||
config.global.stubs = { teleport: true };
|
||||
|
||||
function wrap(Comp: object) {
|
||||
|
||||
@@ -230,6 +230,7 @@ async function onTransfer(endpointId: string) {
|
||||
<template #actions>
|
||||
<n-button
|
||||
type="primary"
|
||||
data-testid="group-create-open"
|
||||
@click="
|
||||
Object.assign(createForm, { id: '', name: '', owner_id: '', member_ids: '' });
|
||||
createOpen = true;
|
||||
@@ -267,23 +268,31 @@ async function onTransfer(endpointId: string) {
|
||||
<n-scrollbar style="max-height: 320px">
|
||||
<n-form label-placement="left" label-width="90" :show-feedback="false">
|
||||
<n-form-item label="编号">
|
||||
<n-input v-model:value="createForm.id" placeholder="留空则生成" />
|
||||
<n-input v-model:value="createForm.id" placeholder="留空则生成" data-testid="group-id" />
|
||||
</n-form-item>
|
||||
<n-form-item label="名称">
|
||||
<n-input v-model:value="createForm.name" />
|
||||
<n-input v-model:value="createForm.name" data-testid="group-name" />
|
||||
</n-form-item>
|
||||
<n-form-item label="群主编号">
|
||||
<n-input v-model:value="createForm.owner_id" />
|
||||
<n-input v-model:value="createForm.owner_id" data-testid="group-owner" />
|
||||
</n-form-item>
|
||||
<n-form-item label="成员" label-placement="top">
|
||||
<n-input v-model:value="createForm.member_ids" type="textarea" :rows="3" placeholder="多个编号用逗号或空格分隔" />
|
||||
<n-input
|
||||
v-model:value="createForm.member_ids"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="多个编号用逗号或空格分隔"
|
||||
data-testid="group-members"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-scrollbar>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="createOpen = false">取消</n-button>
|
||||
<n-button type="primary" :loading="createLoading" @click="submitCreate">创建</n-button>
|
||||
<n-button type="primary" :loading="createLoading" data-testid="group-create-submit" @click="submitCreate">
|
||||
创建
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { config, mount, flushPromises } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { createMemoryHistory, createRouter } from "vue-router";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
NConfigProvider,
|
||||
NDialogProvider,
|
||||
@@ -14,6 +14,8 @@ import LoginView from "./LoginView.vue";
|
||||
import { mockApi } from "@/api/mock";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
vi.mock("@/api/admin", async () => import("@/api/admin-mock"));
|
||||
|
||||
config.global.stubs = { teleport: true };
|
||||
|
||||
function wrap(child: object) {
|
||||
|
||||
@@ -52,9 +52,6 @@ async function onSubmit() {
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
<n-text depth="3" style="display: block; margin-top: 12px; font-size: 12px">
|
||||
假数据模式:用户名 admin,密码 adminpassword
|
||||
</n-text>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -71,16 +71,23 @@ async function generateCode() {
|
||||
开放注册
|
||||
<HelpTip>开启后需同时设置安全码,客户端注册时携带。</HelpTip>
|
||||
</template>
|
||||
<n-switch :value="data.enabled" :loading="saving" @update:value="onToggle" />
|
||||
<n-switch
|
||||
:value="data.enabled"
|
||||
:loading="saving"
|
||||
data-testid="reg-enabled"
|
||||
@update:value="onToggle"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="安全码">
|
||||
<n-input v-model:value="codeDraft" placeholder="8–64 字符" />
|
||||
<n-input v-model:value="codeDraft" placeholder="8–64 字符" data-testid="reg-code" />
|
||||
</n-form-item>
|
||||
<n-form-item label="更新时间">
|
||||
<n-text>{{ formatLocalMs(data.updated_at_ms) }}</n-text>
|
||||
</n-form-item>
|
||||
<n-space>
|
||||
<n-button type="primary" :loading="saving" @click="saveCode">保存安全码</n-button>
|
||||
<n-button type="primary" :loading="saving" data-testid="reg-save-code" @click="saveCode">
|
||||
保存安全码
|
||||
</n-button>
|
||||
<n-button :loading="saving" @click="generateCode">生成安全码</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { config, mount, flushPromises } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
NConfigProvider,
|
||||
NDialogProvider,
|
||||
@@ -12,6 +12,8 @@ import { defineComponent, h, nextTick } from "vue";
|
||||
import TokensView from "./TokensView.vue";
|
||||
import { mockApi } from "@/api/mock";
|
||||
|
||||
vi.mock("@/api/admin", async () => import("@/api/admin-mock"));
|
||||
|
||||
config.global.stubs = { teleport: true };
|
||||
|
||||
function wrap(Comp: object) {
|
||||
|
||||
@@ -40,7 +40,7 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
const columns = computed<DataTableColumns<ApiToken>>(() => [
|
||||
{ title: "ID", key: "id", width: 60 },
|
||||
{ title: "ID", key: "id", width: 140, ellipsis: { tooltip: true } },
|
||||
{ title: "名称", key: "name", width: 160 },
|
||||
{
|
||||
title: "启用",
|
||||
|
||||
+3
-2
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const proxyTarget = process.env.NIXMSG_PROXY_TARGET || "http://127.0.0.1:7443";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
@@ -14,8 +15,8 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:7443",
|
||||
"/healthz": "http://127.0.0.1:7443",
|
||||
"/api": proxyTarget,
|
||||
"/healthz": proxyTarget,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
||||
Reference in New Issue
Block a user