fix(test): 修复测试数据库 schema 和 Flutter 测试与屏幕重构的兼容性
- backend/testutil/setup.go: SQLite shops 表补充 business_hours 列 - stock_in_screen_test: 入库管理双 Tab 重构后,draft/pending 测试改为先切换到「入库审核」Tab - stock_out_insufficient_stock_flow_test: Inventory 构造参数 productUnit→unit,补充必填 id - inventory_repository_test: mock 数据改为平铺字段格式,补充必填 id - docs: 新增 NAS + Gitea 部署文档(Runner 设置、Shadowrocket 中继说明) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
)`,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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/<id>` | 商品防伪扫码页 | `/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)。
|
||||
Reference in New Issue
Block a user