diff --git a/backend/MiaoJiZhang.Api.Tests/ApiIntegrationTests.cs b/backend/MiaoJiZhang.Api.Tests/ApiIntegrationTests.cs index 7d1ef14..d6313b0 100644 --- a/backend/MiaoJiZhang.Api.Tests/ApiIntegrationTests.cs +++ b/backend/MiaoJiZhang.Api.Tests/ApiIntegrationTests.cs @@ -129,9 +129,33 @@ public sealed class ApiFixture : IAsyncLifetime } [Collection(ApiCollection.Name)] -public sealed class ApiIntegrationTests(ApiFixture fixture) -{ - [Fact] +public sealed class ApiIntegrationTests(ApiFixture fixture) +{ + [Fact] + public async Task PublicRuntimeDefaults_AreAvailableForProductionFlows() + { + using var publicClient = fixture.Factory.CreateClient(); + var avatars = await publicClient.GetFromJsonAsync( + "/api/public/avatars"); + var personas = await publicClient.GetFromJsonAsync( + "/api/public/personas"); + + Assert.Contains(avatars.EnumerateArray(), item => + item.GetProperty("key").GetString() == "cat"); + Assert.Contains(personas.EnumerateArray(), item => + item.GetProperty("key").GetString() == "sassy_cat"); + + using var user = await fixture.RegisterAsync("runtime_defaults_user"); + foreach (var type in new[] { "expense", "income" }) + { + var categories = await user.GetFromJsonAsync( + $"/api/categories?type={type}"); + Assert.Contains(categories.EnumerateArray(), item => + item.GetProperty("name").GetString() == "其他"); + } + } + + [Fact] public async Task DataIsolation_TimeZone_Recycle_Budget_AndExport_WorkTogether() { using var owner = await fixture.RegisterAsync("owner_account"); diff --git a/backend/MiaoJiZhang.Api/Program.cs b/backend/MiaoJiZhang.Api/Program.cs index a3a3461..e71a6fe 100644 --- a/backend/MiaoJiZhang.Api/Program.cs +++ b/backend/MiaoJiZhang.Api/Program.cs @@ -168,9 +168,12 @@ using (var scope = app.Services.CreateScope()) await db.Database.MigrateAsync(); await AppConfigDefaults.EnsureAsync(db); await scope.ServiceProvider.GetRequiredService().EnsureAsync(); - if (app.Environment.IsDevelopment()) - await DbSeeder.SeedAsync(db); -} + // These records are runtime defaults, not development fixtures. Production + // databases also need them for onboarding, category fallback and stickers. + // DbSeeder only inserts into an empty catalog, so existing admin-managed + // records are preserved. + await DbSeeder.SeedAsync(db); +} if (app.Environment.IsDevelopment()) app.MapOpenApi(); diff --git a/frontend/lib/features/onboarding/pages/onboarding_page.dart b/frontend/lib/features/onboarding/pages/onboarding_page.dart index 212882d..5e21eba 100644 --- a/frontend/lib/features/onboarding/pages/onboarding_page.dart +++ b/frontend/lib/features/onboarding/pages/onboarding_page.dart @@ -31,37 +31,42 @@ class _OnboardingPageState extends State { } Future _loadConfig() async { + if (mounted) setState(() => _loaded = false); + final results = await Future.wait([_loadAvatars(), _loadPersonas()]); + if (!mounted) return; + final avatars = results[0] as List; + final personas = results[1] as List; + setState(() { + _avatars = avatars; + _personas = personas; + if (!avatars.any((item) => item.key == _avatar) && avatars.isNotEmpty) { + _avatar = avatars.first.key; + } + if (!personas.any((item) => item.key == _persona) && personas.isNotEmpty) { + _persona = personas.first.key; + } + _loaded = true; + }); + } + + Future> _loadAvatars() async { try { - final avatars = await PublicConfigApi.avatars(); - final personas = await PublicConfigApi.personas(); - if (mounted) - setState(() { - _avatars = avatars; - _personas = personas; - if (avatars.isNotEmpty) _avatar = avatars.first.key; - if (personas.isNotEmpty) _persona = personas.first.key; - _loaded = true; - }); + return await PublicConfigApi.avatars(); } catch (_) { - // API 失败用硬编码兜底 - if (mounted) setState(() => _loaded = true); + return const []; } } - // 兜底数据(API 不可用时) - static const _fallbackAvatars = [ - ('cat', '小账喵', AppIcons.cat), - ('dog', '阿福汪', AppIcons.dog), - ('robot', '账小智', AppIcons.robot), - ]; - static const _fallbackPersonas = [ - ('sassy_cat', '毒舌猫娘', '乱花钱会被无情吐槽'), - ('gentle', '温柔小暖', '永远鼓励,温柔提醒'), - ('strict', '严格管家', '理性专业,数据说话'), - ('meme', '沙雕损友', '玩梗高手,快乐记账'), - ]; + Future> _loadPersonas() async { + try { + return await PublicConfigApi.personas(); + } catch (_) { + return const []; + } + } Future _finish() async { + if (!_hasValidCatalogSelection) return; setState(() => _saving = true); try { await AuthApi.completeOnboarding( @@ -81,6 +86,10 @@ class _OnboardingPageState extends State { } } + bool get _hasValidCatalogSelection => + _avatars.any((item) => item.key == _avatar) && + _personas.any((item) => item.key == _persona); + @override Widget build(BuildContext context) { if (!_loaded) @@ -170,9 +179,7 @@ class _OnboardingPageState extends State { } Widget _CompanionStep() { - // 优先 API 数据,回退硬编码 - final avatars = _avatars.isNotEmpty - ? _avatars + final avatars = _avatars .map( (a) => ( a.key, @@ -184,11 +191,10 @@ class _OnboardingPageState extends State { : AppIcons.cat, ), ) - .toList() - : _fallbackAvatars; - final personas = _personas.isNotEmpty - ? _personas.map((p) => (p.key, p.name, p.description)).toList() - : _fallbackPersonas; + .toList(); + final personas = _personas + .map((p) => (p.key, p.name, p.description)) + .toList(); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -226,6 +232,35 @@ class _OnboardingPageState extends State { style: TextStyle(fontSize: 12, color: context.jz.text2), ), SizedBox(height: 14), + if (avatars.isEmpty || personas.isEmpty) + Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcons.icon(AppIcons.cloud, size: 28, color: context.jz.text3), + const SizedBox(height: 12), + Text( + 'AI 伙伴配置暂不可用', + style: TextStyle( + color: context.jz.text, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text('请检查网络后重试', style: TextStyle(color: context.jz.text2, fontSize: 12)), + const SizedBox(height: 12), + TextButton.icon( + onPressed: _loadConfig, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('重新加载'), + ), + ], + ), + ), + ) + else ...[ SizedBox( height: 96, child: Row( @@ -304,7 +339,7 @@ class _OnboardingPageState extends State { curve: Curves.easeOut, padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: on ? context.jz.aiBackground : Colors.white, + color: on ? context.jz.aiBackground : context.jz.card, borderRadius: BorderRadius.circular(14), border: Border.all( color: on ? AppTheme.ai : context.jz.line, @@ -339,11 +374,12 @@ class _OnboardingPageState extends State { }).toList(), ), ), + ], SizedBox( width: double.infinity, child: ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai), - onPressed: _saving ? null : _finish, + onPressed: _saving || !_hasValidCatalogSelection ? null : _finish, child: _saving ? SizedBox( height: 20, diff --git a/frontend/lib/shared/services/guest_merge_service.dart b/frontend/lib/shared/services/guest_merge_service.dart index d1ece1a..5088788 100644 --- a/frontend/lib/shared/services/guest_merge_service.dart +++ b/frontend/lib/shared/services/guest_merge_service.dart @@ -39,15 +39,17 @@ class GuestMergeService { : (existingLedger['id'] as num).toInt(); final categoryMap = {}; final available = >[]; - for (final type in ['expense', 'income']) { + for (final type in ['expense', 'income']) { final response = await _dio.get( '/api/categories', queryParameters: {'type': type}, ); available.addAll( (response.data as List).map((item) => item as Map), - ); - } + ); + } + await _ensureFallbackCategory(available, 'expense'); + await _ensureFallbackCategory(available, 'income'); for (final value in snapshot['categories'] as List? ?? const []) { final category = value as Map; @@ -90,9 +92,10 @@ class GuestMergeService { item['name'] == transaction['categoryName'], ); if (exact.isNotEmpty) return (exact.first['id'] as num).toInt(); - final fallback = available.firstWhere( - (item) => item['type'] == categoryType && item['name'] == '其他', - ); + final fallback = available.firstWhere( + (item) => item['type'] == categoryType && item['name'] == '其他', + orElse: () => throw StateError('$categoryType 分类缺少“其他”,无法导入本机账单'), + ); return (fallback['id'] as num).toInt(); } @@ -145,13 +148,47 @@ class GuestMergeService { await CurrentLedgerStore.instance.ensureLoaded(force: true); await CurrentLedgerStore.instance.select(ledgerId); - return GuestMergeResult( - ledgerId: ledgerId, - transactionCount: transactionCount, - ); - } - - static int? _findMappedDefault( + return GuestMergeResult( + ledgerId: ledgerId, + transactionCount: transactionCount, + ); + } + + static Future _ensureFallbackCategory( + List> available, + String type, + ) async { + bool hasFallback() => available.any( + (item) => item['type'] == type && item['name'] == '其他', + ); + if (hasFallback()) return; + + try { + final response = await _dio.post( + '/api/categories', + data: { + 'name': '其他', + 'iconKey': 'tag', + 'colorKey': type == 'income' ? 'lime' : 'graphite', + 'type': type, + }, + ); + available.add(response.data as Map); + } catch (_) { + // Another request may have created it between the list and create calls. + final response = await _dio.get( + '/api/categories', + queryParameters: {'type': type}, + ); + available.removeWhere((item) => item['type'] == type); + available.addAll( + (response.data as List).map((item) => item as Map), + ); + if (!hasFallback()) rethrow; + } + } + + static int? _findMappedDefault( List> available, Map snapshot, int oldCategoryId, diff --git a/frontend/test/visual_contract_test.dart b/frontend/test/visual_contract_test.dart index a03df91..294b2ea 100644 --- a/frontend/test/visual_contract_test.dart +++ b/frontend/test/visual_contract_test.dart @@ -65,9 +65,12 @@ void main() { ).readAsStringSync(); final addPage = File('lib/features/add/add_page.dart').readAsStringSync(); final chat = File('lib/features/chat/chat_page.dart').readAsStringSync(); - final companion = File( - 'lib/features/settings/companion_page.dart', - ).readAsStringSync(); + final companion = File( + 'lib/features/settings/companion_page.dart', + ).readAsStringSync(); + final onboarding = File( + 'lib/features/onboarding/pages/onboarding_page.dart', + ).readAsStringSync(); final me = File('lib/features/settings/me_page.dart').readAsStringSync(); final report = File( 'lib/features/stats/report_page.dart', @@ -76,13 +79,30 @@ void main() { expect(controls, contains(': context.jz.card')); expect(addPage, isNot(contains('selected ? _activeColor : Colors.white'))); expect(chat, isNot(contains('isMe ? AppTheme.primary : Colors.white'))); - expect( - companion, - isNot(contains('context.jz.aiBackground : Colors.white')), - ); + expect( + companion, + isNot(contains('context.jz.aiBackground : Colors.white')), + ); + expect(onboarding, contains(': context.jz.card')); + expect( + onboarding, + isNot(contains('context.jz.aiBackground : Colors.white')), + ); expect(me, isNot(contains('context.jz.primaryBackground : Colors.white'))); - expect(report, isNot(contains('selected ? Colors.white'))); - }); + expect(report, isNot(contains('selected ? Colors.white'))); + }); + + test('首次引导只提交服务端实际返回的 AI 伙伴配置', () { + final onboarding = File( + 'lib/features/onboarding/pages/onboarding_page.dart', + ).readAsStringSync(); + + expect(onboarding, contains('_hasValidCatalogSelection')); + expect(onboarding, contains('AI 伙伴配置暂不可用')); + expect(onboarding, contains('重新加载')); + expect(onboarding, isNot(contains('_fallbackAvatars'))); + expect(onboarding, isNot(contains('_fallbackPersonas'))); + }); test('聊天附件只保留拍照和相册导入', () { final source = File('lib/features/chat/chat_page.dart').readAsStringSync();