fix: seed production defaults and harden onboarding

This commit is contained in:
2026-08-22 23:18:27 +08:00
parent acf6356ba6
commit c6f98b5445
5 changed files with 182 additions and 62 deletions
@@ -131,6 +131,30 @@ public sealed class ApiFixture : IAsyncLifetime
[Collection(ApiCollection.Name)] [Collection(ApiCollection.Name)]
public sealed class ApiIntegrationTests(ApiFixture fixture) public sealed class ApiIntegrationTests(ApiFixture fixture)
{ {
[Fact]
public async Task PublicRuntimeDefaults_AreAvailableForProductionFlows()
{
using var publicClient = fixture.Factory.CreateClient();
var avatars = await publicClient.GetFromJsonAsync<JsonElement>(
"/api/public/avatars");
var personas = await publicClient.GetFromJsonAsync<JsonElement>(
"/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<JsonElement>(
$"/api/categories?type={type}");
Assert.Contains(categories.EnumerateArray(), item =>
item.GetProperty("name").GetString() == "其他");
}
}
[Fact] [Fact]
public async Task DataIsolation_TimeZone_Recycle_Budget_AndExport_WorkTogether() public async Task DataIsolation_TimeZone_Recycle_Budget_AndExport_WorkTogether()
{ {
+5 -2
View File
@@ -168,8 +168,11 @@ using (var scope = app.Services.CreateScope())
await db.Database.MigrateAsync(); await db.Database.MigrateAsync();
await AppConfigDefaults.EnsureAsync(db); await AppConfigDefaults.EnsureAsync(db);
await scope.ServiceProvider.GetRequiredService<AdminBootstrapService>().EnsureAsync(); await scope.ServiceProvider.GetRequiredService<AdminBootstrapService>().EnsureAsync();
if (app.Environment.IsDevelopment()) // These records are runtime defaults, not development fixtures. Production
await DbSeeder.SeedAsync(db); // 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()) if (app.Environment.IsDevelopment())
app.MapOpenApi(); app.MapOpenApi();
@@ -31,37 +31,42 @@ class _OnboardingPageState extends State<OnboardingPage> {
} }
Future<void> _loadConfig() async { Future<void> _loadConfig() async {
if (mounted) setState(() => _loaded = false);
final results = await Future.wait([_loadAvatars(), _loadPersonas()]);
if (!mounted) return;
final avatars = results[0] as List<AvatarItem>;
final personas = results[1] as List<PersonaItem>;
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<List<AvatarItem>> _loadAvatars() async {
try { try {
final avatars = await PublicConfigApi.avatars(); return 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;
});
} catch (_) { } catch (_) {
// API 失败用硬编码兜底 return const [];
if (mounted) setState(() => _loaded = true);
} }
} }
// 兜底数据(API 不可用时) Future<List<PersonaItem>> _loadPersonas() async {
static const _fallbackAvatars = [ try {
('cat', '小账喵', AppIcons.cat), return await PublicConfigApi.personas();
('dog', '阿福汪', AppIcons.dog), } catch (_) {
('robot', '账小智', AppIcons.robot), return const [];
]; }
static const _fallbackPersonas = [ }
('sassy_cat', '毒舌猫娘', '乱花钱会被无情吐槽'),
('gentle', '温柔小暖', '永远鼓励,温柔提醒'),
('strict', '严格管家', '理性专业,数据说话'),
('meme', '沙雕损友', '玩梗高手,快乐记账'),
];
Future<void> _finish() async { Future<void> _finish() async {
if (!_hasValidCatalogSelection) return;
setState(() => _saving = true); setState(() => _saving = true);
try { try {
await AuthApi.completeOnboarding( await AuthApi.completeOnboarding(
@@ -81,6 +86,10 @@ class _OnboardingPageState extends State<OnboardingPage> {
} }
} }
bool get _hasValidCatalogSelection =>
_avatars.any((item) => item.key == _avatar) &&
_personas.any((item) => item.key == _persona);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!_loaded) if (!_loaded)
@@ -170,9 +179,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
} }
Widget _CompanionStep() { Widget _CompanionStep() {
// 优先 API 数据,回退硬编码 final avatars = _avatars
final avatars = _avatars.isNotEmpty
? _avatars
.map( .map(
(a) => ( (a) => (
a.key, a.key,
@@ -184,11 +191,10 @@ class _OnboardingPageState extends State<OnboardingPage> {
: AppIcons.cat, : AppIcons.cat,
), ),
) )
.toList() .toList();
: _fallbackAvatars; final personas = _personas
final personas = _personas.isNotEmpty .map((p) => (p.key, p.name, p.description))
? _personas.map((p) => (p.key, p.name, p.description)).toList() .toList();
: _fallbackPersonas;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -226,6 +232,35 @@ class _OnboardingPageState extends State<OnboardingPage> {
style: TextStyle(fontSize: 12, color: context.jz.text2), style: TextStyle(fontSize: 12, color: context.jz.text2),
), ),
SizedBox(height: 14), 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( SizedBox(
height: 96, height: 96,
child: Row( child: Row(
@@ -304,7 +339,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
curve: Curves.easeOut, curve: Curves.easeOut,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: on ? context.jz.aiBackground : Colors.white, color: on ? context.jz.aiBackground : context.jz.card,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
border: Border.all( border: Border.all(
color: on ? AppTheme.ai : context.jz.line, color: on ? AppTheme.ai : context.jz.line,
@@ -339,11 +374,12 @@ class _OnboardingPageState extends State<OnboardingPage> {
}).toList(), }).toList(),
), ),
), ),
],
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai), style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
onPressed: _saving ? null : _finish, onPressed: _saving || !_hasValidCatalogSelection ? null : _finish,
child: _saving child: _saving
? SizedBox( ? SizedBox(
height: 20, height: 20,
@@ -48,6 +48,8 @@ class GuestMergeService {
(response.data as List).map((item) => item as Map<String, dynamic>), (response.data as List).map((item) => item as Map<String, dynamic>),
); );
} }
await _ensureFallbackCategory(available, 'expense');
await _ensureFallbackCategory(available, 'income');
for (final value in snapshot['categories'] as List<dynamic>? ?? const []) { for (final value in snapshot['categories'] as List<dynamic>? ?? const []) {
final category = value as Map<String, dynamic>; final category = value as Map<String, dynamic>;
@@ -92,6 +94,7 @@ class GuestMergeService {
if (exact.isNotEmpty) return (exact.first['id'] as num).toInt(); if (exact.isNotEmpty) return (exact.first['id'] as num).toInt();
final fallback = available.firstWhere( final fallback = available.firstWhere(
(item) => item['type'] == categoryType && item['name'] == '其他', (item) => item['type'] == categoryType && item['name'] == '其他',
orElse: () => throw StateError('$categoryType 分类缺少“其他”,无法导入本机账单'),
); );
return (fallback['id'] as num).toInt(); return (fallback['id'] as num).toInt();
} }
@@ -151,6 +154,40 @@ class GuestMergeService {
); );
} }
static Future<void> _ensureFallbackCategory(
List<Map<String, dynamic>> 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<String, dynamic>);
} 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<String, dynamic>),
);
if (!hasFallback()) rethrow;
}
}
static int? _findMappedDefault( static int? _findMappedDefault(
List<Map<String, dynamic>> available, List<Map<String, dynamic>> available,
Map<String, dynamic> snapshot, Map<String, dynamic> snapshot,
+20
View File
@@ -68,6 +68,9 @@ void main() {
final companion = File( final companion = File(
'lib/features/settings/companion_page.dart', 'lib/features/settings/companion_page.dart',
).readAsStringSync(); ).readAsStringSync();
final onboarding = File(
'lib/features/onboarding/pages/onboarding_page.dart',
).readAsStringSync();
final me = File('lib/features/settings/me_page.dart').readAsStringSync(); final me = File('lib/features/settings/me_page.dart').readAsStringSync();
final report = File( final report = File(
'lib/features/stats/report_page.dart', 'lib/features/stats/report_page.dart',
@@ -80,10 +83,27 @@ void main() {
companion, companion,
isNot(contains('context.jz.aiBackground : Colors.white')), 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(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('聊天附件只保留拍照和相册导入', () { test('聊天附件只保留拍照和相册导入', () {
final source = File('lib/features/chat/chat_page.dart').readAsStringSync(); final source = File('lib/features/chat/chat_page.dart').readAsStringSync();