fix: seed production defaults and harden onboarding
This commit is contained in:
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user