fix(backend): JWT config mapstructure tag 修复 + 模型从 hotel 重构为 shop

- 修复 JWTConfig 缺少 mapstructure tag 导致 access_expire_min 解析为 0,
  token 签发即过期,所有 API 请求返回 401
- 全部 config struct 补齐 mapstructure tag(secret/dsn/hmac_secret 等)
- 模型层从 hotel/HotelID 统一重命名为 shop/ShopID
- 删除旧 migrations(001-004),新增 001_init 综合迁移文件
- 更新 schema.sql、testutil、handler/service/model 相关引用

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-04-07 22:20:12 +08:00
parent 37112d6599
commit 31ea370cea
50 changed files with 2675 additions and 951 deletions
+120 -10
View File
@@ -14,6 +14,7 @@ tools: Read, Write, Edit, Glob, Grep, Bash
- **API 文档即契约**:严格按 `docs/api/{功能名称}.md` 对接接口,字段名、类型不得自行修改
- **组件复用优先**:新 UI 优先复用 `client/lib/widgets/` 中已有组件
- **响应式设计**:同时考虑桌面(宽屏)和移动端(窄屏)布局
- **可测试性(强制要求)**:所有可交互元素必须便于 Widget Test 定位,见下方规范
## 开始前必读
@@ -38,7 +39,7 @@ final productsProvider = AsyncNotifierProvider<ProductsNotifier, List<Product>>(
```dart
// client/lib/core/api/api_client.dart 已封装基础请求
// 新功能在对应 provider 中调用,不直接在 Widget 中写 http 请求
// 新功能在对应 repository/provider 中调用,不直接在 Widget 中写 http 请求
```
**页面结构规范**
@@ -46,7 +47,7 @@ final productsProvider = AsyncNotifierProvider<ProductsNotifier, List<Product>>(
```dart
// screens/{模块}/{功能}_screen.dart — 页面
// screens/{模块}/{功能}_form.dart — 表单弹窗
// 复杂表格用 widgets/data_table_widget.dart
// 复杂表格用 widgets/data_table_card.dart
```
**UI 风格**(参考截图中的参考系统):
@@ -60,22 +61,23 @@ final productsProvider = AsyncNotifierProvider<ProductsNotifier, List<Product>>(
```
client/lib/
├── models/{功能}.dart # 数据模型(与 API 响应字段对应)
├── providers/{功能}_provider.dart # Riverpod 状态
├── models/{功能}.dart # 数据模型(与 API 响应字段对应)
├── repositories/{功能}_repository.dart # HTTP 调用层
├── providers/{功能}_provider.dart # Riverpod 状态
└── screens/{模块}/
├── {功能}_screen.dart # 列表/主页面
└── {功能}_form.dart # 新建/编辑表单
├── {功能}_screen.dart # 列表/主页面
└── {功能}_form.dart # 新建/编辑表单
```
## 数据模型规范
```dart
// 使用 freezed 或手写 fromJson/toJson
// 使用手写 fromJson/toJson(项目未引入 freezed/json_serializable
class Product {
final int id;
final String name;
final String? series;
final Map<String, dynamic>? customFields; // 对应 custom_fields
final Map<String, dynamic>? customFields;
const Product({required this.id, required this.name, this.series, this.customFields});
@@ -85,11 +87,119 @@ class Product {
series: json['series'] as String?,
customFields: json['custom_fields'] as Map<String, dynamic>?,
);
Map<String, dynamic> toJson() => {
'name': name,
if (series != null) 'series': series,
if (customFields != null) 'custom_fields': customFields,
};
}
```
---
## 可测试性规范(强制执行)
test-engineer 会对每个按钮写 Widget Test,你必须确保所有可交互元素**可被 `find` 定位**
### 按钮文字唯一性
按钮必须有**唯一且语义明确**的文字或 Key,避免只用图标:
```dart
// ✅ 好:有文字,可被 find.text() 定位
ElevatedButton.icon(
onPressed: _onCreate,
icon: const Icon(Icons.add),
label: const Text('新建'),
)
// ✅ 好:纯图标按钮加 Key
IconButton(
key: const Key('btn_edit_product'),
onPressed: _onEdit,
icon: const Icon(Icons.edit),
)
// ❌ 差:多个同名按钮,无法区分
IconButton(onPressed: _onEdit, icon: const Icon(Icons.edit)) // 列表每行都有
```
### 表单字段必须有 labelText 或 hintText
```dart
// ✅ 可被 find.widgetWithText(TextFormField, '商品名称') 定位
TextFormField(
decoration: const InputDecoration(labelText: '商品名称', hintText: '请输入商品名称'),
validator: (v) => (v == null || v.isEmpty) ? '不能为空' : null,
)
```
### 对话框按钮文字固定
确认类对话框按钮文字统一,方便测试定位:
- 确认删除:`'删除'` + `'取消'`
- 保存表单:`'保存'` + `'取消'`
- 审核操作:`'通过'` + `'拒绝'` + `'取消'`
### 异步状态必须有可检测的 Widget
```dart
// 加载中
if (state.isLoading) return const CircularProgressIndicator();
// 错误
if (state.hasError) return Column(children: [
Text('加载失败:${state.error}'),
ElevatedButton(onPressed: _retry, child: const Text('重试')),
]);
// 空列表
if (items.isEmpty) return const Center(child: Text('暂无数据'));
```
### 错误提示用 SnackBar 统一格式
```dart
// 操作失败统一用 SnackBar,便于测试断言
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('错误:$message')),
);
```
---
## 错误处理规范
所有 API 调用必须处理错误,不得让异常冒泡到 UI 未处理:
```dart
Future<void> _onSave() async {
try {
await ref.read(productsProvider.notifier).create(_formData);
if (mounted) Navigator.pop(context); // 成功:关闭表单
} on AppException catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)), // 业务错误:提示
);
} catch (_) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('网络异常,请重试')),
);
}
}
```
---
## 完成后必做
1. 确认 `flutter analyze` 无 errorwarning 可以有但要说明)
2. 在新路由处注册页面(`client/lib/core/router/`
1. `flutter analyze` 无 errorwarning 可以有但要说明)
2. 在新路由处注册页面(`client/lib/core/router/app_router.dart`
3. 确认在桌面宽度(1280px+)和移动端宽度(375px)下布局正常
4. **自查可测试性 checklist**
- [ ] 每个按钮有唯一文字或 Key
- [ ] 每个表单字段有 labelText 和 validator(含"不能为空"错误文字)
- [ ] 加载中/加载失败/空数据三种状态都有对应 Widget
- [ ] 操作成功/失败有 SnackBar 提示
- [ ] 删除/审核等危险操作有确认对话框
+224 -42
View File
@@ -10,56 +10,237 @@ tools: Read, Write, Glob, Grep, Bash
## 工作准则
- **只写 `*_test.go``test/` 目录文件**:不修改任何业务代码
- **发现问题 → 写报告**:业务逻辑有 bug 时,将问题详情写入 `docs/review/{功能名称}-bugs.md`,让 backend-coder 修复
- **测试要有意义**:不写只验证"函数被调用了"的空测试,要验证业务行为
- **覆盖边界情况**:正常流程 + 边界值 + 错误情况都要覆盖
- **只写 `*_test.go``client/test/` 目录文件**:不修改任何业务代码
- **发现问题 → 写报告**:业务逻辑有 bug 时,将问题详情写入 `docs/review/{功能名称}-bugs.md`,让对应 coder 修复
- **测试要有意义**:不写只验证"函数被调用了"的空测试,要验证业务行为和数据正确性
- **覆盖边界情况(强制要求)**:正常流程 + 边界值 + 错误情况,三类缺一不可
- **每个按钮都要测试(前端强制要求)**:UI 中所有可交互元素必须有对应 Widget Test
## 开始前必读
1. `docs/requirements/{功能名称}.md` — 验收标准(测试用例来源)
2. `docs/api/{功能名称}.md` — 接口规范(HTTP 层测试依据)
3. 要测试的具体实现文件
4. `backend/internal/handler/testhelper_test.go` — 现有测试工具函数
4. `backend/internal/handler/testhelper_test.go` — 现有 Go 测试工具函数
5. `client/test/` — 现有 Flutter 测试参考
## 测试策略
---
### Go 后端测试
## Go 后端测试策略
### Service 单元测试(使用 SQLite in-memory
**Service 单元测试**(使用 SQLite in-memory):
```go
// 测试文件:internal/service/xxx_test.go
func TestXxxService_YyyMethod_Success(t *testing.T) { ... }
func TestXxxService_YyyMethod_ErrorCase(t *testing.T) { ... }
func TestXxxService_Create_Success(t *testing.T) { ... }
func TestXxxService_Create_DuplicateName(t *testing.T) { ... } // 边界:重复
func TestXxxService_Create_EmptyName(t *testing.T) { ... } // 边界:空值
func TestXxxService_Delete_NotFound(t *testing.T) { ... } // 边界:不存在
```
**Handler 集成测试**(使用 httptest + SQLite in-memory
### Handler 集成测试(使用 httptest + SQLite in-memory
```go
// 测试文件:internal/handler/xxx_test.go
// 必须覆盖:成功路径、参数缺失400、未认证401、不存在404、多租户隔离
func TestXxxHandler_Create_Success(t *testing.T) { ... }
func TestXxxHandler_Create_MissingParams(t *testing.T) { ... }
func TestXxxHandler_HotelIsolation(t *testing.T) { ... }
// 必须覆盖:
func TestXxxHandler_Create_Success(t *testing.T) { ... } // 正常路径
func TestXxxHandler_Create_MissingParams(t *testing.T) { ... } // 400:缺必填
func TestXxxHandler_Create_InvalidType(t *testing.T) { ... } // 400:类型错误
func TestXxxHandler_Get_NotFound(t *testing.T) { ... } // 404:不存在
func TestXxxHandler_NoToken(t *testing.T) { ... } // 401:未认证
func TestXxxHandler_ShopIsolation(t *testing.T) { ... } // 多租户隔离
func TestXxxHandler_Update_OtherShopData(t *testing.T) { ... } // 403/404:跨租户操作
```
**测试命名规范**
**边界值必须包含**
- 字符串:空字符串、超长字符串(超过字段 size 限制)
- 数字:0、负数、极大值
- 枚举:合法值、非法值
- 可选字段:omit 时的默认行为
### 测试命名规范
`Test{Handler/Service}_{方法名}_{场景描述}`
**必须测试的通用场景**
- 多租户隔离:A 酒店创建的数据,B 酒店查不到
- 权限验证:无 token 返回 401
- 参数校验:缺少必填字段返回 400
- 不存在资源:返回 404
---
### 测试数据库工具(复用现有)
## Flutter 前端测试策略
### 1. Unit Test — State/Notifier 测试
测试 Riverpod `StateNotifier` / `AsyncNotifier` 的状态变化,使用 mock repository。
```dart
// client/test/{功能名称}_state_test.dart
test('初始状态为 loading', () { ... });
test('加载成功后 state 包含数据列表', () async { ... });
test('加载失败后 state 包含错误信息', () async { ... });
test('创建后列表自动刷新', () async { ... });
test('删除不存在的 ID 抛出 AppException', () async { ... }); // 边界
```
### 2. Repository Test — HTTP Mock 测试
使用 `http_mock_adapter` 拦截 Dio 请求,测试 JSON 解析和错误处理。
```dart
// client/test/{功能名称}_repository_test.dart
test('list() 正常返回分页数据', () async { ... });
test('list() 空列表时返回空数组不报错', () async { ... }); // 边界:空数据
test('create() 400 响应抛出含错误信息的 AppException', () { }); // 边界:校验失败
test('create() 401 响应触发 logout', () async { ... }); // 边界:未认证
test('网络超时抛出 AppException 含友好提示', () async { ... }); // 边界:网络异常
```
### 3. Widget Test — UI 交互测试(强制:每个按钮必须测试)
每个 Screen 必须有对应的 `_screen_test.dart`,覆盖所有可交互元素。
```dart
// client/test/{功能名称}_screen_test.dart
// ---- 渲染测试 ----
testWidgets('正常加载后显示列表数据', (tester) async { ... });
testWidgets('加载中显示 CircularProgressIndicator', (tester) async { ... });
testWidgets('加载失败显示错误提示和重试按钮', (tester) async { ... });
testWidgets('空数据显示空状态占位图', (tester) async { ... }); // 边界:空列表
// ---- 按钮交互测试(UI 中每个按钮都要有对应测试)----
testWidgets('点击「新建」按钮弹出表单对话框', (tester) async {
await tester.tap(find.text('新建'));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
});
testWidgets('表单提交空数据显示校验错误', (tester) async {
await tester.tap(find.text('新建'));
await tester.pumpAndSettle();
await tester.tap(find.text('保存')); // 空表单直接提交
await tester.pump();
expect(find.text('不能为空'), findsAtLeastNWidgets(1)); // 边界:必填校验
});
testWidgets('点击「编辑」按钮弹出表单且字段预填充', (tester) async { ... });
testWidgets('点击「删除」按钮弹出确认对话框', (tester) async {
await tester.tap(find.byIcon(Icons.delete).first);
await tester.pumpAndSettle();
expect(find.text('确认删除'), findsOneWidget);
expect(find.text('取消'), findsOneWidget); // 确认对话框两个按钮都要测
expect(find.text('删除'), findsOneWidget);
});
testWidgets('确认删除后列表刷新', (tester) async { ... });
testWidgets('取消删除列表不变', (tester) async { ... }); // 边界:取消操作
testWidgets('搜索框输入触发过滤', (tester) async {
await tester.enterText(find.byType(TextField).first, '茅台');
await tester.pumpAndSettle();
// 只显示匹配结果
});
testWidgets('点击分页下一页加载新数据', (tester) async { ... });
testWidgets('点击「导出」按钮触发导出逻辑', (tester) async { ... });
// ... 其余按钮依此类推
```
**注入 Mock Provider 模板**
```dart
Widget buildTestScreen(List<Product> mockData) {
return ProviderScope(
overrides: [
productListProvider.overrideWith(
(ref) => AsyncValue.data(mockData),
),
],
child: MaterialApp(home: ProductsScreen()),
);
}
```
**边界情况必须包含**
- 列表为空(空状态 UI
- 列表超过一页(分页)
- 表单必填字段为空(校验)
- 表单字段超长输入
- 网络错误时 UI 显示错误信息
- 操作成功后 UI 刷新
---
## 集成测试(前后端联调验证)
集成测试验证:**前端提交数据 → 后端真正写入数据库**。
### 前提条件
集成测试需要真实后端运行:
```bash
cd backend && export PATH="/opt/homebrew/bin:$PATH" && go run main.go &
```
### Go 后端集成测试
直接对真实 MySQL 运行(非 SQLite),测试完整链路:
```go
// 复用 testhelper_test.go 中的:
db := setupTestDB(t) // SQLite in-memory
token := getToken(t, db, ...) // 获取 JWT token
router := setupRouter(db) // 获取测试用 gin router
// internal/integration/xxx_integration_test.go
// build tag: //go:build integration
func TestStockIn_FullFlow_Integration(t *testing.T) {
// 1. 创建入库单(POST /stock-in/orders
// 2. 提交审核(PUT .../submit
// 3. 审核通过(PUT .../approve
// 4. 直接查数据库验证库存变化
var inv model.Inventory
db.Where("product_id = ?", productID).First(&inv)
assert.Equal(t, expectedQty, inv.Quantity)
// 5. 验证 inventory_logs 写入了一条记录
var logCount int64
db.Model(&model.InventoryLog{}).Where("ref_id = ?", orderID).Count(&logCount)
assert.Equal(t, int64(1), logCount)
}
```
### Flutter 集成测试(对接真实后端)
```dart
// client/integration_test/{功能名称}_integration_test.dart
// 使用 flutter_test integration_test package
testWidgets('前端新建商品 → 后端数据库写入验证', (tester) async {
// 1. 登录(真实 token
await loginWithRealBackend(tester, shopCode: 'TEST001');
// 2. 模拟点击新建
await tester.tap(find.text('新建'));
await tester.pumpAndSettle();
// 3. 填表单
await tester.enterText(find.widgetWithText(TextFormField, '商品名称'), '集成测试商品');
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
// 4. 前端列表刷新后显示新条目
expect(find.text('集成测试商品'), findsOneWidget);
// 5. 通过 HTTP 直接查后端 API 验证持久化
final resp = await http.get(Uri.parse('http://localhost:8080/api/v1/products'),
headers: {'Authorization': 'Bearer $token'});
final body = jsonDecode(resp.body);
final names = (body['data'] as List).map((p) => p['name']).toList();
expect(names, contains('集成测试商品'));
});
```
运行集成测试:
```bash
cd client
flutter test integration_test/ --device-id=macos
```
---
## 问题报告格式
当发现业务逻辑 bug 时,写入 `docs/review/{功能名称}-bugs.md`
@@ -70,31 +251,32 @@ router := setupRouter(db) // 获取测试用 gin router
## BUG-001
**严重程度**:高 / 中 / 低
**来源**:单元测试 / Widget 测试 / 集成测试
**问题描述**
(清晰描述发现了什么问题)
**问题描述**(清晰描述发现了什么问题)
**复现步骤**
1. 调用 POST /api/v1/xxx,传入 {...}
2. 期望返回 201,实际返回 500
**失败的测试用例**
```go
func TestXxxHandler_YYY(t *testing.T) {
// 这个测试会失败,揭示了业务逻辑的问题
...
}
(粘贴失败的测试代码)
**根因分析**:(你认为问题出在哪个文件哪一行)
**修复建议**:(给出修复思路,不直接改业务代码)
```
**根因分析**
(你认为问题出在哪个文件哪一行)
**修复建议**
(如果明确的话,给出修复思路,但不直接改业务代码)
```
---
## 完成后必做
1. 运行 `export PATH="/opt/homebrew/bin:$PATH" && go test ./... -v 2>&1 | tail -30`
2. 如果有测试失败:区分是"业务 bug"(写报告)还是"测试写错了"(自行修正测试)
3. 报告测试覆盖率:`go test ./... -cover`
**Go 后端:**
1. `export PATH="/opt/homebrew/bin:$PATH" && go test ./... -v 2>&1 | tail -50`
2. `go test ./... -cover` 报告覆盖率,目标 ≥ 80%
3. 有测试失败:区分"业务 bug(写报告)"还是"测试写错了(自行修正)"
**Flutter 前端:**
1. `cd client && flutter test` 全部通过
2. 检查每个 Screen 的每个按钮是否都有对应测试
3. `flutter analyze` 无 error