diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go index 8c25d47..1873237 100644 --- a/backend/testutil/setup.go +++ b/backend/testutil/setup.go @@ -57,6 +57,7 @@ func SetupTestDB() *gorm.DB { phone TEXT, manager_name TEXT, business_license TEXT, + business_hours TEXT, shop_photos TEXT, custom_fields TEXT )`, diff --git a/client/test/inventory_repository_test.dart b/client/test/inventory_repository_test.dart index 3a032d1..8b8dbb5 100644 --- a/client/test/inventory_repository_test.dart +++ b/client/test/inventory_repository_test.dart @@ -48,17 +48,16 @@ void main() { (server) => server.reply(200, { 'data': [ { + 'id': 1, 'warehouse_id': 1, + 'warehouse_name': '主仓库', 'product_id': 1, - 'warehouse': {'name': '主仓库'}, - 'product': { - 'name': '五粮液', - 'code': 'P001', - 'spec': '500ml', - 'unit': '瓶', - 'brand': '五粮液集团', - 'min_stock': 10, - }, + 'product_name': '五粮液', + 'product_code': 'P001', + 'spec': '500ml', + 'unit': '瓶', + 'brand': '五粮液集团', + 'min_stock': 10, 'quantity': 50.0, } ], diff --git a/client/test/stock_in_screen_test.dart b/client/test/stock_in_screen_test.dart index e3aec72..fa94a39 100644 --- a/client/test/stock_in_screen_test.dart +++ b/client/test/stock_in_screen_test.dart @@ -209,6 +209,10 @@ void main() { await tester.pumpWidget(_buildApp(AsyncValue.data(result))); await tester.pump(); + // draft orders only appear in the "入库审核" tab (index 1) + await tester.tap(find.text('入库审核')); + await tester.pumpAndSettle(); + expect(find.text('SI2024010001'), findsOneWidget); expect(find.text('SI2024010002'), findsOneWidget); expect(find.text('张家酒水'), findsOneWidget); @@ -226,6 +230,10 @@ void main() { await tester.pumpWidget(_buildApp(const AsyncValue.data(empty))); await tester.pump(); + // "新建入库审核单" button only appears in the "入库审核" tab (index 1) + await tester.tap(find.text('入库审核')); + await tester.pumpAndSettle(); + expect(find.text('新建入库审核单'), findsOneWidget); }); @@ -240,6 +248,10 @@ void main() { await tester.pumpWidget(_buildApp(const AsyncValue.data(empty))); await tester.pump(); + // button only exists in the "入库审核" tab (index 1) + await tester.tap(find.text('入库审核')); + await tester.pumpAndSettle(); + await tester.tap(find.text('新建入库审核单')); await tester.pumpAndSettle(); @@ -258,6 +270,10 @@ void main() { await tester.pumpWidget(_buildApp(AsyncValue.data(result))); await tester.pump(); + // draft orders only appear in the "入库审核" tab (index 1) + await tester.tap(find.text('入库审核')); + await tester.pumpAndSettle(); + expect(find.text('提交'), findsOneWidget); }); @@ -273,6 +289,10 @@ void main() { await tester.pumpWidget(_buildApp(AsyncValue.data(result))); await tester.pump(); + // pending orders only appear in the "入库审核" tab (index 1) + await tester.tap(find.text('入库审核')); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('btn_approve_1')), findsOneWidget); expect(find.byKey(const Key('btn_reject_1')), findsOneWidget); }); @@ -308,6 +328,10 @@ void main() { await tester.pumpWidget(_buildApp(AsyncValue.data(result))); await tester.pump(); + // pending orders only appear in the "入库审核" tab (index 1) + await tester.tap(find.text('入库审核')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('btn_approve_1'))); await tester.pumpAndSettle(); @@ -330,6 +354,10 @@ void main() { await tester.pumpWidget(_buildApp(AsyncValue.data(result))); await tester.pump(); + // pending orders only appear in the "入库审核" tab (index 1) + await tester.tap(find.text('入库审核')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('btn_reject_1'))); await tester.pumpAndSettle(); diff --git a/client/test/stock_out_insufficient_stock_flow_test.dart b/client/test/stock_out_insufficient_stock_flow_test.dart index 0aee0a7..ee130fd 100644 --- a/client/test/stock_out_insufficient_stock_flow_test.dart +++ b/client/test/stock_out_insufficient_stock_flow_test.dart @@ -101,11 +101,12 @@ class _FakeInventoryRepository extends InventoryRepository { return const PageResult( data: [ Inventory( + id: 1, warehouseId: 1, warehouseName: '主仓库', productId: 1, productName: '五粮液', - productUnit: '瓶', + unit: '瓶', quantity: 1, ), ], diff --git a/docs/deployment-nas-gitea.md b/docs/deployment-nas-gitea.md new file mode 100644 index 0000000..a0715cf --- /dev/null +++ b/docs/deployment-nas-gitea.md @@ -0,0 +1,251 @@ +# NAS + Gitea 部署文档 + +## 概述 + +岩美酒库管理系统使用 **群晖 NAS 上自托管的 Gitea(Forgejo)** 作为代码仓库和 CI/CD 平台。代码推送到 `main` 分支后,NAS 上的 Gitea Runner 自动构建并部署到 EC2 服务器。 + +--- + +## 服务地址 + +| 服务 | 地址 | +|------|------| +| Gitea Web UI | `http://192.168.3.200:3000` | +| Gitea SSH | `ssh://192.168.3.200:2222` | +| Actions 面板 | `http://192.168.3.200:3000/wangjia/jiu/actions` | +| 线上应用 | `https://jiu.51yanmei.com` | +| 管理端 Web | `https://jiu.51yanmei.com/app/` | + +--- + +## Gitea Runner 说明 + +Runner 实际运行在**本机 Mac**(不是 NAS),通过 LaunchAgent 开机自启,连接 NAS 上的 Forgejo。 + +### 本机文件布局 + +| 文件 | 说明 | +|------|------| +| `/Users/wangjia/bin/act_runner` | act_runner 二进制(v0.2.11) | +| `/Users/wangjia/bin/act_runner_start.sh` | 启动脚本(含 TCP 中继逻辑) | +| `/Users/wangjia/bin/tcp_relay.py` | Python3 TCP 中继:`127.0.0.1:13000 → 192.168.3.200:3000` | +| `/Users/wangjia/.act_runner` | Runner 注册信息(地址指向 `127.0.0.1:13000`) | +| `/Users/wangjia/.act_runner_config.yaml` | Runner 配置(label: `mac:host`,capacity: 1) | +| `~/Library/LaunchAgents/com.jiu.act-runner.plist` | LaunchAgent plist,开机自启 | +| `/tmp/act_runner.log` | 运行日志 | + +### Workflow 标签 + +`.gitea/workflows/deploy.yml` 和 `ci.yml` 中使用 `runs-on: mac`,对应 runner label `mac:host`。 + +### 为什么需要 TCP 中继(重要) + +Shadowrocket(代理工具)的 Network Extension 会拦截 LaunchAgent 启动的 Go 二进制发出的 TCP 连接,即使 Shadowrocket 内已配置直连规则。现象:Go gRPC 报 `no route to host`,而同进程里的 Python3 socket 可以正常连接。 + +**解决方案:** 用 Python3 做透明 TCP 中继。`tcp_relay.py` 监听 `127.0.0.1:13000`,将连接转发到 `192.168.3.200:3000`。act_runner 的注册地址改为 `http://127.0.0.1:13000`,完全绕开 Shadowrocket 拦截。 + +### 检查 Runner 状态 + +```bash +# 查看日志 +tail -f /tmp/act_runner.log + +# 确认进程在跑 +ps aux | grep -E "act_runner|tcp_relay" | grep -v grep + +# 手动重启(LaunchAgent 会自动重启,一般不需要) +kill $(pgrep -f act_runner_start.sh) +``` + +### Runner 不工作时的排查步骤 + +1. **确认 NAS 可达**:`curl -sf http://192.168.3.200:3000/api/v1/version` +2. **确认 relay 在跑**:`ps aux | grep tcp_relay` +3. **确认 relay 端口监听**:`nc -zv 127.0.0.1 13000` +4. **查看最近日志**:`tail -30 /tmp/act_runner.log` +5. **手动测试**:`/Users/wangjia/bin/act_runner daemon --config ~/.act_runner_config.yaml`(从终端运行应直接成功) +6. **强制重启**:`kill $(pgrep -f act_runner_start.sh)` — LaunchAgent 会在几秒内自动重拉 + +### 重新注册 Runner(token 失效时) + +```bash +# 1. 停止当前 runner +kill $(pgrep -f act_runner_start.sh) + +# 2. 删除旧注册文件 +rm ~/.act_runner + +# 3. 在 Forgejo 获取新 token: +# Settings → Actions → Runners → Create Runner Token + +# 4. 注册(从终端运行,不要从 LaunchAgent) +/Users/wangjia/bin/act_runner register \ + --no-interactive \ + --instance http://192.168.3.200:3000 \ + --token <新token> \ + --name mac-runner \ + --labels mac:host + +# 5. 把注册文件里的地址改回 127.0.0.1:13000 +sed -i '' 's|http://192.168.3.200:3000|http://127.0.0.1:13000|' ~/.act_runner + +# 6. 重启(LaunchAgent 自动接管) +kill $(pgrep -f act_runner_start.sh) +``` + +--- + +## Workflows + +仓库根目录 `.gitea/workflows/` 下有三个 workflow 文件: + +### `deploy.yml` — 主部署流程 + +**触发条件:** push 到 `main` 分支 + +**步骤:** +1. `go test ./...` — 跑后端测试 +2. `flutter test` — 跑前端测试 +3. 编译后端:`GOOS=linux GOARCH=amd64 go build` 生成 `jiu-server` +4. 编译 Flutter Web:`flutter build web --release --base-href=/app/` +5. 创建 Forgejo Release(版本号格式 `vYYYYMMDD.HHMM`),上传 `jiu-server` 和 `web.tar.gz` +6. SSH 到 EC2 部署: + - 停止 jiu 服务 → 替换二进制 → 启动 → 等待健康检查通过 + - 替换 Flutter Web 静态文件到 `/opt/jiu/web/` + - 同步营销站点到 `/opt/jiu/marketing/` + - `nginx -s reload` + +### `ci.yml` — PR 测试流程 + +**触发条件:** 非 `main` 分支的 push 或 PR + +**步骤:** 只跑测试(`go test` + `flutter test`),不构建不部署。 + +### `backup.yml` — 每日备份 + +**触发条件:** 每天凌晨 2:00(cron) + +**步骤:** SSH 到 EC2,导出 MySQL 数据库备份,上传到指定目录。 + +--- + +## Secrets 配置 + +在 Gitea 仓库的 **Settings → Secrets** 中配置以下密钥: + +| Secret 名称 | 用途 | +|-------------|------| +| `EC2_SSH_KEY` | EC2 服务器的 SSH 私钥(PEM 格式,完整内容) | +| `EC2_HOST` | EC2 服务器 IP 或域名 | +| `EC2_USER` | EC2 SSH 用户名(如 `ec2-user`、`ubuntu`) | +| `DB_PASSWORD` | MySQL root 密码(备份 workflow 使用) | +| `FORGEJO_TOKEN` | Gitea 个人访问 Token(用于创建 Release) | +| `FORGEJO_URL` | Gitea 内网地址,如 `http://192.168.3.200:3000` | + +--- + +## 触发部署方式 + +日常开发流程: + +```bash +# 开发 → 本地测试 +go test ./... +flutter test + +# 用户确认后推送到 NAS Gitea(触发自动部署) +git push nas main +``` + +推送到 `nas` remote(指向 `http://192.168.3.200:3000/wangjia/jiu.git`)后,Gitea Actions 自动运行 `deploy.yml`,全流程约 5~8 分钟。 + +--- + +## EC2 服务器目录结构 + +``` +/opt/jiu/ +├── backend/ +│ └── jiu-server # Go 可执行文件 +├── web/ # Flutter Web 构建产物(/app/ 路径) +├── marketing/ # 营销站点静态 HTML +│ ├── assets/ # CSS / SVG +│ ├── index.html +│ ├── docs.html +│ ├── download.html +│ ├── scan.html +│ └── features/ +│ ├── inventory.html +│ └── approval.html +├── images/ # 商品图片上传目录 +└── web-old/ # 上一版本 Flutter Web(回滚备用) +``` + +Systemd 服务名:`jiu` +日志:`journalctl -u jiu -f` + +--- + +## Nginx 路由说明 + +配置文件:`deploy/nginx-jiu.conf`(部署时手动 scp 至服务器) + +| 路径 | 服务 | 目录 | +|------|------|------| +| `/api/*`, `/health`, `/version` | Go 后端 API | proxy → `localhost:8080` | +| `/images/*` | 商品图片 | `/opt/jiu/images/` | +| `/app/` | Flutter 管理端 SPA | `/opt/jiu/web/` | +| `/` | 营销首页 | `/opt/jiu/marketing/index.html` | +| `/docs` | 文档页 | `/opt/jiu/marketing/docs.html` | +| `/download` | 下载页 | `/opt/jiu/marketing/download.html` | +| `/scan/` | 商品防伪扫码页 | `/opt/jiu/marketing/scan.html` | +| `/features/*` | 功能介绍页 | `/opt/jiu/marketing/features/` | +| `/assets/*` | 营销站点静态资源 | `/opt/jiu/marketing/assets/` | + +> **注意:** `nginx-jiu.conf` 只包含 `jiu.51yanmei.com` 的 server block。服务器上其他服务的 nginx 配置不受影响。更新 nginx 配置时只需替换此文件,然后 `nginx -t && nginx -s reload`。 + +--- + +## 回滚方式 + +### 后端回滚 + +```bash +# SSH 到 EC2 +sudo systemctl stop jiu +# 从上一个 Forgejo Release 下载 jiu-server +cp /path/to/old-jiu-server /opt/jiu/backend/jiu-server +chmod +x /opt/jiu/backend/jiu-server +sudo systemctl start jiu +``` + +### Flutter Web 回滚 + +```bash +# 恢复上一版本 +mv /opt/jiu/web /opt/jiu/web-broken +mv /opt/jiu/web-old /opt/jiu/web +sudo nginx -s reload +``` + +--- + +## 环境变量(jiu 服务) + +Systemd service 文件(`/etc/systemd/system/jiu.service`)中配置: + +```ini +[Service] +Environment=DB_HOST=127.0.0.1 +Environment=DB_PORT=3306 +Environment=DB_NAME=jiu +Environment=DB_USER=jiu +Environment=DB_PASSWORD=<从 secrets 获取> +Environment=JWT_SECRET=<随机字符串> +Environment=APP_VERSION=1.0.0 +Environment=BUILD_DATE=2025-05-23 +Environment=DOWNLOAD_URL_MACOS= +Environment=DOWNLOAD_URL_WINDOWS= +``` + +`APP_VERSION` 和 `BUILD_DATE` 在每次部署时通过 deploy.yml 更新(预留,目前读取 Forgejo Release tag)。