diff --git a/apps/heqi_design_system/lib/src/components.dart b/apps/heqi_design_system/lib/src/components.dart index 535d21d..4b0a828 100644 --- a/apps/heqi_design_system/lib/src/components.dart +++ b/apps/heqi_design_system/lib/src/components.dart @@ -1,3 +1,5 @@ +// 功能描述:共享移动端布局与状态组件,支持 Material 点击反馈。 +// 版本:1.1.0 import 'package:flutter/material.dart'; import 'tokens.dart'; @@ -35,11 +37,11 @@ class HeqiSurfaceSection extends StatelessWidget { final EdgeInsetsGeometry padding; @override - Widget build(BuildContext context) => DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, + Widget build(BuildContext context) => Material( + color: Theme.of(context).colorScheme.surface, + shape: Theme.of(context).cardTheme.shape ?? RoundedRectangleBorder( borderRadius: BorderRadius.circular(HeqiRadius.large), - border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + side: BorderSide(color: Theme.of(context).colorScheme.outlineVariant), ), child: Padding(padding: padding, child: child), ); diff --git a/apps/heqi_design_system/lib/src/theme.dart b/apps/heqi_design_system/lib/src/theme.dart index d86d349..7c55476 100644 --- a/apps/heqi_design_system/lib/src/theme.dart +++ b/apps/heqi_design_system/lib/src/theme.dart @@ -1,3 +1,4 @@ +// 功能描述:提供双品牌语义主题、组件尺寸与颜色对比规则。版本:0.1.1。 import 'package:flutter/material.dart'; import 'tokens.dart'; @@ -25,9 +26,30 @@ abstract final class HeqiTheme { secondary: dark ? const Color(0xFF86D7B5) : HeqiColors.success, error: dark ? const Color(0xFFFFB4AB) : HeqiColors.danger, surface: dark ? HeqiColors.darkSurface : Colors.white, + secondaryContainer: !dark && brand == HeqiBrand.consumer + ? const Color(0xFFE8F0FF) + : null, + onSecondaryContainer: !dark && brand == HeqiBrand.consumer + ? const Color(0xFF161A22) + : null, + // 用户端使用明确蓝白色板,避免种子推导出紫色容器。 + primaryContainer: !dark && brand == HeqiBrand.consumer + ? const Color(0xFFE8F0FF) + : null, + onSurface: !dark && brand == HeqiBrand.consumer + ? const Color(0xFF161A22) + : null, + onSurfaceVariant: !dark && brand == HeqiBrand.consumer + ? const Color(0xFF626977) + : null, + outlineVariant: !dark && brand == HeqiBrand.consumer + ? const Color(0xFFE1E5EC) + : null, ); final textTheme = _textTheme; - final radius = BorderRadius.circular(HeqiRadius.large); + final radius = BorderRadius.circular( + brand == HeqiBrand.consumer ? 10 : HeqiRadius.large, + ); return ThemeData( brightness: brightness, @@ -43,7 +65,7 @@ abstract final class HeqiTheme { foregroundColor: scheme.onSurface, elevation: 0, scrolledUnderElevation: 0, - centerTitle: false, + centerTitle: brand == HeqiBrand.consumer, titleTextStyle: textTheme.titleLarge?.copyWith(color: scheme.onSurface), ), cardTheme: CardThemeData( @@ -96,6 +118,9 @@ abstract final class HeqiTheme { ), outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( + side: brand == HeqiBrand.consumer + ? BorderSide(color: scheme.primary) + : null, minimumSize: const Size.fromHeight(HeqiSize.buttonHeight), shape: RoundedRectangleBorder(borderRadius: radius), textStyle: textTheme.labelLarge, @@ -108,10 +133,15 @@ abstract final class HeqiTheme { ), ), navigationBarTheme: NavigationBarThemeData( - height: HeqiSize.navigationBarHeight, + height: brand == HeqiBrand.consumer ? 64 : HeqiSize.navigationBarHeight, elevation: 0, backgroundColor: scheme.surface, indicatorColor: scheme.primaryContainer, + iconTheme: brand == HeqiBrand.consumer + ? WidgetStateProperty.resolveWith( + (states) => IconThemeData(color: scheme.onSurfaceVariant), + ) + : null, labelTextStyle: WidgetStateProperty.resolveWith( (states) => textTheme.labelSmall?.copyWith( color: states.contains(WidgetState.selected) @@ -124,11 +154,12 @@ abstract final class HeqiTheme { ), ), chipTheme: ChipThemeData( - side: BorderSide.none, + side: BorderSide(color: scheme.outlineVariant), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(HeqiRadius.small), ), - labelStyle: textTheme.labelSmall, + // 显式指定文字对比色,避免 Chip 继承按钮前景色后白字落在白底。 + labelStyle: textTheme.labelSmall?.copyWith(color: scheme.onSurface), ), listTileTheme: const ListTileThemeData( minTileHeight: 56, diff --git a/apps/heqi_design_system/pubspec.yaml b/apps/heqi_design_system/pubspec.yaml index 103073b..392bb39 100644 --- a/apps/heqi_design_system/pubspec.yaml +++ b/apps/heqi_design_system/pubspec.yaml @@ -1,6 +1,6 @@ name: heqi_design_system description: 和气物联网智能瓶阀移动端共享 Design System。 -version: 0.1.0 +version: 0.1.1 publish_to: none environment: diff --git a/apps/user_app/android/app/src/main/AndroidManifest.xml b/apps/user_app/android/app/src/main/AndroidManifest.xml index 0e085c3..ce0c18e 100644 --- a/apps/user_app/android/app/src/main/AndroidManifest.xml +++ b/apps/user_app/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ + + + + + diff --git a/apps/user_app/assets/design/delivery-route-map.png b/apps/user_app/assets/design/delivery-route-map.png new file mode 100644 index 0000000..411ae8b Binary files /dev/null and b/apps/user_app/assets/design/delivery-route-map.png differ diff --git a/apps/user_app/assets/design/login-source.png b/apps/user_app/assets/design/login-source.png new file mode 100644 index 0000000..c82b0b0 Binary files /dev/null and b/apps/user_app/assets/design/login-source.png differ diff --git a/apps/user_app/assets/design/shop-source.png b/apps/user_app/assets/design/shop-source.png new file mode 100644 index 0000000..045ecb0 Binary files /dev/null and b/apps/user_app/assets/design/shop-source.png differ diff --git a/apps/user_app/assets/products/lpg-cylinder.png b/apps/user_app/assets/products/lpg-cylinder.png new file mode 100644 index 0000000..56f43d9 Binary files /dev/null and b/apps/user_app/assets/products/lpg-cylinder.png differ diff --git a/apps/user_app/assets/staff/delivery-worker.png b/apps/user_app/assets/staff/delivery-worker.png new file mode 100644 index 0000000..1e4de32 Binary files /dev/null and b/apps/user_app/assets/staff/delivery-worker.png differ diff --git a/apps/user_app/design-qa.md b/apps/user_app/design-qa.md index 28097f6..a5a6f5e 100644 --- a/apps/user_app/design-qa.md +++ b/apps/user_app/design-qa.md @@ -1,25 +1,18 @@ -# 用户端首页服务归属视觉验收 +# 第33页家庭成员与设备共享视觉核对 -## 验收基线 +## Reference -- 视觉事实源:`D:\5k\platforms\docs\设计参考\用户端首页_服务归属方案1.png` -- 实现截图:`D:\5k\platforms\apps\user_app\design-qa\用户端首页_服务归属实现.png` -- 验收视口:390 × 844 CSS 像素,设备像素比 1 -- 页面状态:固定示例数据;所属气站“和气城南气站”,服务配送点“安顺路配送点”,公告 6 条 -- 事实源像素尺寸:853 × 1844;按 390 × 844 归一化后与实现截图比较 -- 实现截图像素尺寸:390 × 844;截图尺寸与 CSS 视口一致,无额外密度换算 +- 设计源:`doc/用户端APP-最新参考产品设计/33-家庭成员与设备共享.png` +- 目标视口:390 × 844 +- 实现截图:`docs/视觉验收/A1/33_390_1.0.png` +- 并排证据:`docs/视觉验收/A1/33_并排对照.png` -## 对照证据 +## Comparison -- 全页对照:`D:\5k\platforms\apps\user_app\design-qa\对照_全页.png`,左侧为归一化事实源,右侧为实现 -- 服务归属局部对照:`D:\5k\platforms\apps\user_app\design-qa\对照_服务归属卡片.png`,截取相同视口区域,左侧为事实源,右侧为实现 +- 已对齐:顶部返回、居中标题、右侧添加;家庭概览卡;成员卡及房主/家人/房东/待确认状态;三类权限标签;三台共享设备;安全规则;底部固定主按钮。 +- 已收敛:第二轮缩小成员和设备行高、标题字号与间距,使 390 × 844 首屏能同时显示三台设备、安全规则和主按钮。 +- 数据边界:Fixture只用于同状态视觉比较;真实浏览器页面读取远程账号,当前显示0位成员、0台设备,未伪造设计样例。 +- 可见差异:设计稿包含四张人物照片,当前成员模型没有受控头像资源,页面使用姓名首字头像;设备图标采用 Material 图标,与设计稿专属线性图标存在细节差异。 +- 功能边界:真实邀请和撤回已在内置浏览器完成。接受邀请和设备权限需第二账号及真实设备,当前证据不足以证明完整双账号闭环。 -## 比较记录 - -1. 第一轮发现 P1:实现卡片使用 20 像素内边距和 72 像素图标底座,导致标题与两行字段整体比事实源右移约 12 像素,卡片高度也偏高约 7 像素。 -2. 修正为 16 像素内边距和 64 像素图标底座后重新截图;标题、标签、值、分隔线与图标的相对位置已与事实源对齐。 -3. 第二轮未发现 P0、P1 或 P2 视觉问题。页面无横向溢出,底部导航无遮挡,卡片边框、圆角、行距和信息层级一致。 - -## 最终结果 - -passed +final result: blocked diff --git a/apps/user_app/ios/Runner/Info.plist b/apps/user_app/ios/Runner/Info.plist index c4e188a..1143a3e 100644 --- a/apps/user_app/ios/Runner/Info.plist +++ b/apps/user_app/ios/Runner/Info.plist @@ -26,6 +26,14 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSCameraUsageDescription + 拍摄照片用于设置个人头像或提交报修现场照片 + NSPhotoLibraryUsageDescription + 从相册选择个人头像或报修现场照片 + NSMicrophoneUsageDescription + 仅在您点击语音输入时使用麦克风,录入报修故障描述 + NSSpeechRecognitionUsageDescription + 使用系统语音识别将故障描述转换为可编辑文字 UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/apps/user_app/lib/app/app.dart b/apps/user_app/lib/app/app.dart index 0133b2d..e01fb34 100644 --- a/apps/user_app/lib/app/app.dart +++ b/apps/user_app/lib/app/app.dart @@ -1,4 +1,8 @@ +// 功能:装配用户端主题与路由,并让桌面 Web 预览保持产品图手机画布宽度。 +// 版本:1.1.0。 +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import '../ui/core/app_theme.dart'; import 'dependencies.dart'; @@ -19,10 +23,28 @@ class _UserClientAppState extends State { @override Widget build(BuildContext context) => MaterialApp.router( title: '瓶安芯', + // 首期面向中文用户,系统日期与时间控件统一使用简体中文。 + locale: const Locale('zh', 'CN'), + supportedLocales: const [Locale('zh', 'CN')], + localizationsDelegates: GlobalMaterialLocalizations.delegates, debugShowCheckedModeBanner: false, theme: AppTheme.light(), darkTheme: AppTheme.dark(), themeMode: ThemeMode.system, routerConfig: _router, + builder: (context, child) { + if (!kIsWeb) return child ?? const SizedBox.shrink(); + // 产品设计宽853像素按2倍密度绘制;Web预览以427 CSS像素展示同一手机画布。 + return ColoredBox( + color: const Color(0xFFEFF2F7), + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 427), + child: SizedBox(width: double.infinity, child: child), + ), + ), + ); + }, ); } diff --git a/apps/user_app/lib/app/dependencies.dart b/apps/user_app/lib/app/dependencies.dart index a934a88..aec6059 100644 --- a/apps/user_app/lib/app/dependencies.dart +++ b/apps/user_app/lib/app/dependencies.dart @@ -6,15 +6,19 @@ import 'package:flutter/foundation.dart'; import '../data/repositories/client_repository.dart'; import '../data/services/api_client.dart'; import '../data/services/secure_session_store.dart'; +import '../data/services/repair_draft_store.dart'; +import '../domain/models/primary_models.dart'; class AppDependencies { AppDependencies({ required this.session, required this.repository, + this.repairDraftStore, }); final UserSession session; final ClientRepository repository; + final RepairDraftStore? repairDraftStore; static Future create() async { final store = SecureSessionStore(); @@ -24,7 +28,11 @@ class AppDependencies { () => session.token, onUnauthorized: session.invalidate, ); - return AppDependencies(session: session, repository: ClientRepository(api)); + return AppDependencies( + session: session, + repository: ClientRepository(api), + repairDraftStore: SecureRepairDraftStore(), + ); } } @@ -36,6 +44,13 @@ class UserSession extends ChangeNotifier { final SessionStore _store; String _token = ''; bool _expired = false; + + /// 本次登录是否持久化;默认兼容旧调用方,登录页由用户显式选择。 + bool rememberLogin = true; + + /// 只保存本次明确同意的内容版本,不在客户端推测当前发布版本。 + List loginConsents = const []; + DateTime? loginConsentShownAt; Future _pendingClear = Future.value(); String get token => _token; @@ -66,6 +81,15 @@ class UserSession extends ChangeNotifier { 'password': verification ? '' : password, 'code': verificationCode ?? '', 'request_identity': requestIdentity ?? '', + if (loginConsents.isNotEmpty) + 'consents': [ + for (final c in loginConsents) + { + 'identity': c.identity, + 'version': c.version, + 'shown_at': loginConsentShownAt?.toUtc().toIso8601String(), + }, + ], }, ), ); @@ -74,9 +98,16 @@ class UserSession extends ChangeNotifier { // 等待旧令牌清理结束,避免迟到的删除任务误删刚写入的新令牌。 await _pendingClear; - await _store.writeToken(token); + if (rememberLogin) { + await _store.writeToken(token); + } else { + // 清理历史令牌失败时不能声称已关闭持久会话。 + await _store.clear(); + } _token = token; _expired = false; + loginConsents = const []; + loginConsentShownAt = null; notifyListeners(); } @@ -89,9 +120,32 @@ class UserSession extends ChangeNotifier { body: {'phone': phone, 'purpose': purpose}, ), ); + // Mock环境只生成服务端校验记录,并未向用户手机发送短信,不能启动已发送倒计时。 + if (details['delivery_status'] == 'not_sent') { + throw const ApiException(2410, '短信验证码暂未开放'); + } return details['request_identity'] as String? ?? ''; } + /// 使用重置专用验证码修改密码,服务端校验验证码用途和密码规则。 + Future resetPassword({ + required String phone, + required String code, + required String requestIdentity, + required String password, + }) async { + await ApiClient(() => '').post( + '$_root/auth/reset-password', + authenticated: false, + body: { + 'phone': phone, + 'code': code, + 'request_identity': requestIdentity, + 'new_password': password, + }, + ); + } + Future logout() async { _token = ''; _expired = false; diff --git a/apps/user_app/lib/app/router.dart b/apps/user_app/lib/app/router.dart index 38e62c0..03b3d07 100644 --- a/apps/user_app/lib/app/router.dart +++ b/apps/user_app/lib/app/router.dart @@ -4,12 +4,45 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../ui/features/auth/login_page.dart'; +import '../ui/features/address/addresses_page.dart'; import '../ui/features/auth/register_page.dart'; import '../ui/features/home/home_page.dart'; import '../ui/features/orders/orders_page.dart'; +import '../ui/features/orders/gas_contracts_page.dart'; +import '../ui/features/orders/shop_order_detail_page.dart'; +import '../ui/features/orders/payment_confirmation_page.dart'; +import '../ui/features/orders/gas_order_create_page.dart'; +import '../ui/features/orders/delivery_detail_page.dart'; +import '../ui/features/orders/delivery_track_page.dart'; +import '../ui/features/orders/invoice_preview_page.dart'; import '../ui/features/profile/profile_page.dart'; -import '../ui/features/shared/record_list_page.dart'; +import '../ui/features/profile/devices_page.dart'; +import '../ui/features/profile/device_groups_page.dart'; +import '../ui/features/profile/emergency_contacts_page.dart'; +import '../ui/features/profile/family_sharing_page.dart'; +import '../ui/features/profile/profile_edit_page.dart'; +import '../ui/features/profile/user_records_page.dart'; +import '../ui/features/profile/usage_statistics_page.dart'; +import '../ui/features/profile/message_center_page.dart'; +import '../ui/features/settings/settings_page.dart'; +import '../ui/features/settings/login_password_page.dart'; +import '../ui/features/settings/payment_password_page.dart'; +import '../ui/features/wallet/wallet_page.dart'; +import '../ui/features/wallet/recharge_records_page.dart'; +import '../ui/features/wallet/recharge_page.dart'; +import '../ui/features/wallet/wallet_bills_page.dart'; +import '../ui/features/wallet/withdrawal_page.dart'; +import '../ui/features/wallet/bank_cards_page.dart'; +import '../ui/features/wallet/deposit_page.dart'; +import '../ui/features/wallet/deposit_return_page.dart'; import '../ui/features/shop/shop_page.dart'; +import '../ui/features/shop/checkout_page.dart'; +import '../ui/features/shop/cart_page.dart'; +import '../ui/features/shop/favorites_page.dart'; +import '../ui/features/shop/product_detail_page.dart'; +import '../ui/features/home/safety_contents_page.dart'; +import '../ui/features/tickets/ticket_detail_page.dart'; +import '../ui/features/tickets/repair_page.dart'; import 'auth_navigation.dart'; import 'dependencies.dart'; @@ -21,7 +54,13 @@ GoRouter createRouter( refreshListenable: dependencies.session, redirect: (context, state) { final authRoute = state.matchedLocation == '/login' || state.matchedLocation == '/register'; - if (!dependencies.session.isAuthenticated && !authRoute) { + final publicRoute = + state.matchedLocation == '/home' || + state.matchedLocation == '/shop' || + state.matchedLocation == '/contents' || + state.matchedLocation.startsWith('/contents/') || + state.matchedLocation.startsWith('/products/'); + if (!dependencies.session.isAuthenticated && !authRoute && !publicRoute) { return buildAuthLocation( '/login', redirectTarget: state.uri.toString(), @@ -34,10 +73,167 @@ GoRouter createRouter( return null; }, routes: [ + GoRoute( + path: '/devices', + builder: (_, state) => DevicesPage(repository: dependencies.repository), + ), + GoRoute( + path: '/records', + builder: (_, state) => UserRecordsPage(repository: dependencies.repository), + ), + GoRoute( + path: '/usage', + builder: (_, state) => UsageStatisticsPage(repository: dependencies.repository), + ), + GoRoute( + path: '/messages', + builder: (_, state) => MessageCenterPage(repository: dependencies.repository), + ), + GoRoute( + path: '/device-groups', + builder: (_, state) => DeviceGroupsPage(repository: dependencies.repository), + ), + GoRoute( + path: '/safety/contacts', + builder: (_, state) => EmergencyContactsPage(repository: dependencies.repository), + ), + GoRoute( + path: '/family', + builder: (_, state) => FamilySharingPage(repository: dependencies.repository), + ), + GoRoute( + path: '/contents', + builder: (_, state) => SafetyContentsPage(repository: dependencies.repository), + ), + GoRoute( + path: '/contents/:identity', + builder: (_, state) => SafetyContentsPage( + repository: dependencies.repository, + identity: state.pathParameters['identity']!, + ), + ), + GoRoute( + path: '/settings/payment-password', + builder: (_, state) => PaymentPasswordPage(repository: dependencies.repository), + ), + GoRoute( + path: '/settings', + builder: (_, state) => + SettingsPage(repository: dependencies.repository, session: dependencies.session), + ), + GoRoute( + path: '/settings/password', + builder: (_, state) => + LoginPasswordPage(repository: dependencies.repository, session: dependencies.session), + ), + GoRoute( + path: '/gas/order', + builder: (_, _) => GasOrderCreatePage(repository: dependencies.repository), + ), + GoRoute( + path: '/gas/orders/:identity', + builder: (_, state) => ShopOrderDetailPage( + repository: dependencies.repository, + identity: state.pathParameters['identity']!, + business: 'gas', + ), + ), + GoRoute( + path: '/shop/orders/:identity', + builder: (_, state) => ShopOrderDetailPage( + repository: dependencies.repository, + identity: state.pathParameters['identity']!, + ), + ), + GoRoute( + path: '/gas/orders/:identity/delivery', + builder: (_, state) => DeliveryDetailPage( + repository: dependencies.repository, + identity: state.pathParameters['identity']!, + ), + ), + GoRoute( + path: '/gas/orders/:identity/delivery/track', + builder: (_, state) => DeliveryTrackPage( + repository: dependencies.repository, + identity: state.pathParameters['identity']!, + ), + ), + GoRoute( + path: '/invoice/:business/:identity', + builder: (_, state) => InvoicePreviewPage( + repository: dependencies.repository, + business: state.pathParameters['business']!, + identity: state.pathParameters['identity']!, + ), + ), + GoRoute( + path: '/payment/:business/:identity', + builder: (_, state) => PaymentConfirmationPage( + repository: dependencies.repository, + business: state.pathParameters['business']!, + identity: state.pathParameters['identity']!, + ), + ), + GoRoute( + path: '/favorites', + builder: (_, state) => FavoritesPage(repository: dependencies.repository), + ), + GoRoute( + path: '/cart', + builder: (_, state) => CartPage(repository: dependencies.repository), + ), + GoRoute( + path: '/cart/checkout', + builder: (_, state) => + CheckoutPage(repository: dependencies.repository, productIdentity: '', fromCart: true), + ), + GoRoute( + path: '/repair', + builder: (_, state) => RepairPage( + repository: dependencies.repository, + draftStore: dependencies.repairDraftStore, + ), + ), + GoRoute( + path: '/tickets/:identity', + builder: (_, state) => TicketDetailPage( + repository: dependencies.repository, + identity: state.pathParameters['identity']!, + ), + ), + GoRoute( + path: '/checkout/:identity', + builder: (_, state) => CheckoutPage( + repository: dependencies.repository, + productIdentity: state.pathParameters['identity']!, + initialQuantity: int.tryParse(state.uri.queryParameters['quantity'] ?? '') ?? 1, + ), + ), + GoRoute( + path: '/products/:identity', + builder: (_, state) => ProductDetailPage( + repository: dependencies.repository, + identity: state.pathParameters['identity']!, + authenticated: dependencies.session.isAuthenticated, + ), + ), + GoRoute( + path: '/addresses', + builder: (context, state) => AddressesPage( + repository: dependencies.repository, + selecting: state.uri.queryParameters['select'] == '1', + ), + ), + GoRoute( + path: '/profile/edit', + builder: (context, state) => ProfileEditPage(repository: dependencies.repository), + ), GoRoute( path: '/login', builder: (context, state) => LoginPage( session: dependencies.session, + repository: dependencies.repository, redirectTarget: sanitizeRedirectTarget(state.uri.queryParameters['redirect']), showSessionExpiredMessage: state.uri.queryParameters['reason'] == 'expired', ), @@ -52,20 +248,41 @@ GoRouter createRouter( ), GoRoute( path: '/records/contracts', - builder: (context, state) => RecordListPage( - title: '供气合同', - eyebrow: '可信履约', - sourceKey: 'contracts', - loader: dependencies.repository.contracts, - ), + builder: (context, state) => GasContractsPage(repository: dependencies.repository), + ), + GoRoute( + path: '/wallet', + builder: (context, state) => WalletPage(repository: dependencies.repository), ), GoRoute( path: '/records/wallet', - builder: (context, state) => RecordListPage( - title: '钱包流水', - eyebrow: '资金记录', - sourceKey: 'wallet_records', - loader: dependencies.repository.walletRecords, + builder: (context, state) => WalletBillsPage(repository: dependencies.repository), + ), + GoRoute( + path: '/wallet/recharge-records', + builder: (context, state) => RechargeRecordsPage(repository: dependencies.repository), + ), + GoRoute( + path: '/wallet/recharge', + builder: (context, state) => RechargePage(repository: dependencies.repository), + ), + GoRoute( + path: '/wallet/withdraw', + builder: (context, state) => WithdrawalPage(repository: dependencies.repository), + ), + GoRoute( + path: '/wallet/banks', + builder: (context, state) => BankCardsPage(repository: dependencies.repository), + ), + GoRoute( + path: '/deposits', + builder: (context, state) => DepositPage(repository: dependencies.repository), + ), + GoRoute( + path: '/deposits/return', + builder: (context, state) => DepositReturnPage( + repository: dependencies.repository, + depositIdentity: state.uri.queryParameters['deposit'] ?? '', ), ), StatefulShellRoute.indexedStack( @@ -75,7 +292,10 @@ GoRouter createRouter( routes: [ GoRoute( path: '/home', - builder: (context, state) => HomePage(repository: dependencies.repository), + builder: (context, state) => HomePage( + repository: dependencies.repository, + authenticated: dependencies.session.isAuthenticated, + ), ), ], ), @@ -83,7 +303,10 @@ GoRouter createRouter( routes: [ GoRoute( path: '/shop', - builder: (context, state) => ShopPage(repository: dependencies.repository), + builder: (context, state) => ShopPage( + repository: dependencies.repository, + authenticated: dependencies.session.isAuthenticated, + ), ), ], ), @@ -91,7 +314,16 @@ GoRouter createRouter( routes: [ GoRoute( path: '/orders', - builder: (context, state) => OrdersPage(repository: dependencies.repository), + builder: (context, state) => OrdersPage( + key: ValueKey(state.uri.queryParameters['tab']), + repository: dependencies.repository, + initialTab: switch (state.uri.queryParameters['tab']) { + 'gas' => 1, + 'shop' => 0, + 'tickets' => 3, + _ => 1, + }, + ), ), ], ), @@ -99,8 +331,11 @@ GoRouter createRouter( routes: [ GoRoute( path: '/me', - builder: (context, state) => - ProfilePage(session: dependencies.session, repository: dependencies.repository), + builder: (context, state) => ProfilePage( + session: dependencies.session, + repository: dependencies.repository, + openRepair: state.uri.queryParameters['repair'] == '1', + ), ), ], ), diff --git a/apps/user_app/lib/data/repositories/client_repository.dart b/apps/user_app/lib/data/repositories/client_repository.dart index e54e1ae..e31dc4b 100644 --- a/apps/user_app/lib/data/repositories/client_repository.dart +++ b/apps/user_app/lib/data/repositories/client_repository.dart @@ -1,6 +1,27 @@ +// 功能描述:保留用户端兼容仓储与可选分页读取,新增页面通过领域适配器消费。 +// 版本:1.1.0。 import 'dart:typed_data'; +import 'dart:async'; import '../../domain/models/client_models.dart'; +import '../../domain/models/shipping_address.dart'; +import '../../domain/models/product_detail.dart'; +import '../../domain/models/cart_item.dart'; +import '../../domain/models/product_favorite.dart'; +import '../../domain/models/product_recommendations.dart'; +import '../../domain/models/shop_order_detail.dart'; +import '../../domain/models/gas_order_detail.dart'; +import '../../domain/models/gas_order_checkout.dart'; +import '../../domain/models/wallet_bill.dart'; +import '../../domain/models/wallet_account.dart'; +import '../../domain/models/recharge.dart'; +import '../../domain/models/device_group.dart'; +import '../../domain/models/deposit.dart'; +import '../../domain/models/delivery_detail.dart'; +import '../../domain/models/delivery_track.dart'; +import '../../domain/models/usage_statistics.dart'; +import '../../domain/models/message_center.dart'; +import '../../domain/models/family_sharing.dart'; import '../services/api_client.dart'; class ClientRepository { @@ -9,6 +30,479 @@ class ClientRepository { static const root = '/heqi/client/v1/user'; final ApiClient _api; + /// 读取本人作为房主的家庭成员、设备及权限汇总。 + Future familyDashboard() async => FamilyDashboardData.fromJson( + jsonMap(await _api.get('$root/family')), + ); + + /// 邀请成员时预设设备权限,邀请接受前服务端不会授予访问权。 + Future inviteFamilyMember({ + required String name, + required String phone, + required String relationship, + required String requestNo, + required List permissions, + }) async { + final result = jsonMap( + await _api.post( + '$root/family-members', + body: { + 'name': name, + 'phone': phone, + 'relationship': relationship, + 'request_no': requestNo, + 'device_permissions': permissions.map((item) => item.toJson()).toList(), + }, + ), + ); + if ((result['identity'] as String? ?? '').isEmpty) { + throw const ApiException(1714, '邀请结果未确认,请刷新重试'); + } + } + + /// 调整一个成员的全部设备授权,空列表表示撤销全部设备权限。 + Future updateFamilyPermissions( + String identity, + List permissions, + ) async { + final result = jsonMap( + await _api.put( + '$root/family-members/${Uri.encodeComponent(identity)}/device-permissions', + body: {'device_permissions': permissions.map((item) => item.toJson()).toList()}, + ), + ); + if (result['updated'] != true) throw const ApiException(1714, '权限修改结果未确认'); + } + + /// 撤回邀请或移除成员,并由服务端同步撤销全部设备权限。 + Future revokeFamilyMember(String identity) async { + final result = jsonMap( + await _api.delete('$root/family-members/${Uri.encodeComponent(identity)}'), + ); + if (result['revoked'] != true) throw const ApiException(1714, '撤销结果未确认'); + } + + /// 受邀账号本人接受或拒绝邀请,其他账号无法代替响应。 + Future respondFamilyInvitation(String identity, bool accept) async { + final result = jsonMap( + await _api.post( + '$root/family-invitations/${Uri.encodeComponent(identity)}/respond', + body: {'accept': accept}, + ), + ); + if (result['accepted'] != accept) { + throw const ApiException(1714, '邀请响应结果未确认'); + } + } + + /// 读取本人押金汇总;押金金额和可退状态只接受服务端事实。 + Future deposits() async { + final current = _api.captureSessionGuard(); + final data = jsonMap(await _api.get('$root/deposits')); + if (!current()) throw const SessionExpiredException(); + return DepositSummary.fromJson(data); + } + + /// 提交本人退瓶申请;时段、地址归属和押金状态由服务端再次校验。 + Future createDepositReturn({ + required String depositIdentity, + required ShippingAddress address, + required DateTime appointmentStart, + required DateTime appointmentEnd, + required String requestNo, + }) async { + final current = _api.captureSessionGuard(); + final data = jsonMap( + await _api.post( + '$root/deposit-returns', + body: { + 'deposit_identity': depositIdentity, + 'address_identity': address.identity, + 'appointment_start': appointmentStart.toUtc().toIso8601String(), + 'appointment_end': appointmentEnd.toUtc().toIso8601String(), + 'request_no': requestNo, + 'bottle_intact': true, + 'valve_safe': true, + 'stopped_using': true, + 'rule_accepted': true, + }, + ), + ); + if (!current()) throw const SessionExpiredException(); + return DepositReturnRequest.fromJson(data); + } + + /// 完整读取本人明确分类的设备;账户切换不能显示旧会话数据。 + Future> devices() async { + final current = _api.captureSessionGuard(); + final rows = await _records('$root/devices', titleKeys: ['name'], subtitleKeys: ['code']); + if (!current()) throw const SessionExpiredException(); + return rows; + } + + /// 读取本人选定设备的权威用气统计,无数据由服务端明确返回不可用。 + Future usageStatistics({ + required String deviceIdentity, + required String period, + DateTime? anchor, + }) async { + final current = _api.captureSessionGuard(); + final query = {'device_identity': deviceIdentity, 'period': period}; + if (anchor != null) query['anchor'] = anchor.toIso8601String().substring(0, 10); + final encoded = Uri(queryParameters: query).query; + final data = jsonMap(await _api.get('$root/usage-statistics?$encoded')); + if (!current()) throw const SessionExpiredException(); + return UsageStatistics.fromJson(data); + } + + /// 读取本人设备分组,响应只含公开标识和当前仍归属本人的设备。 + Future> deviceGroups() async { + final current = _api.captureSessionGuard(); + final rows = jsonList(await _api.get('$root/device-groups')); + if (!current()) throw const SessionExpiredException(); + return rows.map(DeviceGroup.fromJson).toList(); + } + + /// 新建重试复用requestNo;编辑使用分组公开标识。 + Future saveDeviceGroup({ + String? identity, + required String name, + required String requestNo, + }) async { + final current = _api.captureSessionGuard(); + final result = jsonMap( + identity == null + ? await _api.post('$root/device-groups', body: {'name': name, 'request_no': requestNo}) + : await _api.put( + '$root/device-groups/${Uri.encodeComponent(identity)}', + body: {'name': name}, + ), + ); + if (!current()) throw const SessionExpiredException(); + if ((result['identity'] as String? ?? '').isEmpty) { + throw const ApiException(1714, '分组保存结果未确认,请刷新重试'); + } + } + + /// 删除分组后设备自动回到未分组;只有明确成功才刷新页面。 + Future deleteDeviceGroup(String identity) async { + final current = _api.captureSessionGuard(); + final result = jsonMap( + await _api.delete('$root/device-groups/${Uri.encodeComponent(identity)}'), + ); + if (!current()) throw const SessionExpiredException(); + if (result['deleted'] != true) throw const ApiException(1714, '删除结果未确认,请刷新重试'); + } + + /// 空分组标识表示移回未分组,服务端再次校验设备和分组均属于本人。 + Future assignDeviceGroup(String deviceIdentity, String groupIdentity) async { + final current = _api.captureSessionGuard(); + final result = jsonMap( + await _api.put( + '$root/devices/${Uri.encodeComponent(deviceIdentity)}/group', + body: {'group_identity': groupIdentity}, + ), + ); + if (!current()) throw const SessionExpiredException(); + if (result['updated'] != true) throw const ApiException(1714, '设备归组结果未确认,请刷新重试'); + } + + /// 本人联系人资料;会话切换后丢弃旧账户响应。 + Future> emergencyContacts() async { + final current = _api.captureSessionGuard(); + final rows = jsonList(await _api.get('$root/emergency-contacts')); + if (!current()) throw const SessionExpiredException(); + return rows + .map( + (row) => ClientRecord( + identity: row['identity'] as String, + title: row['name'] as String, + subtitle: row['phone'] as String, + raw: row, + ), + ) + .toList(); + } + + /// 同一次新增重试必须复用requestNo,服务端最多保存五人。 + Future saveEmergencyContact({ + String? identity, + required String name, + required String phone, + required String relationship, + required String requestNo, + }) async { + final current = _api.captureSessionGuard(); + final body = { + 'name': name, + 'phone': phone, + 'relationship': relationship, + 'request_no': requestNo, + }; + if (identity == null) { + await _api.post('$root/emergency-contacts', body: body); + } else { + await _api.put('$root/emergency-contacts/${Uri.encodeComponent(identity)}', body: body); + } + if (!current()) throw const SessionExpiredException(); + } + + /// 仅明确删除成功后刷新联系人列表。 + Future deleteEmergencyContact(String identity) async { + final current = _api.captureSessionGuard(); + final result = jsonMap( + await _api.delete('$root/emergency-contacts/${Uri.encodeComponent(identity)}'), + ); + if (!current()) throw const SessionExpiredException(); + if (result['deleted'] != true) throw const ApiException(1714, '删除结果未确认,请刷新重试'); + } + + /// 修改当前账户登录密码,只有明确收到服务端成功标记才允许退出重登。 + Future changeLoginPassword(String currentPassword, String newPassword) async { + final current = _api.captureSessionGuard(); + final response = jsonMap( + await _api.put( + '$root/auth/password', + body: { + 'current_password': currentPassword, + 'new_password': newPassword, + }, + ), + ); + if (!current()) throw const SessionExpiredException(); + if (response['changed'] != true) throw const ApiException(1714, '未能确认密码修改结果'); + } + + /// 供气订单使用本人独立详情,保持与商城不同的履约状态。 + Future gasOrderDetail(String identity) async { + final current = _api.captureSessionGuard(); + final value = await _api.get('$root/gas/orders/${Uri.encodeComponent(identity)}'); + if (!current()) throw const SessionExpiredException(); + final result = GasOrderDetail.fromJson(jsonMap(value), resolveImageUrl); + if (result.identity != identity) throw const ApiException(1714, '订单资料不一致'); + return result; + } + + /// 配送详情独立读取,避免从列表或订单页拼接员工及资质信息。 + Future gasOrderDelivery(String identity) async { + final current = _api.captureSessionGuard(); + final value = await _api.get( + '$root/gas/orders/${Uri.encodeComponent(identity)}/delivery', + ); + if (!current()) throw const SessionExpiredException(); + final result = DeliveryDetail.fromJson(jsonMap(value)); + if (result.identity != identity) throw const ApiException(1714, '配送资料不一致'); + return result; + } + + /// 读取本人订单的隐私化配送轨迹;精确位置由服务端约化后再下发。 + Future gasOrderDeliveryTrack(String identity) async { + final current = _api.captureSessionGuard(); + final value = await _api.get( + '$root/gas/orders/${Uri.encodeComponent(identity)}/delivery/track', + ); + if (!current()) throw const SessionExpiredException(); + final result = DeliveryTrack.fromJson(jsonMap(value)); + if (result.identity != identity) throw const ApiException(1714, '配送轨迹资料不一致'); + return result; + } + + Future gasContractDetail(String identity) async { + final current = _api.captureSessionGuard(); + final value = await _api.get('$root/gas/contracts/${Uri.encodeComponent(identity)}'); + if (!current()) throw const SessionExpiredException(); + final result = GasContractDetail.fromJson(jsonMap(value)); + if (result.identity != identity) throw const ApiException(1714, '合同资料不一致'); + return result; + } + + /// 只下载本人受保护PDF;校验文件头及大小,JSON错误页不能保存成合同。 + Future gasContractPdf(String identity) async { + final current = _api.captureSessionGuard(); + final bytes = await _api.getBytes( + '$root/gas/contracts/${Uri.encodeComponent(identity)}/attachment', + accept: 'application/pdf', + failureMessage: '合同附件下载失败', + ); + if (!current()) throw const SessionExpiredException(); + if (bytes == null) throw const ApiException(404, '合同附件暂不可用,请联系供气单位补充'); + if (bytes.length < 5 || + bytes.length > 10 * 1024 * 1024 || + String.fromCharCodes(bytes.take(5)) != '%PDF-') { + throw const ApiException(1714, '合同附件格式异常'); + } + return bytes; + } + + /// 本人合同历史独立鉴权,切换会话后丢弃旧响应。 + Future> gasContractHistory(String identity) async { + final current = _api.captureSessionGuard(); + final value = await _api.get('$root/gas/contracts/${Uri.encodeComponent(identity)}/history'); + if (!current()) throw const SessionExpiredException(); + return List.unmodifiable((value as List).map((row) => GasContractEvent.fromJson(jsonMap(row)))); + } + + /// 申请进入本人合同专属受理队列,返回是否复用了已有进行中申请。 + Future requestContractChange(String identity, String description, String requestNo) async { + final current = _api.captureSessionGuard(); + final value = jsonMap( + await _api.post( + '$root/gas/contracts/${Uri.encodeComponent(identity)}/requests', + body: {'description': description, 'request_no': requestNo}, + ), + ); + if (!current()) throw const SessionExpiredException(); + return value['existing'] as bool; + } + + Future> contractChangeRequests(String identity) async { + final current = _api.captureSessionGuard(); + final value = await _api.get('$root/gas/contracts/${Uri.encodeComponent(identity)}/requests'); + if (!current()) throw const SessionExpiredException(); + return List.unmodifiable( + (value as List).map((row) => ContractChangeRequest.fromJson(jsonMap(row))), + ); + } + + Future confirmGasReceipt(String identity, {required String requestNo}) async { + final current = _api.captureSessionGuard(); + await _api.post( + '$root/gas/orders/${Uri.encodeComponent(identity)}/confirm-receipt', + body: {'request_no': requestNo}, + ); + if (!current()) throw const SessionExpiredException(); + } + + /// 本人订单详情独立读取,深链打开不依赖旧列表缓存。 + Future shopOrderDetail(String identity) async { + final current = _api.captureSessionGuard(); + final value = await _api.get('$root/shop/orders/${Uri.encodeComponent(identity)}'); + if (!current()) throw const SessionExpiredException(); + final result = ShopOrderDetail.fromJson(jsonMap(value), resolveImageUrl); + if (result.identity != identity) throw const ApiException(1714, '订单资料不一致'); + return result; + } + + /// 推荐数据按账户场景分页,不发送本人购物车明细或账户标识到查询参数。 + Future recommendations({required String source, int page = 1}) async { + final current = _api.captureSessionGuard(); + final result = ProductRecommendations.fromJson( + jsonMap( + await _api.get( + '$root/shop/recommendations?source=${Uri.encodeComponent(source)}&page=$page&page_size=2', + ), + ), + resolveImageUrl, + ); + if (!current()) throw const SessionExpiredException(); + if (result.page != page) throw const ApiException(1714, '推荐分页不一致'); + return result; + } + + final _favoriteChanges = StreamController.broadcast(sync: true); + + /// 同一会话内各入口同步服务端已确认状态,事件流不缓存账号数据。 + Stream get favoriteChanges => _favoriteChanges.stream; + Future> favorites() async { + final current = _api.captureSessionGuard(); + final values = await _readPages('$root/shop/favorites'); + if (!current()) throw const SessionExpiredException(); + return values.map((value) => ProductFavorite.fromJson(value, resolveImageUrl)).toList(); + } + + Future favoriteState(String identity) async { + final current = _api.captureSessionGuard(); + final value = await _api.get('$root/shop/favorites/items/${Uri.encodeComponent(identity)}'); + if (!current()) throw const SessionExpiredException(); + return ProductFavoriteState.fromJson(jsonMap(value)); + } + + Future setFavorite(ProductFavoriteState state, bool active) async { + final current = _api.captureSessionGuard(); + final result = ProductFavoriteState.fromJson( + jsonMap( + await _api.put( + '$root/shop/favorites/items/${Uri.encodeComponent(state.productIdentity)}', + body: {'favorite': active, 'revision': state.revision}, + ), + ), + ); + if (!current()) throw const SessionExpiredException(); + _favoriteChanges.add(result); + return result; + } + + /// 购物车读取与条件写入,网络重试必须复用原快照和绝对数量。 + Future> cart() async { + final current = _api.captureSessionGuard(); + final values = jsonList(await _api.get('$root/shop/cart')); + if (!current()) throw const SessionExpiredException(); + return values.map((value) => CartItem.fromJson(value, resolveImageUrl)).toList(); + } + + Future cartItem(String identity) async { + final current = _api.captureSessionGuard(); + final value = await _api.get('$root/shop/cart/items/${Uri.encodeComponent(identity)}'); + if (!current()) throw const SessionExpiredException(); + return CartItem.fromJson(jsonMap(value), resolveImageUrl); + } + + Future setCartItem( + CartItem item, { + required int quantity, + required bool selected, + }) async { + final current = _api.captureSessionGuard(); + final value = await _api.put( + '$root/shop/cart/items/${Uri.encodeComponent(item.product.identity)}', + body: {'quantity': quantity, 'selected': selected, 'revision': item.revision}, + ); + if (!current()) throw const SessionExpiredException(); + return CartItem.fromJson(jsonMap(value), resolveImageUrl); + } + + /// 多商品订单在服务端事务中校验并消费指定购物车版本。 + Future> submitCartOrder({ + required String requestNo, + required List items, + required ShippingAddress address, + required int expectedAmount, + String remark = '', + }) async => jsonMap( + await _api.post( + '$root/shop/orders', + body: { + 'request_no': requestNo, + 'address_identity': address.identity, + 'contact_name': address.contactName, + 'contact_phone': address.contactPhone, + 'remark': remark.trim(), + 'expected_payable_amount': expectedAmount, + 'items': items + .map( + (item) => { + 'product_identity': item.product.identity, + 'quantity': item.quantity, + 'cart_revision': item.revision, + }, + ) + .toList(), + }, + ), + ); + + /// 后台图片既支持绝对 URL,也支持同一 API 服务下的绝对路径。 + String resolveImageUrl(String value) { + if (value.isEmpty) return ''; + final uri = Uri.tryParse(value); + if (uri == null) return ''; + if (uri.scheme == 'http' || uri.scheme == 'https') return value; + if (value.startsWith('/') && !value.startsWith('//')) { + return Uri.parse(_api.baseUrl).resolve(value).toString(); + } + return ''; + } + Future> contents() async { final values = jsonList(await _api.get('$root/public/contents', authenticated: false)); return values @@ -23,8 +517,24 @@ class ClientRepository { .toList(); } + /// 单篇安全内容按标识读取,发布状态由服务端实时校验。 + Future safetyContent(String identity) async { + final value = jsonMap( + await _api.get( + '$root/public/contents/${Uri.encodeComponent(identity)}', + authenticated: false, + ), + ); + return ClientRecord( + identity: value['identity'] as String, + title: value['title'] as String, + subtitle: '', + raw: value, + ); + } + Future> products() async { - final values = jsonList(await _api.get('$root/public/products', authenticated: false)); + final values = await _readPages('$root/public/products', authenticated: false); return values .map( (value) => ClientRecord( @@ -38,14 +548,223 @@ class ClientRepository { .toList(); } + /// 独立公开详情支持游客和售罄商品,关联参数由服务端按商品归属过滤。 + Future productDetail(String identity) async { + try { + return ProductDetail.fromJson( + jsonMap( + await _api.get( + '$root/public/products/${Uri.encodeComponent(identity)}', + authenticated: false, + ), + ), + resolveImageUrl, + ); + } on ApiException catch (error) { + if (error.code == 1112) { + throw const ApiException(1112, '商品已下架或不存在'); + } + rethrow; + } + } + Future profile() async => UserProfile.fromJson(jsonMap(await _api.get('$root/auth/profile'))); /// 通过用户端鉴权接口读取当前登录用户的头像二进制内容。 Future profileAvatar() => _api.getBytes('$root/auth/avatar'); - Future wallet() async => - WalletSummary.fromJson(jsonMap(await _api.get('$root/wallet'))); + /// 上传返回受控 URI;只在保存资料成功后才更换账户头像。 + Future uploadAvatar(Uint8List bytes, String filename) async { + final response = jsonMap(await _api.uploadAvatar(bytes, filename)); + final uri = response['uri']; + if (uri is! String || !uri.startsWith('/uploads/avatars/')) { + throw const ApiException(500, '头像上传结果异常,请重试'); + } + return uri; + } + + /// 保存昵称及可选头像,省略头像时保留服务端原图。 + Future updateProfile(String name, {String? avatar}) async { + await _api.put( + '$root/auth/profile', + body: {'name': name.trim(), 'avatar': ?avatar}, + ); + } + + Future wallet() async { + final current = _api.captureSessionGuard(); + final response = await _api.get('$root/wallet'); + if (!current()) throw const SessionExpiredException(); + final data = jsonMap(response); + final balance = data['balance'], available = data['withdrawal_balance']; + if (balance is! int || + available is! int || + balance < 0 || + available < 0 || + available > balance) { + throw const ApiException(1714, '钱包金额数据异常'); + } + return WalletSummary.fromJson(data); + } + + /// 读取本人银行卡,响应不得包含完整卡号、证件号或预留手机号。 + Future> walletBanks() async { + final guard = _api.captureSessionGuard(); + final data = jsonList(await _api.get('$root/wallet/banks')); + if (!guard()) throw const SessionExpiredException(); + try { + return List.unmodifiable(data.map(WalletBank.fromJson)); + } on FormatException { + throw const ApiException(1714, '银行卡数据不完整'); + } + } + + /// 绑定银行卡只接受当前用户主动输入的敏感字段,服务端负责加密保存。 + Future bindWalletBank({ + required String cardNo, + required String bankName, + required String cardOwner, + required String idCard, + required String phone, + required String paymentPassword, + String bankType = 'debit', + }) async { + final guard = _api.captureSessionGuard(); + final result = jsonMap( + await _api.post( + '$root/wallet/banks', + body: { + 'card_no': cardNo.replaceAll(' ', ''), + 'bank_name': bankName.trim(), + 'card_owner': cardOwner.trim(), + 'id_card': idCard.trim(), + 'phone': phone.trim(), + 'bank_type': bankType, + 'payment_password': paymentPassword, + }, + ), + ); + if (!guard()) throw const SessionExpiredException(); + if ((result['identity'] as String? ?? '').isEmpty || + (result['card_no_masked'] as String? ?? '').isEmpty) { + throw const ApiException(1714, '银行卡绑定结果未确认'); + } + } + + /// 解绑要求支付密码,只有服务端明确归档后才更新页面。 + Future unbindWalletBank(String identity, String paymentPassword) async { + final guard = _api.captureSessionGuard(); + final result = jsonMap( + await _api.delete( + '$root/wallet/banks/${Uri.encodeComponent(identity)}', + body: {'payment_password': paymentPassword}, + ), + ); + if (!guard()) throw const SessionExpiredException(); + if (result['archived'] != true) throw const ApiException(1714, '银行卡解绑结果未确认'); + } + + /// 默认到账卡由服务端原子切换,页面不在本地推测默认状态。 + Future setDefaultWalletBank(String identity) async { + final guard = _api.captureSessionGuard(); + final result = jsonMap( + await _api.post('$root/wallet/banks/${Uri.encodeComponent(identity)}/default'), + ); + if (!guard()) throw const SessionExpiredException(); + if (result['changed'] != true) throw const ApiException(1714, '默认银行卡设置结果未确认'); + } + + Future> walletWithdrawals() async { + final guard = _api.captureSessionGuard(); + final data = jsonList(await _api.get('$root/wallet/withdrawals')); + if (!guard()) throw const SessionExpiredException(); + try { + return List.unmodifiable(data.map(WalletWithdrawal.fromJson)); + } on FormatException { + throw const ApiException(1714, '提现记录数据不完整'); + } + } + + /// 提现创建复用调用方请求号;返回待审核不等于资金已经到账。 + Future createWalletWithdrawal({ + required String bankIdentity, + required int amount, + required String requestNo, + required String paymentPassword, + }) async { + final guard = _api.captureSessionGuard(); + final data = jsonMap( + await _api.post( + '$root/wallet/withdrawals', + body: { + 'bank_identity': bankIdentity, + 'amount': amount, + 'request_no': requestNo, + 'payment_password': paymentPassword, + }, + ), + ); + if (!guard()) throw const SessionExpiredException(); + try { + return WalletWithdrawal.fromJson(data); + } on FormatException { + throw const ApiException(1714, '提现申请结果未确认'); + } + } + + /// 验证码手机号只取当前账户资料;不接受页面传入任意收件手机号。 + Future requestPaymentPasswordCode({required bool resetting}) async { + final current = _api.captureSessionGuard(); + final account = await profile(); + if (!current()) throw const SessionExpiredException(); + final response = jsonMap( + await _api.post( + '$root/auth/verification-code', + authenticated: false, + body: { + 'phone': account.phone, + 'purpose': resetting ? 'reset_payment_password' : 'set_payment_password', + }, + ), + ); + if (!current()) throw const SessionExpiredException(); + final identity = response['request_identity']; + if (identity is! String || identity.isEmpty) throw const ApiException(1714, '验证码请求结果未确认'); + return PaymentCodeRequest( + identity: identity, + delivered: response['delivery_status'] == 'sent', + maskedPhone: account.phone.replaceFirstMapped( + RegExp(r'^(\d{3})\d{4}(\d{4})$'), + (m) => '${m[1]}****${m[2]}', + ), + retryAfter: ((response['retry_after'] as num?)?.toInt() ?? 60).clamp(1, 3600), + ); + } + + /// 支付密码设置与修改只接受明确的服务端确认,不改变钱包金额。 + Future setPaymentPassword({ + required String newPassword, + String? currentPassword, + String? requestIdentity, + String? code, + }) async { + final current = _api.captureSessionGuard(); + final response = jsonMap( + await _api.put( + '$root/wallet/payment-password', + body: { + 'new_password': newPassword, + 'current_password': ?currentPassword, + 'request_identity': ?requestIdentity, + 'code': ?code, + }, + ), + ); + if (!current()) throw const SessionExpiredException(); + if (response['changed'] != true) throw const ApiException(1714, '未能确认支付密码修改结果'); + return response['lock_cleared'] == true; + } Future> addresses() => _records('$root/addresses', titleKeys: const ['address'], subtitleKeys: const ['is_default']); @@ -64,6 +783,35 @@ class ClientRepository { statusKey: 'order_status', ); + /// 气瓶规格、合同价格、押金和预约时段全部读取服务端报价。 + Future gasOrderOptions() async { + final guard = _api.captureSessionGuard(); + final value = await _api.get('$root/gas/order-options'); + if (!guard()) throw const SessionExpiredException(); + return GasOrderCheckoutData.fromJson(jsonMap(value)); + } + + /// 创建待支付供气订单;服务端会重新计算价格并锁定实体气瓶。 + Future createGasOrder({ + required String requestNo, + required String addressIdentity, + required List itemIdentities, + required DateTime appointmentAt, + required int expectedAmount, + }) async { + final value = await _api.post( + '$root/gas/orders', + body: { + 'request_no': requestNo, + 'address_identity': addressIdentity, + 'item_identities': itemIdentities, + 'appointment_at': appointmentAt.toUtc().toIso8601String(), + 'expected_payable_amount': expectedAmount, + }, + ); + return GasOrderCreateResult.fromJson(jsonMap(value)); + } + Future> contracts() => _records( '$root/gas/contracts', titleKeys: const ['contract_no'], @@ -71,19 +819,197 @@ class ClientRepository { statusKey: 'contract_status', ); - Future> tickets() => _records( + /// 查询本人合同,迟到的旧会话响应不得显示给新登录账户。 + Future> gasContracts() async { + final guard = _api.captureSessionGuard(); + final value = await _api.get('$root/gas/contracts'); + if (!guard()) throw const SessionExpiredException(); + return List.unmodifiable( + (value as List).map((row) => GasContractSummary.fromJson(jsonMap(row))), + ); + } + + Future> tickets() async => (await _records( '$root/tickets', titleKeys: const ['ticket_no'], subtitleKeys: const ['description', 'category'], statusKey: 'ticket_status', + )).where((ticket) => ticket.raw['category'] != 'contract_change').toList(); + + /// 兼容现有本人列表接口,重新查询详情,避免使用列表旧快照执行动作。 + Future ticket(String identity) async { + final records = await tickets(); + for (final record in records) { + if (record.identity == identity) return record; + } + throw const ApiException(1704, '工单不存在或已不可访问'); + } + + /// 服务端再次检查归属、状态与重复操作;客户端不能直接修改状态字段。 + Future cancelTicket(String identity) async { + await _api.post('$root/tickets/${Uri.encodeComponent(identity)}/cancel'); + } + + Future confirmTicket(String identity) async { + await _api.post('$root/tickets/${Uri.encodeComponent(identity)}/confirm'); + } + + /// 报修照片与头像使用不同目录和资源权限;只接受服务端返回的受控URI。 + Future uploadTicketPhoto(Uint8List bytes, String filename) async { + final result = jsonMap(await _api.uploadImage('$root/ticket-photos', bytes, filename)); + final uri = result['uri']; + if (uri is! String || !uri.startsWith('/uploads/ticket-photos/')) { + throw const ApiException(500, '照片上传结果异常'); + } + return uri; + } + + Future ticketPhoto(String ticketIdentity, String photoIdentity) => _api.getBytes( + '$root/tickets/${Uri.encodeComponent(ticketIdentity)}/photos/${Uri.encodeComponent(photoIdentity)}', + failureMessage: '照片加载失败', ); + /// 从当前鉴权账户获取草稿命名空间,不能使用输入手机号推测所有者。 + Future repairDraftOwner() async { + final identity = (await profile()).identity; + if (identity.isEmpty) throw const ApiException(500, '无法识别草稿所属账户'); + return '${_api.baseUrl}#$identity'; + } + + /// 充值恢复记录使用服务端确认的账号及API环境进行隔离。 + Future rechargeDraftOwner() async { + final guard = _api.captureSessionGuard(); + final identity = (await profile()).identity; + if (!guard()) throw const SessionExpiredException(); + if (identity.isEmpty) throw const ApiException(1714, '无法识别充值账户'); + return '${_api.baseUrl}#$identity'; + } + + /// 恢复尚未提交的本人照片;服务端只从本账户目录解析文件名。 + Future uploadedTicketPhoto(String uri) { + if (!uri.startsWith('/uploads/ticket-photos/')) throw const ApiException(1704, '草稿照片标识无效'); + return _api.getBytes( + '$root/ticket-photos/${Uri.encodeComponent(uri.split('/').last)}', + failureMessage: '草稿照片加载失败', + ); + } + + /// 本人账单独立分页,账户切换后丢弃迟到结果。 + Future walletBills({String direction = '', String cursor = ''}) async { + final guard = _api.captureSessionGuard(); + final query = Uri( + queryParameters: { + if (direction.isNotEmpty) 'direction': direction, + if (cursor.isNotEmpty) 'cursor': cursor, + }, + ).query; + final response = jsonMap(await _api.get('$root/wallet/bills${query.isEmpty ? '' : '?$query'}')); + if (!guard()) throw const SessionExpiredException(); + final items = response['items']; + if (items is! List || response['next_cursor'] is! String) { + throw const ApiException(1714, '账单数据不完整'); + } + return WalletBillPage( + items.map((item) => WalletBill.fromJson(jsonMap(item))).toList(), + response['next_cursor'] as String, + ); + } + Future> walletRecords() => _records( '$root/wallet/records', titleKeys: const ['trade_type', 'record_no'], subtitleKeys: const ['amount', 'direction'], ); + /// 充值读取、创建和查询均拒绝账户切换后的迟到结果。 + Future rechargeOptions() async { + final guard = _api.captureSessionGuard(); + final data = await _api.get('$root/wallet/recharge-options'); + if (!guard()) throw const SessionExpiredException(); + return RechargeOptions.fromJson(jsonMap(data)); + } + + /// 记录明确勾选的协议版本,确认不完整时不能继续充值。 + Future confirmRechargeAgreement(RechargeAgreement agreement) async { + final guard = _api.captureSessionGuard(); + final account = await profile(); + if (!guard()) throw const SessionExpiredException(); + final data = jsonMap( + await _api.post( + '$root/contents/read-confirmations', + body: { + 'content_identity': agreement.identity, + 'content_version': agreement.version, + 'client_version': 'user_app', + 'request_no': 'consent:${account.identity}:${agreement.identity}:${agreement.version}', + }, + ), + ); + if (!guard()) throw const SessionExpiredException(); + if (data['content_version'] != agreement.version || + (data['identity'] as String? ?? '').isEmpty) { + throw const ApiException(1714, '充值协议确认未完成'); + } + } + + Future rechargeRecords({String cursor = ''}) async { + final guard = _api.captureSessionGuard(); + final data = jsonMap( + await _api.get( + '$root/wallet/recharges${cursor.isEmpty ? '' : '?cursor=${Uri.encodeQueryComponent(cursor)}'}', + ), + ); + if (!guard()) throw const SessionExpiredException(); + if (data['items'] is! List || data['next_cursor'] is! String) { + throw const ApiException(1714, '充值记录不完整'); + } + return RechargeRecordPage([ + for (final item in data['items'] as List) RechargeRecord.fromJson(jsonMap(item)), + ], data['next_cursor'] as String); + } + + Future rechargeResult(String request) async { + final guard = _api.captureSessionGuard(); + final data = await _api.get('$root/wallet/recharge-requests/${Uri.encodeComponent(request)}'); + if (!guard()) throw const SessionExpiredException(); + return RechargeRecord.fromJson(jsonMap(data)); + } + + Future rechargeDetail(String identity) async { + final guard = _api.captureSessionGuard(); + final data = await _api.get('$root/wallet/recharges/${Uri.encodeComponent(identity)}'); + if (!guard()) throw const SessionExpiredException(); + return RechargeRecord.fromJson(jsonMap(data)); + } + + Future> createRecharge({ + required String request, + required int amount, + required String channel, + required String payType, + }) async { + final guard = _api.captureSessionGuard(); + final data = jsonMap( + await _api.post( + '$root/wallet/recharges', + body: { + 'request_no': request, + 'amount': amount, + 'channel': channel, + 'pay_type': payType, + }, + ), + ); + if (!guard()) throw const SessionExpiredException(); + if (data['amount'] != amount || + data['channel'] != channel || + data['pay_type'] != payType || + (data['recharge_identity'] as String? ?? '').isEmpty) { + throw const ApiException(1714, '充值订单结果未确认'); + } + return data; + } + Future> refunds() => _records( '$root/refunds', titleKeys: const ['refund_no'], @@ -91,6 +1017,20 @@ class ClientRepository { statusKey: 'refund_status', ); + /// 商城状态操作只使用公开订单标识,服务端校验归属和当前状态。 + Future cancelShopOrder(String identity) async { + await _api.post('$root/shop/orders/${Uri.encodeComponent(identity)}/cancel'); + } + + /// 气瓶订单取消使用独立路由,服务端重新校验本人归属和可取消状态。 + Future cancelGasOrder(String identity) async { + await _api.post('$root/gas/orders/${Uri.encodeComponent(identity)}/cancel'); + } + + Future confirmShopReceipt(String identity) async { + await _api.post('$root/shop/orders/${Uri.encodeComponent(identity)}/confirm-receipt'); + } + Future> payOrder({ required String business, required String identity, @@ -98,17 +1038,30 @@ class ClientRepository { required String channel, required String payType, String openid = '', - }) async => jsonMap( - await _api.post( - '$root/$business/orders/$identity/pay', - body: { - 'request_no': requestNo, - 'channel': channel, - 'pay_type': payType, - if (openid.isNotEmpty) 'openid': openid, - }, - ), - ); + String paymentPassword = '', + }) async { + if (!const {'shop', 'gas'}.contains(business)) { + throw const ApiException(1714, '订单类型不支持支付'); + } + final guard = _api.captureSessionGuard(); + final data = jsonMap( + await _api.post( + '$root/$business/orders/${Uri.encodeComponent(identity)}/pay', + body: { + 'request_no': requestNo, + 'channel': channel, + 'pay_type': payType, + if (openid.isNotEmpty) 'openid': openid, + if (paymentPassword.isNotEmpty) 'payment_password': paymentPassword, + }, + ), + ); + if (!guard()) throw const SessionExpiredException(); + if (channel == 'wallet' && data['paid'] != true) { + throw const ApiException(1714, '余额支付结果未确认'); + } + return data; + } Future createRefund({ required String business, @@ -137,6 +1090,30 @@ class ClientRepository { await _api.post('$root/addresses', body: {'address': address, 'is_default': isDefault}); } + /// 独立收货联系人用于编辑及下单,不再将所有地址绑定到账号姓名。 + Future> shippingAddresses() async => + jsonList(await _api.get('$root/addresses')).map(ShippingAddress.fromJson).toList(); + + /// 新增使用稳定幂等号;更新沿用地址公开标识。 + Future saveShippingAddress(ShippingAddress address, {required String requestNo}) async { + if (address.identity.isEmpty) { + await _api.post('$root/addresses', body: {...address.toJson(), 'request_no': requestNo}); + } else { + await _api.put( + '$root/addresses/${Uri.encodeComponent(address.identity)}', + body: address.toJson(), + ); + } + } + + Future setDefaultAddress(String identity) async { + await _api.post('$root/addresses/${Uri.encodeComponent(identity)}/default'); + } + + Future deleteAddress(String identity) async { + await _api.delete('$root/addresses/${Uri.encodeComponent(identity)}'); + } + Future createTicket({ required String requestNo, required String category, @@ -148,6 +1125,34 @@ class ClientRepository { ); } + /// 提交确认页使用稳定请求号,地址联系人由服务端从本人地址生成快照。 + Future submitRepair({ + required String requestNo, + required String description, + required String faultType, + required String addressIdentity, + DateTime? appointment, + List> photos = const [], + }) async { + final result = jsonMap( + await _api.post( + '$root/tickets', + body: { + 'request_no': requestNo, + 'category': 'repair', + 'description': description, + 'fault_type': faultType, + 'address_identity': addressIdentity, + 'photos': photos, + if (appointment != null) 'appointment_at': appointment.toUtc().toIso8601String(), + }, + ), + ); + final identity = result['identity']; + if (identity is! String || identity.isEmpty) throw const ApiException(500, '提交结果待确认,请重试'); + return identity; + } + Future createShopOrder({ required String requestNo, required String productIdentity, @@ -169,13 +1174,38 @@ class ClientRepository { ); } + /// 提交已确认的商品数量和金额,服务端仍按数据库价格计算并校验报价。 + Future> submitShopOrder({ + required String requestNo, + required String productIdentity, + required ShippingAddress address, + required int quantity, + required int expectedAmount, + String remark = '', + }) async => jsonMap( + await _api.post( + '$root/shop/orders', + body: { + 'request_no': requestNo, + 'address_identity': address.identity, + 'contact_name': address.contactName, + 'contact_phone': address.contactPhone, + 'remark': remark.trim(), + 'expected_payable_amount': expectedAmount, + 'items': [ + {'product_identity': productIdentity, 'quantity': quantity}, + ], + }, + ), + ); + Future> _records( String path, { required List titleKeys, required List subtitleKeys, String? statusKey, }) async { - final values = jsonList(await _api.get(path)); + final values = await _readPages(path); return values.map((value) { String pick(List keys) { for (final key in keys) { @@ -194,4 +1224,32 @@ class ClientRepository { ); }).toList(); } + + /// 消息正文来自本人订单、工单或已发布公告,已读状态由服务端保存。 + Future messages() async => MessageCenterData.fromJson( + jsonMap(await _api.get('$root/messages')), + ); + + Future markMessagesRead(List keys) async { + if (keys.isEmpty) return; + await _api.put('$root/messages/read', body: {'keys': keys}); + } + + /// 按页读取服务端列表;兼容旧版直接返回数组,错误不得降级成空成功。 + Future>> _readPages(String path, {bool authenticated = true}) async { + final records = >[]; + for (var page = 1; page <= 100000; page++) { + final data = await _api.get('$path?page=$page&page_size=50', authenticated: authenticated); + if (data is List) return jsonList(data); + final envelope = jsonMap(data); + if (envelope['items'] is! List || envelope['has_more'] is! bool) { + throw const ApiException(1714, '列表数据格式错误'); + } + final items = jsonList(envelope['items']); + records.addAll(items); + if (envelope['has_more'] == false) return records; + if (items.isEmpty) throw const ApiException(1714, '分页数据异常'); + } + throw const ApiException(1714, '列表超过可读取范围,请缩小筛选条件'); + } } diff --git a/apps/user_app/lib/data/repositories/primary_repository.dart b/apps/user_app/lib/data/repositories/primary_repository.dart new file mode 100644 index 0000000..effe08d --- /dev/null +++ b/apps/user_app/lib/data/repositories/primary_repository.dart @@ -0,0 +1,54 @@ +// 功能描述:将兼容仓储响应转换为一级页面领域模型,隔离通用记录。 +// 版本:1.0.0 +import '../../domain/models/primary_models.dart'; +import '../services/api_client.dart'; +import 'client_repository.dart'; + +/// 首页与商城的兼容迁移适配器;旧 Repository 接口继续保留。 +class PrimaryRepository { + const PrimaryRepository(this.client); + final ClientRepository client; + + /// 返回已发布内容;旧接口缺少正文时保留空值,不拼造内容。 + Future> contents() async => (await client.contents()) + .map( + (r) => PublishedContent( + identity: r.identity, + title: r.title, + body: r.raw['body'] as String? ?? '', + type: r.raw['content_type'] as String? ?? '', + version: r.raw['version_no'] as int? ?? 1, + publishedAt: DateTime.tryParse(r.raw['created_at'] as String? ?? ''), + ), + ) + .toList(growable: false); + + /// 返回服务归属,空响应表示尚未建立服务关系。 + Future relation() async { + final data = await client.serviceRelation(); + if (data == null) return null; + return ServiceRelation( + gasName: data['gas_name'] as String? ?? '', + deliveryName: data['delivery_name'] as String? ?? '', + ); + } + + /// 严格检查金额、库存类型,避免把畸形价格显示为零元。 + Future> products() async => (await client.products()) + .map((r) { + final price = r.raw['price_amount']; + final stock = r.raw['stock_quantity']; + if (price is! int || price < 0 || stock is! int || stock < 0) { + throw const ApiException(1714, '商品数据异常,请刷新后重试'); + } + return ProductSummary( + identity: r.identity, + name: r.title, + price: price, + stock: stock, + category: r.raw['category_name'] as String? ?? '', + imageUrl: client.resolveImageUrl(r.raw['image_url'] as String? ?? ''), + ); + }) + .toList(growable: false); +} diff --git a/apps/user_app/lib/data/services/api_client.dart b/apps/user_app/lib/data/services/api_client.dart index b4e1a03..7b664f0 100644 --- a/apps/user_app/lib/data/services/api_client.dart +++ b/apps/user_app/lib/data/services/api_client.dart @@ -57,6 +57,10 @@ const Map _apiErrorMessages = { 1713: '服务暂时不可用,请稍后重试', 1714: '服务数据异常,请稍后重试', 1715: '请先登录', + 2410: '短信验证码暂未开放', + 2511: '最多可创建20个设备分组', + 2512: '该分组名称已存在', + 2513: '该新增请求已处理,请关闭表单并刷新分组列表', }; final RegExp _chineseCharacterPattern = RegExp(r'[\u3400-\u9fff]'); @@ -112,13 +116,23 @@ class ApiClient { final http.Client _client; final UnauthorizedCallback? onUnauthorized; + /// 捕获当前会话而不暴露令牌,供跨页面事件拒绝前一个账号的迟到响应。 + bool Function() captureSessionGuard() { + final token = _tokenProvider(); + return () => token == _tokenProvider(); + } + Future get(String path, {bool authenticated = true}) => _send('GET', path, authenticated: authenticated); /// 读取需要鉴权的二进制资源;资源不存在时返回空值。 - Future getBytes(String path) async { + Future getBytes( + String path, { + String failureMessage = '头像加载失败', + String accept = 'image/jpeg, image/png', + }) async { final request = http.Request('GET', Uri.parse('$baseUrl$path')); - request.headers['accept'] = 'image/jpeg, image/png'; + request.headers['accept'] = accept; final token = _tokenProvider(); if (token.isNotEmpty) request.headers['authorization'] = token; final response = await _sendRequest(request); @@ -131,7 +145,7 @@ class ApiClient { } if (response.statusCode == 404) return null; if (response.statusCode < 200 || response.statusCode >= 300) { - throw ApiException(response.statusCode, '头像加载失败'); + throw ApiException(response.statusCode, failureMessage); } return response.bodyBytes.isEmpty ? null : response.bodyBytes; } @@ -144,6 +158,24 @@ class ApiClient { Future put(String path, {Map? body}) => _send('PUT', path, body: body); + /// 上传头像二进制,沿用统一鉴权、错误解析和会话失效处理。 + Future uploadAvatar(Uint8List bytes, String filename) async { + return uploadImage('/upload/avatar', bytes, filename); + } + + /// 仅由仓储传入固定受控端点,沿用统一鉴权与图片体积约束。 + Future uploadImage(String path, Uint8List bytes, String filename) async { + if (bytes.isEmpty || bytes.length > 2 * 1024 * 1024) { + throw const ApiException(422, '请选择不超过 2MB 的 JPG 或 PNG 图片'); + } + final request = http.MultipartRequest('POST', Uri.parse('$baseUrl$path')); + final token = _tokenProvider(); + request.headers['accept'] = 'application/json'; + if (token.isNotEmpty) request.headers['authorization'] = token; + request.files.add(http.MultipartFile.fromBytes('file', bytes, filename: filename)); + return _decode(await _sendRequest(request), authenticated: true, requestToken: token); + } + Future delete(String path, {Map? body}) => _send('DELETE', path, body: body); diff --git a/apps/user_app/lib/data/services/app_settings_service.dart b/apps/user_app/lib/data/services/app_settings_service.dart new file mode 100644 index 0000000..f986068 --- /dev/null +++ b/apps/user_app/lib/data/services/app_settings_service.dart @@ -0,0 +1,21 @@ +// 功能描述:读取真实构建版本与可恢复的图片内存缓存;版本:1.0.0。 +import 'package:flutter/painting.dart'; +import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +/// 仅管理图片缓存,不接触账户令牌、报修草稿或远程业务数据。 +class AppSettingsService { + Future version() async { + // Web显式使用页面目录,避免插件先解析相对assets路径时无法取得origin。 + final info = await PackageInfo.fromPlatform( + baseUrl: kIsWeb ? Uri.base.resolve('.').toString() : null, + ); + if (info.version.trim().isEmpty) throw StateError('构建版本缺失'); + return info.buildNumber.isEmpty ? info.version : '${info.version} (${info.buildNumber})'; + } + + int get imageCacheBytes => PaintingBinding.instance.imageCache.currentSizeBytes; + + /// 清理可重新读取的图片;已在屏幕展示的活动图片仍可继续绘制。 + void clearImageCache() => PaintingBinding.instance.imageCache.clear(); +} diff --git a/apps/user_app/lib/data/services/recharge_draft_store.dart b/apps/user_app/lib/data/services/recharge_draft_store.dart new file mode 100644 index 0000000..65e16e1 --- /dev/null +++ b/apps/user_app/lib/data/services/recharge_draft_store.dart @@ -0,0 +1,73 @@ +// 功能描述:按环境和账号保存待确认充值请求,避免重开页面重复发单;版本:1.0.0。 +import 'dart:convert'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// 仅保存恢复订单所需字段,不保存支付签名、令牌或密码。 +class PendingRecharge { + const PendingRecharge({ + required this.request, + required this.amount, + required this.channel, + required this.payType, + }); + final String request, channel, payType; + final int amount; + Map toJson() => { + 'schema': 1, + 'request': request, + 'amount': amount, + 'channel': channel, + 'pay_type': payType, + }; + factory PendingRecharge.fromJson(Map json) { + if (json['schema'] != 1 || + json['request'] is! String || + !RegExp(r'^[a-zA-Z0-9_-]{1,64}$').hasMatch(json['request'] as String) || + json['amount'] is! int || + (json['amount'] as int) <= 0 || + !['wechat', 'alipay'].contains(json['channel']) || + !['app', 'wap'].contains(json['pay_type']) || + (json['channel'] == 'wechat' && json['pay_type'] == 'wap')) { + throw const FormatException('待确认充值记录无效'); + } + return PendingRecharge( + request: json['request'] as String, + amount: json['amount'] as int, + channel: json['channel'] as String, + payType: json['pay_type'] as String, + ); + } +} + +abstract interface class RechargeDraftStore { + Future read(String owner); + Future write(String owner, PendingRecharge draft); + Future delete(String owner); +} + +/// 独立命名空间避免覆盖报修草稿;损坏记录必须报错,不能当作无待处理订单。 +class SecureRechargeDraftStore implements RechargeDraftStore { + SecureRechargeDraftStore({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + final FlutterSecureStorage _storage; + String _key(String owner) { + if (owner.trim().isEmpty) throw const FormatException('无法识别充值账户'); + return 'user_app_recharge_v1_${Uri.encodeComponent(owner)}'; + } + + @override + Future read(String owner) async { + final raw = await _storage.read(key: _key(owner)); + if (raw == null) return null; + return PendingRecharge.fromJson(Map.from(jsonDecode(raw) as Map)); + } + + @override + Future write(String owner, PendingRecharge draft) { + final valid = PendingRecharge.fromJson(draft.toJson()); + return _storage.write(key: _key(owner), value: jsonEncode(valid.toJson())); + } + + @override + Future delete(String owner) => _storage.delete(key: _key(owner)); +} diff --git a/apps/user_app/lib/data/services/recharge_flow.dart b/apps/user_app/lib/data/services/recharge_flow.dart new file mode 100644 index 0000000..130ae4a --- /dev/null +++ b/apps/user_app/lib/data/services/recharge_flow.dart @@ -0,0 +1,74 @@ +// 功能描述:充值创建前持久化及到账恢复,不将支付拉起视作到账;版本:1.0.0。 +import '../repositories/client_repository.dart'; +import '../../domain/models/recharge.dart'; +import 'recharge_draft_store.dart'; +import 'api_client.dart'; + +/// 单个页面使用一个实例;所有网络写入前确认存储所属账号。 +class RechargeFlow { + RechargeFlow(this.repository, this.store); + final ClientRepository repository; + final RechargeDraftStore store; + bool _busy = false; + + Future> create(PendingRecharge draft) async { + if (_busy) throw StateError('充值正在处理中'); + _busy = true; + try { + final owner = await repository.rechargeDraftOwner(); + final previous = await store.read(owner); + if (previous != null && + (previous.request != draft.request || + previous.amount != draft.amount || + previous.channel != draft.channel || + previous.payType != draft.payType)) { + throw StateError('请先查询待确认充值'); + } + if (previous != null) { + // 重试先读取入账事实,避免到账后再次拉起原支付参数。 + try { + final result = await repository.rechargeResult(previous.request); + if (result.amount != previous.amount || result.channel != previous.channel) { + throw StateError('充值结果与原请求不一致'); + } + if (await repository.rechargeDraftOwner() != owner) throw StateError('充值账户已切换'); + if (result.credited) return {'recharge_status': 23}; + } on ApiException catch (error) { + // 明确不存在才允许使用相同请求重发,网络异常不能推断订单不存在。 + if (error.code != 1112) rethrow; + } + } + await store.write(owner, draft); + if (await repository.rechargeDraftOwner() != owner) throw StateError('充值账户已切换'); + return await repository.createRecharge( + request: draft.request, + amount: draft.amount, + channel: draft.channel, + payType: draft.payType, + ); + } finally { + _busy = false; + } + } + + /// 未到账、查询失败或业务关联异常均保留原请求,方便再次核对。 + Future recover() async { + if (_busy) throw StateError('充值正在处理中'); + _busy = true; + try { + final owner = await repository.rechargeDraftOwner(); + final draft = await store.read(owner); + if (draft == null) return null; + if (await repository.rechargeDraftOwner() != owner) throw StateError('充值账户已切换'); + final result = await repository.rechargeResult(draft.request); + if (result.amount != draft.amount || result.channel != draft.channel) { + throw StateError('充值结果与原请求不一致'); + } + if (await repository.rechargeDraftOwner() != owner) throw StateError('充值账户已切换'); + if (result.credited) await store.delete(owner); + return result; + } finally { + _busy = false; + } + } +} diff --git a/apps/user_app/lib/data/services/repair_draft_store.dart b/apps/user_app/lib/data/services/repair_draft_store.dart new file mode 100644 index 0000000..47b3c9b --- /dev/null +++ b/apps/user_app/lib/data/services/repair_draft_store.dart @@ -0,0 +1,34 @@ +// 功能描述:按API环境和账户隔离报修草稿,照片只保存受控资源标识,不保存大块图片或令牌。 +// 版本:1.0.0。 +import 'dart:convert'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +abstract interface class RepairDraftStore { + Future?> read(String owner); + Future write(String owner, Map draft); + Future delete(String owner); +} + +/// 沿用平台安全存储,单个草稿只保存表单字段和最多三个照片URI。 +class SecureRepairDraftStore implements RepairDraftStore { + SecureRepairDraftStore({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + final FlutterSecureStorage _storage; + String _key(String owner) => 'user_app_repair_v1_${Uri.encodeComponent(owner)}'; + @override + Future?> read(String owner) async { + final raw = await _storage.read(key: _key(owner)); + if (raw == null) return null; + final value = jsonDecode(raw); + if (value is! Map || value['schema'] != 1) { + throw const FormatException('草稿版本无法读取'); + } + return Map.from(value); + } + + @override + Future write(String owner, Map draft) => + _storage.write(key: _key(owner), value: jsonEncode(draft)); + @override + Future delete(String owner) => _storage.delete(key: _key(owner)); +} diff --git a/apps/user_app/lib/data/services/repair_speech.dart b/apps/user_app/lib/data/services/repair_speech.dart new file mode 100644 index 0000000..750823b --- /dev/null +++ b/apps/user_app/lib/data/services/repair_speech.dart @@ -0,0 +1,77 @@ +// 功能描述:故障描述语音识别适配,应用只复用一个系统识别实例,不保存录音。 +// 版本:1.0.0。 +import 'package:speech_to_text/speech_to_text.dart'; + +/// 识别结果为本轮完整短句;调用方负责替换临时结果,不能逐次追加。 +abstract interface class RepairSpeech { + Future start({ + required void Function(String) onWords, + required void Function(String) onError, + required void Function() onDone, + }); + Future stop(); + Future cancel(); +} + +/// 系统回调只初始化一次,每轮重新绑定当前界面,离页后忽略迟到结果。 +class SystemRepairSpeech implements RepairSpeech { + SystemRepairSpeech._(); + static final SystemRepairSpeech instance = SystemRepairSpeech._(); + final SpeechToText _speech = SpeechToText(); + void Function(String)? _words, _error; + void Function()? _done; + int _generation = 0; + @override + Future start({ + required void Function(String) onWords, + required void Function(String) onError, + required void Function() onDone, + }) async { + final generation = ++_generation; + _words = onWords; + _error = onError; + _done = onDone; + final available = await _speech.initialize( + onError: (error) => _error?.call(error.errorMsg), + onStatus: (status) { + if (status == SpeechToText.doneStatus) _done?.call(); + }, + options: [SpeechToText.androidNoBluetooth], + ); + if (generation != _generation || !available) return false; + final locales = await _speech.locales(); + if (generation != _generation) return false; + String? locale; + for (final item in locales) { + if (item.localeId.toLowerCase().replaceAll('_', '-') == 'zh-cn') { + locale = item.localeId; + break; + } + } + await _speech.listen( + onResult: (result) { + if (generation == _generation) _words?.call(result.recognizedWords); + }, + listenOptions: SpeechListenOptions( + localeId: locale, + partialResults: true, + cancelOnError: true, + listenMode: ListenMode.dictation, + listenFor: const Duration(seconds: 45), + pauseFor: const Duration(seconds: 5), + ), + ); + return generation == _generation; + } + + @override + Future stop() => _speech.stop(); + @override + Future cancel() async { + ++_generation; + _words = null; + _error = null; + _done = null; + await _speech.cancel(); + } +} diff --git a/apps/user_app/lib/domain/models/cart_item.dart b/apps/user_app/lib/domain/models/cart_item.dart new file mode 100644 index 0000000..d12bc7c --- /dev/null +++ b/apps/user_app/lib/domain/models/cart_item.dart @@ -0,0 +1,53 @@ +// 功能描述:购物车条目、并发版本和结算快照;版本:1.0.0。 +import 'primary_models.dart'; + +/// 服务端购物车快照;revision 用于阻止过期操作覆盖其他设备的修改。 +class CartItem { + const CartItem({ + required this.product, + required this.quantity, + required this.selected, + required this.available, + required this.revision, + this.specification = '', + }); + final ProductSummary product; + final int quantity; + final bool selected, available; + final String revision; + final String specification; + bool get purchasable => available && quantity > 0 && quantity <= product.stock; + int get amount => product.price * quantity; + + factory CartItem.fromJson(Map json, String Function(String) resolve) { + final price = json['price_amount'], stock = json['stock_quantity'], quantity = json['quantity']; + if (price is! int || + price < 0 || + stock is! int || + quantity is! int || + quantity < 0 || + quantity > 999 || + json['product_identity'] is! String || + (json['product_identity'] as String).isEmpty || + json['revision'] is! String || + json['selected'] is! bool || + json['available'] is! bool) { + throw const FormatException('购物车数据异常'); + } + return CartItem( + product: ProductSummary( + identity: json['product_identity'] as String, + name: json['name'] as String? ?? '', + price: price, + stock: stock, + imageUrl: resolve(json['image_url'] as String? ?? ''), + category: json['category_name'] as String? ?? '', + ), + quantity: quantity, + selected: json['selected'] as bool, + available: json['available'] as bool, + revision: json['revision'] as String, + specification: json['specification'] as String? ?? '', + ); + } +} diff --git a/apps/user_app/lib/domain/models/client_models.dart b/apps/user_app/lib/domain/models/client_models.dart index a8cde20..b5cf406 100644 --- a/apps/user_app/lib/domain/models/client_models.dart +++ b/apps/user_app/lib/domain/models/client_models.dart @@ -20,31 +20,54 @@ class UserProfile { required this.name, required this.phone, required this.avatar, + this.realName = '', }); final String identity; final String name; final String phone; final String avatar; + final String realName; factory UserProfile.fromJson(Map json) => UserProfile( identity: json['identity'] as String? ?? '', name: json['name'] as String? ?? '', phone: json['phone'] as String? ?? '', avatar: json['avatar'] as String? ?? '', + realName: json['real_name'] as String? ?? '', ); } class WalletSummary { - const WalletSummary({required this.balance, required this.withdrawalBalance}); + const WalletSummary({ + required this.balance, + required this.withdrawalBalance, + this.paymentPasswordSet, + }); final int balance; final int withdrawalBalance; + // 缺少状态时保持未知,不能误当作未设置支付密码。 + final bool? paymentPasswordSet; factory WalletSummary.fromJson(Map json) => WalletSummary( balance: (json['balance'] as num?)?.toInt() ?? 0, withdrawalBalance: (json['withdrawal_balance'] as num?)?.toInt() ?? 0, + paymentPasswordSet: json['payment_password_set'] as bool?, ); } +/// 手机验证码申请结果,明确区分请求建立与短信已发送。 +class PaymentCodeRequest { + const PaymentCodeRequest({ + required this.identity, + required this.delivered, + required this.maskedPhone, + required this.retryAfter, + }); + final String identity, maskedPhone; + final bool delivered; + final int retryAfter; +} + String moneyText(int cents) => '¥${(cents / 100).toStringAsFixed(2)}'; diff --git a/apps/user_app/lib/domain/models/delivery_detail.dart b/apps/user_app/lib/domain/models/delivery_detail.dart new file mode 100644 index 0000000..f9252c0 --- /dev/null +++ b/apps/user_app/lib/domain/models/delivery_detail.dart @@ -0,0 +1,66 @@ +// 功能描述:用户可见的配送人员、资质、配送点及订单交付快照;版本:1.0.0。 +class DeliveryProduct { + const DeliveryProduct({required this.name, required this.quantity}); + final String name; + final int quantity; +} + +/// 只接受本人配送接口的显式事实;不可用能力保留布尔状态,不由客户端猜测。 +class DeliveryDetail { + DeliveryDetail.fromJson(Map data) + : identity = _required(data, 'identity'), + orderNo = _required(data, 'order_no'), + statusName = _required(data, 'status_name'), + statusMessage = _required(data, 'status_message'), + address = _required(data, 'address'), + contactName = _required(data, 'contact_name'), + contactPhoneMasked = _required(data, 'contact_phone_masked'), + stationName = _text(data, 'station_name'), + deliveryName = _text(data, 'delivery_name'), + deliveryAddress = _text(data, 'delivery_address'), + staffName = _text(data, 'staff_name'), + staffAvatar = _text(data, 'staff_avatar'), + credentialType = _text(data, 'credential_type'), + appointmentAt = DateTime.parse(_required(data, 'appointment_at')).toLocal(), + trackUpdatedAt = _date(data['track_updated_at']), + staffAssigned = _flag(data, 'staff_assigned'), + credentialVerified = _flag(data, 'credential_verified'), + trackAvailable = _flag(data, 'track_available'), + controlledCallAvailable = _flag(data, 'controlled_call_available'), + messageAvailable = _flag(data, 'message_available'), + vehicleConfigured = _flag(data, 'vehicle_configured'), + products = List.unmodifiable([ + for (final row in data['products'] as List) + DeliveryProduct( + name: _required(Map.from(row as Map), 'name'), + quantity: (row['quantity'] as int?) ?? 0, + ), + ]) { + if (products.any((item) => item.quantity < 1)) throw const FormatException('配送商品数量异常'); + } + + final String identity, orderNo, statusName, statusMessage, address, contactName; + final String contactPhoneMasked, stationName, deliveryName, deliveryAddress, staffName; + final String staffAvatar; + final String credentialType; + final DateTime appointmentAt; + final DateTime? trackUpdatedAt; + final bool staffAssigned, credentialVerified, trackAvailable; + final bool controlledCallAvailable, messageAvailable, vehicleConfigured; + final List products; + + static String _text(Map data, String key) => data[key] as String? ?? ''; + static String _required(Map data, String key) { + final value = _text(data, key); + if (value.trim().isEmpty) throw FormatException('配送资料不完整:$key'); + return value; + } + + static bool _flag(Map data, String key) { + if (data[key] is! bool) throw FormatException('配送状态异常:$key'); + return data[key] as bool; + } + + static DateTime? _date(Object? value) => + value == null ? null : DateTime.tryParse('$value')?.toLocal(); +} diff --git a/apps/user_app/lib/domain/models/delivery_track.dart b/apps/user_app/lib/domain/models/delivery_track.dart new file mode 100644 index 0000000..eaec5c6 --- /dev/null +++ b/apps/user_app/lib/domain/models/delivery_track.dart @@ -0,0 +1,88 @@ +// 功能描述:用户可见的隐私化配送轨迹、履约节点与人员摘要;版本:1.0.0。 +class DeliveryTrackPoint { + DeliveryTrackPoint.fromJson(Map data) + : longitude = _number(data, 'longitude'), + latitude = _number(data, 'latitude'), + occurredAt = DateTime.parse(_required(data, 'occurred_at')).toLocal(); + + final double longitude, latitude; + final DateTime occurredAt; + + static double _number(Map data, String key) { + final value = data[key]; + if (value is! num) throw FormatException('配送轨迹坐标异常:$key'); + return value.toDouble(); + } +} + +class DeliveryTrackEvent { + DeliveryTrackEvent.fromJson(Map data) + : statusCode = data['status_code'] as int? ?? 0, + title = _required(data, 'title'), + detail = _required(data, 'detail'), + occurredAt = DateTime.parse(_required(data, 'occurred_at')).toLocal(); + + final int statusCode; + final String title, detail; + final DateTime occurredAt; +} + +/// 只接受服务端明确返回的本人订单事实;客户端不推算路线、位置或送达时间。 +class DeliveryTrack { + DeliveryTrack.fromJson(Map data) + : identity = _required(data, 'identity'), + statusCode = data['status_code'] as int? ?? 0, + statusName = _required(data, 'status_name'), + statusMessage = _required(data, 'status_message'), + appointmentAt = DateTime.parse(_required(data, 'appointment_at')).toLocal(), + updatedAt = _date(data['updated_at']), + stationName = _text(data, 'station_name'), + staffName = _text(data, 'staff_name'), + staffAvatar = _text(data, 'staff_avatar'), + staffPhoneMasked = _text(data, 'staff_phone_masked'), + controlledCallAvailable = _flag(data, 'controlled_call_available'), + routeAvailable = _flag(data, 'route_available'), + destinationAvailable = _flag(data, 'destination_available'), + destinationLongitude = _optionalNumber(data['destination_longitude']), + destinationLatitude = _optionalNumber(data['destination_latitude']), + points = List.unmodifiable([ + for (final row in data['points'] as List) + DeliveryTrackPoint.fromJson(Map.from(row as Map)), + ]), + timeline = List.unmodifiable([ + for (final row in data['timeline'] as List) + DeliveryTrackEvent.fromJson(Map.from(row as Map)), + ]); + + final String identity, statusName, statusMessage, stationName, staffName; + final String staffAvatar, staffPhoneMasked; + final int statusCode; + final DateTime appointmentAt; + final DateTime? updatedAt; + final bool controlledCallAvailable, routeAvailable, destinationAvailable; + final double? destinationLongitude, destinationLatitude; + final List points; + final List timeline; + + static String _text(Map data, String key) => data[key] as String? ?? ''; + static String _required(Map data, String key) { + final value = _text(data, key); + if (value.trim().isEmpty) throw FormatException('配送轨迹资料不完整:$key'); + return value; + } + + static bool _flag(Map data, String key) { + if (data[key] is! bool) throw FormatException('配送轨迹状态异常:$key'); + return data[key] as bool; + } + + static double? _optionalNumber(Object? value) => value is num ? value.toDouble() : null; + static DateTime? _date(Object? value) => + value == null ? null : DateTime.tryParse('$value')?.toLocal(); +} + +String _required(Map data, String key) { + final value = data[key] as String? ?? ''; + if (value.trim().isEmpty) throw FormatException('配送轨迹资料不完整:$key'); + return value; +} diff --git a/apps/user_app/lib/domain/models/deposit.dart b/apps/user_app/lib/domain/models/deposit.dart new file mode 100644 index 0000000..af0bd32 --- /dev/null +++ b/apps/user_app/lib/domain/models/deposit.dart @@ -0,0 +1,111 @@ +// 功能描述:用户押金汇总与单瓶押金事实;版本:1.0.0。 + +/// 单只气瓶的押金状态,金额单位为分。 +class DepositRecord { + const DepositRecord({ + required this.identity, + required this.depositNo, + required this.status, + required this.statusName, + required this.amount, + required this.productName, + required this.productCode, + required this.paidAt, + required this.refundedAt, + required this.allowedActions, + this.returnRequestIdentity = '', + this.returnStatusName = '', + }); + + final String identity, depositNo, statusName, productName, productCode; + final String returnRequestIdentity, returnStatusName; + final int status, amount; + final DateTime paidAt; + final DateTime? refundedAt; + final List allowedActions; + + factory DepositRecord.fromJson(Map data) { + final paidAt = DateTime.tryParse(data['paid_at']?.toString() ?? ''); + if (data['identity'] is! String || + data['deposit_no'] is! String || + data['deposit_status'] is! int || + data['amount'] is! int || + (data['amount'] as int) < 0 || + paidAt == null) { + throw const FormatException('押金记录数据异常'); + } + return DepositRecord( + identity: data['identity'] as String, + depositNo: data['deposit_no'] as String, + status: data['deposit_status'] as int, + statusName: data['status_name'] as String? ?? '处理中', + amount: data['amount'] as int, + productName: data['product_name'] as String? ?? '', + productCode: data['product_code'] as String? ?? '', + paidAt: paidAt.toLocal(), + refundedAt: data['refunded_at'] == null + ? null + : DateTime.tryParse(data['refunded_at'].toString())?.toLocal(), + allowedActions: (data['allowed_actions'] as List? ?? []).whereType().toList(), + returnRequestIdentity: data['return_request_identity'] as String? ?? '', + returnStatusName: data['return_status_name'] as String? ?? '', + ); + } +} + +/// 押金页一次读取的服务端快照。 +class DepositSummary { + const DepositSummary({ + required this.refundableAmount, + required this.usingCount, + required this.items, + required this.ruleText, + }); + final int refundableAmount, usingCount; + final List items; + final String ruleText; + + factory DepositSummary.fromJson(Map data) { + if (data['refundable_amount'] is! int || + data['using_count'] is! int || + data['items'] is! List) { + throw const FormatException('押金汇总数据异常'); + } + return DepositSummary( + refundableAmount: data['refundable_amount'] as int, + usingCount: data['using_count'] as int, + items: (data['items'] as List) + .whereType>() + .map((item) => DepositRecord.fromJson(Map.from(item))) + .toList(), + ruleText: data['rule_text'] as String? ?? '', + ); + } +} + +// DepositReturnRequest 表示退瓶申请的可见状态,不暴露内部关联主键。 +class DepositReturnRequest { + const DepositReturnRequest({ + required this.identity, + required this.status, + required this.statusName, + required this.estimatedAmount, + required this.refundAmount, + }); + + final String identity, statusName; + final int status, estimatedAmount, refundAmount; + + factory DepositReturnRequest.fromJson(Map data) { + if (data['identity'] is! String || data['return_status'] is! int) { + throw const FormatException('退瓶申请数据不完整'); + } + return DepositReturnRequest( + identity: data['identity'] as String, + status: data['return_status'] as int, + statusName: data['status_name'] as String? ?? '处理中', + estimatedAmount: data['estimated_amount'] as int? ?? 0, + refundAmount: data['refund_amount'] as int? ?? 0, + ); + } +} diff --git a/apps/user_app/lib/domain/models/device_group.dart b/apps/user_app/lib/domain/models/device_group.dart new file mode 100644 index 0000000..23f8cd0 --- /dev/null +++ b/apps/user_app/lib/domain/models/device_group.dart @@ -0,0 +1,34 @@ +// 功能:解析本人设备分组及其公开设备标识;版本:1.0.0。 +import 'client_models.dart'; + +/// 设备分组只表达用户自定义归类,不代表设备在线或可控制。 +class DeviceGroup { + const DeviceGroup({ + required this.identity, + required this.name, + required this.sortNo, + required this.deviceIdentities, + }); + + final String identity; + final String name; + final int sortNo; + final Set deviceIdentities; + + factory DeviceGroup.fromJson(Map json) => DeviceGroup( + identity: json['identity'] as String? ?? '', + name: json['name'] as String? ?? '', + sortNo: (json['sort_no'] as num?)?.toInt() ?? 0, + deviceIdentities: { + for (final value in json['device_identities'] as List? ?? const []) + if (value is String && value.isNotEmpty) value, + }, + ); +} + +/// 同一次页面刷新读取的设备与分组快照,避免两次渲染状态互相错配。 +class DeviceCatalog { + const DeviceCatalog({required this.devices, required this.groups}); + final List devices; + final List groups; +} diff --git a/apps/user_app/lib/domain/models/family_sharing.dart b/apps/user_app/lib/domain/models/family_sharing.dart new file mode 100644 index 0000000..d0ce07d --- /dev/null +++ b/apps/user_app/lib/domain/models/family_sharing.dart @@ -0,0 +1,137 @@ +// 功能:家庭成员、设备及共享权限领域模型;版本:1.0.0。 + +class FamilyDevicePermission { + const FamilyDevicePermission({ + required this.deviceIdentity, + required this.deviceName, + required this.deviceKind, + required this.canView, + required this.canAlert, + required this.canControl, + }); + final String deviceIdentity, deviceName, deviceKind; + final bool canView, canAlert, canControl; + + factory FamilyDevicePermission.fromJson(Map json) => FamilyDevicePermission( + deviceIdentity: json['device_identity'] as String? ?? '', + deviceName: json['device_name'] as String? ?? '', + deviceKind: json['device_kind'] as String? ?? 'unknown', + canView: json['can_view'] == true, + canAlert: json['can_alert'] == true, + canControl: json['can_control'] == true, + ); + + Map toJson() => { + 'device_identity': deviceIdentity, + 'can_view': canView, + 'can_alert': canAlert, + 'can_control': canControl, + }; +} + +class FamilyMember { + const FamilyMember({ + required this.identity, + required this.name, + required this.phoneMasked, + required this.relationship, + required this.inviteStatus, + required this.isOwner, + required this.permissions, + this.expiresAt, + }); + final String identity, name, phoneMasked, relationship; + final int inviteStatus; + final bool isOwner; + final List permissions; + final DateTime? expiresAt; + + bool get pending => inviteStatus == 10; + factory FamilyMember.fromJson(Map json) => FamilyMember( + identity: json['identity'] as String? ?? '', + name: json['name'] as String? ?? '', + phoneMasked: json['phone_masked'] as String? ?? '', + relationship: json['relationship'] as String? ?? '', + inviteStatus: (json['invite_status'] as num?)?.toInt() ?? 10, + isOwner: json['is_owner'] == true, + permissions: (json['device_permissions'] as List? ?? const []) + .whereType>() + .map((e) => FamilyDevicePermission.fromJson(Map.from(e))) + .toList(), + expiresAt: DateTime.tryParse(json['expires_at'] as String? ?? ''), + ); +} + +class FamilyDevice { + const FamilyDevice({ + required this.identity, + required this.name, + required this.kind, + required this.shareCount, + required this.mappingConfigured, + }); + final String identity, name, kind; + final int shareCount; + final bool mappingConfigured; + factory FamilyDevice.fromJson(Map json) => FamilyDevice( + identity: json['identity'] as String? ?? '', + name: json['name'] as String? ?? '', + kind: json['kind'] as String? ?? 'unknown', + shareCount: (json['share_count'] as num?)?.toInt() ?? 0, + mappingConfigured: json['mapping_configured'] == true, + ); +} + +class FamilyDashboardData { + const FamilyDashboardData({ + required this.householdName, + required this.ownerName, + required this.memberCount, + required this.deviceCount, + required this.members, + required this.devices, + this.incomingInvitations = const [], + }); + final String householdName, ownerName; + final int memberCount, deviceCount; + final List members; + final List devices; + final List incomingInvitations; + factory FamilyDashboardData.fromJson(Map json) => FamilyDashboardData( + householdName: json['household_name'] as String? ?? '我的家庭', + ownerName: json['owner_name'] as String? ?? '', + memberCount: (json['member_count'] as num?)?.toInt() ?? 0, + deviceCount: (json['device_count'] as num?)?.toInt() ?? 0, + members: (json['members'] as List? ?? const []) + .whereType>() + .map((e) => FamilyMember.fromJson(Map.from(e))) + .toList(), + devices: (json['devices'] as List? ?? const []) + .whereType>() + .map((e) => FamilyDevice.fromJson(Map.from(e))) + .toList(), + incomingInvitations: (json['incoming_invitations'] as List? ?? const []) + .whereType>() + .map((e) => FamilyInvitation.fromJson(Map.from(e))) + .toList(), + ); +} + +class FamilyInvitation { + const FamilyInvitation({ + required this.identity, + required this.ownerName, + required this.name, + required this.relationship, + required this.expiresAt, + }); + final String identity, ownerName, name, relationship; + final DateTime? expiresAt; + factory FamilyInvitation.fromJson(Map json) => FamilyInvitation( + identity: json['identity'] as String? ?? '', + ownerName: json['owner_name'] as String? ?? '', + name: json['name'] as String? ?? '', + relationship: json['relationship'] as String? ?? '', + expiresAt: DateTime.tryParse(json['expires_at'] as String? ?? ''), + ); +} diff --git a/apps/user_app/lib/domain/models/gas_order_checkout.dart b/apps/user_app/lib/domain/models/gas_order_checkout.dart new file mode 100644 index 0000000..d57d5e6 --- /dev/null +++ b/apps/user_app/lib/domain/models/gas_order_checkout.dart @@ -0,0 +1,94 @@ +// 功能描述:气瓶下单报价、规格和服务端预约时段强类型模型;版本:1.0.0。 +import 'shipping_address.dart'; + +class GasOrderProductOption { + const GasOrderProductOption({ + required this.name, + required this.description, + required this.unitPrice, + required this.depositAmount, + required this.itemIdentities, + this.orderable = true, + this.unavailableReason = '', + }); + final String name, description; + final int unitPrice, depositAmount; + final List itemIdentities; + final bool orderable; + final String unavailableReason; + + factory GasOrderProductOption.fromJson(Map json) => GasOrderProductOption( + name: json['name'] as String? ?? '', + description: json['description'] as String? ?? '', + unitPrice: (json['unit_price'] as num?)?.toInt() ?? 0, + depositAmount: (json['deposit_amount'] as num?)?.toInt() ?? 0, + orderable: json['orderable'] != false, + unavailableReason: json['unavailable_reason'] as String? ?? '', + itemIdentities: List.unmodifiable( + (json['item_identities'] as List? ?? const []).whereType(), + ), + ); +} + +class GasOrderAppointmentSlot { + const GasOrderAppointmentSlot({required this.startAt, required this.label}); + final DateTime startAt; + final String label; + + factory GasOrderAppointmentSlot.fromJson(Map json) { + final start = DateTime.tryParse(json['start_at'] as String? ?? ''); + if (start == null) throw const FormatException('预约时段无效'); + return GasOrderAppointmentSlot(startAt: start, label: json['label'] as String? ?? ''); + } +} + +class GasOrderCheckoutData { + const GasOrderCheckoutData({ + required this.stationName, + required this.stationStatus, + required this.deliveryScope, + required this.deliveryFee, + required this.products, + required this.addresses, + required this.slots, + }); + final String stationName, stationStatus, deliveryScope; + final int deliveryFee; + final List products; + final List addresses; + final List slots; + + factory GasOrderCheckoutData.fromJson(Map json) => GasOrderCheckoutData( + stationName: json['station_name'] as String? ?? '', + stationStatus: json['station_status'] as String? ?? '', + deliveryScope: json['delivery_scope'] as String? ?? '', + deliveryFee: (json['delivery_fee'] as num?)?.toInt() ?? 0, + products: List.unmodifiable([ + for (final item in json['products'] as List? ?? const []) + GasOrderProductOption.fromJson(Map.from(item as Map)), + ]), + addresses: List.unmodifiable([ + for (final item in json['addresses'] as List? ?? const []) + ShippingAddress.fromJson(Map.from(item as Map)), + ]), + slots: List.unmodifiable([ + for (final item in json['appointment_slots'] as List? ?? const []) + GasOrderAppointmentSlot.fromJson(Map.from(item as Map)), + ]), + ); +} + +class GasOrderCreateResult { + const GasOrderCreateResult({required this.identity, required this.payableAmount}); + final String identity; + final int payableAmount; + + factory GasOrderCreateResult.fromJson(Map json) { + final identity = json['identity'] as String? ?? ''; + if (identity.isEmpty) throw const FormatException('订单标识缺失'); + return GasOrderCreateResult( + identity: identity, + payableAmount: (json['payable_amount'] as num?)?.toInt() ?? 0, + ); + } +} diff --git a/apps/user_app/lib/domain/models/gas_order_detail.dart b/apps/user_app/lib/domain/models/gas_order_detail.dart new file mode 100644 index 0000000..20795b6 --- /dev/null +++ b/apps/user_app/lib/domain/models/gas_order_detail.dart @@ -0,0 +1,135 @@ +// 功能描述:供气订单履约、支付记录及合同正文适配;版本:1.0.0。 +import 'shop_order_detail.dart'; + +/// 复用成交明细与整数分金额,供气配送费、人员和状态历史单独保存。 +class GasOrderDetail extends ShopOrderDetail { + GasOrderDetail.fromJson(super.data, super.resolve) + : deliveryFee = _amount(data['delivery_fee']), + depositAmount = _amount(data['deposit_amount'] ?? 0), + stationName = data['station_name'] as String? ?? '', + staffName = data['delivery_staff_name'] as String? ?? '', + contractIdentity = data['contract_identity'] as String? ?? '', + contractNo = data['contract_no'] as String? ?? '', + timeline = List.unmodifiable( + (data['timeline'] as List).map( + (row) => GasOrderEvent.fromJson(Map.from(row as Map)), + ), + ), + payments = List.unmodifiable( + (data['payments'] as List).map( + (row) => GasOrderPayment.fromJson(Map.from(row as Map)), + ), + ), + super.fromJson(); + final int deliveryFee, depositAmount; + final String stationName, staffName, contractIdentity, contractNo; + final List timeline; + final List payments; + static int _amount(Object? value) { + if (value is! int || value < 0) throw const FormatException('供气金额异常'); + return value; + } +} + +class GasOrderEvent { + GasOrderEvent.fromJson(Map data) + : name = data['status_name'] as String, + time = DateTime.parse(data['occurred_at'] as String); + final String name; + final DateTime time; +} + +class GasOrderPayment { + GasOrderPayment.fromJson(Map data) + : amount = GasOrderDetail._amount(data['amount']), + channel = data['channel'] as String, + time = data['paid_at'] == null ? null : DateTime.parse(data['paid_at'] as String); + final int amount; + final String channel; + final DateTime? time; + String get channelName => switch (channel) { + 'alipay' => '支付宝', + 'wechat' => '微信', + 'wallet' => '余额', + _ => '其他支付渠道', + }; +} + +/// 合同仅用于阅读,未实现的签署/下载能力不能伪装为成功。 +class GasContractDetail { + GasContractDetail.fromJson(Map data) + : identity = data['identity'] as String, + number = data['contract_no'] as String, + title = data['title'] as String, + terms = data['terms'] as String, + status = data['contract_status'] as int, + signedAt = _date(data['signed_at']), + effectiveAt = _date(data['effective_at']), + expiredAt = _date(data['expired_at']), + hasAttachment = data['has_attachment'] as bool; + final String identity, number, title, terms; + final int status; + final DateTime? signedAt, effectiveAt, expiredAt; + final bool hasAttachment; + static DateTime? _date(Object? value) { + final date = DateTime.tryParse(value?.toString() ?? ''); + return date == null || date.year < 1900 ? null : date; + } +} + +/// 合同列表保留服务端业务状态,不将草稿推断为待用户签署。 +class GasContractSummary extends GasContractDetail { + GasContractSummary.fromJson(super.data) + : stationName = data['station_name'] as String? ?? '', + super.fromJson(); + final String stationName; + String get statusName => switch (status) { + 0 => '草稿', + 11 => '生效中', + 12 => '已到期', + 13 => '已终止', + _ => '状态待更新', + }; +} + +/// 不可变的合同状态与有效期快照,区别于电子签名/签署证据。 +class GasContractEvent { + GasContractEvent.fromJson(Map data) + : identity = data['identity'] as String, + action = data['action'] as String, + status = data['contract_status'] as int, + occurredAt = DateTime.parse(data['occurred_at'] as String), + effectiveAt = GasContractDetail._date(data['effective_at']), + expiredAt = GasContractDetail._date(data['expired_at']); + final String identity, action; + final int status; + final DateTime occurredAt; + final DateTime? effectiveAt, expiredAt; + String get actionName => switch (action) { + 'activate' => '合同生效', + 'renew' => '合同续期', + 'terminate' => '合同终止', + 'create' => '合同建立', + _ => '合同变更', + }; + String get statusName => switch (status) { + 0 => '草稿', + 11 => '生效中', + 12 => '已到期', + 13 => '已终止', + _ => '状态待更新', + }; +} + +/// 合同变更申请是客服受理记录,不表示合同条款已经发生变化。 +class ContractChangeRequest { + ContractChangeRequest.fromJson(Map data) + : identity = data['identity'] as String, + number = data['ticket_no'] as String, + statusName = data['status_name'] as String, + description = data['description'] as String, + result = data['result'] as String? ?? '', + actions = List.from(data['allowed_actions'] as List); + final String identity, number, statusName, description, result; + final List actions; +} diff --git a/apps/user_app/lib/domain/models/message_center.dart b/apps/user_app/lib/domain/models/message_center.dart new file mode 100644 index 0000000..c135d7d --- /dev/null +++ b/apps/user_app/lib/domain/models/message_center.dart @@ -0,0 +1,54 @@ +// 功能描述:解析消息中心真实业务消息及分类计数;版本:1.0.0。 +class UserMessage { + const UserMessage({ + required this.key, + required this.category, + required this.title, + required this.summary, + required this.statusText, + required this.target, + required this.targetIdentity, + required this.occurredAt, + required this.read, + }); + + factory UserMessage.fromJson(Map json) => UserMessage( + key: json['key']?.toString() ?? '', + category: json['category']?.toString() ?? '', + title: json['title']?.toString() ?? '', + summary: json['summary']?.toString() ?? '', + statusText: json['status_text']?.toString() ?? '', + target: json['target']?.toString() ?? '', + targetIdentity: json['target_identity']?.toString() ?? '', + occurredAt: + DateTime.tryParse(json['occurred_at']?.toString() ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0), + read: json['read'] == true, + ); + + final String key, category, title, summary, statusText, target, targetIdentity; + final DateTime occurredAt; + final bool read; +} + +class MessageCenterData { + const MessageCenterData({required this.items, required this.counts}); + factory MessageCenterData.fromJson(Map json) { + final rawItems = json['items'] is List ? json['items'] as List : const []; + final rawCounts = json['counts'] is Map + ? Map.from(json['counts'] as Map) + : const {}; + return MessageCenterData( + items: List.unmodifiable( + rawItems.whereType>().map( + (item) => UserMessage.fromJson(Map.from(item)), + ), + ), + counts: Map.unmodifiable( + rawCounts.map((key, value) => MapEntry(key, value is num ? value.toInt() : 0)), + ), + ); + } + final List items; + final Map counts; +} diff --git a/apps/user_app/lib/domain/models/order_summary.dart b/apps/user_app/lib/domain/models/order_summary.dart new file mode 100644 index 0000000..4009f5c --- /dev/null +++ b/apps/user_app/lib/domain/models/order_summary.dart @@ -0,0 +1,93 @@ +// 功能描述:订单中心强类型摘要,状态、金额和动作由服务端提供。 +// 版本:1.0.0 +import 'dart:convert'; +import 'client_models.dart'; + +/// 订单项退款输入,只保留公开标识和服务端数量。 +class OrderItemSummary { + const OrderItemSummary( + this.identity, + this.quantity, { + this.name = '', + this.specification = '', + this.imageUrl = '', + }); + final String identity, name, specification, imageUrl; + final int quantity; + + /// 商城使用成交快照、气瓶订单使用类型快照,不以当前商品覆盖历史事实。 + factory OrderItemSummary.fromJson( + Map item, { + String Function(String value)? resolveImage, + }) { + Object? snapshot = item['product_snapshot']; + if (snapshot is String) { + try { + snapshot = jsonDecode(snapshot); + } on FormatException { + snapshot = null; + } + } + final name = snapshot is Map ? snapshot['name'] : item['product_type_name']; + final image = snapshot is Map ? snapshot['image_url'] : null; + Object? params = item['product_params']; + if (params is String) { + try { + params = jsonDecode(params); + } on FormatException { + params = null; + } + } + return OrderItemSummary( + item['identity'] as String? ?? '', + (item['quantity'] as num?)?.toInt() ?? 1, + name: name is String ? name : '', + specification: params is Map && params['weight'] is String ? params['weight'] as String : '', + imageUrl: image is String && image.isNotEmpty ? (resolveImage?.call(image) ?? image) : '', + ); + } +} + +/// 兼容旧订单记录的领域适配模型;页面不再直接读取原始 Map。 +class OrderSummary { + const OrderSummary({ + required this.record, + required this.amount, + required this.statusName, + required this.actions, + required this.items, + required this.orderNo, + required this.stationName, + this.createdAt, + }); + final ClientRecord record; + final int? amount; + final String statusName; + final Set actions; + final List items; + final String orderNo, stationName; + final DateTime? createdAt; + + /// 解析服务端状态和权限;旧响应缺少动作时不自行推导权限。 + factory OrderSummary.fromRecord( + ClientRecord record, { + String Function(String value)? resolveImage, + }) { + final raw = record.raw; + final rawOrderNo = raw['order_no']?.toString() ?? record.title; + return OrderSummary( + record: record, + amount: (raw['payable_amount'] as num?)?.toInt(), + statusName: raw['status_name'] as String? ?? '状态待更新', + orderNo: rawOrderNo.startsWith('订单号:') ? rawOrderNo.substring(4) : rawOrderNo, + stationName: raw['station_name'] as String? ?? '', + createdAt: DateTime.tryParse(raw['created_at']?.toString() ?? ''), + actions: Set.unmodifiable((raw['allowed_actions'] as List? ?? []).whereType()), + items: List.unmodifiable( + (raw['items'] as List? ?? []).whereType>().map( + (item) => OrderItemSummary.fromJson(item, resolveImage: resolveImage), + ), + ), + ); + } +} diff --git a/apps/user_app/lib/domain/models/primary_models.dart b/apps/user_app/lib/domain/models/primary_models.dart new file mode 100644 index 0000000..551086d --- /dev/null +++ b/apps/user_app/lib/domain/models/primary_models.dart @@ -0,0 +1,37 @@ +// 功能描述:一级页面使用的不可变领域数据,金额统一为整数分。 +// 版本:1.0.0 + +/// 已发布内容;正文和版本由内容后台维护。 +class PublishedContent { + const PublishedContent({ + required this.identity, + required this.title, + required this.body, + required this.type, + required this.version, + this.publishedAt, + }); + final String identity, title, body, type; + final int version; + final DateTime? publishedAt; +} + +/// 商品公开摘要,只使用公开标识和服务端价格库存。 +class ProductSummary { + const ProductSummary({ + required this.identity, + required this.name, + required this.price, + required this.stock, + this.category = '', + this.imageUrl = '', + }); + final String identity, name, category, imageUrl; + final int price, stock; +} + +/// 有效服务归属;配送点为空表示气站直接服务。 +class ServiceRelation { + const ServiceRelation({required this.gasName, required this.deliveryName}); + final String gasName, deliveryName; +} diff --git a/apps/user_app/lib/domain/models/product_detail.dart b/apps/user_app/lib/domain/models/product_detail.dart new file mode 100644 index 0000000..bc75eae --- /dev/null +++ b/apps/user_app/lib/domain/models/product_detail.dart @@ -0,0 +1,51 @@ +// 功能描述:商品详情公开字段及严格金额校验,不将缺失参数或图片伪装成真实数据。 +// 版本:1.0.0。 +import '../../data/services/api_client.dart'; + +class ProductDetail { + const ProductDetail({ + required this.identity, + required this.name, + required this.price, + required this.stock, + this.category = '', + this.images = const [], + this.attributes = const [], + }); + final String identity, name, category; + final int price, stock; + final List images; + final List<({String name, String value})> attributes; + factory ProductDetail.fromJson(Map data, String Function(String) resolveImage) { + final price = data['price_amount'], + stock = data['stock_quantity'], + identity = data['identity'], + name = data['name']; + if (price is! int || + price < 0 || + stock is! int || + stock < 0 || + identity is! String || + identity.isEmpty || + name is! String) { + throw const ApiException(1714, '商品数据异常,请重试'); + } + return ProductDetail( + identity: identity, + name: name, + price: price, + stock: stock, + category: data['category_name'] as String? ?? '', + images: [ + for (final image in data['images'] as List? ?? []) + if (image is Map && image['image_url'] is String) + resolveImage(image['image_url'] as String), + ].where((url) => url.isNotEmpty).toSet().toList(), + attributes: [ + for (final value in data['attributes'] as List? ?? []) + if (value is Map && value['name'] is String && value['value'] is String) + (name: value['name'] as String, value: value['value'] as String), + ], + ); + } +} diff --git a/apps/user_app/lib/domain/models/product_favorite.dart b/apps/user_app/lib/domain/models/product_favorite.dart new file mode 100644 index 0000000..60ae3f4 --- /dev/null +++ b/apps/user_app/lib/domain/models/product_favorite.dart @@ -0,0 +1,62 @@ +// 功能描述:商品收藏状态、并发版本和列表公开数据;版本:1.0.0。 +import 'primary_models.dart'; + +/// 每次操作必须携带读取时的状态版本,不能把切换命令作为可重试写入。 +class ProductFavoriteState { + const ProductFavoriteState({ + required this.productIdentity, + required this.active, + required this.revision, + }); + final String productIdentity, revision; + final bool active; + factory ProductFavoriteState.fromJson(Map data) { + if (data['product_identity'] is! String || + (data['product_identity'] as String).isEmpty || + data['favorite'] is! bool || + data['revision'] is! String) { + throw const FormatException('收藏状态数据异常'); + } + return ProductFavoriteState( + productIdentity: data['product_identity'] as String, + active: data['favorite'] as bool, + revision: data['revision'] as String, + ); + } +} + +/// 下架收藏仍保留展示信息,available仅用于禁用购买,结算仍由服务端验证。 +class ProductFavorite { + const ProductFavorite({ + required this.state, + required this.product, + required this.available, + this.specifications = const [], + }); + final ProductFavoriteState state; + final ProductSummary product; + final bool available; + final List specifications; + factory ProductFavorite.fromJson(Map data, String Function(String) resolve) { + final state = ProductFavoriteState.fromJson(data); + if (data['price_amount'] is! int || + (data['price_amount'] as int) < 0 || + data['stock_quantity'] is! int || + data['available'] is! bool) { + throw const FormatException('收藏商品数据异常'); + } + return ProductFavorite( + state: state, + product: ProductSummary( + identity: state.productIdentity, + name: data['name'] as String? ?? '', + price: data['price_amount'] as int, + stock: data['stock_quantity'] as int, + category: data['category_name'] as String? ?? '', + imageUrl: resolve(data['image_url'] as String? ?? ''), + ), + available: data['available'] as bool, + specifications: (data['specifications'] as List? ?? []).whereType().toList(), + ); + } +} diff --git a/apps/user_app/lib/domain/models/product_recommendations.dart b/apps/user_app/lib/domain/models/product_recommendations.dart new file mode 100644 index 0000000..62cecfc --- /dev/null +++ b/apps/user_app/lib/domain/models/product_recommendations.dart @@ -0,0 +1,49 @@ +// 功能描述:推荐商品分页与真实金额校验;版本:1.0.0。 +import 'primary_models.dart'; + +class ProductRecommendations { + const ProductRecommendations({required this.items, required this.page, required this.hasMore}); + final List items; + final int page; + final bool hasMore; + factory ProductRecommendations.fromJson( + Map data, + String Function(String) resolve, + ) { + if (data['items'] is! List || + data['page'] is! int || + (data['page'] as int) < 1 || + data['has_more'] is! bool) { + throw const FormatException('推荐分页数据异常'); + } + final items = []; + for (final row in data['items'] as List) { + if (row is! Map || + row['identity'] is! String || + (row['identity'] as String).isEmpty || + row['name'] is! String || + row['price_amount'] is! int || + (row['price_amount'] as int) < 0 || + row['stock_quantity'] is! int || + (row['stock_quantity'] as int) < 0) { + throw const FormatException('推荐商品数据异常'); + } + items.add( + ProductSummary( + identity: row['identity'] as String, + name: row['name'] as String, + price: row['price_amount'] as int, + stock: row['stock_quantity'] as int, + category: row['category_name'] as String? ?? '', + imageUrl: resolve(row['image_url'] as String? ?? ''), + ), + ); + } + if (items.isEmpty && data['has_more'] == true) throw const FormatException('推荐分页为空'); + return ProductRecommendations( + items: items, + page: data['page'] as int, + hasMore: data['has_more'] as bool, + ); + } +} diff --git a/apps/user_app/lib/domain/models/recharge.dart b/apps/user_app/lib/domain/models/recharge.dart new file mode 100644 index 0000000..a184343 --- /dev/null +++ b/apps/user_app/lib/domain/models/recharge.dart @@ -0,0 +1,132 @@ +// 功能描述:充值配置、创建结果和入账事实的严格类型;版本:1.0.0。 +import 'client_models.dart'; + +int rechargeCents(Object? value) { + if (value is! int || value < 0) throw const FormatException('充值金额格式异常'); + return value; +} + +/// 十进制文本直接转换为分,禁止浮点舍入、指数或负数。 +int? parseRechargeAmount(String value) { + final text = value.trim(); + if (!RegExp(r'^\d{1,9}(\.\d{1,2})?$').hasMatch(text)) return null; + final parts = text.split('.'); + return int.parse(parts[0]) * 100 + (parts.length == 1 ? 0 : int.parse(parts[1].padRight(2, '0'))); +} + +class RechargeOptions { + const RechargeOptions({ + required this.min, + required this.max, + required this.channels, + this.agreement, + }); + final int min, max; + final List channels; + final RechargeAgreement? agreement; + factory RechargeOptions.fromJson(Map json) { + final min = rechargeCents(json['min_amount']), max = rechargeCents(json['max_amount']); + if (min < 1 || max < min || json['channels'] is! List) throw const FormatException('充值配置异常'); + return RechargeOptions( + min: min, + max: max, + channels: [ + for (final item in json['channels'] as List) + RechargeChannel.fromJson(Map.from(item as Map)), + ], + agreement: json['agreement'] == null + ? null + : RechargeAgreement.fromJson(Map.from(json['agreement'] as Map)), + ); + } +} + +class RechargeChannel { + const RechargeChannel(this.name, {required this.app, required this.wap}); + final String name; + final bool app, wap; + String get title => name == 'wechat' ? '微信支付' : '支付宝'; + factory RechargeChannel.fromJson(Map json) { + if (!['wechat', 'alipay'].contains(json['channel']) || + json['app'] is! bool || + json['wap'] is! bool) { + throw const FormatException('支付方式异常'); + } + return RechargeChannel( + json['channel'] as String, + app: json['app'] as bool, + wap: json['wap'] as bool, + ); + } +} + +class RechargeAgreement { + const RechargeAgreement(this.identity, this.version, this.title, this.body); + final String identity, title, body; + final int version; + factory RechargeAgreement.fromJson(Map json) { + if (json['identity'] is! String || + json['body'] is! String || + json['version'] is! int || + (json['identity'] as String).trim().isEmpty || + (json['body'] as String).trim().isEmpty || + (json['version'] as int) < 1) { + throw const FormatException('充值协议不完整'); + } + return RechargeAgreement( + json['identity'] as String, + json['version'] as int, + json['title'] as String? ?? '充值协议', + json['body'] as String, + ); + } +} + +/// 只有充值状态23表示钱包已经入账;渠道关闭或SDK返回都不能替代这一事实。 +class RechargeRecord { + const RechargeRecord({ + required this.identity, + required this.number, + required this.amount, + required this.status, + required this.channel, + required this.createdAt, + this.paymentStatus, + }); + final String identity, number, channel; + final int amount, status; + final int? paymentStatus; + final DateTime createdAt; + bool get credited => status == 23; + String get statusText => credited + ? '已到账' + : paymentStatus == 30 + ? '支付已关闭,未入账' + : status == 10 + ? '待确认到账' + : '状态待确认'; + String get amountText => moneyText(amount); + factory RechargeRecord.fromJson(Map json) { + final date = DateTime.tryParse(json['created_at'] as String? ?? ''); + if ((json['identity'] as String? ?? '').isEmpty || + json['recharge_status'] is! int || + date == null) { + throw const FormatException('充值记录不完整'); + } + return RechargeRecord( + identity: json['identity'] as String, + number: json['recharge_no'] as String? ?? '', + amount: rechargeCents(json['amount']), + status: json['recharge_status'] as int, + channel: json['channel'] as String? ?? '', + createdAt: date.toLocal(), + paymentStatus: json['payment_status'] as int?, + ); + } +} + +class RechargeRecordPage { + const RechargeRecordPage(this.items, this.nextCursor); + final List items; + final String nextCursor; +} diff --git a/apps/user_app/lib/domain/models/service_ticket.dart b/apps/user_app/lib/domain/models/service_ticket.dart new file mode 100644 index 0000000..e596b81 --- /dev/null +++ b/apps/user_app/lib/domain/models/service_ticket.dart @@ -0,0 +1,49 @@ +// 功能描述:工单展示适配,状态动作只取服务端许可,不猜测派单和处理时间。 +// 版本:1.0.0。 +import 'client_models.dart'; + +class ServiceTicket { + const ServiceTicket(this.record); + final ClientRecord record; + String get identity => record.identity; + String get number => text('ticket_no', record.title); + String get state => text('status_name', '状态待核实'); + String get description => text('description', '暂无描述'); + String get result => text('result'); + String get address => text('address', '未填写地址'); + List> get photos => (record.raw['photos'] as List? ?? []) + .whereType>() + .map((value) => Map.from(value)) + .where((value) => value['identity'] is String) + .toList(); + String get fault => + const {'valve': '阀门故障', 'alarm': '报警器故障', 'leak': '燃气泄漏', 'other': '其他问题'}[text( + 'fault_type', + )] ?? + category; + String get category => + const { + 'repair': '维修', + 'installation': '安装', + 'inspection': '安检', + 'reinspection': '复检', + 'customer_service': '客户服务', + }[text('category')] ?? + '其他服务'; + List get actions => + (record.raw['allowed_actions'] as List?)?.whereType().toList() ?? []; + + /// 返回可展示文字,空值采用调用方指定的占位。 + String text(String key, [String fallback = '']) { + final value = record.raw[key]; + return value is String && value.trim().isNotEmpty ? value : fallback; + } + + /// 使用接口的真实时间转换到本地,缺失时不生成推测进度。 + String time(String key) { + final value = DateTime.tryParse(text(key))?.toLocal(); + if (value == null) return '未记录'; + String pad(int n) => n.toString().padLeft(2, '0'); + return '${value.year}-${pad(value.month)}-${pad(value.day)} ${pad(value.hour)}:${pad(value.minute)}'; + } +} diff --git a/apps/user_app/lib/domain/models/shipping_address.dart b/apps/user_app/lib/domain/models/shipping_address.dart new file mode 100644 index 0000000..a04746a --- /dev/null +++ b/apps/user_app/lib/domain/models/shipping_address.dart @@ -0,0 +1,40 @@ +// 功能描述:用户收货地址模型,列表脱敏展示、编辑使用本人完整数据。 +// 版本:1.0.0。 +class ShippingAddress { + const ShippingAddress({ + this.identity = '', + required this.address, + required this.contactName, + required this.contactPhone, + this.longitude = '', + this.latitude = '', + this.isDefault = false, + }); + final String identity, address, contactName, contactPhone, longitude, latitude; + final bool isDefault; + + /// 从兼容地址响应读取字段;历史空联系人在编辑时要求补齐。 + factory ShippingAddress.fromJson(Map json) => ShippingAddress( + identity: json['identity'] as String? ?? '', + address: json['address'] as String? ?? '', + contactName: json['contact_name'] as String? ?? '', + contactPhone: json['contact_phone'] as String? ?? '', + longitude: json['longitude'] as String? ?? '', + latitude: json['latitude'] as String? ?? '', + isDefault: json['is_default'] == true, + ); + + String get maskedPhone => contactPhone.length == 11 + ? '${contactPhone.substring(0, 3)}****${contactPhone.substring(7)}' + : contactPhone; + + /// 保存地址的完整字段;不接收内部账户编号。 + Map toJson() => { + 'address': address, + 'contact_name': contactName, + 'contact_phone': contactPhone, + 'longitude': longitude, + 'latitude': latitude, + 'is_default': isDefault, + }; +} diff --git a/apps/user_app/lib/domain/models/shop_order_detail.dart b/apps/user_app/lib/domain/models/shop_order_detail.dart new file mode 100644 index 0000000..6a25034 --- /dev/null +++ b/apps/user_app/lib/domain/models/shop_order_detail.dart @@ -0,0 +1,91 @@ +// 功能描述:商城订单成交快照、费用和履约时间;版本:1.0.0。 +import 'client_models.dart'; +import 'order_summary.dart'; + +/// 历史商品名称与单价不随商品目录改动;图片仅接受服务端快照。 +class ShopOrderLine { + const ShopOrderLine({ + required this.identity, + required this.productIdentity, + required this.name, + required this.quantity, + required this.unitAmount, + required this.imageUrl, + }); + final String identity, productIdentity, name, imageUrl; + final int quantity, unitAmount; + int get amount => quantity * unitAmount; +} + +/// 金额一律为整数分;缺失时间保留未知,不依据当前时间补写历史。 +class ShopOrderDetail { + ShopOrderDetail.fromJson(Map data, String Function(String) resolve) + : identity = _text(data, 'identity'), + orderNo = _text(data, 'order_no'), + statusName = _text(data, 'status_name'), + address = _text(data, 'address'), + contactName = _text(data, 'contact_name'), + contactPhone = _text(data, 'contact_phone'), + remark = _text(data, 'remark'), + logisticsNo = _text(data, 'logistics_no'), + logisticsCompany = _text(data, 'logistics_company'), + productAmount = _amount(data, 'product_amount'), + discountAmount = _amount(data, 'discount_amount'), + payableAmount = _amount(data, 'payable_amount'), + createdAt = _date(data['created_at']), + paidAt = _date(data['paid_at']), + shippedAt = _date(data['shipped_at']), + receivedAt = _date(data['received_at']), + actions = Set.unmodifiable((data['allowed_actions'] as List).whereType()), + items = List.unmodifiable( + (data['items'] as List).map((raw) { + final row = Map.from(raw as Map); + final qty = _amount(row, 'quantity'); + if (qty < 1) throw const FormatException('订单商品数量异常'); + return ShopOrderLine( + identity: _text(row, 'identity'), + productIdentity: _text(row, 'product_identity'), + name: _text(row, 'name'), + quantity: qty, + unitAmount: _amount(row, 'sale_amount'), + imageUrl: resolve(_text(row, 'image_url')), + ); + }), + ) { + if (identity.isEmpty || orderNo.isEmpty || statusName.isEmpty) { + throw const FormatException('订单资料不完整'); + } + } + final String identity, + orderNo, + statusName, + address, + contactName, + contactPhone, + remark, + logisticsNo, + logisticsCompany; + final int productAmount, discountAmount, payableAmount; + final DateTime? createdAt, paidAt, shippedAt, receivedAt; + final Set actions; + final List items; + OrderSummary get summary => OrderSummary( + record: ClientRecord(identity: identity, title: orderNo, subtitle: '', raw: const {}), + amount: payableAmount, + statusName: statusName, + orderNo: orderNo, + stationName: '', + actions: actions, + createdAt: createdAt, + items: [for (final i in items) OrderItemSummary(i.identity, i.quantity, name: i.name)], + ); + static String _text(Map data, String key) => data[key] as String? ?? ''; + static int _amount(Map data, String key) { + final value = data[key]; + if (value is! int || value < 0) throw FormatException('订单金额或数量异常:$key'); + return value; + } + + static DateTime? _date(Object? value) => + value == null ? null : DateTime.tryParse(value.toString()); +} diff --git a/apps/user_app/lib/domain/models/usage_statistics.dart b/apps/user_app/lib/domain/models/usage_statistics.dart new file mode 100644 index 0000000..8919de9 --- /dev/null +++ b/apps/user_app/lib/domain/models/usage_statistics.dart @@ -0,0 +1,78 @@ +// 功能描述:定义用气统计的服务端事实模型;版本:1.0.0。 + +class UsagePoint { + const UsagePoint({required this.date, required this.usage}); + final DateTime date; + final double usage; + + factory UsagePoint.fromJson(Map json) => UsagePoint( + date: DateTime.parse(json['date'] as String), + usage: (json['usage'] as num).toDouble(), + ); +} + +class UsageComposition { + const UsageComposition({required this.breakfast, required this.lunch, required this.dinner}); + final double breakfast, lunch, dinner; + + factory UsageComposition.fromJson(Map json) => UsageComposition( + breakfast: (json['breakfast'] as num? ?? 0).toDouble(), + lunch: (json['lunch'] as num? ?? 0).toDouble(), + dinner: (json['dinner'] as num? ?? 0).toDouble(), + ); +} + +class UsageStatistics { + const UsageStatistics({ + required this.available, + required this.deviceIdentity, + required this.deviceName, + required this.deviceCode, + required this.period, + required this.periodStart, + required this.periodEnd, + required this.unit, + required this.points, + required this.totalUsage, + required this.averageUsage, + required this.composition, + required this.source, + required this.calcVersion, + required this.updatedAt, + required this.unavailableReason, + }); + + final bool available; + final String deviceIdentity, deviceName, deviceCode, period, unit; + final DateTime periodStart, periodEnd; + final List points; + final double totalUsage, averageUsage; + final UsageComposition composition; + final String source, calcVersion, unavailableReason; + final DateTime? updatedAt; + + factory UsageStatistics.fromJson(Map json) { + final device = json['device'] as Map? ?? const {}; + final composition = json['composition'] as Map? ?? const {}; + return UsageStatistics( + available: json['available'] == true, + deviceIdentity: device['identity'] as String? ?? '', + deviceName: device['name'] as String? ?? '', + deviceCode: device['code'] as String? ?? '', + period: json['period'] as String? ?? 'month', + periodStart: DateTime.parse(json['period_start'] as String), + periodEnd: DateTime.parse(json['period_end'] as String), + unit: json['unit'] as String? ?? 'kg', + points: (json['points'] as List? ?? const []) + .map((item) => UsagePoint.fromJson(item as Map)) + .toList(), + totalUsage: (json['total_usage'] as num? ?? 0).toDouble(), + averageUsage: (json['average_usage'] as num? ?? 0).toDouble(), + composition: UsageComposition.fromJson(composition), + source: json['source'] as String? ?? '', + calcVersion: json['calc_version'] as String? ?? '', + updatedAt: DateTime.tryParse(json['updated_at']?.toString() ?? '')?.toLocal(), + unavailableReason: json['unavailable_reason'] as String? ?? '', + ); + } +} diff --git a/apps/user_app/lib/domain/models/wallet_account.dart b/apps/user_app/lib/domain/models/wallet_account.dart new file mode 100644 index 0000000..230d91d --- /dev/null +++ b/apps/user_app/lib/domain/models/wallet_account.dart @@ -0,0 +1,93 @@ +// 功能描述:银行卡与提现申请的严格领域模型;版本:1.0.0。 + +/// 本人已绑定银行卡只保留服务端返回的脱敏字段。 +class WalletBank { + const WalletBank({ + required this.identity, + required this.maskedNumber, + required this.bankName, + required this.owner, + required this.type, + required this.isDefault, + }); + + final String identity, maskedNumber, bankName, owner, type; + final bool isDefault; + + factory WalletBank.fromJson(Map json) { + final identity = json['identity'], number = json['card_no_masked']; + if (identity is! String || identity.isEmpty || number is! String || number.isEmpty) { + throw const FormatException('银行卡数据不完整'); + } + return WalletBank( + identity: identity, + maskedNumber: number, + bankName: json['bank_name'] as String? ?? '银行卡', + owner: json['card_owner'] as String? ?? '', + type: json['bank_type'] as String? ?? '', + isDefault: json['is_default'] == true, + ); + } + + String get typeName => switch (type) { + 'debit' || 'saving' => '储蓄卡', + 'credit' => '信用卡', + _ => type, + }; +} + +/// 提现状态必须来自服务端,申请成功只表示进入审核流程。 +class WalletWithdrawal { + const WalletWithdrawal({ + required this.identity, + required this.number, + required this.amount, + required this.fee, + required this.status, + required this.createdAt, + }); + + final String identity, number; + final int amount, fee, status; + final DateTime createdAt; + + factory WalletWithdrawal.fromJson(Map json) { + final identity = json['identity'], amount = json['amount'], fee = json['fee']; + final createdAt = DateTime.tryParse(json['created_at'] as String? ?? ''); + if (identity is! String || + identity.isEmpty || + amount is! int || + amount <= 0 || + fee is! int || + fee < 0 || + json['apply_status'] is! int || + createdAt == null) { + throw const FormatException('提现记录数据不完整'); + } + return WalletWithdrawal( + identity: identity, + number: json['cash_no'] as String? ?? '', + amount: amount, + fee: fee, + status: json['apply_status'] as int, + createdAt: createdAt.toLocal(), + ); + } + + String get statusName => switch (status) { + 10 => '待审核', + 18 => '处理中', + 23 => '已到账', + 30 => '已拒绝', + 31 => '已退回', + _ => '状态待确认', + }; +} + +/// 金额文本直接转整数分,禁止浮点舍入和指数格式。 +int? parseWithdrawalAmount(String value) { + final text = value.trim(); + if (!RegExp(r'^\d{1,9}(\.\d{1,2})?$').hasMatch(text)) return null; + final parts = text.split('.'); + return int.parse(parts[0]) * 100 + (parts.length == 1 ? 0 : int.parse(parts[1].padRight(2, '0'))); +} diff --git a/apps/user_app/lib/domain/models/wallet_bill.dart b/apps/user_app/lib/domain/models/wallet_bill.dart new file mode 100644 index 0000000..7117113 --- /dev/null +++ b/apps/user_app/lib/domain/models/wallet_bill.dart @@ -0,0 +1,67 @@ +// 功能描述:钱包账单的严格金额与收支语义模型;版本:1.0.0。 +import 'client_models.dart'; + +/// 账单是已记账事实,不把未完成订单或提现申请伪装成成功流水。 +class WalletBill { + const WalletBill({ + required this.identity, + required this.number, + required this.direction, + required this.tradeType, + required this.amount, + required this.fee, + required this.balanceAfter, + required this.createdAt, + required this.channel, + }); + final String identity, number, direction, tradeType, channel; + final int amount, fee, balanceAfter; + final DateTime createdAt; + bool get income => direction == 'income'; + String get signedAmount => '${income ? '+' : '-'}${moneyText(amount)}'; + String get title => switch (tradeType) { + 'recharge' => '余额充值', + 'refund' => '退款入账', + 'ec_order' => '商城订单', + 'gas_order' => '气瓶订单', + 'withdrawal_reserve' => '提现资金冻结', + 'withdrawal_release' => '提现资金退回', + 'withdrawal_complete' => '提现完成', + _ => '其他账单', + }; + factory WalletBill.fromJson(Map json) { + int amount(String key, {bool positive = false}) { + final value = json[key]; + if (value is! int || value < (positive ? 1 : 0)) { + throw const FormatException('账单金额格式异常'); + } + return value; + } + + final direction = json['direction']; + final date = DateTime.tryParse(json['created_at'] as String? ?? ''); + if (!['income', 'expense'].contains(direction) || + date == null || + (json['identity'] as String? ?? '').isEmpty) { + throw const FormatException('账单信息不完整'); + } + return WalletBill( + identity: json['identity'] as String, + number: json['record_no'] as String? ?? '', + direction: direction as String, + tradeType: json['trade_type'] as String? ?? '', + amount: amount('amount', positive: true), + fee: amount('fee'), + balanceAfter: amount('balance_after'), + createdAt: date.toLocal(), + channel: json['pay_channel'] as String? ?? '', + ); + } +} + +/// 分页游标由服务端提供,不依据客户端条数猜测是否还有数据。 +class WalletBillPage { + const WalletBillPage(this.items, this.nextCursor); + final List items; + final String nextCursor; +} diff --git a/apps/user_app/lib/ui/core/async_content.dart b/apps/user_app/lib/ui/core/async_content.dart new file mode 100644 index 0000000..23fe76e --- /dev/null +++ b/apps/user_app/lib/ui/core/async_content.dart @@ -0,0 +1,79 @@ +// 功能描述:统一首次加载、刷新保留内容、空数据、错误与重试状态。 +// 版本:1.0.0 +import 'package:flutter/material.dart'; +import '../../data/services/api_client.dart'; +import 'widgets.dart'; + +/// 异步页面容器;刷新失败保留旧数据并提供重试,不冒充最新结果。 +class AsyncContent extends StatefulWidget { + const AsyncContent({required this.load, required this.builder, this.empty, super.key}); + final Future Function() load; + final Widget Function(BuildContext, T) builder; + final bool Function(T)? empty; + @override + State> createState() => AsyncContentState(); +} + +/// 管理加载世代,忽略页面销毁或后发请求覆盖前发结果。 +class AsyncContentState extends State> { + T? _data; + Object? _error; + bool _loading = true; + int _generation = 0; + @override + void initState() { + super.initState(); + refresh(); + } + + /// 重新读取服务端结果;返回值用于下拉刷新完成通知。 + Future refresh() async { + final generation = ++_generation; + setState(() { + _loading = true; + _error = null; + }); + try { + final value = await widget.load(); + if (mounted && generation == _generation) setState(() => _data = value); + } catch (error) { + if (mounted && generation == _generation) setState(() => _error = error); + } finally { + if (mounted && generation == _generation) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + if (_error is SessionExpiredException) return const SizedBox.shrink(); + if (_data == null) { + if (_loading) return const Center(child: CircularProgressIndicator()); + return EmptyState(title: '加载失败', description: _message, onRetry: refresh); + } + return Column( + children: [ + if (_loading) const LinearProgressIndicator(minHeight: 2), + if (_error != null) + MaterialBanner( + content: Text('刷新失败,当前显示上次数据。$_message'), + actions: [TextButton(onPressed: refresh, child: const Text('重试'))], + ), + Expanded( + child: RefreshIndicator( + onRefresh: refresh, + child: widget.empty?.call(_data as T) == true + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + EmptyState(title: '暂无数据', description: '下拉刷新以获取最新内容'), + ], + ) + : widget.builder(context, _data as T), + ), + ), + ], + ); + } + + String get _message => _error is ApiException ? _error.toString() : '请检查网络后重试'; +} diff --git a/apps/user_app/lib/ui/core/feature_entry.dart b/apps/user_app/lib/ui/core/feature_entry.dart new file mode 100644 index 0000000..5791fac --- /dev/null +++ b/apps/user_app/lib/ui/core/feature_entry.dart @@ -0,0 +1,64 @@ +// 功能描述:保留后续业务入口,并明确说明尚未开放的能力边界。 +// 版本:1.0.0 +import 'package:flutter/material.dart'; + +/// 只有需求明确归入二期的调用方才传入secondPhase,默认不把缺项推到二期。 +enum UnavailableStage { + firstPhase('暂未开放', '该功能暂未开放,目前无法使用。'), + secondPhase('即将开放', '该功能将在后续版本开放,敬请期待。'); + + const UnavailableStage(this.label, this.message); + final String label, message; +} + +/// 保留入口并说明当前状态,不创建假设备、金额或成功回执。 +Future showUnavailableFeature( + BuildContext context, + String title, { + UnavailableStage stage = UnavailableStage.firstPhase, +}) => showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(stage.message), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('知道了'))], + ), +); + +/// 常用功能图标入口;未开放时保留可访问的原因说明。 +class FeatureEntry extends StatelessWidget { + const FeatureEntry({ + required this.icon, + required this.label, + this.onTap, + this.stage = UnavailableStage.firstPhase, + this.showStatus = true, + this.compact = false, + super.key, + }); + final IconData icon; + final String label; + final VoidCallback? onTap; + final UnavailableStage stage; + final bool showStatus; + final bool compact; + @override + Widget build(BuildContext context) => InkWell( + onTap: onTap ?? () => showUnavailableFeature(context, label, stage: stage), + child: Padding( + padding: EdgeInsets.symmetric(vertical: compact ? 8 : 12, horizontal: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 28, color: Theme.of(context).colorScheme.primary), + SizedBox(height: compact ? 4 : 8), + Text(label, textAlign: TextAlign.center, style: const TextStyle(fontSize: 13)), + if (onTap == null && showStatus) ...[ + const SizedBox(height: 4), + Text(stage.label, style: const TextStyle(fontSize: 11, color: Color(0xFF777F8D))), + ], + ], + ), + ), + ); +} diff --git a/apps/user_app/lib/ui/core/payment_pin_field.dart b/apps/user_app/lib/ui/core/payment_pin_field.dart new file mode 100644 index 0000000..bec9852 --- /dev/null +++ b/apps/user_app/lib/ui/core/payment_pin_field.dart @@ -0,0 +1,65 @@ +// 功能描述:六位支付密码的可访问原生输入与产品圆点样式;版本:1.0.0。 +import 'package:flutter/material.dart'; + +/// 支付密码保留原生键盘和自动化输入能力,界面只显示六个脱敏圆点。 +class PaymentPinField extends StatelessWidget { + const PaymentPinField({ + required this.controller, + required this.enabled, + this.onChanged, + this.fieldKey, + super.key, + }); + + final TextEditingController controller; + final bool enabled; + final VoidCallback? onChanged; + final Key? fieldKey; + + @override + Widget build(BuildContext context) => SizedBox( + height: 40, + child: Stack( + alignment: Alignment.centerLeft, + children: [ + ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, child) => Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate( + 6, + (index) => Container( + width: 17, + height: 17, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: index < value.text.length ? const Color(0xFF2563EB) : Colors.white, + border: Border.all(color: const Color(0xFF9CA3AF)), + ), + ), + ), + ), + ), + Positioned.fill( + child: Opacity( + opacity: 0.01, + child: TextField( + key: fieldKey, + controller: controller, + enabled: enabled, + obscureText: true, + keyboardType: TextInputType.number, + maxLength: 6, + onChanged: (_) => onChanged?.call(), + decoration: const InputDecoration( + counterText: '', + border: InputBorder.none, + hintText: '请输入6位支付密码', + ), + ), + ), + ), + ], + ), + ); +} diff --git a/apps/user_app/lib/ui/core/reference_image.dart b/apps/user_app/lib/ui/core/reference_image.dart new file mode 100644 index 0000000..efa934d --- /dev/null +++ b/apps/user_app/lib/ui/core/reference_image.dart @@ -0,0 +1,88 @@ +// 功能描述:复用用户提供设计稿中的品牌与宣传位原始像素,不重绘或生成近似图。 +// 版本:1.0.0。 +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; + +/// 原稿图片区域组件;仅绘制独立视觉素材,所有表单、数据和交互仍由 Flutter 构建。 +class ReferenceImage extends StatefulWidget { + const ReferenceImage({required this.asset, required this.region, required this.label, super.key}); + const ReferenceImage.brand({super.key}) + : asset = 'assets/design/login-source.png', + region = const Rect.fromLTWH(287, 184, 343, 125), + label = '瓶安芯'; + const ReferenceImage.shopBanner({super.key}) + : asset = 'assets/design/shop-source.png', + region = const Rect.fromLTWH(40, 421, 773, 274), + label = '安全用气 幸福万家,定期检查、正确使用、安全相伴'; + final String asset, label; + final Rect region; + @override + State createState() => _ReferenceImageState(); +} + +/// 使用 Flutter 图片缓存并正确解除监听,避免页面切换后保留解码资源。 +class _ReferenceImageState extends State { + ImageStream? _stream; + ImageInfo? _info; + late final ImageStreamListener _listener = ImageStreamListener((info, _) { + _info?.dispose(); + setState(() => _info = info); + }); + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _resolve(); + } + + @override + void didUpdateWidget(covariant ReferenceImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.asset != widget.asset) _resolve(); + } + + void _resolve() { + final stream = AssetImage(widget.asset).resolve(createLocalImageConfiguration(context)); + if (_stream?.key == stream.key) return; + _stream?.removeListener(_listener); + _stream = stream..addListener(_listener); + } + + @override + void dispose() { + _stream?.removeListener(_listener); + _info?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Semantics( + image: true, + label: widget.label, + child: AspectRatio( + aspectRatio: widget.region.width / widget.region.height, + child: CustomPaint(painter: _OriginalPixels(_info?.image, widget.region)), + ), + ); +} + +/// 将原始位图的指定矩形映射到显示区域,不替换 logo、商品或宣传图的内容。 +class _OriginalPixels extends CustomPainter { + const _OriginalPixels(this.image, this.region); + final ui.Image? image; + final Rect region; + @override + void paint(Canvas canvas, Size size) { + if (image != null) { + canvas.drawImageRect( + image!, + region, + Offset.zero & size, + Paint()..filterQuality = FilterQuality.high, + ); + } + } + + @override + bool shouldRepaint(covariant _OriginalPixels oldDelegate) => + oldDelegate.image != image || oldDelegate.region != region; +} diff --git a/apps/user_app/lib/ui/core/text_entry_dialog.dart b/apps/user_app/lib/ui/core/text_entry_dialog.dart new file mode 100644 index 0000000..1eb74e3 --- /dev/null +++ b/apps/user_app/lib/ui/core/text_entry_dialog.dart @@ -0,0 +1,59 @@ +// 功能描述:由弹窗自身管理文本控制器,避免关闭动画期间提前释放。版本:1.0.0。 +import 'package:flutter/material.dart'; + +/// 单字段输入弹窗;返回经过空白裁剪的非空文本,取消返回 null。 +class TextEntryDialog extends StatefulWidget { + const TextEntryDialog({ + required this.title, + required this.label, + required this.action, + this.maxLines = 1, + super.key, + }); + final String title; + final String label; + final String action; + final int maxLines; + + @override + State createState() => _TextEntryDialogState(); +} + +/// 控制器随弹窗卸载释放,空值在原弹窗内提示。 +class _TextEntryDialogState extends State { + final _controller = TextEditingController(); + bool _empty = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AlertDialog( + title: Text(widget.title), + content: TextField( + controller: _controller, + maxLines: widget.maxLines, + decoration: InputDecoration( + labelText: widget.label, + errorText: _empty ? '请填写${widget.label}' : null, + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')), + FilledButton( + onPressed: () { + final value = _controller.text.trim(); + if (value.isEmpty) { + setState(() => _empty = true); + return; + } + Navigator.pop(context, value); + }, + child: Text(widget.action), + ), + ], + ); +} diff --git a/apps/user_app/lib/ui/core/widgets.dart b/apps/user_app/lib/ui/core/widgets.dart index f2a45b7..e42d8b5 100644 --- a/apps/user_app/lib/ui/core/widgets.dart +++ b/apps/user_app/lib/ui/core/widgets.dart @@ -64,7 +64,12 @@ class RecordCard extends StatelessWidget { subtitle: record.subtitle.isEmpty ? null : Text(record.subtitle), trailing: record.status == null ? const Icon(Icons.chevron_right_rounded) - : StatusPill(label: '状态 ${record.status}', tone: StatusTone.info), + : StatusPill( + label: record.raw['status_name'] is String + ? record.raw['status_name'] as String + : '状态 ${record.status}', + tone: StatusTone.info, + ), onTap: onTap, ), ); diff --git a/apps/user_app/lib/ui/features/address/address_edit_page.dart b/apps/user_app/lib/ui/features/address/address_edit_page.dart new file mode 100644 index 0000000..a679730 --- /dev/null +++ b/apps/user_app/lib/ui/features/address/address_edit_page.dart @@ -0,0 +1,232 @@ +// 功能描述:收货地址新增和编辑,保存失败保留输入,删除前确认。 +// 版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../data/services/api_client.dart'; +import '../../../domain/models/shipping_address.dart'; + +/// 以已有地址预填;返回 true 表示服务端已保存或删除。 +class AddressEditPage extends StatefulWidget { + const AddressEditPage({required this.repository, this.address, super.key}); + final ClientRepository repository; + final ShippingAddress? address; + @override + State createState() => _AddressEditPageState(); +} + +class _AddressEditPageState extends State { + final _form = GlobalKey(); + final _requestNo = const Uuid().v7(); + late final _name = TextEditingController(text: widget.address?.contactName); + late final _phone = TextEditingController(text: widget.address?.contactPhone); + late final _address = TextEditingController(text: widget.address?.address); + late final _longitude = TextEditingController(text: widget.address?.longitude); + late final _latitude = TextEditingController(text: widget.address?.latitude); + late bool _default = widget.address?.isDefault ?? false; + bool _busy = false, _dirty = false; + String? _error; + + @override + void dispose() { + for (final controller in [_name, _phone, _address, _longitude, _latitude]) { + controller.dispose(); + } + super.dispose(); + } + + /// 只有成功响应才退出;网络异常保留表单与同一个新增请求号。 + Future _save() async { + if (_busy || !_form.currentState!.validate()) return; + await _mutate( + () => widget.repository.saveShippingAddress( + ShippingAddress( + identity: widget.address?.identity ?? '', + address: _address.text.trim(), + contactName: _name.text.trim(), + contactPhone: _phone.text.trim(), + longitude: _longitude.text.trim(), + latitude: _latitude.text.trim(), + isDefault: _default, + ), + requestNo: _requestNo, + ), + ); + } + + Future _mutate(Future Function() action) async { + setState(() { + _busy = true; + _error = null; + }); + try { + await action(); + if (!mounted) return; + setState(() { + _dirty = false; + _busy = false; + }); + // 等待 PopScope 更新后再返回,避免将保存成功当作放弃编辑。 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) Navigator.pop(context, true); + }); + } on SessionExpiredException { + if (mounted) setState(() => _busy = false); + } catch (error) { + if (mounted) { + setState(() { + _busy = false; + _error = error is ApiException ? error.message : '操作失败,请重试'; + }); + } + } + } + + Future _delete() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('删除收货地址?'), + content: const Text('已有订单中的收货信息会保留。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('取消')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('删除')), + ], + ), + ); + if (confirmed == true && mounted) { + await _mutate(() => widget.repository.deleteAddress(widget.address!.identity)); + } + } + + /// 未保存退出需确认;保存中禁止重复提交与退出。 + Future _back() async { + if (_busy) return; + if (!_dirty) { + Navigator.pop(context); + return; + } + final discard = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('放弃未保存的修改?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('继续编辑')), + TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('放弃')), + ], + ), + ); + if (discard == true && mounted) { + setState(() => _dirty = false); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) Navigator.pop(context); + }); + } + } + + String? _coordinate(String? value, double maximum, String other) { + final text = value?.trim() ?? ''; + if (text.isEmpty && other.trim().isEmpty) return null; + final number = double.tryParse(text); + return number == null || !number.isFinite || number.abs() > maximum ? '请填写有效坐标' : null; + } + + @override + Widget build(BuildContext context) => PopScope( + canPop: !_busy && !_dirty, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) _back(); + }, + child: Scaffold( + appBar: AppBar(title: Text(widget.address == null ? '新增收货地址' : '编辑收货地址')), + body: Form( + key: _form, + onChanged: () { + if (!_dirty) setState(() => _dirty = true); + }, + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ), + TextFormField( + controller: _name, + enabled: !_busy, + maxLength: 64, + decoration: const InputDecoration(labelText: '联系人'), + validator: (v) => v == null || v.trim().isEmpty ? '请填写联系人' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _phone, + enabled: !_busy, + keyboardType: TextInputType.phone, + maxLength: 11, + decoration: const InputDecoration(labelText: '联系电话'), + validator: (v) => + RegExp(r'^1[3-9]\d{9}$').hasMatch(v?.trim() ?? '') ? null : '请填写正确的手机号', + ), + const SizedBox(height: 12), + TextFormField( + controller: _address, + enabled: !_busy, + minLines: 2, + maxLines: 4, + maxLength: 255, + decoration: const InputDecoration(labelText: '详细地址', hintText: '省市区、街道及门牌号'), + validator: (v) => v == null || v.trim().isEmpty ? '请填写详细地址' : null, + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('设为默认地址'), + value: _default, + onChanged: _busy + ? null + : (v) => setState(() { + _default = v; + _dirty = true; + }), + ), + ExpansionTile( + title: const Text('地址坐标'), + tilePadding: EdgeInsets.zero, + children: [ + TextFormField( + controller: _longitude, + enabled: !_busy, + decoration: const InputDecoration(labelText: '经度'), + keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true), + validator: (v) => _coordinate(v, 180, _latitude.text), + ), + const SizedBox(height: 12), + TextFormField( + controller: _latitude, + enabled: !_busy, + decoration: const InputDecoration(labelText: '纬度'), + keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true), + validator: (v) => _coordinate(v, 90, _longitude.text), + ), + ], + ), + if (widget.address != null) + TextButton( + onPressed: _busy ? null : _delete, + style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error), + child: const Text('删除地址'), + ), + ], + ), + ), + bottomNavigationBar: SafeArea( + minimum: const EdgeInsets.fromLTRB(20, 10, 20, 16), + child: FilledButton( + onPressed: _busy ? null : _save, + child: Text(_busy ? '正在保存…' : '保存地址'), + ), + ), + ), + ); +} diff --git a/apps/user_app/lib/ui/features/address/addresses_page.dart b/apps/user_app/lib/ui/features/address/addresses_page.dart new file mode 100644 index 0000000..99da428 --- /dev/null +++ b/apps/user_app/lib/ui/features/address/addresses_page.dart @@ -0,0 +1,240 @@ +// 功能描述:按 29 号设计组织地址卡片与服务说明,支持管理及下单选择。 +// 版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../data/services/api_client.dart'; +import '../../../domain/models/shipping_address.dart'; +import '../../core/async_content.dart'; +import 'address_edit_page.dart'; + +class AddressesPage extends StatefulWidget { + const AddressesPage({required this.repository, this.selecting = false, super.key}); + final ClientRepository repository; + final bool selecting; + @override + State createState() => _AddressesPageState(); +} + +/// 每次变更成功重新读库;默认切换失败不改变界面事实。 +class _AddressesPageState extends State { + final _contentKey = GlobalKey>>(); + bool _busy = false; + + Future _edit([ShippingAddress? address]) async { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + final changed = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => AddressEditPage(repository: widget.repository, address: address), + ), + ); + if (mounted && changed == true) await _contentKey.currentState?.refresh(); + } + + Future _setDefault(ShippingAddress address) async { + if (_busy || address.isDefault) return; + setState(() => _busy = true); + try { + await widget.repository.setDefaultAddress(address.identity); + if (mounted) await _contentKey.currentState?.refresh(); + } on SessionExpiredException { + return; + } catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error is ApiException ? error.message : '默认地址设置失败,请重试')), + ); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + leading: BackButton( + onPressed: () { + if (Navigator.canPop(context)) { + Navigator.pop(context); + } else { + context.go('/me'); + } + }, + ), + title: Text(widget.selecting ? '选择收货地址' : '地址管理'), + actions: [ + TextButton(onPressed: _busy ? null : () => _edit(), child: const Text('添加')), + ], + ), + body: AsyncContent>( + key: _contentKey, + load: widget.repository.shippingAddresses, + builder: (context, addresses) => ListView( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 24), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + if (addresses.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 48), + child: Center(child: Text('暂无收货地址')), + ), + for (final address in addresses) _card(address), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFE5E7EB)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info, size: 26, color: Color(0xFF2563EB)), + SizedBox(width: 8), + Text('服务说明', style: TextStyle(fontWeight: FontWeight.w600)), + ], + ), + SizedBox(height: 8), + Text( + '地址用于气瓶配送、上门回收及维修服务,请填写真实门牌信息。', + style: TextStyle(fontSize: 13, color: Color(0xFF6B7280), height: 1.5), + ), + ], + ), + ), + ], + ), + ), + bottomNavigationBar: SafeArea( + minimum: const EdgeInsets.fromLTRB(18, 10, 18, 16), + child: FilledButton.icon( + onPressed: _busy ? null : () => _edit(), + icon: const Icon(Icons.add_circle_outline), + label: const Text('新增收货地址'), + ), + ), + ); + + Widget _card(ShippingAddress address) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Material( + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: const BorderSide(color: Color(0xFFE5E7EB)), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + InkWell( + onTap: _busy + ? null + : () { + if (widget.selecting) { + if (address.contactName.isEmpty || address.contactPhone.isEmpty) { + _edit(address); + return; + } + Navigator.pop(context, address); + } else { + _edit(address); + } + }, + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 18, 10, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.location_on_outlined, color: Color(0xFF2563EB), size: 22), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 10, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + address.contactName.isEmpty ? '待补充联系人' : address.contactName, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + Text( + address.maskedPhone, + style: const TextStyle(fontSize: 13, color: Color(0xFF6B7280)), + ), + if (address.isDefault) + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + border: Border.all(color: const Color(0xFFBFDBFE)), + borderRadius: BorderRadius.circular(3), + ), + child: const Text( + '默认', + style: TextStyle(color: Color(0xFF2563EB), fontSize: 12), + ), + ), + ], + ), + const SizedBox(height: 8), + Text(address.address, style: const TextStyle(fontSize: 14, height: 1.5)), + const SizedBox(height: 10), + const Text( + '配送范围待气站确认', + style: TextStyle(fontSize: 12, color: Color(0xFF9A6700)), + ), + ], + ), + ), + const Icon(Icons.chevron_right, color: Color(0xFF9CA3AF), size: 20), + ], + ), + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + Expanded( + child: TextButton.icon( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 14), + disabledForegroundColor: const Color(0xFF2563EB), + ), + onPressed: _busy || address.isDefault ? null : () => _setDefault(address), + icon: Icon( + Icons.check_circle_outline, + size: 18, + ), + label: const Text('设为默认'), + ), + ), + Container(height: 18, width: 1, color: const Color(0xFFE5E7EB)), + Expanded( + child: TextButton.icon( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 14), + foregroundColor: const Color(0xFF111827), + ), + onPressed: _busy ? null : () => _edit(address), + icon: const Icon(Icons.edit_outlined, size: 20, color: Color(0xFF2563EB)), + label: const Text('编辑'), + ), + ), + ], + ), + ), + ], + ), + ), + ); +} diff --git a/apps/user_app/lib/ui/features/auth/login_page.dart b/apps/user_app/lib/ui/features/auth/login_page.dart index 7d8245b..c807190 100644 --- a/apps/user_app/lib/ui/features/auth/login_page.dart +++ b/apps/user_app/lib/ui/features/auth/login_page.dart @@ -1,11 +1,16 @@ -// 功能描述:提供用户端手机号密码登录界面及本地输入校验。 -// 版本:1.2.0 +// 功能描述:提供验证码和密码登录、协议确认与本地输入校验。 +// 版本:1.4.0 +import 'dart:async'; import 'package:flutter/material.dart'; +import '../../core/reference_image.dart'; import 'package:go_router/go_router.dart'; import '../../../app/auth_navigation.dart'; import '../../../app/dependencies.dart'; import '../../../data/services/api_client.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../data/repositories/primary_repository.dart'; +import 'login_support.dart'; /// 用户端手机号密码登录页面。 class LoginPage extends StatefulWidget { @@ -13,10 +18,12 @@ class LoginPage extends StatefulWidget { required this.session, this.redirectTarget, this.showSessionExpiredMessage = false, + this.repository, super.key, }); final UserSession session; + final ClientRepository? repository; final String? redirectTarget; final bool showSessionExpiredMessage; @@ -37,6 +44,13 @@ class _LoginPageState extends State { String? _phoneError; String? _passwordError; bool _obscurePassword = true; + bool _verificationMode = true; + bool _remember = false; + bool _sendingCode = false; + int _seconds = 0; + String? _requestIdentity; + String? _codePhone; + Timer? _timer; @override void initState() { @@ -48,6 +62,7 @@ class _LoginPageState extends State { @override void dispose() { + _timer?.cancel(); _phone.dispose(); _password.dispose(); _phoneFocus.dispose(); @@ -59,17 +74,23 @@ class _LoginPageState extends State { Future _login() async { final phone = _phone.text.trim(); final phoneValid = _phonePattern.hasMatch(phone); - final passwordValid = _password.text.isNotEmpty; + final passwordValid = _verificationMode + ? RegExp(r'^\d{4,8}$').hasMatch(_password.text.trim()) + : _password.text.isNotEmpty; if (!phoneValid || !passwordValid) { setState(() { _phoneError = phoneValid ? null : '请输入正确的11位手机号'; - _passwordError = passwordValid ? null : '请输入密码'; + _passwordError = passwordValid ? null : (_verificationMode ? '请输入有效验证码' : '请输入密码'); _error = null; }); // 聚焦第一个无效字段,便于键盘和辅助技术用户立即修正。 (phoneValid ? _passwordFocus : _phoneFocus).requestFocus(); return; } + if (_verificationMode && (_requestIdentity == null || _codePhone != phone)) { + setState(() => _error = '请先获取当前手机号的验证码'); + return; + } setState(() { _submitting = true; @@ -78,7 +99,53 @@ class _LoginPageState extends State { _error = null; }); try { - await widget.session.login(phone: phone, password: _password.text); + if (widget.repository != null) { + final agreements = (await PrimaryRepository( + widget.repository!, + ).contents()).where((c) => c.type == 'agreement').toList(); + if (!mounted) return; + if (agreements.isNotEmpty) { + widget.session.loginConsentShownAt = DateTime.now(); + final agreed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('请阅读并确认协议'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final agreement in agreements) + ExpansionTile( + title: Text(agreement.title), + subtitle: Text('版本 ${agreement.version}'), + children: [SelectableText(agreement.body)], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('不同意'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('同意并登录'), + ), + ], + ), + ); + if (agreed != true) return; + } + widget.session.loginConsents = agreements; + } + widget.session.rememberLogin = _remember; + await widget.session.login( + phone: phone, + password: _verificationMode ? '' : _password.text, + verificationCode: _verificationMode ? _password.text.trim() : null, + requestIdentity: _verificationMode ? _requestIdentity : null, + ); } catch (error) { // 登录接口使用统一密码错误码,页面不区分账号是否存在,避免泄露账号状态。 final message = error is ApiException @@ -90,51 +157,77 @@ class _LoginPageState extends State { } } + /// 获取验证码;手机号变化后旧验证码请求不可继续使用。 + Future _sendCode() async { + final phone = _phone.text.trim(); + if (!_phonePattern.hasMatch(phone)) { + setState(() => _phoneError = '请输入正确的11位手机号'); + _phoneFocus.requestFocus(); + return; + } + setState(() { + _sendingCode = true; + _error = null; + }); + try { + final identity = await widget.session.sendCode(phone, 'login'); + if (identity.isEmpty) throw const ApiException(1714, '验证码发送结果待确认,请稍后重试'); + if (!mounted) return; + setState(() { + _requestIdentity = identity; + _codePhone = phone; + _seconds = 60; + }); + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + setState(() => _seconds--); + if (_seconds <= 0) timer.cancel(); + }); + } catch (error) { + if (mounted) setState(() => _error = error is ApiException ? error.toString() : '验证码发送失败'); + } finally { + if (mounted) setState(() => _sendingCode = false); + } + } + @override Widget build(BuildContext context) => Scaffold( + backgroundColor: Colors.white, body: SafeArea( - child: Center( + child: Align( + alignment: Alignment.topCenter, child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(24, 32, 24, 24), + padding: const EdgeInsets.fromLTRB(20, 76, 20, 32), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Align( - child: Container( - width: 72, - height: 72, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(24), - ), - child: Icon( - Icons.health_and_safety_rounded, - size: 38, - color: Theme.of(context).colorScheme.primary, - semanticLabel: '瓶安芯安全标识', - ), - ), - ), - const SizedBox(height: 24), - Text( - '瓶安芯', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineMedium, - ), + const Center(child: SizedBox(width: 140, child: ReferenceImage.brand())), const SizedBox(height: 8), Text( - '安全服务与生活采购', + '智能角阀,安全守护', textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + style: const TextStyle(fontSize: 15, height: 1.4, color: Color(0xFF666666)), ), - const SizedBox(height: 40), + const SizedBox(height: 26), + Row( + children: [ + _modeTab(true, '验证码登录'), + _modeTab(false, '密码登录'), + ], + ), + const SizedBox(height: 29), + const Text('手机号', style: TextStyle(fontSize: 14)), + const SizedBox(height: 10), TextField( controller: _phone, focusNode: _phoneFocus, + style: const TextStyle(fontSize: 14, height: 1.4), keyboardType: TextInputType.phone, autofillHints: const [AutofillHints.telephoneNumber], onChanged: (_) { @@ -146,40 +239,88 @@ class _LoginPageState extends State { } }, decoration: InputDecoration( - labelText: '手机号', - prefixIcon: const Icon(Icons.phone_outlined), + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 13, vertical: 14), + enabledBorder: _inputBorder, + hintText: '请输入手机号', errorText: _phoneError, ), ), - const SizedBox(height: 14), - TextField( - controller: _password, - focusNode: _passwordFocus, - obscureText: _obscurePassword, - autofillHints: const [AutofillHints.password], - onChanged: (_) { - if (_passwordError != null || _error != null) { - setState(() { - _passwordError = null; - _error = null; - }); - } - }, - onSubmitted: (_) => _submitting ? null : _login(), - decoration: InputDecoration( - labelText: '密码', - prefixIcon: const Icon(Icons.lock_outline), - errorText: _passwordError, - suffixIcon: IconButton( - tooltip: _obscurePassword ? '显示密码' : '隐藏密码', - onPressed: () => setState(() => _obscurePassword = !_obscurePassword), - icon: Icon( - _obscurePassword - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, + const SizedBox(height: 24), + Text(_verificationMode ? '验证码' : '密码', style: const TextStyle(fontSize: 14)), + const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + // 切换验证码/密码或显隐时重建输入连接,避免Web沿用已移除的输入DOM。 + key: ValueKey('login-secret-$_verificationMode-$_obscurePassword'), + controller: _password, + focusNode: _passwordFocus, + style: const TextStyle(fontSize: 14, height: 1.4), + obscureText: !_verificationMode && _obscurePassword, + keyboardType: _verificationMode + ? TextInputType.number + : TextInputType.visiblePassword, + autofillHints: [ + _verificationMode ? AutofillHints.oneTimeCode : AutofillHints.password, + ], + onChanged: (_) { + if (_passwordError != null || _error != null) { + setState(() { + _passwordError = null; + _error = null; + }); + } + }, + onSubmitted: (_) => _submitting ? null : _login(), + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 13, vertical: 14), + enabledBorder: _inputBorder, + hintText: _verificationMode ? '请输入验证码' : '请输入密码', + errorText: _passwordError, + suffixIcon: _verificationMode + ? null + : IconButton( + tooltip: _obscurePassword ? '显示密码' : '隐藏密码', + onPressed: () => _configureSecretInput( + () => _obscurePassword = !_obscurePassword, + ), + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), + ), ), ), - ), + if (_verificationMode) ...[ + const SizedBox(width: 17), + SizedBox( + width: 113, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 50), + foregroundColor: const Color(0xFF005FFF), + side: const BorderSide(color: Color(0xFF005FFF)), + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + onPressed: _sendingCode || _seconds > 0 ? null : _sendCode, + child: Text( + _sendingCode + ? '发送中' + : _seconds > 0 + ? '${_seconds}s' + : '获取验证码', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400), + ), + ), + ), + ], + ], ), if (_error != null) ...[ const SizedBox(height: 12), @@ -206,15 +347,62 @@ class _LoginPageState extends State { ), ), ], - const SizedBox(height: 24), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: const Text('记住登录状态', style: TextStyle(fontSize: 14)), + activeColor: const Color(0xFF005FFF), + value: _remember, + onChanged: _submitting + ? null + : (value) => setState(() => _remember = value ?? false), + ), + const SizedBox(height: 10), FilledButton( + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF0661EE), + minimumSize: const Size(0, 51), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + ), onPressed: _submitting ? null : _login, child: _submitting ? const SizedBox.square( dimension: 22, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Text('安全登录'), + : const Text( + '登录', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500), + ), + ), + TextButton( + onPressed: _submitting + ? null + : () => showDialog( + context: context, + builder: (_) => ResetPasswordDialog(session: widget.session), + ), + child: const Text( + '忘记密码', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w400), + ), + ), + TextButton( + style: TextButton.styleFrom(padding: EdgeInsets.zero), + onPressed: () => showPublishedAgreements(context), + child: const Text.rich( + TextSpan( + text: '登录即表示你已阅读并同意 ', + style: TextStyle(fontSize: 12.5, height: 1.6, color: Color(0xFF666666)), + children: [ + TextSpan( + text: '《用户协议》和《隐私政策》', + style: TextStyle(color: Color(0xFF005FFF)), + ), + ], + ), + textAlign: TextAlign.center, + ), ), TextButton( onPressed: () => context.push( @@ -226,14 +414,6 @@ class _LoginPageState extends State { ), child: const Text('首次使用?注册账号'), ), - const SizedBox(height: 8), - Text( - '登录即表示你已阅读并同意《用户协议》和《隐私政策》', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), ], ), ), @@ -241,4 +421,67 @@ class _LoginPageState extends State { ), ), ); + + // 图 01 使用浅灰一像素边框,错误和焦点边框仍沿用主题状态。 + static const _inputBorder = OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(6)), + borderSide: BorderSide(color: Color(0xFFCCCCCC)), + ); + + /// 登录方式保持双标签布局,切换时清除上一种方式的凭据与错误。 + Widget _modeTab(bool verification, String label) { + final selected = _verificationMode == verification; + return Expanded( + child: Semantics( + selected: selected, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: selected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outlineVariant, + width: selected ? 2 : 1, + ), + ), + ), + child: TextButton( + style: TextButton.styleFrom( + foregroundColor: selected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + minimumSize: const Size(0, 44), + ), + onPressed: _submitting + ? null + : () { + if (_verificationMode == verification) return; + _configureSecretInput(() { + _verificationMode = verification; + _password.clear(); + _passwordError = null; + _error = null; + }); + }, + child: Text( + label, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400), + ), + ), + ), + ), + ); + } + + /// 先结束旧输入连接,再应用输入类型;已有焦点在下一帧恢复,显隐保留文本。 + void _configureSecretInput(VoidCallback change) { + final restoreFocus = _passwordFocus.hasFocus; + _passwordFocus.unfocus(); + setState(change); + if (restoreFocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _passwordFocus.requestFocus(); + }); + } + } } diff --git a/apps/user_app/lib/ui/features/auth/login_support.dart b/apps/user_app/lib/ui/features/auth/login_support.dart new file mode 100644 index 0000000..26e442e --- /dev/null +++ b/apps/user_app/lib/ui/features/auth/login_support.dart @@ -0,0 +1,175 @@ +// 功能描述:登录页的已发布协议阅读与验证码重置密码交互。 +// 版本:1.0.0 +import 'package:flutter/material.dart'; +import '../../../app/dependencies.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../data/repositories/primary_repository.dart'; +import '../../../data/services/api_client.dart'; +import '../../../domain/models/primary_models.dart'; +import '../../core/async_content.dart'; + +/// 读取后台已发布协议并展示实际版本;正文缺失时不提供虚构条款。 +Future showPublishedAgreements(BuildContext context, {ClientRepository? repository}) => + showDialog( + context: context, + builder: (context) => Dialog( + child: SizedBox( + width: 440, + height: 520, + child: Column( + children: [ + AppBar( + title: const Text('协议与隐私'), + automaticallyImplyLeading: false, + actions: [ + IconButton( + tooltip: '关闭', + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close), + ), + ], + ), + Expanded( + child: AsyncContent>( + load: () async => (await PrimaryRepository( + repository ?? ClientRepository(ApiClient(() => '')), + ).contents()).where((c) => c.type == 'agreement').toList(), + empty: (items) => items.isEmpty, + builder: (context, items) => ListView( + padding: const EdgeInsets.all(16), + children: [ + for (final item in items) + ExpansionTile( + title: Text(item.title), + subtitle: Text('版本 ${item.version}'), + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: SelectableText(item.body), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + +/// 重置密码表单;验证码仅限重置用途,提交成功才关闭弹窗。 +class ResetPasswordDialog extends StatefulWidget { + const ResetPasswordDialog({required this.session, super.key}); + final UserSession session; + @override + State createState() => _ResetPasswordDialogState(); +} + +/// 管理重置验证码和密码输入,失败保留用户输入。 +class _ResetPasswordDialogState extends State { + final _phone = TextEditingController(); + final _code = TextEditingController(); + final _password = TextEditingController(); + String? _request, _requestedPhone, _error; + bool _busy = false; + DateTime? _sentAt; + @override + void dispose() { + _phone.dispose(); + _code.dispose(); + _password.dispose(); + super.dispose(); + } + + /// 执行发送或重置;参数标识发送验证码,异常统一展示中文。 + Future _run(bool send) async { + final phone = _phone.text.trim(); + if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(phone)) { + setState(() => _error = '请输入正确的11位手机号'); + return; + } + if (send && _sentAt != null && DateTime.now().difference(_sentAt!).inSeconds < 60) { + setState(() => _error = '请稍后再获取验证码'); + return; + } + if (!send && + (_request == null || + phone != _requestedPhone || + _code.text.trim().isEmpty || + _password.text.runes.length < 6)) { + setState(() => _error = '请获取当前手机号的验证码,并填写至少6位新密码'); + return; + } + setState(() { + _busy = true; + _error = null; + }); + try { + if (send) { + final request = await widget.session.sendCode(phone, 'reset_login_password'); + if (request.isEmpty) throw const ApiException(1714, '验证码发送结果待确认'); + _request = request; + _requestedPhone = phone; + _sentAt = DateTime.now(); + } else { + await widget.session.resetPassword( + phone: phone, + code: _code.text.trim(), + requestIdentity: _request!, + password: _password.text, + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('密码已修改,请重新登录'))); + Navigator.pop(context); + } + } + } catch (error) { + if (mounted) setState(() => _error = error is ApiException ? error.toString() : '请求失败,请稍后重试'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) => AlertDialog( + title: const Text('找回密码'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _phone, + keyboardType: TextInputType.phone, + decoration: const InputDecoration(labelText: '手机号'), + ), + const SizedBox(height: 12), + TextField( + controller: _code, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: '验证码'), + ), + TextButton( + onPressed: _busy ? null : () => _run(true), + child: Text(_request == null ? '获取验证码' : '重新获取验证码'), + ), + TextField( + controller: _password, + obscureText: true, + decoration: const InputDecoration(labelText: '新密码'), + ), + if (_error != null) + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + ), + ), + actions: [ + TextButton(onPressed: _busy ? null : () => Navigator.pop(context), child: const Text('取消')), + FilledButton( + onPressed: _busy ? null : () => _run(false), + child: Text(_busy ? '处理中' : '修改密码'), + ), + ], + ); +} diff --git a/apps/user_app/lib/ui/features/auth/register_page.dart b/apps/user_app/lib/ui/features/auth/register_page.dart index 9a38e1d..4630408 100644 --- a/apps/user_app/lib/ui/features/auth/register_page.dart +++ b/apps/user_app/lib/ui/features/auth/register_page.dart @@ -40,7 +40,8 @@ class _RegisterPageState extends State { final identity = await widget.session.sendCode(_phone.text.trim(), 'register'); setState(() { _requestIdentity = identity; - _message = '验证码已发送'; + // 当前接口只建立Mock请求,不能把记录写入Redis描述为短信送达。 + _message = '验证码请求已建立,当前环境未发送短信'; }); } catch (error) { setState(() => _message = error.toString()); diff --git a/apps/user_app/lib/ui/features/home/device_status_placeholder.dart b/apps/user_app/lib/ui/features/home/device_status_placeholder.dart new file mode 100644 index 0000000..eedfd76 --- /dev/null +++ b/apps/user_app/lib/ui/features/home/device_status_placeholder.dart @@ -0,0 +1,72 @@ +// 功能:首页设备能力未接入时保留设计结构,禁止展示虚构遥测;版本:1.0.0。 +import 'package:flutter/material.dart'; + +/// 仅作为未开放状态;接入真实设备接口后替换,不构造模拟在线或安全结论。 +class DeviceStatusPlaceholder extends StatelessWidget { + const DeviceStatusPlaceholder({super.key}); + @override + Widget build(BuildContext context) => Column( + children: [ + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.settings_input_component, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('设备服务暂未开放', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + SizedBox(height: 2), + Text('设备状态与遥测暂不可查询', style: TextStyle(fontSize: 12, color: Color(0xFF626977))), + ], + ), + ), + ], + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Divider(), + ), + LayoutBuilder( + builder: (context, constraints) { + final columns = + constraints.maxWidth < 330 || MediaQuery.textScalerOf(context).scale(12) > 14 ? 2 : 4; + final width = constraints.maxWidth / columns; + return Wrap( + children: [ + for (final label in ['环境压力', '温度', '可燃气体', '最后更新时间']) + SizedBox( + width: width, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Column( + children: [ + Text( + label, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12, color: Color(0xFF727987)), + ), + const SizedBox(height: 2), + const Text('暂不可查询', style: TextStyle(fontSize: 11)), + ], + ), + ), + ), + ], + ); + }, + ), + ], + ); +} diff --git a/apps/user_app/lib/ui/features/home/home_page.dart b/apps/user_app/lib/ui/features/home/home_page.dart index 81fd1eb..f2ec621 100644 --- a/apps/user_app/lib/ui/features/home/home_page.dart +++ b/apps/user_app/lib/ui/features/home/home_page.dart @@ -1,105 +1,331 @@ -// 功能描述:展示用户端首页的安全内容、服务归属与公告列表。 -// 版本:1.1.0 +// 功能描述:首页服务归属、内容和保留入口;未开放设备不展示虚构遥测。 +// 版本:2.0.0 import 'package:flutter/material.dart'; - +import 'package:go_router/go_router.dart'; import '../../../data/repositories/client_repository.dart'; -import '../../../data/services/api_client.dart'; -import '../../../domain/models/client_models.dart'; +import '../../../data/repositories/primary_repository.dart'; +import '../../../domain/models/primary_models.dart'; +import '../../core/async_content.dart'; +import '../../core/feature_entry.dart'; import '../../core/widgets.dart'; import 'service_relation_card.dart'; +import 'device_status_placeholder.dart'; -/// 用户端首页。 -class HomePage extends StatefulWidget { - const HomePage({required this.repository, super.key}); - +/// 首页;游客可浏览内容,个人归属仅登录后读取。 +class HomePage extends StatelessWidget { + const HomePage({required this.repository, this.authenticated = true, super.key}); final ClientRepository repository; + final bool authenticated; - @override - State createState() => _HomePageState(); -} - -/// 管理首页远端数据加载与下拉刷新状态。 -class _HomePageState extends State { - late Future<(List, Map?)> _future; - - @override - void initState() { - super.initState(); - _future = _load(); + /// 并行获取独立数据;所有异常由公共异步容器处理。 + Future<(List, ServiceRelation?)> _load() async { + final source = PrimaryRepository(repository); + final results = await Future.wait([ + source.contents(), + authenticated ? source.relation() : Future.value(null), + ]); + return (results[0] as List, results[1] as ServiceRelation?); } - /// 并行语义上聚合公告和当前服务归属数据。 - Future<(List, Map?)> _load() async => - (await widget.repository.contents(), await widget.repository.serviceRelation()); - - /// 重新加载首页数据并等待刷新完成。 - Future _refresh() async { - setState(() => _future = _load()); - await _future; + /// 登录入口保留当前目标页,避免认证后丢失用户意图。 + void _openProtected(BuildContext context, String location) { + if (authenticated) { + context.push(location); + return; + } + context.push('/login?redirect=${Uri.encodeComponent(location)}'); } @override Widget build(BuildContext context) => Scaffold( body: SafeArea( - child: FutureBuilder<(List, Map?)>( - future: _future, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError) { - if (snapshot.error is SessionExpiredException) { - return const SizedBox.shrink(); - } - return EmptyState( - title: '首页加载失败', - description: snapshot.error.toString(), - onRetry: _refresh, - ); - } - final (contents, relation) = snapshot.data ?? (const [], null); - return RefreshIndicator( - onRefresh: _refresh, - child: ListView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(bottom: 24), - children: [ - const PageIntro( - eyebrow: '安全生活', - title: '今天也要安心用气', - description: '设备控制能力尚未开放,本页只展示真实服务与安全内容。', - ), - AppGutter( - child: ServiceRelationCard(relation: relation), - ), - const SizedBox(height: 32), - const AppGutter( - child: SectionHeader(title: '安全公告', description: '来自平台的最新安全提醒与服务信息'), - ), - const SizedBox(height: 12), - if (contents.isEmpty) - const AppGutter( - child: SurfaceSection(child: Text('暂无已发布内容')), - ) - else - AppGutter( - child: SurfaceSection( - padding: EdgeInsets.zero, - child: Column( - children: [ - for (var index = 0; index < contents.take(6).length; index++) ...[ - RecordCard(record: contents[index]), - if (index < contents.take(6).length - 1) - const Divider(indent: 16, endIndent: 16), - ], - ], + child: AsyncContent<(List, ServiceRelation?)>( + key: ValueKey(authenticated), + load: _load, + builder: (context, data) => ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(18, 16, 18, 20), + children: [ + Text( + '安全生活', + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text('今天也要安心用气', style: Theme.of(context).textTheme.headlineMedium), + const SizedBox(height: 6), + const Text( + '设备控制能力尚未开放,本页只展示真实服务与安全内容。', + style: TextStyle(fontSize: 12), + ), + const SizedBox(height: 16), + if (authenticated) + ServiceRelationCard.typed(data.$2) + else + ServiceRelationCard( + relation: null, + emptyMessage: '登录后查看所属气站与配送点', + onTap: () => _openProtected(context, '/home'), + ), + const SizedBox(height: 10), + SurfaceSection( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: Text( + '我的智能设备', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + InkWell( + onTap: () => _openProtected(context, '/devices'), + borderRadius: BorderRadius.circular(8), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 4, vertical: 5), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('查看详情', style: TextStyle(color: Color(0xFF2563EB))), + SizedBox(width: 2), + Icon(Icons.chevron_right, size: 18, color: Color(0xFF2563EB)), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 4), + const DeviceStatusPlaceholder(), + ], + ), + ), + const SizedBox(height: 10), + SurfaceSection( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: IntrinsicHeight( + child: Row( + children: [ + const Expanded( + child: FeatureEntry( + icon: Icons.qr_code_scanner, + label: '扫码添加', + compact: true, + showStatus: false, ), ), + const VerticalDivider(indent: 10, endIndent: 10), + const Expanded( + child: FeatureEntry( + icon: Icons.bluetooth, + label: '蓝牙连接', + compact: true, + showStatus: false, + ), + ), + const VerticalDivider(indent: 10, endIndent: 10), + Expanded( + child: FeatureEntry( + icon: Icons.grid_view_outlined, + label: '分组控制', + compact: true, + showStatus: false, + onTap: () => _openProtected(context, '/device-groups'), + ), + ), + const VerticalDivider(indent: 10, endIndent: 10), + Expanded( + child: FeatureEntry( + icon: Icons.build_outlined, + label: '一键报修', + compact: true, + showStatus: false, + onTap: () => _openProtected(context, '/repair'), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 10), + SurfaceSection( + padding: EdgeInsets.zero, + child: Column( + children: [ + _entry( + context, + Icons.shield_outlined, + '安全记录 / 最近告警', + '查看设备状态与告警记录', ), + const Divider(indent: 12, endIndent: 12), + _entry( + context, + Icons.propane_tank_outlined, + '气瓶下单', + '选择本人合同气瓶并预约配送', + onTap: () => _openProtected(context, '/gas/order'), + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + children: [ + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '安全公告', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + SizedBox(height: 2), + Text( + '来自平台的最新安全提醒与服务信息', + style: TextStyle(fontSize: 11, color: Color(0xFF626977)), + ), + ], + ), + ), + InkWell( + onTap: () => context.push('/contents'), + borderRadius: BorderRadius.circular(8), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 4, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('查看全部', style: TextStyle(color: Color(0xFF2563EB))), + SizedBox(width: 2), + Icon(Icons.chevron_right, size: 18, color: Color(0xFF2563EB)), + ], + ), + ), + ), ], ), - ); - }, + const SizedBox(height: 4), + if (data.$1.where((c) => c.type == 'notice').isEmpty) + const SurfaceSection(child: Text('暂无已发布内容')), + if (data.$1.where((c) => c.type == 'notice').isNotEmpty) + SurfaceSection( + padding: EdgeInsets.zero, + child: Column( + children: [ + for (final (index, content) + in data.$1.where((c) => c.type == 'notice').take(2).indexed) ...[ + _AnnouncementRow( + content: content, + onTap: () => context.push( + '/contents/${Uri.encodeComponent(content.identity)}', + ), + ), + if (index == 0 && data.$1.where((c) => c.type == 'notice').length > 1) + const Divider(indent: 12, endIndent: 12), + ], + ], + ), + ), + ], + ), + ), + ), + ); + + /// 保留安全与气瓶入口,点击解释当前可用性。 + Widget _entry( + BuildContext context, + IconData icon, + String text, + String description, { + VoidCallback? onTap, + }) => InkWell( + onTap: onTap ?? () => showUnavailableFeature(context, text), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + child: Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 21, color: Theme.of(context).colorScheme.primary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(text, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + Text( + description, + style: const TextStyle(fontSize: 12, color: Color(0xFF626977)), + ), + ], + ), + ), + const Icon(Icons.chevron_right, color: Color(0xFF626977)), + ], + ), + ), + ); +} + +/// 公告行只显示服务端真实标题、摘要与日期,缺失字段保持空白。 +class _AnnouncementRow extends StatelessWidget { + const _AnnouncementRow({required this.content, required this.onTap}); + final PublishedContent content; + final VoidCallback onTap; + + String get _date { + final value = content.publishedAt?.toLocal(); + return value == null ? '' : '${value.year}/${value.month}/${value.day}'; + } + + @override + Widget build(BuildContext context) => InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + content.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 1), + Text( + content.body, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 11, color: Color(0xFF626977)), + ), + ], + ), + ), + if (_date.isNotEmpty) ...[ + const SizedBox(width: 8), + Text(_date, style: const TextStyle(fontSize: 11, color: Color(0xFF777F8D))), + ], + const SizedBox(width: 4), + const Icon(Icons.chevron_right, size: 20, color: Color(0xFF626977)), + ], ), ), ); diff --git a/apps/user_app/lib/ui/features/home/safety_contents_page.dart b/apps/user_app/lib/ui/features/home/safety_contents_page.dart new file mode 100644 index 0000000..6ddf429 --- /dev/null +++ b/apps/user_app/lib/ui/features/home/safety_contents_page.dart @@ -0,0 +1,181 @@ +// 功能:安全内容中心的真实公告搜索、分类入口与正文阅读;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../data/repositories/primary_repository.dart'; +import '../../../data/services/api_client.dart'; +import '../../../domain/models/primary_models.dart'; +import '../../core/async_content.dart'; +import '../../core/feature_entry.dart'; + +/// 图文内容使用后台发布接口;安全视频尚缺媒体能力,保留入口但不伪造播放。 +class SafetyContentsPage extends StatefulWidget { + const SafetyContentsPage({required this.repository, this.identity, super.key}); + final ClientRepository repository; + final String? identity; + @override + State createState() => _SafetyContentsPageState(); +} + +class _SafetyContentsPageState extends State { + String _query = ''; + int _selected = 0; + final _searchFocus = FocusNode(); + + /// 列表保留兼容接口,正文不再重复下载全部文章。 + Future> _load() async { + if (widget.identity == null) return PrimaryRepository(widget.repository).contents(); + try { + final item = await widget.repository.safetyContent(widget.identity!); + return [ + PublishedContent( + identity: item.identity, + title: item.title, + body: item.raw['body'] as String? ?? '', + type: item.raw['content_type'] as String? ?? '', + version: item.raw['version_no'] as int? ?? 1, + ), + ]; + } on ApiException catch (error) { + if (error.code == 1112) return []; + rethrow; + } + } + + @override + void dispose() { + _searchFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: Text(widget.identity == null ? '安全内容' : '内容详情'), + leading: IconButton( + tooltip: '返回', + icon: const Icon(Icons.arrow_back), + onPressed: () => context.canPop() ? context.pop() : context.go('/home'), + ), + actions: [ + if (widget.identity == null) + IconButton( + tooltip: '搜索公告', + onPressed: _searchFocus.requestFocus, + icon: const Icon(Icons.search), + ), + ], + ), + body: AsyncContent>( + key: ValueKey(widget.identity), + load: _load, + builder: (context, contents) { + final notices = contents + .where((item) => ['notice', 'safety_article', 'law'].contains(item.type)) + .toList(); + if (widget.identity != null) { + final matches = notices.where((item) => item.identity == widget.identity); + if (matches.isEmpty) return const Center(child: Text('内容不存在或已下架')); + final item = matches.first; + return ListView( + padding: const EdgeInsets.all(20), + children: [ + SelectableText( + item.title, + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 12), + const Text('平台发布', style: TextStyle(color: Color(0xFF727987))), + const Divider(height: 32), + SelectableText( + item.body.isEmpty ? '暂无正文' : item.body, + style: const TextStyle(fontSize: 16, height: 1.7), + ), + ], + ); + } + final type = {1: 'safety_article', 3: 'law', 4: 'notice'}[_selected]; + final shown = notices + .where( + (item) => + (type == null || item.type == type) && + item.title.toLowerCase().contains(_query.toLowerCase()), + ) + .toList(); + return ListView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final (index, label) in ['全部', '安全宣传', '安全视频', '法律法规', '平台公告'].indexed) + TextButton( + onPressed: () { + if (index == 2) { + showUnavailableFeature(context, label); + return; + } + setState(() => _selected = index); + }, + child: Column( + children: [ + Text( + label, + style: TextStyle( + color: _selected == index + ? const Color(0xFF0064FF) + : const Color(0xFF616979), + ), + ), + if (index == 2) + const Text( + '暂未开放', + style: TextStyle(fontSize: 10, color: Color(0xFF727987)), + ), + if (_selected == index) + const SizedBox( + width: 24, + child: Divider(color: Color(0xFF0064FF), thickness: 2), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 12), + TextField( + focusNode: _searchFocus, + onChanged: (value) => setState(() => _query = value.trim()), + decoration: InputDecoration( + hintText: _selected == 4 ? '搜索公告标题' : '搜索内容标题', + suffixIcon: const Icon(Icons.search), + ), + ), + const SizedBox(height: 20), + if (shown.isEmpty) + const Padding( + padding: EdgeInsets.all(28), + child: Center(child: Text('暂无符合条件的内容')), + ), + for (final item in shown) ...[ + ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 8), + leading: const Icon(Icons.campaign_outlined, size: 32, color: Color(0xFF0064FF)), + title: Text( + item.title, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + subtitle: const Padding(padding: EdgeInsets.only(top: 8), child: Text('平台发布')), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/contents/${Uri.encodeComponent(item.identity)}'), + ), + const Divider(height: 1), + ], + ], + ); + }, + ), + ); +} diff --git a/apps/user_app/lib/ui/features/home/service_relation_card.dart b/apps/user_app/lib/ui/features/home/service_relation_card.dart index 3b432bb..d012d3e 100644 --- a/apps/user_app/lib/ui/features/home/service_relation_card.dart +++ b/apps/user_app/lib/ui/features/home/service_relation_card.dart @@ -2,13 +2,28 @@ // 版本:1.0.0 import 'package:flutter/material.dart'; import 'package:heqi_design_system/heqi_design_system.dart'; +import '../../../domain/models/primary_models.dart'; /// 服务归属信息卡片,负责清晰区分气站主体与配送履约网点。 class ServiceRelationCard extends StatelessWidget { - const ServiceRelationCard({required this.relation, super.key}); + const ServiceRelationCard({ + required this.relation, + this.emptyMessage = '尚未建立服务关系', + this.onTap, + super.key, + }); + + /// 以强类型归属创建卡片,保留旧构造函数供未迁移页面使用。 + factory ServiceRelationCard.typed(ServiceRelation? value) => ServiceRelationCard( + relation: value == null + ? null + : {'gas_name': value.gasName, 'delivery_name': value.deliveryName}, + ); /// 服务端返回的当前有效服务关系;为空表示尚未建立归属。 final Map? relation; + final String emptyMessage; + final VoidCallback? onTap; /// 提取并清理指定关系字段,空字符串按无值处理。 String? _relationValue(String key) { @@ -23,53 +38,60 @@ class ServiceRelationCard extends StatelessWidget { final hasRelation = relation != null && gasName != null; return HeqiSurfaceSection( - padding: const EdgeInsets.all(HeqiSpacing.x4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(HeqiRadius.extraLarge), - ), - child: Icon( - Icons.store_mall_directory_outlined, - size: HeqiSize.iconLarge, - color: Theme.of(context).colorScheme.primary, - semanticLabel: '服务归属', - ), + padding: EdgeInsets.zero, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.all(HeqiSpacing.x3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(HeqiRadius.extraLarge), + ), + child: Icon( + Icons.store_mall_directory_outlined, + size: 30, + color: Theme.of(context).colorScheme.primary, + semanticLabel: '服务归属', + ), + ), + const SizedBox(width: HeqiSpacing.x3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('当前服务归属', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: HeqiSpacing.x1), + if (!hasRelation) + Text( + emptyMessage, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ) + else ...[ + _RelationLine(label: '所属气站', value: gasName), + const Padding( + padding: EdgeInsets.symmetric(vertical: HeqiSpacing.x1), + child: Divider(height: 1), + ), + _RelationLine( + label: deliveryName == null ? '服务方式' : '服务配送点', + value: deliveryName ?? '气站直接服务', + ), + ], + ], + ), + ), + ], ), - const SizedBox(width: HeqiSpacing.x4), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('当前服务归属', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: HeqiSpacing.x3), - if (!hasRelation) - Text( - '尚未建立服务关系', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ) - else ...[ - _RelationLine(label: '所属气站', value: gasName), - const Padding( - padding: EdgeInsets.symmetric(vertical: HeqiSpacing.x3), - child: Divider(height: 1), - ), - _RelationLine( - label: deliveryName == null ? '服务方式' : '服务配送点', - value: deliveryName ?? '气站直接服务', - ), - ], - ], - ), - ), - ], + ), ), ); } diff --git a/apps/user_app/lib/ui/features/orders/contract_change_requests.dart b/apps/user_app/lib/ui/features/orders/contract_change_requests.dart new file mode 100644 index 0000000..8137e83 --- /dev/null +++ b/apps/user_app/lib/ui/features/orders/contract_change_requests.dart @@ -0,0 +1,186 @@ +// 功能描述:提交本人合同变更申请、查看处理结果、取消和确认;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/gas_order_detail.dart'; +import '../../core/async_content.dart'; + +Future showContractChangeRequests( + BuildContext context, + ClientRepository repository, + String identity, +) => showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: true, + builder: (context) => FractionallySizedBox( + heightFactor: .9, + child: ContractChangeRequests(repository: repository, identity: identity), + ), +); + +class ContractChangeRequests extends StatefulWidget { + const ContractChangeRequests({required this.repository, required this.identity, super.key}); + final ClientRepository repository; + final String identity; + @override + State createState() => _ContractChangeRequestsState(); +} + +/// 请求号在失败重试期间保持不变;服务端同时防止同合同重复待办。 +class _ContractChangeRequestsState extends State { + final _description = TextEditingController(); + final _content = GlobalKey>>(); + String _requestNo = const Uuid().v4(); + String? _notice; + bool _busy = false; + @override + void dispose() { + _description.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_busy) return; + if (_description.text.trim().isEmpty) { + setState(() => _notice = '请填写希望变更的内容及原因'); + return; + } + setState(() { + _busy = true; + _notice = null; + }); + try { + final existing = await widget.repository.requestContractChange( + widget.identity, + _description.text.trim(), + _requestNo, + ); + if (!mounted) return; + _description.clear(); + _requestNo = const Uuid().v4(); + setState(() => _notice = existing ? '已有进行中的申请,请查看下方处理进度' : '申请已提交,等待供气单位受理'); + await _content.currentState?.refresh(); + } catch (error) { + if (mounted) setState(() => _notice = error.toString()); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _act(ContractChangeRequest request, bool confirm) async { + if (_busy) return; + final accepted = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(confirm ? '确认处理结果' : '取消申请'), + content: Text(confirm ? '确认已阅读供气单位的处理结果?' : '确定取消这份合同变更申请?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('返回')), + TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('确认')), + ], + ), + ); + if (accepted != true || !mounted) return; + setState(() { + _busy = true; + _notice = null; + }); + try { + if (confirm) { + await widget.repository.confirmTicket(request.identity); + } else { + await widget.repository.cancelTicket(request.identity); + } + } catch (error) { + if (mounted) setState(() => _notice = error.toString()); + } finally { + if (mounted) { + await _content.currentState?.refresh(); + if (mounted) setState(() => _busy = false); + } + } + } + + @override + Widget build(BuildContext context) => Column( + children: [ + Row( + children: [ + const Expanded( + child: Padding( + padding: EdgeInsets.only(left: 16), + child: Text('合同变更申请', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + ), + ), + IconButton( + tooltip: '关闭申请', + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close), + ), + ], + ), + Expanded( + child: AsyncContent>( + key: _content, + load: () => widget.repository.contractChangeRequests(widget.identity), + builder: (context, requests) => ListView( + padding: EdgeInsets.fromLTRB(16, 8, 16, 16 + MediaQuery.viewInsetsOf(context).bottom), + children: [ + TextField( + controller: _description, + enabled: !_busy, + minLines: 3, + maxLines: 5, + maxLength: 2000, + decoration: const InputDecoration(labelText: '变更内容及原因', hintText: '请说明希望变更的合同内容'), + ), + FilledButton(onPressed: _busy ? null : _submit, child: const Text('提交申请')), + if (_notice != null) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Semantics(liveRegion: true, child: Text(_notice!)), + ), + const SizedBox(height: 16), + const Text('申请记录', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)), + if (requests.isEmpty) + const Padding(padding: EdgeInsets.symmetric(vertical: 24), child: Text('暂无变更申请')), + for (final request in requests) + Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${request.number} · ${request.statusName}', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Text(request.description), + if (request.result.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text('处理结果:${request.result}'), + ), + if (request.actions.contains('cancel')) + TextButton( + onPressed: _busy ? null : () => _act(request, false), + child: const Text('取消申请'), + ), + if (request.actions.contains('confirm')) + TextButton( + onPressed: _busy ? null : () => _act(request, true), + child: const Text('确认处理结果'), + ), + const Divider(), + ], + ), + ), + ], + ), + ), + ), + ], + ); +} diff --git a/apps/user_app/lib/ui/features/orders/contract_download_button.dart b/apps/user_app/lib/ui/features/orders/contract_download_button.dart new file mode 100644 index 0000000..9b0e977 --- /dev/null +++ b/apps/user_app/lib/ui/features/orders/contract_download_button.dart @@ -0,0 +1,101 @@ +// 功能描述:本人PDF下载及系统保存,支持取消、失败重试与防重复点击;版本:1.0.0。 +import 'package:file_saver/file_saver.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../data/services/api_client.dart'; + +typedef ContractPdfSaver = Future Function(String name, Uint8List bytes); + +/// 系统保存仅收到已验证文件字节,不接触令牌或私有服务端路径。 +Future saveContractPdf(String name, Uint8List bytes) async { + if (kIsWeb) { + await FileSaver.instance.saveFile( + name: name, + bytes: bytes, + fileExtension: 'pdf', + mimeType: MimeType.pdf, + ); + return true; + } + final result = await FileSaver.instance.saveAs( + name: name, + bytes: bytes, + fileExtension: 'pdf', + mimeType: MimeType.pdf, + ); + return result != null && result.isNotEmpty; +} + +class ContractDownloadButton extends StatefulWidget { + const ContractDownloadButton({ + required this.repository, + required this.identity, + required this.number, + this.saver = saveContractPdf, + super.key, + }); + final ClientRepository repository; + final String identity, number; + final ContractPdfSaver saver; + @override + State createState() => _ContractDownloadButtonState(); +} + +class _ContractDownloadButtonState extends State { + bool _busy = false; + String? _error; + Future _download() async { + if (_busy) return; + setState(() { + _busy = true; + _error = null; + }); + try { + final bytes = await widget.repository.gasContractPdf(widget.identity); + if (!mounted) return; + final name = '${widget.number.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '_')}_合同'; + final saved = await widget.saver(name, bytes); + if (mounted && saved) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(kIsWeb ? '已交给浏览器下载' : '合同已保存'))); + } + } catch (error) { + if (mounted) { + setState(() => _error = error is ApiException ? error.toString() : '保存失败,请重试'); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + OutlinedButton.icon( + onPressed: _busy ? null : _download, + icon: _busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined), + label: const Text('下载PDF'), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Semantics( + liveRegion: true, + child: Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12), + ), + ), + ), + ], + ); +} diff --git a/apps/user_app/lib/ui/features/orders/delivery_detail_page.dart b/apps/user_app/lib/ui/features/orders/delivery_detail_page.dart new file mode 100644 index 0000000..06e236c --- /dev/null +++ b/apps/user_app/lib/ui/features/orders/delivery_detail_page.dart @@ -0,0 +1,309 @@ +// 功能描述:图22配送详情,展示本人订单的配送人员、资质、交付信息与首期能力边界;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/delivery_detail.dart'; +import '../../core/async_content.dart'; + +class DeliveryDetailPage extends StatelessWidget { + const DeliveryDetailPage({required this.repository, required this.identity, super.key}); + final ClientRepository repository; + final String identity; + + Future _pending(BuildContext context, String title, String detail) => showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text('$title暂未开放'), + content: Text(detail), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('知道了')), + ], + ), + ); + + String _time(DateTime value) { + final local = value.toLocal(); + return '${local.month.toString().padLeft(2, '0')}月${local.day.toString().padLeft(2, '0')}日 ' + '${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('配送详情'), + centerTitle: true, + leading: BackButton( + onPressed: () => context.canPop() ? context.pop() : context.go('/gas/orders/$identity'), + ), + ), + body: AsyncContent( + load: () => repository.gasOrderDelivery(identity), + builder: (context, detail) => ListView( + padding: const EdgeInsets.fromLTRB(14, 8, 14, 12), + children: [ + _card([ + Row( + children: [ + const CircleAvatar( + radius: 27, + backgroundColor: Color(0xFFEAF1FF), + child: Icon(Icons.local_shipping_outlined, color: Color(0xFF2563EB), size: 32), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + detail.statusName, + style: const TextStyle( + color: Color(0xFF2563EB), + fontSize: 22, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + '预约 ${_time(detail.appointmentAt)}', + style: const TextStyle(fontSize: 15), + ), + ], + ), + ), + _statusTag(detail.trackAvailable ? '实时更新' : '状态更新'), + ], + ), + const SizedBox(height: 14), + Text( + detail.statusMessage, + style: const TextStyle(color: Color(0xFF5F6877), height: 1.4), + ), + ]), + _card([ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _avatar(detail), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + detail.staffAssigned ? detail.staffName : '配送员暂未分配', + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + Text( + detail.credentialVerified + ? '${_credentialName(detail.credentialType)} · 已核验' + : '配送资质暂未核验', + style: TextStyle( + color: detail.credentialVerified + ? const Color(0xFF12A866) + : const Color(0xFFC7352A), + ), + ), + const SizedBox(height: 4), + Text(detail.stationName.isEmpty ? '所属气站暂未配置' : '所属:${detail.stationName}'), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + if (!detail.controlledCallAvailable || !detail.messageAvailable) + const Padding( + padding: EdgeInsets.only(bottom: 6), + child: Text( + '电话与订单消息暂未开放', + style: TextStyle(color: Color(0xFF777F8D), fontSize: 12), + ), + ), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: detail.controlledCallAvailable + ? null + : () => _pending(context, '电话联系', '当前尚未接入保护配送员隐私的受控呼叫服务。'), + icon: const Icon(Icons.phone_outlined), + label: const Text('电话联系'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + onPressed: detail.messageAvailable + ? null + : () => _pending(context, '发送消息', '当前尚未接入订单内的受控消息服务。'), + icon: const Icon(Icons.chat_bubble_outline), + label: const Text('发送消息'), + ), + ), + ], + ), + ]), + _card([ + Row( + children: [ + const CircleAvatar( + radius: 25, + backgroundColor: Color(0xFFEAF1FF), + child: Icon(Icons.local_shipping, color: Color(0xFF2563EB), size: 30), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + detail.vehicleConfigured ? '配送车辆' : '配送车辆暂未配置', + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + Text( + detail.deliveryName.isEmpty ? '配送点暂未配置' : detail.deliveryName, + style: const TextStyle(color: Color(0xFF6B7280)), + ), + ], + ), + ), + _statusTag(detail.vehicleConfigured ? '资质已核验' : '暂未开放'), + ], + ), + ]), + _card([ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Image.asset( + 'assets/products/lpg-cylinder.png', + width: 62, + height: 82, + fit: BoxFit.contain, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final item in detail.products) + Text( + '${item.name} × ${item.quantity}', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 7), + Text( + '订单号:${detail.orderNo}', + style: const TextStyle(color: Color(0xFF6B7280)), + ), + const SizedBox(height: 7), + Text('送达地址:${detail.address}', style: const TextStyle(height: 1.35)), + const SizedBox(height: 7), + Text('联系人:${detail.contactName} ${detail.contactPhoneMasked}'), + ], + ), + ), + ], + ), + ]), + _card([ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const CircleAvatar( + radius: 25, + backgroundColor: Color(0xFFFFF4E8), + child: Icon(Icons.verified_user_outlined, color: Color(0xFFF59E0B), size: 30), + ), + const SizedBox(width: 14), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('安全交付提示', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), + SizedBox(height: 8), + Text( + '• 核对钢瓶封签,确认封签完好\n• 核对气瓶编号,确保与订单一致\n• 安装后检查接口及管道,确认无泄漏', + style: TextStyle(height: 1.55), + ), + ], + ), + ), + ], + ), + ]), + ], + ), + ), + bottomNavigationBar: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 8, 14, 10), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => _pending(context, '联系平台', '平台受控客服与订单会话尚未接入。'), + child: const Text('联系平台'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton( + onPressed: () => + context.push('/gas/orders/${Uri.encodeComponent(identity)}/delivery/track'), + child: const Text('查看实时轨迹'), + ), + ), + ], + ), + ), + ), + ); + + Widget _avatar(DeliveryDetail detail) { + if (detail.staffAvatar.startsWith('asset:')) { + return CircleAvatar( + radius: 42, + backgroundImage: AssetImage(detail.staffAvatar.substring('asset:'.length)), + ); + } + return CircleAvatar( + radius: 42, + backgroundColor: const Color(0xFFEAF1FF), + child: Text( + detail.staffName.isEmpty ? '待分配' : detail.staffName.substring(0, 1), + style: const TextStyle(color: Color(0xFF2563EB), fontSize: 20, fontWeight: FontWeight.w700), + ), + ); + } + + Widget _card(List children) => Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE2E7EE)), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children), + ); + + Widget _statusTag(String text) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFEAF8F2), + borderRadius: BorderRadius.circular(18), + ), + child: Text(text, style: const TextStyle(color: Color(0xFF12A866), fontSize: 12)), + ); + + String _credentialName(String type) => switch (type) { + 'delivery' => '配送人员资质', + 'hazmat' => '危险品从业资质', + _ => type.isEmpty ? '配送人员资质' : type, + }; +} diff --git a/apps/user_app/lib/ui/features/orders/delivery_track_page.dart b/apps/user_app/lib/ui/features/orders/delivery_track_page.dart new file mode 100644 index 0000000..d7f5a21 --- /dev/null +++ b/apps/user_app/lib/ui/features/orders/delivery_track_page.dart @@ -0,0 +1,446 @@ +// 功能描述:图23配送轨迹,展示隐私化路线、履约时间线与首期能力边界;版本:1.0.0。 +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/delivery_track.dart'; +import '../../core/async_content.dart'; + +class DeliveryTrackPage extends StatefulWidget { + const DeliveryTrackPage({required this.repository, required this.identity, super.key}); + final ClientRepository repository; + final String identity; + + @override + State createState() => _DeliveryTrackPageState(); +} + +class _DeliveryTrackPageState extends State { + int _revision = 0; + + Future _pending(String title, String detail) => showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text('$title暂未开放'), + content: Text(detail), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('知道了')), + ], + ), + ); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('配送轨迹'), + centerTitle: true, + leading: BackButton( + onPressed: () => context.canPop() + ? context.pop() + : context.go('/gas/orders/${widget.identity}/delivery'), + ), + actions: [ + IconButton( + tooltip: '刷新轨迹', + onPressed: () => setState(() => _revision++), + icon: const Icon(Icons.refresh), + ), + ], + ), + body: AsyncContent( + key: ValueKey(_revision), + load: () => widget.repository.gasOrderDeliveryTrack(widget.identity), + builder: (context, track) => ListView( + padding: EdgeInsets.zero, + children: [ + _RouteMap(track: track), + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 10), + child: Column( + children: [ + _statusCard(track), + const SizedBox(height: 10), + _timelineCard(track), + const SizedBox(height: 10), + _staffCard(track), + const SizedBox(height: 10), + _safetyCard(), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => _pending('联系配送员', '当前尚未接入保护配送员隐私的受控呼叫服务。'), + child: const Text('联系配送员'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton( + onPressed: () => _pending('配送问题', '配送异常上报与订单客服会话正在首期开发。'), + child: const Text('配送有问题'), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + + Widget _statusCard(DeliveryTrack track) => _card([ + Row( + children: [ + const CircleAvatar( + radius: 27, + backgroundColor: Color(0xFFEAF1FF), + child: Icon(Icons.local_shipping_outlined, color: Color(0xFF1768E5), size: 32), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + track.statusName, + style: const TextStyle( + color: Color(0xFF1768E5), + fontSize: 22, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text('预计 ${_clock(track.appointmentAt)} 送达', style: const TextStyle(fontSize: 16)), + ], + ), + ), + if (track.updatedAt != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFEAF8F2), + borderRadius: BorderRadius.circular(18), + ), + child: Text( + '实时更新 ${_clock(track.updatedAt!)}', + style: const TextStyle(color: Color(0xFF11A667), fontSize: 12), + ), + ), + ], + ), + const SizedBox(height: 8), + Text(track.statusMessage, style: const TextStyle(color: Color(0xFF697386), height: 1.4)), + ]); + + Widget _timelineCard(DeliveryTrack track) { + final events = track.timeline; + if (events.isEmpty) { + return _card(const [Text('履约进度暂未更新', style: TextStyle(color: Color(0xFF697386)))]); + } + return _card([ + for (final event in events) _timelineRow(event, false, false), + if (track.statusCode == 33) + _timelineItem( + time: track.updatedAt ?? events.last.occurredAt, + title: '正在前往', + detail: '配送员正在为您配送', + active: true, + last: false, + ), + if (track.statusCode == 33) + _timelineItem( + time: track.appointmentAt, + title: '送达', + detail: '商品将送达您指定的地址', + active: false, + last: true, + pending: true, + ), + ]); + } + + Widget _timelineRow(DeliveryTrackEvent event, bool active, bool last) => _timelineItem( + time: event.occurredAt, + title: event.title, + detail: event.detail, + active: active, + last: last, + ); + + Widget _timelineItem({ + required DateTime time, + required String title, + required String detail, + required bool active, + required bool last, + bool pending = false, + }) => IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 54, + child: Text( + pending ? '预计${_clock(time)}' : _clock(time), + style: TextStyle( + fontSize: 12, + color: active ? const Color(0xFF1768E5) : const Color(0xFF4F5B6F), + ), + ), + ), + SizedBox( + width: 34, + child: Column( + children: [ + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: active || pending ? Colors.white : const Color(0xFF18B968), + border: active + ? Border.all(color: const Color(0xFF1768E5), width: 5) + : pending + ? Border.all(color: const Color(0xFF8B96A8), width: 2) + : null, + ), + child: active || pending + ? null + : const Icon(Icons.check, color: Colors.white, size: 14), + ), + if (!last) Expanded(child: Container(width: 2, color: const Color(0xFF23BE72))), + ], + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: active ? const Color(0xFF1768E5) : const Color(0xFF151922), + ), + ), + const SizedBox(height: 2), + Text( + detail, + style: const TextStyle(color: Color(0xFF697386), fontSize: 12, height: 1.25), + ), + ], + ), + ), + ), + ], + ), + ); + + Widget _staffCard(DeliveryTrack track) => _card([ + Row( + children: [ + CircleAvatar( + radius: 30, + backgroundColor: const Color(0xFFEAF1FF), + backgroundImage: track.staffAvatar.startsWith('asset:') + ? AssetImage(track.staffAvatar.substring('asset:'.length)) + : null, + child: track.staffAvatar.startsWith('asset:') + ? null + : Text( + track.staffName.isEmpty ? '待' : track.staffName.substring(0, 1), + style: const TextStyle( + color: Color(0xFF1768E5), + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + track.staffName.isEmpty ? '配送员暂未分配' : track.staffName, + style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + Text( + track.staffPhoneMasked.isEmpty ? '受控联系方式暂未开放' : track.staffPhoneMasked, + style: const TextStyle(color: Color(0xFF697386)), + ), + ], + ), + ), + SizedBox( + width: 105, + child: OutlinedButton.icon( + onPressed: () => _pending('电话联系', '当前尚未接入保护配送员隐私的受控呼叫服务。'), + icon: const Icon(Icons.phone_outlined, size: 18), + label: const Text('电话联系', style: TextStyle(fontSize: 13)), + ), + ), + ], + ), + ]); + + Widget _safetyCard() => Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF8F0), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + children: [ + Icon(Icons.verified_user, color: Color(0xFFFF9418), size: 34), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('安全提示', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), + SizedBox(height: 4), + Text('实际到达时间可能受路况影响', style: TextStyle(color: Color(0xFF697386))), + ], + ), + ), + ], + ), + ); + + Widget _card(List children) => Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE2E7EE)), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children), + ); + + String _clock(DateTime value) { + final local = value.toLocal(); + return '${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}'; + } +} + +class _RouteMap extends StatelessWidget { + const _RouteMap({required this.track}); + final DeliveryTrack track; + + @override + Widget build(BuildContext context) => SizedBox( + height: 205, + child: Stack( + fit: StackFit.expand, + children: [ + Image.asset('assets/design/delivery-route-map.png', fit: BoxFit.cover), + if (track.routeAvailable) + CustomPaint( + painter: _RoutePainter(track.points), + ) + else + Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .92), + borderRadius: BorderRadius.circular(6), + ), + child: const Text('轨迹点暂未上报', style: TextStyle(color: Color(0xFF5F6877))), + ), + ), + if (track.routeAvailable) + Positioned( + left: 16, + top: 16, + child: _mapLabel( + Icons.local_gas_station, + track.stationName.isEmpty ? '供气站' : track.stationName, + const Color(0xFF18B968), + ), + ), + if (track.routeAvailable) + Positioned( + right: 16, + bottom: 18, + child: _mapLabel(Icons.home, '收货地址', const Color(0xFFFF8A00)), + ), + ], + ), + ); + + Widget _mapLabel(IconData icon, String text, Color color) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .94), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: 6), + Text(text, style: const TextStyle(fontWeight: FontWeight.w700)), + ], + ), + ); +} + +/// 将隐私化坐标按当前数据范围映射到地图区域,不恢复或推测精确道路。 +class _RoutePainter extends CustomPainter { + const _RoutePainter(this.points); + final List points; + + @override + void paint(Canvas canvas, Size size) { + if (points.isEmpty) return; + final minLon = points.map((e) => e.longitude).reduce(math.min); + final maxLon = points.map((e) => e.longitude).reduce(math.max); + final minLat = points.map((e) => e.latitude).reduce(math.min); + final maxLat = points.map((e) => e.latitude).reduce(math.max); + const horizontalPadding = 54.0; + const verticalPadding = 60.0; + Offset position(DeliveryTrackPoint point) { + final xRatio = maxLon == minLon ? .5 : (point.longitude - minLon) / (maxLon - minLon); + final yRatio = maxLat == minLat ? .5 : (point.latitude - minLat) / (maxLat - minLat); + return Offset( + horizontalPadding + xRatio * (size.width - horizontalPadding * 2), + size.height - verticalPadding - yRatio * (size.height - verticalPadding * 2), + ); + } + + final path = Path()..moveTo(position(points.first).dx, position(points.first).dy); + for (final point in points.skip(1)) { + final offset = position(point); + path.lineTo(offset.dx, offset.dy); + } + canvas.drawPath( + path, + Paint() + ..color = const Color(0xFF1768E5) + ..strokeWidth = 6 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round, + ); + canvas.drawCircle(position(points.first), 8, Paint()..color = const Color(0xFF18B968)); + final last = position(points.last); + canvas.drawCircle(last, 17, Paint()..color = Colors.white); + canvas.drawCircle(last, 13, Paint()..color = const Color(0xFF1768E5)); + } + + @override + bool shouldRepaint(covariant _RoutePainter oldDelegate) => oldDelegate.points != points; +} diff --git a/apps/user_app/lib/ui/features/orders/gas_contract_history.dart b/apps/user_app/lib/ui/features/orders/gas_contract_history.dart new file mode 100644 index 0000000..6feb7eb --- /dev/null +++ b/apps/user_app/lib/ui/features/orders/gas_contract_history.dart @@ -0,0 +1,87 @@ +// 功能描述:本人合同状态及有效期变更记录,支持重试与刷新;版本:1.0.0。 +import 'package:flutter/material.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/gas_order_detail.dart'; +import '../../core/async_content.dart'; + +/// 历史接口仅提供合同变更事实,不展示内部备注或冒充签署审计。 +Future showGasContractHistory( + BuildContext context, + ClientRepository repository, + String identity, +) => showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: true, + builder: (context) => FractionallySizedBox( + heightFactor: .85, + child: Column( + children: [ + Row( + children: [ + const Expanded( + child: Padding( + padding: EdgeInsets.only(left: 16), + child: Text('合同变更记录', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + ), + ), + IconButton( + tooltip: '关闭变更记录', + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close), + ), + ], + ), + Expanded( + child: AsyncContent>( + load: () => repository.gasContractHistory(identity), + builder: (context, records) => ListView( + padding: const EdgeInsets.all(16), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + if (records.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 48), + child: Center(child: Text('暂无合同变更记录')), + ), + for (final record in records) + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.history, color: Color(0xFF2563EB)), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + record.actionName, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + Text(record.occurredAt.toLocal().toString().substring(0, 16)), + Text('变更后状态:${record.statusName}'), + if (record.effectiveAt != null) + Text( + '生效日期:${record.effectiveAt!.toLocal().toString().substring(0, 10)}', + ), + Text( + '到期日期:${record.expiredAt == null ? '未约定截止日期' : record.expiredAt!.toLocal().toString().substring(0, 10)}', + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), +); diff --git a/apps/user_app/lib/ui/features/orders/gas_contracts_page.dart b/apps/user_app/lib/ui/features/orders/gas_contracts_page.dart new file mode 100644 index 0000000..293f80e --- /dev/null +++ b/apps/user_app/lib/ui/features/orders/gas_contracts_page.dart @@ -0,0 +1,231 @@ +// 功能描述:本人供气合同列表、搜索、状态筛选与正文阅读;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/gas_order_detail.dart'; +import '../../core/async_content.dart'; +import 'gas_order_sections.dart'; +import 'contract_download_button.dart'; +import 'gas_contract_history.dart'; +import 'contract_change_requests.dart'; + +class GasContractsPage extends StatefulWidget { + const GasContractsPage({required this.repository, super.key}); + final ClientRepository repository; + @override + State createState() => _GasContractsPageState(); +} + +/// 搜索与筛选只处理本人已读取合同,详情仍独立校验归属并支持重试。 +class _GasContractsPageState extends State { + int? _status; + String _query = ''; + bool _searching = false; + final _search = TextEditingController(); + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + String _date(DateTime? date) => date == null ? '未记录' : date.toLocal().toString().substring(0, 10); + Widget _field(String label, String value) => Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 86, + child: Text(label, style: const TextStyle(color: Color(0xFF6B7280))), + ), + Expanded(child: Text(value)), + ], + ), + ); + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('供气合同'), + leading: BackButton(onPressed: () => context.canPop() ? context.pop() : context.go('/me')), + actions: [ + IconButton( + tooltip: _searching ? '关闭搜索' : '搜索合同', + icon: Icon(_searching ? Icons.close : Icons.search), + onPressed: () => setState(() { + _searching = !_searching; + if (!_searching) { + _search.clear(); + _query = ''; + } + }), + ), + ], + ), + body: Column( + children: [ + if (_searching) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + controller: _search, + autofocus: true, + decoration: const InputDecoration(hintText: '搜索合同编号、标题或供气单位'), + onChanged: (value) => setState(() => _query = value.trim().toLowerCase()), + ), + ), + SizedBox( + height: 68, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + children: [ + for (final item in <(int?, String)>[ + (null, '全部'), + (11, '生效中'), + (12, '已到期'), + (0, '草稿'), + (13, '已终止'), + ]) + Padding( + padding: const EdgeInsets.only(right: 10), + child: ChoiceChip( + label: Text(item.$2), + selected: _status == item.$1, + selectedColor: const Color(0xFF0058FF), + backgroundColor: Colors.white, + shape: const StadiumBorder(), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + labelStyle: TextStyle( + fontSize: 16, + color: _status == item.$1 ? Colors.white : const Color(0xFF4B5563), + ), + showCheckmark: false, + onSelected: (_) => setState(() => _status = item.$1), + ), + ), + ], + ), + ), + Expanded( + child: AsyncContent>( + load: widget.repository.gasContracts, + builder: (context, contracts) { + final visible = contracts + .where( + (c) => + (_status == null || c.status == _status) && + '${c.number} ${c.title} ${c.stationName}'.toLowerCase().contains(_query), + ) + .toList(); + return ListView( + padding: const EdgeInsets.all(16), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + if (visible.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 56), + child: Center(child: Text('暂无符合条件的合同')), + ), + for (final contract in visible) _card(contract), + ], + ); + }, + ), + ), + ], + ), + ); + + Widget _card(GasContractSummary contract) => Container( + key: ValueKey(contract.identity), + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE5E7EB)), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.description, + color: contract.status == 11 ? const Color(0xFF16A56B) : const Color(0xFF6B7280), + size: 36, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + contract.title, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + Text( + '合同编号 ${contract.number}', + style: const TextStyle(color: Color(0xFF6B7280)), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + _field('合同状态', contract.statusName), + _field('供气单位', contract.stationName.isEmpty ? '暂无供气单位资料' : contract.stationName), + _field('签署日期', _date(contract.signedAt)), + _field( + '有效期', + '${_date(contract.effectiveAt)} 至 ${contract.expiredAt == null ? '未约定截止日期' : _date(contract.expiredAt)}', + ), + const Divider(height: 24), + TextButton.icon( + onPressed: () => + showContractChangeRequests(context, widget.repository, contract.identity), + icon: const Icon(Icons.edit_note), + label: const Text('申请变更'), + ), + TextButton.icon( + onPressed: () => showGasContractHistory(context, widget.repository, contract.identity), + icon: const Icon(Icons.history), + label: const Text('变更记录'), + ), + LayoutBuilder( + builder: (context, constraints) { + final width = + constraints.maxWidth >= 310 && MediaQuery.textScalerOf(context).scale(14) <= 16 + ? (constraints.maxWidth - 12) / 2 + : constraints.maxWidth; + return Wrap( + spacing: 12, + runSpacing: 8, + children: [ + SizedBox( + width: width, + child: OutlinedButton.icon( + onPressed: () => showGasContract(context, widget.repository, contract.identity), + icon: const Icon(Icons.visibility_outlined), + label: const Text('查看合同'), + ), + ), + SizedBox( + width: width, + child: ContractDownloadButton( + repository: widget.repository, + identity: contract.identity, + number: contract.number, + ), + ), + ], + ); + }, + ), + ], + ), + ); +} diff --git a/apps/user_app/lib/ui/features/orders/gas_order_create_page.dart b/apps/user_app/lib/ui/features/orders/gas_order_create_page.dart new file mode 100644 index 0000000..efc4bff --- /dev/null +++ b/apps/user_app/lib/ui/features/orders/gas_order_create_page.dart @@ -0,0 +1,533 @@ +// 功能描述:按服务端合同报价创建气瓶配送订单;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/gas_order_checkout.dart'; +import '../../../domain/models/client_models.dart'; +import '../../../domain/models/shipping_address.dart'; +import '../../core/async_content.dart'; + +class GasOrderCreatePage extends StatefulWidget { + const GasOrderCreatePage({required this.repository, super.key}); + final ClientRepository repository; + + @override + State createState() => _GasOrderCreatePageState(); +} + +class _GasOrderCreatePageState extends State { + late Future _future; + final Map _quantities = {}; + ShippingAddress? _address; + GasOrderAppointmentSlot? _slot; + bool _submitting = false; + + @override + void initState() { + super.initState(); + _future = widget.repository.gasOrderOptions(); + } + + void _initialize(GasOrderCheckoutData data) { + _address ??= + data.addresses.where((item) => item.isDefault).firstOrNull ?? data.addresses.firstOrNull; + _slot ??= data.slots.firstOrNull; + } + + int _quantity(int index) => _quantities[index] ?? 0; + + int _productAmount(GasOrderCheckoutData data) => data.products.indexed.fold( + 0, + (sum, row) => sum + row.$2.unitPrice * _quantity(row.$1), + ); + + int _depositAmount(GasOrderCheckoutData data) => data.products.indexed.fold( + 0, + (sum, row) => sum + row.$2.depositAmount * _quantity(row.$1), + ); + + int _deliveryAmount(GasOrderCheckoutData data) => + _selectedIdentities(data).isEmpty ? 0 : data.deliveryFee; + + int _total(GasOrderCheckoutData data) => + _productAmount(data) + _depositAmount(data) + _deliveryAmount(data); + + String _amount(int cents) => cents % 100 == 0 ? '¥${cents ~/ 100}' : moneyText(cents); + + List _selectedIdentities(GasOrderCheckoutData data) => [ + for (final row in data.products.indexed) ...row.$2.itemIdentities.take(_quantity(row.$1)), + ]; + + Future _submit(GasOrderCheckoutData data) async { + if (_selectedIdentities(data).isEmpty) { + _message('请至少选择一只气瓶'); + return; + } + if (_address == null) { + _message('请先添加配送地址'); + return; + } + if (_address!.contactName.trim().isEmpty || _address!.contactPhone.length != 11) { + _message('配送地址的联系人资料不完整,请先修改'); + return; + } + if (_slot == null) { + _message('请选择配送时段'); + return; + } + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('确认创建气瓶订单?'), + content: Text('应付金额 ${_amount(_total(data))},创建后进入支付确认。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('再检查一下')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('确认创建')), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _submitting = true); + try { + final result = await widget.repository.createGasOrder( + requestNo: 'gas:${DateTime.now().microsecondsSinceEpoch}', + addressIdentity: _address!.identity, + itemIdentities: _selectedIdentities(data), + appointmentAt: _slot!.startAt, + expectedAmount: _total(data), + ); + if (!mounted) return; + context.go('/payment/gas/${Uri.encodeComponent(result.identity)}'); + } catch (error) { + if (mounted) _message('下单结果未确认:$error'); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + void _message(String text) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); + } + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: const Color(0xFFF7F8FA), + appBar: AppBar(title: const Text('气瓶下单')), + body: AsyncContent( + load: () => _future, + builder: (context, data) { + _initialize(data); + return Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(14, 8, 14, 8), + children: [ + _station(data), + const SizedBox(height: 6), + _products(data), + const SizedBox(height: 6), + _delivery(data), + const SizedBox(height: 6), + _deposit(data), + const SizedBox(height: 6), + _fees(data), + ], + ), + ), + SafeArea( + top: false, + minimum: const EdgeInsets.fromLTRB(14, 8, 14, 10), + child: SizedBox( + width: double.infinity, + height: 48, + child: FilledButton( + onPressed: _submitting || !data.products.any((item) => item.orderable) + ? null + : () => _submit(data), + child: _submitting + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Text( + '确认下单', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + ), + ), + ), + ), + ], + ); + }, + ), + ); + + Widget _section({required Widget child, EdgeInsets padding = const EdgeInsets.all(14)}) => + Container( + padding: padding, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE6E8EC)), + ), + child: child, + ); + + Widget _station(GasOrderCheckoutData data) => _section( + padding: const EdgeInsets.all(10), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: const Color(0xFFF0F5FF), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.storefront, color: Color(0xFF2563EB), size: 27), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + data.stationName.isEmpty ? '服务气站' : data.stationName, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + color: const Color(0xFFEAF8F1), + child: Text( + data.stationStatus, + style: const TextStyle(color: Color(0xFF16875D), fontSize: 12), + ), + ), + ], + ), + const SizedBox(height: 5), + Row( + children: [ + const Icon(Icons.location_on_outlined, size: 17, color: Color(0xFF626977)), + const SizedBox(width: 4), + Expanded( + child: Text( + data.deliveryScope.isEmpty ? '配送范围暂未配置' : '配送范围:${data.deliveryScope}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: Color(0xFF626977)), + ), + ), + ], + ), + ], + ), + ), + const Icon(Icons.chevron_right, color: Color(0xFF374151)), + ], + ), + ); + + Widget _products(GasOrderCheckoutData data) => _section( + padding: const EdgeInsets.fromLTRB(10, 10, 10, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('选择气瓶规格', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)), + const SizedBox(height: 5), + if (data.products.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 22), + child: Center(child: Text('暂无可订气瓶,请联系气站配置合同气瓶')), + ), + for (final row in data.products.indexed) ...[ + _productRow(row.$1, row.$2), + if (row.$1 != data.products.length - 1) const Divider(height: 1), + ], + const SizedBox(height: 4), + const Row( + children: [ + Icon(Icons.info_outline, size: 16, color: Color(0xFF626977)), + SizedBox(width: 5), + Expanded( + child: Text( + '配送气瓶为钢瓶,请留意家中存放空间', + style: TextStyle(fontSize: 11, color: Color(0xFF626977)), + ), + ), + ], + ), + ], + ), + ); + + Widget _productRow(int index, GasOrderProductOption product) { + final quantity = _quantity(index); + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 78), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + Image.asset( + 'assets/products/lpg-cylinder.png', + width: 52, + height: 72, + fit: BoxFit.contain, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.name, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 1), + Text( + product.orderable ? product.description : product.unavailableReason, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 10, + color: Color(0xFF626977), + backgroundColor: Color(0xFFF4F4F5), + ), + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFFF5A52)), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _amount(product.unitPrice), + style: const TextStyle(color: Color(0xFFE53935), fontSize: 14), + ), + ), + ], + ), + ), + Container( + height: 36, + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFCBD1DC)), + borderRadius: BorderRadius.circular(7), + ), + child: Row( + children: [ + IconButton( + tooltip: '减少', + visualDensity: VisualDensity.compact, + onPressed: quantity == 0 + ? null + : () => setState(() => _quantities[index] = quantity - 1), + icon: const Icon(Icons.remove, size: 16), + ), + SizedBox( + width: 15, + child: Text( + '$quantity', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14), + ), + ), + IconButton( + tooltip: '增加', + visualDensity: VisualDensity.compact, + onPressed: !product.orderable || quantity >= product.itemIdentities.length + ? null + : () => setState(() => _quantities[index] = quantity + 1), + icon: Icon( + Icons.add, + size: 16, + color: product.orderable ? const Color(0xFF2563EB) : const Color(0xFF9CA3AF), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _delivery(GasOrderCheckoutData data) => _section( + padding: const EdgeInsets.fromLTRB(12, 6, 8, 7), + child: Column( + children: [ + Row( + children: [ + const Expanded( + child: Text('配送信息', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)), + ), + TextButton( + style: TextButton.styleFrom( + minimumSize: const Size(44, 28), + padding: const EdgeInsets.symmetric(horizontal: 6), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => _chooseAddress(data), + child: const Text('修改'), + ), + ], + ), + _info(Icons.location_on_outlined, _address?.address ?? '请先添加配送地址'), + _info( + Icons.person_outline, + _address == null ? '联系人未设置' : '${_address!.contactName} ${_address!.maskedPhone}', + ), + InkWell( + onTap: () => _chooseSlot(data), + child: _info(Icons.schedule, _slot?.label ?? '请选择配送时段', trailing: true), + ), + ], + ), + ); + + Widget _info(IconData icon, String text, {bool trailing = false}) => Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + children: [ + Icon(icon, size: 17, color: const Color(0xFF4B5563)), + const SizedBox(width: 9), + Expanded( + child: Text( + text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ), + ), + if (trailing) const Icon(Icons.chevron_right, size: 18, color: Color(0xFF2563EB)), + ], + ), + ); + + Widget _deposit(GasOrderCheckoutData data) => _section( + padding: const EdgeInsets.fromLTRB(12, 7, 12, 7), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('押金说明', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)), + const SizedBox(height: 2), + Text( + '• 首次用瓶按后台规则收取可退押金 ${_amount(_depositAmount(data))}', + style: const TextStyle(fontSize: 11), + ), + const SizedBox(height: 1), + const Text('• 已缴纳押金的本公司气瓶,换气时不会重复收取', style: TextStyle(fontSize: 11)), + ], + ), + ); + + Widget _fees(GasOrderCheckoutData data) => _section( + padding: const EdgeInsets.fromLTRB(12, 7, 12, 7), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('费用明细', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)), + const SizedBox(height: 2), + _moneyLine('气费', _productAmount(data)), + _moneyLine('押金(可退)', _depositAmount(data)), + _moneyLine('配送费', _deliveryAmount(data)), + const Divider(), + _moneyLine('合计', _total(data), total: true), + ], + ), + ); + + Widget _moneyLine(String label, int amount, {bool total = false}) => Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: TextStyle(fontWeight: total ? FontWeight.w700 : FontWeight.w400), + ), + ), + Text( + _amount(amount), + style: TextStyle( + color: total ? const Color(0xFF2563EB) : const Color(0xFF111827), + fontSize: total ? 19 : 14, + fontWeight: total ? FontWeight.w700 : FontWeight.w400, + ), + ), + ], + ), + ); + + Future _chooseAddress(GasOrderCheckoutData data) async { + if (data.addresses.isEmpty) { + await context.push('/addresses'); + if (mounted) setState(() => _future = widget.repository.gasOrderOptions()); + return; + } + final value = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + const ListTile( + title: Text('选择配送地址', style: TextStyle(fontWeight: FontWeight.w700)), + ), + for (final address in data.addresses) + ListTile( + leading: Icon( + address.identity == _address?.identity + ? Icons.check_circle + : Icons.circle_outlined, + color: const Color(0xFF2563EB), + ), + title: Text(address.address), + subtitle: Text('${address.contactName} ${address.maskedPhone}'), + onTap: () => Navigator.pop(context, address), + ), + ], + ), + ), + ); + if (value != null && mounted) setState(() => _address = value); + } + + Future _chooseSlot(GasOrderCheckoutData data) async { + final value = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + const ListTile( + title: Text('选择配送时段', style: TextStyle(fontWeight: FontWeight.w700)), + ), + for (final slot in data.slots) + ListTile( + leading: Icon( + identical(slot, _slot) ? Icons.check_circle : Icons.circle_outlined, + color: const Color(0xFF2563EB), + ), + title: Text(slot.label), + onTap: () => Navigator.pop(context, slot), + ), + ], + ), + ), + ); + if (value != null && mounted) setState(() => _slot = value); + } +} diff --git a/apps/user_app/lib/ui/features/orders/gas_order_sections.dart b/apps/user_app/lib/ui/features/orders/gas_order_sections.dart new file mode 100644 index 0000000..9fb3b2f --- /dev/null +++ b/apps/user_app/lib/ui/features/orders/gas_order_sections.dart @@ -0,0 +1,217 @@ +// 功能描述:供气订单状态历史、服务人员与本人合同正文;版本:1.0.0。 +import 'package:flutter/material.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/gas_order_detail.dart'; +import '../../core/async_content.dart'; +import 'contract_download_button.dart'; + +/// 逐条展示服务端发生时间,不推算预计送达或虚构已完成节点。 +class GasOrderTimeline extends StatelessWidget { + const GasOrderTimeline({required this.order, super.key}); + final GasOrderDetail order; + @override + Widget build(BuildContext context) { + final events = <({String name, DateTime time})>[ + if (order.createdAt != null) (name: '已下单', time: order.createdAt!), + for (final event in order.timeline) (name: event.name, time: event.time), + ]; + // 常规字号按设计横向排列;长历史与大字体保留完整记录并允许纵向阅读。 + return LayoutBuilder( + builder: (context, constraints) { + if (events.length >= 2 && + events.length <= 4 && + constraints.maxWidth >= 310 && + MediaQuery.textScalerOf(context).scale(14) <= 16) { + return Column( + children: [ + const Divider(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var index = 0; index < events.length; index++) + Expanded( + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Divider( + color: index == 0 ? Colors.transparent : const Color(0xFF2563EB), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Icon(Icons.check_circle, color: Color(0xFF2563EB), size: 18), + ), + Expanded( + child: Divider( + color: index == events.length - 1 + ? Colors.transparent + : const Color(0xFF2563EB), + ), + ), + ], + ), + const SizedBox(height: 3), + Text( + events[index].name, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13), + ), + const SizedBox(height: 2), + Text( + events[index].time.toLocal().toString().substring(5, 16), + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280)), + ), + ], + ), + ), + ], + ), + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (order.timeline.isNotEmpty) const Divider(), + for (final event in events) + Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 3), + child: Icon(Icons.check_circle, size: 16, color: Color(0xFF2563EB)), + ), + const SizedBox(width: 8), + Expanded(child: Text(event.name)), + const SizedBox(width: 8), + Text(event.time.toLocal().toString().substring(0, 16)), + ], + ), + ), + ], + ); + }, + ); + } +} + +class GasOrderServiceInfo extends StatelessWidget { + const GasOrderServiceInfo({required this.order, required this.repository, super.key}); + final GasOrderDetail order; + final ClientRepository repository; + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(16), + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE5E7EB)), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.store_outlined, color: Color(0xFF2563EB)), + const SizedBox(width: 10), + Expanded( + child: Text( + order.stationName.isEmpty ? '暂无供气单位资料' : order.stationName, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + ], + ), + const SizedBox(height: 12), + Text('配送员:${order.staffName.isEmpty ? '暂无配送员资料' : order.staffName}'), + if (order.contractIdentity.isNotEmpty) ...[ + const Divider(), + Text('供气合同编号:${order.contractNo}'), + TextButton.icon( + onPressed: () => showGasContract(context, repository, order.contractIdentity), + icon: const Icon(Icons.description_outlined), + label: const Text('查看合同'), + ), + ], + ], + ), + ); +} + +/// 正文通过独立鉴权接口读取;已有附件但无正文时明确说明资料缺失。 +Future showGasContract(BuildContext context, ClientRepository repository, String identity) => + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: true, + builder: (context) => FractionallySizedBox( + heightFactor: .85, + child: Column( + children: [ + Row( + children: [ + const Expanded( + child: Padding( + padding: EdgeInsets.only(left: 16), + child: Text( + '供气合同', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + ), + ), + IconButton( + tooltip: '关闭合同', + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close), + ), + ], + ), + Expanded( + child: AsyncContent( + load: () => repository.gasContractDetail(identity), + builder: (context, contract) => ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + contract.title, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 12), + SelectableText('合同编号:${contract.number}'), + const SizedBox(height: 8), + Text(switch (contract.status) { + 11 => '生效中', + 12 => '已到期', + 13 => '已终止', + 0 => '草稿', + _ => '合同状态待更新', + }), + if (contract.signedAt != null) + Text('签署日期:${contract.signedAt!.toLocal().toString().substring(0, 10)}'), + if (contract.effectiveAt != null) + Text('生效日期:${contract.effectiveAt!.toLocal().toString().substring(0, 10)}'), + if (contract.expiredAt != null) + Text('到期日期:${contract.expiredAt!.toLocal().toString().substring(0, 10)}'), + const Divider(height: 32), + SelectableText(contract.terms.isEmpty ? '暂无可读取的合同正文' : contract.terms), + const SizedBox(height: 24), + ContractDownloadButton( + repository: repository, + identity: contract.identity, + number: contract.number, + ), + ], + ), + ), + ), + ], + ), + ), + ); diff --git a/apps/user_app/lib/ui/features/orders/invoice_preview_page.dart b/apps/user_app/lib/ui/features/orders/invoice_preview_page.dart new file mode 100644 index 0000000..62435a7 --- /dev/null +++ b/apps/user_app/lib/ui/features/orders/invoice_preview_page.dart @@ -0,0 +1,242 @@ +// 功能描述:图24电子发票二期占位页,展示真实订单事实并明确禁止伪造开票;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/client_models.dart'; +import '../../../domain/models/gas_order_detail.dart'; +import '../../../domain/models/shop_order_detail.dart'; +import '../../core/async_content.dart'; + +class InvoicePreviewPage extends StatelessWidget { + const InvoicePreviewPage({ + required this.repository, + required this.business, + required this.identity, + super.key, + }); + + final ClientRepository repository; + final String business, identity; + + Future _load() => business == 'gas' + ? repository.gasOrderDetail(identity) + : repository.shopOrderDetail(identity); + + Future _showSecondPhase(BuildContext context) => showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('电子发票即将开放'), + content: const Text('电子发票已明确属于二期功能。当前仅展示订单事实,不会提交开票申请。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('知道了')), + ], + ), + ); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('申请电子发票'), + centerTitle: true, + leading: BackButton( + onPressed: () => context.canPop() + ? context.pop() + : context.go('/${business == 'gas' ? 'gas' : 'shop'}/orders/$identity'), + ), + ), + body: AsyncContent( + load: _load, + builder: (context, order) => ListView( + padding: const EdgeInsets.fromLTRB(14, 8, 14, 16), + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFEAF1FF), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + children: [ + Icon(Icons.schedule_outlined, color: Color(0xFF1768E5)), + SizedBox(width: 10), + Expanded( + child: Text( + '二期功能 · 即将开放', + style: TextStyle(color: Color(0xFF1768E5), fontWeight: FontWeight.w700), + ), + ), + ], + ), + ), + const SizedBox(height: 10), + _orderCard(order), + const SizedBox(height: 10), + _selectRow('发票类型', '电子普通发票'), + const SizedBox(height: 10), + _formCard(), + const SizedBox(height: 10), + _amountCard(order), + const SizedBox(height: 10), + CheckboxListTile( + value: false, + onChanged: (_) => _showSecondPhase(context), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: const EdgeInsets.symmetric(horizontal: 10), + title: const Text('保存为常用抬头'), + subtitle: const Text('二期开放后可设置'), + shape: RoundedRectangleBorder( + side: const BorderSide(color: Color(0xFFE2E7EE)), + borderRadius: BorderRadius.circular(8), + ), + ), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF8F0), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + children: [ + Icon(Icons.info_outline, color: Color(0xFFFF8A00)), + SizedBox(width: 10), + Expanded(child: Text('电子发票开具、预览和下载将在二期接入。')), + ], + ), + ), + const SizedBox(height: 14), + FilledButton( + onPressed: () => _showSecondPhase(context), + child: const Text('提交开票申请 · 即将开放'), + ), + ], + ), + ), + ); + + Widget _orderCard(ShopOrderDetail order) { + final productName = order.items.isEmpty ? '订单商品资料暂缺' : order.items.first.name; + return Container( + padding: const EdgeInsets.all(14), + decoration: _box(), + child: Row( + children: [ + const CircleAvatar( + radius: 28, + backgroundColor: Color(0xFFEAF1FF), + child: Icon(Icons.receipt_long_outlined, color: Color(0xFF1768E5), size: 30), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('订单号:${order.orderNo}', style: const TextStyle(fontWeight: FontWeight.w700)), + const SizedBox(height: 6), + Text(productName, style: const TextStyle(color: Color(0xFF697386))), + if (order is GasOrderDetail) ...[ + const SizedBox(height: 8), + const Text('押金不计入发票金额', style: TextStyle(color: Color(0xFF697386), fontSize: 12)), + ], + ], + ), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const Text('订单商品金额', style: TextStyle(color: Color(0xFF697386), fontSize: 12)), + const SizedBox(height: 4), + Text( + moneyText(order.productAmount), + style: const TextStyle( + color: Color(0xFF1768E5), + fontSize: 19, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _formCard() => Container( + padding: const EdgeInsets.all(14), + decoration: _box(), + child: const Column( + children: [ + _InvoiceField(label: '抬头类型', value: '个人 / 企业(二期选择)'), + _InvoiceField(label: '单位名称', value: '二期开放后填写'), + _InvoiceField(label: '税号', value: '二期开放后填写'), + _InvoiceField(label: '发票内容', value: '二期配置'), + _InvoiceField(label: '接收邮箱', value: '二期开放后填写'), + _InvoiceField(label: '手机号', value: '二期开放后确认', last: true), + ], + ), + ); + + Widget _amountCard(ShopOrderDetail order) => Container( + padding: const EdgeInsets.all(14), + decoration: _box(), + child: Row( + children: [ + const Expanded( + child: Text('开票金额', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), + ), + const Text( + '待二期规则确认', + style: TextStyle(color: Color(0xFF1768E5), fontWeight: FontWeight.w700), + ), + ], + ), + ); + + Widget _selectRow(String label, String value) => Container( + padding: const EdgeInsets.all(14), + decoration: _box(), + child: Row( + children: [ + Expanded( + child: Text(label, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), + ), + Text(value, style: const TextStyle(color: Color(0xFF697386))), + const SizedBox(width: 4), + const Icon(Icons.chevron_right, color: Color(0xFF697386)), + ], + ), + ); + + BoxDecoration _box() => BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE2E7EE)), + borderRadius: BorderRadius.circular(8), + ); +} + +class _InvoiceField extends StatelessWidget { + const _InvoiceField({required this.label, required this.value, this.last = false}); + final String label, value; + final bool last; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + border: last ? null : const Border(bottom: BorderSide(color: Color(0xFFE7EBF0))), + ), + child: Row( + children: [ + SizedBox( + width: 88, + child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), + ), + Expanded( + child: Text(value, style: const TextStyle(color: Color(0xFF8A93A3))), + ), + ], + ), + ); +} diff --git a/apps/user_app/lib/ui/features/orders/order_action_handler.dart b/apps/user_app/lib/ui/features/orders/order_action_handler.dart new file mode 100644 index 0000000..4055d5c --- /dev/null +++ b/apps/user_app/lib/ui/features/orders/order_action_handler.dart @@ -0,0 +1,240 @@ +// 功能描述:订单列表与详情共用确认、退款和支付操作;版本:1.0.0。 +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:uuid/uuid.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../data/services/api_client.dart'; +import '../../../data/services/payment_launcher.dart'; +import '../../../domain/models/order_summary.dart'; + +/// 每个页面持有一个处理器,未知结果重试保留原请求号。 +class OrderActionHandler { + OrderActionHandler({required this.repository, required this.onChanged}); + final ClientRepository repository; + final void Function(String business) onChanged; + final _launcher = PaymentLauncher(); + final _requests = {}; + bool _acting = false; + Future run( + BuildContext context, + OrderSummary order, + String business, { + String? selectedAction, + }) async { + if (_acting) return; + _acting = true; + try { + await _performActions(context, order, business, selectedAction: selectedAction); + } finally { + _acting = false; + } + } + + /// 显式提交前使用服务端允许动作,结果未知时保留原幂等键。 + Future _performActions( + BuildContext context, + OrderSummary order, + String business, { + String? selectedAction, + }) async { + final record = order.record; + final action = + selectedAction ?? + await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), + child: Align( + alignment: Alignment.centerLeft, + child: Text('订单操作', style: Theme.of(context).textTheme.titleLarge), + ), + ), + if (order.actions.contains('pay')) ...[ + ListTile( + title: const Text('确认支付'), + subtitle: const Text('核对金额并选择支付方式'), + onTap: () => Navigator.pop(context, 'pay'), + ), + ], + if (order.actions.contains('refund')) + ListTile( + title: const Text('申请退款'), + onTap: () => Navigator.pop(context, 'refund'), + ), + if (order.actions.contains('cancel')) + ListTile( + title: const Text('取消订单'), + onTap: () => Navigator.pop(context, 'cancel'), + ), + if (order.actions.contains('confirm_receipt')) + ListTile( + title: const Text('确认收货'), + onTap: () => Navigator.pop(context, 'confirm_receipt'), + ), + if (order.actions.isEmpty) const ListTile(title: Text('当前没有可执行操作,请刷新查看最新状态')), + ], + ), + ), + ); + if (action == null || !context.mounted) return; + if (action == 'pay') { + await context.push( + '/payment/${Uri.encodeComponent(business)}/${Uri.encodeComponent(record.identity)}', + ); + if (context.mounted) onChanged(business); + return; + } + try { + if (action == 'cancel' || action == 'confirm_receipt') { + final cancelling = action == 'cancel'; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(cancelling ? '取消这笔订单?' : '确认已收到商品?'), + content: Text( + cancelling + ? '取消后将释放本订单占用的库存。' + : business == 'gas' + ? '请核对气瓶数量、封签和编号,确认已收到本订单全部气瓶。' + : '请核对商品数量和完好情况。', + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('返回')), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(cancelling ? '确认取消' : '确认收货'), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + if (cancelling) { + if (business == 'gas') { + await repository.cancelGasOrder(record.identity); + } else { + await repository.cancelShopOrder(record.identity); + } + } else if (business == 'gas') { + await repository.confirmGasReceipt( + record.identity, + requestNo: _requests.putIfAbsent( + 'gas:${record.identity}:confirm_receipt', + () => const Uuid().v7(), + ), + ); + } else { + await repository.confirmShopReceipt(record.identity); + } + if (context.mounted) { + onChanged(business); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(cancelling ? '订单已取消' : '已确认收货'))); + } + return; + } + if (action == 'refund') { + final reason = await _reason(context); + if (reason == null || !context.mounted) return; + await repository.createRefund( + business: business, + identity: record.identity, + requestNo: _requests.putIfAbsent( + '$business:${record.identity}:refund:$reason', + () => const Uuid().v7(), + ), + reason: reason, + items: order.items.map((item) { + return { + 'identity': item.identity, + 'quantity': item.quantity, + }; + }).toList(), + ); + if (context.mounted) { + onChanged(business); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('退款申请已提交')), + ); + } + return; + } + final payment = await repository.payOrder( + business: business, + identity: record.identity, + requestNo: _requests.putIfAbsent( + '$business:${record.identity}:$action', + () => const Uuid().v7(), + ), + channel: action, + payType: kIsWeb ? (action == 'alipay' ? 'wap' : 'jsapi') : 'app', + openid: kIsWeb && action == 'wechat' ? Uri.base.queryParameters['openid'] ?? '' : '', + ); + await _launcher.launch(payment); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('支付结果确认中,请稍后刷新')), + ); + } + } catch (error) { + if (error is SessionExpiredException) return; + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error.toString())), + ); + } + } + } + + Future _reason(BuildContext context) async { + final value = await showDialog( + context: context, + builder: (context) => const _RefundReasonDialog(), + ); + return value?.isEmpty == true ? null : value; + } +} + +/// 独立管理退款原因输入框生命周期,避免关闭动画期间提前释放控制器。 +class _RefundReasonDialog extends StatefulWidget { + const _RefundReasonDialog(); + + @override + State<_RefundReasonDialog> createState() => _RefundReasonDialogState(); +} + +class _RefundReasonDialogState extends State<_RefundReasonDialog> { + final TextEditingController _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AlertDialog( + title: const Text('申请退款'), + content: TextField( + controller: _controller, + maxLines: 3, + decoration: const InputDecoration(labelText: '退款原因'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, _controller.text.trim()), + child: const Text('提交'), + ), + ], + ); +} diff --git a/apps/user_app/lib/ui/features/orders/order_list.dart b/apps/user_app/lib/ui/features/orders/order_list.dart new file mode 100644 index 0000000..adbb3a9 --- /dev/null +++ b/apps/user_app/lib/ui/features/orders/order_list.dart @@ -0,0 +1,370 @@ +// 功能描述:订单中心的状态筛选、强类型金额展示与刷新。 +// 版本:1.0.0 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../../domain/models/client_models.dart'; +import '../../../domain/models/order_summary.dart'; +import '../../core/async_content.dart'; +import '../../core/feature_entry.dart'; +import '../../core/widgets.dart'; + +/// 保留订单实例滚动位置;刷新令牌变化后重新读取事实。 +class OrderList extends StatefulWidget { + const OrderList({ + required this.loader, + this.business = 'shop', + this.searchQuery = '', + this.resolveImage, + this.onTap, + this.onDetail, + this.onAction, + this.onRefunds, + this.refreshToken = 0, + super.key, + }); + final Future> Function() loader; + final String business, searchQuery; + final String Function(String value)? resolveImage; + final void Function(OrderSummary)? onTap; + final void Function(OrderSummary)? onDetail; + final void Function(OrderSummary, String)? onAction; + final VoidCallback? onRefunds; + final int refreshToken; + @override + State createState() => _OrderListState(); +} + +/// 状态筛选只决定展示范围,不决定可执行操作。 +class _OrderListState extends State with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + final _key = GlobalKey>>(); + String _filter = ''; + @override + void didUpdateWidget(covariant OrderList oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.refreshToken != widget.refreshToken) { + WidgetsBinding.instance.addPostFrameCallback((_) => _key.currentState?.refresh()); + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + return _build(context); + } + + Widget _build(BuildContext context) => AsyncContent>( + key: _key, + load: () async => (await widget.loader()) + .map((record) => OrderSummary.fromRecord(record, resolveImage: widget.resolveImage)) + .toList(), + builder: (context, orders) { + final defaults = widget.business == 'gas' + ? const ['待付款', '待配送', '配送中', '已完成'] + : const ['待付款', '待发货', '待收货', '已完成']; + final states = {...defaults, ...orders.map((o) => o.statusName)}; + final query = widget.searchQuery; + final shown = orders.where( + (o) => + (_filter.isEmpty || o.statusName == _filter) && + (query.isEmpty || + o.orderNo.contains(query) || + o.items.any((item) => item.name.contains(query))), + ); + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(12, 10, 12, 16), + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final status in ['', ...states]) + Padding( + padding: const EdgeInsets.only(right: 6), + child: ChoiceChip( + label: Text(status.isEmpty ? '全部' : status), + selected: _filter == status, + showCheckmark: false, + labelPadding: const EdgeInsets.symmetric(horizontal: 3), + labelStyle: TextStyle( + fontSize: 11, + color: _filter == status ? Colors.white : const Color(0xFF4B5563), + ), + selectedColor: const Color(0xFF1762F4), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onSelected: (_) => setState(() => _filter = status), + ), + ), + if (widget.onRefunds != null) + ActionChip( + label: const Text('退款/售后', style: TextStyle(fontSize: 11)), + labelPadding: const EdgeInsets.symmetric(horizontal: 3), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onPressed: widget.onRefunds, + ), + ], + ), + ), + const SizedBox(height: 12), + if (shown.isEmpty) const EmptyState(title: '暂无订单', description: '可前往商城选购或下拉刷新'), + for (final order in shown) + Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFDFE2E9)), + borderRadius: BorderRadius.circular(10), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: widget.onDetail != null + ? () => widget.onDetail!(order) + : widget.onTap == null + ? null + : () => widget.onTap!(order), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + '订单号:${order.orderNo}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: Color(0xFF626A7A)), + ), + ), + if (order.createdAt != null) + Text( + '下单时间:${order.createdAt!.toLocal().toString().substring(0, 16)}', + style: const TextStyle(fontSize: 10, color: Color(0xFF626A7A)), + ), + ], + ), + const Divider(height: 22, color: Color(0xFFEAECF1)), + Align( + alignment: Alignment.centerRight, + child: Text( + order.statusName, + style: TextStyle( + color: _statusColor(order.statusName), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + for (final item in order.items.where((item) => item.name.isNotEmpty)) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (item.imageUrl.isNotEmpty) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.network( + item.imageUrl, + width: 76, + height: 92, + fit: BoxFit.cover, + errorBuilder: (_, error, stack) => const SizedBox.shrink(), + ), + ), + const SizedBox(width: 10), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${item.name} × ${item.quantity}', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + if (order.stationName.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + order.stationName, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + if (item.specification.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + item.specification, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ], + ), + ), + ], + ), + ), + if (order.amount != null || widget.onTap != null) ...[ + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (order.amount != null) + SizedBox( + width: 88, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + moneyText(order.amount!), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const Text( + '实付金额', + style: TextStyle(fontSize: 10, color: Color(0xFF626A7A)), + ), + ], + ), + ), + if (order.amount != null && widget.onTap != null) + const SizedBox(width: 8), + if (widget.onTap != null) + Expanded( + child: Align( + alignment: Alignment.bottomRight, + child: Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.end, + children: _orderActions(context, order), + ), + ), + ), + ], + ), + ], + ], + ), + ), + ), + ), + ), + ], + ); + }, + ); + + Color _statusColor(String status) { + if (status.contains('完成') || status.contains('签收')) return const Color(0xFF16875D); + if (status.contains('取消') || status.contains('失败')) return const Color(0xFF6B7280); + if (status.contains('配送') || status.contains('发货')) return const Color(0xFF1762F4); + return const Color(0xFFB86400); + } + + /// 仅为需要弹出操作面板的动作保留按钮,避免与行内动作重复。 + bool _hasSheetActions(OrderSummary order) => + order.actions.contains('pay') || + order.actions.contains('refund') || + (widget.onAction == null && + (order.actions.contains('cancel') || order.actions.contains('confirm_receipt'))); + + /// 将首期真实动作与明确列入二期的入口放在同一操作区。 + List _orderActions(BuildContext context, OrderSummary order) => [ + if (widget.onAction != null && order.actions.contains('cancel')) + OutlinedButton( + style: _compactOutlinedStyle(context), + onPressed: () => widget.onAction!(order, 'cancel'), + child: const Text('取消订单'), + ), + if (widget.onAction != null && order.actions.contains('confirm_receipt')) + FilledButton( + style: _compactFilledStyle(), + onPressed: () => widget.onAction!(order, 'confirm_receipt'), + child: const Text('确认收货'), + ), + if (_hasSheetActions(order)) + if (order.actions.contains('pay')) + FilledButton( + style: _compactFilledStyle(), + onPressed: () => widget.onTap!(order), + child: const Text('去支付'), + ) + else + TextButton( + style: _compactTextStyle(), + onPressed: () => widget.onTap!(order), + child: const Text('查看操作'), + ), + if (order.statusName == '已完成') ...[ + TextButton( + style: _compactTextStyle(), + onPressed: () => showUnavailableFeature( + context, + '再次购买', + stage: UnavailableStage.secondPhase, + ), + child: const Text('再次购买'), + ), + TextButton( + style: _compactTextStyle(), + onPressed: () => showUnavailableFeature( + context, + '申请售后', + stage: UnavailableStage.secondPhase, + ), + child: const Text('申请售后'), + ), + TextButton( + style: _compactTextStyle(), + onPressed: () { + final router = GoRouter.maybeOf(context); + if (router == null) { + showUnavailableFeature(context, '电子发票', stage: UnavailableStage.secondPhase); + return; + } + context.push( + '/invoice/${Uri.encodeComponent(widget.business)}/${Uri.encodeComponent(order.record.identity)}', + ); + }, + child: const Text('开发票'), + ), + ], + ]; + + /// 订单卡按钮按设计稿使用紧凑触控样式,避免三枚操作按钮换行。 + ButtonStyle _compactTextStyle() => TextButton.styleFrom( + minimumSize: const Size(64, 34), + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + + ButtonStyle _compactOutlinedStyle(BuildContext context) => OutlinedButton.styleFrom( + minimumSize: const Size(76, 34), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + foregroundColor: Theme.of(context).colorScheme.onSurface, + side: const BorderSide(color: Color(0xFFD1D5DB)), + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + + ButtonStyle _compactFilledStyle() => FilledButton.styleFrom( + minimumSize: const Size(76, 34), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); +} diff --git a/apps/user_app/lib/ui/features/orders/orders_page.dart b/apps/user_app/lib/ui/features/orders/orders_page.dart index e791b30..da6b3d6 100644 --- a/apps/user_app/lib/ui/features/orders/orders_page.dart +++ b/apps/user_app/lib/ui/features/orders/orders_page.dart @@ -1,232 +1,224 @@ // 功能描述:展示用户订单、退款和工单,并处理显式确认后的业务操作。 // 版本:1.1.0 -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:uuid/uuid.dart'; +import 'package:go_router/go_router.dart'; import '../../../data/repositories/client_repository.dart'; -import '../../../data/services/api_client.dart'; -import '../../../data/services/payment_launcher.dart'; -import '../../../domain/models/client_models.dart'; +import '../../../domain/models/order_summary.dart'; +import 'order_list.dart'; +import 'order_action_handler.dart'; import '../shared/record_list_page.dart'; class OrdersPage extends StatefulWidget { - const OrdersPage({required this.repository, super.key}); + const OrdersPage({required this.repository, this.initialTab = 0, super.key}); final ClientRepository repository; + final int initialTab; @override State createState() => _OrdersPageState(); } -class _OrdersPageState extends State { - final PaymentLauncher _launcher = PaymentLauncher(); +class _OrdersPageState extends State with SingleTickerProviderStateMixin { + late final OrderActionHandler _actions; + late final TabController _tabs; + late final TextEditingController _searchController; int _shopRefreshToken = 0; int _gasRefreshToken = 0; int _refundRefreshToken = 0; + int _ticketRefreshToken = 0; + bool _searching = false; + String _search = ''; - Future _openActions( - BuildContext context, - ClientRecord record, - String business, - ) async { - final action = await showModalBottomSheet( - context: context, - builder: (context) => SafeArea( - top: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), - child: Align( - alignment: Alignment.centerLeft, - child: Text('订单操作', style: Theme.of(context).textTheme.titleLarge), - ), - ), - if (record.status == 16 || record.status == 18) ...[ - ListTile( - title: const Text('支付宝支付'), - onTap: () => Navigator.pop(context, 'alipay'), - ), - ListTile( - title: const Text('微信支付'), - onTap: () => Navigator.pop(context, 'wechat'), - ), - ], - if (record.status == 18 || record.status == 35) - ListTile( - title: const Text('申请退款'), - onTap: () => Navigator.pop(context, 'refund'), - ), - ], - ), - ), + @override + void initState() { + super.initState(); + _tabs = TabController( + length: 3, + vsync: this, + initialIndex: switch (widget.initialTab) { + 1 => 0, + 3 => 2, + _ => 1, + }, ); - if (action == null || !context.mounted) return; - try { - if (action == 'refund') { - final reason = await _reason(context); - if (reason == null || !context.mounted) return; - final rawItems = record.raw['items'] as List? ?? const []; - await widget.repository.createRefund( - business: business, - identity: record.identity, - requestNo: const Uuid().v7(), - reason: reason, - items: rawItems.map((item) { - final value = (item as Map).cast(); - return { - 'identity': value['identity'], - 'quantity': value['quantity'] ?? 1, - }; - }).toList(), - ); - if (context.mounted) { + _searchController = TextEditingController(); + _actions = OrderActionHandler( + repository: widget.repository, + onChanged: (business) { + if (mounted) { setState(() { if (business == 'shop') { - _shopRefreshToken += 1; + _shopRefreshToken++; } else { - _gasRefreshToken += 1; + _gasRefreshToken++; } - _refundRefreshToken += 1; + _refundRefreshToken++; }); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('退款申请已提交')), - ); } - return; - } - final payment = await widget.repository.payOrder( - business: business, - identity: record.identity, - requestNo: const Uuid().v7(), - channel: action, - payType: kIsWeb ? (action == 'alipay' ? 'wap' : 'jsapi') : 'app', - openid: kIsWeb && action == 'wechat' ? Uri.base.queryParameters['openid'] ?? '' : '', - ); - await _launcher.launch(payment); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('支付结果确认中,请稍后刷新')), - ); - } - } catch (error) { - if (error is SessionExpiredException) return; - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(error.toString())), - ); - } + }, + ); + // 保留旧构造参数 2 打开退款记录的语义,主标签按最新设计收敛为三类。 + if (widget.initialTab == 2) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _openRefunds(); + }); } } - Future _reason(BuildContext context) async { - final value = await showDialog( - context: context, - builder: (context) => const _RefundReasonDialog(), - ); - return value?.isEmpty == true ? null : value; - } - - @override - Widget build(BuildContext context) => DefaultTabController( - length: 4, - child: Scaffold( - appBar: AppBar( - title: const Text('我的订单'), - bottom: const TabBar( - tabs: [ - Tab(text: '商城'), - Tab(text: '供气'), - Tab(text: '退款'), - Tab(text: '工单'), - ], - ), - ), - body: TabBarView( - children: [ - RecordListPage( - key: const PageStorageKey('shop_orders'), - title: '商城订单', - eyebrow: '交易', - description: '查看商城订单并处理待支付或退款事项', - embedded: true, - sourceKey: 'shop_orders', - loader: widget.repository.shopOrders, - refreshToken: _shopRefreshToken, - onRecordTap: (record) => _openActions(context, record, 'shop'), - ), - RecordListPage( - key: const PageStorageKey('gas_orders'), - title: '供气订单', - eyebrow: '履约', - description: '查看供气订单与当前履约状态', - embedded: true, - sourceKey: 'gas_orders', - loader: widget.repository.gasOrders, - refreshToken: _gasRefreshToken, - onRecordTap: (record) => _openActions(context, record, 'gas'), - ), - RecordListPage( - key: const PageStorageKey('refunds'), - title: '退款记录', - eyebrow: '资金', - description: '退款结果以服务端审核和资金流水为准', - embedded: true, - sourceKey: 'refunds', - loader: widget.repository.refunds, - refreshToken: _refundRefreshToken, - ), - RecordListPage( - key: const PageStorageKey('tickets'), - title: '服务工单', - eyebrow: '服务', - description: '查看维修、安装与其他服务工单', - embedded: true, - sourceKey: 'tickets', - loader: widget.repository.tickets, - ), - ], - ), - ), - ); -} - -/// 独立管理退款原因输入框生命周期,避免关闭动画期间提前释放控制器。 -class _RefundReasonDialog extends StatefulWidget { - const _RefundReasonDialog(); - - @override - State<_RefundReasonDialog> createState() => _RefundReasonDialogState(); -} - -class _RefundReasonDialogState extends State<_RefundReasonDialog> { - final TextEditingController _controller = TextEditingController(); - @override void dispose() { - _controller.dispose(); + _tabs.dispose(); + _searchController.dispose(); super.dispose(); } - @override - Widget build(BuildContext context) => AlertDialog( - title: const Text('申请退款'), - content: TextField( - controller: _controller, - maxLines: 3, - decoration: const InputDecoration(labelText: '退款原因'), + void _toggleSearch() { + setState(() { + _searching = !_searching; + if (!_searching) { + _searchController.clear(); + _search = ''; + } + }); + } + + /// 退款入口独立打开原有列表,每次打开都重新读取服务端状态。 + Future _openRefunds() => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => SafeArea( + child: SizedBox( + height: MediaQuery.sizeOf(context).height * 0.8, + child: Column( + children: [ + Row( + children: [ + const Expanded( + child: Padding(padding: EdgeInsets.all(16), child: Text('退款记录')), + ), + IconButton( + tooltip: '关闭退款记录', + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close), + ), + ], + ), + Expanded( + child: RecordListPage( + title: '退款记录', + eyebrow: '资金', + description: '退款结果以服务端审核和资金流水为准', + embedded: true, + sourceKey: 'refunds', + loader: widget.repository.refunds, + refreshToken: _refundRefreshToken, + ), + ), + ], + ), + ), ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('取消'), + ); + + Future _openActions( + BuildContext context, + OrderSummary order, + String business, { + String? selectedAction, + }) => _actions.run(context, order, business, selectedAction: selectedAction); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: _searching + ? TextField( + controller: _searchController, + autofocus: true, + decoration: const InputDecoration( + hintText: '搜索订单号或商品名称', + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + ), + onChanged: (value) => setState(() => _search = value.trim()), + ) + : const Text('我的订单'), + actions: [ + IconButton( + tooltip: _searching ? '关闭搜索' : '搜索订单', + onPressed: _toggleSearch, + icon: Icon(_searching ? Icons.close : Icons.search), + ), + ], + bottom: TabBar( + controller: _tabs, + tabs: [ + Tab(text: '气瓶订单'), + Tab(text: '商城订单'), + Tab(text: '报修工单'), + ], ), - FilledButton( - onPressed: () => Navigator.pop(context, _controller.text.trim()), - child: const Text('提交'), - ), - ], + ), + body: TabBarView( + controller: _tabs, + children: [ + OrderList( + key: const PageStorageKey('gas_orders'), + business: 'gas', + loader: widget.repository.gasOrders, + resolveImage: widget.repository.resolveImageUrl, + searchQuery: _search, + onDetail: (order) async { + await context.push('/gas/orders/${Uri.encodeComponent(order.record.identity)}'); + if (mounted) { + setState(() { + _gasRefreshToken++; + _refundRefreshToken++; + }); + } + }, + refreshToken: _gasRefreshToken, + onTap: (record) => _openActions(context, record, 'gas'), + onAction: (order, action) => _openActions(context, order, 'gas', selectedAction: action), + onRefunds: _openRefunds, + ), + OrderList( + key: const PageStorageKey('shop_orders'), + business: 'shop', + loader: widget.repository.shopOrders, + resolveImage: widget.repository.resolveImageUrl, + searchQuery: _search, + onDetail: (order) async { + await context.push('/shop/orders/${Uri.encodeComponent(order.record.identity)}'); + if (mounted) { + setState(() { + _shopRefreshToken++; + _refundRefreshToken++; + }); + } + }, + onAction: (order, action) => _openActions(context, order, 'shop', selectedAction: action), + refreshToken: _shopRefreshToken, + onTap: (record) => _openActions(context, record, 'shop'), + onRefunds: _openRefunds, + ), + RecordListPage( + key: const PageStorageKey('tickets'), + title: '服务工单', + eyebrow: '服务', + description: '查看维修、安装与其他服务工单', + embedded: true, + sourceKey: 'tickets', + loader: widget.repository.tickets, + refreshToken: _ticketRefreshToken, + query: _search, + onRecordTap: (record) async { + await context.push('/tickets/${Uri.encodeComponent(record.identity)}'); + if (mounted) setState(() => _ticketRefreshToken++); + }, + ), + ], + ), ); } diff --git a/apps/user_app/lib/ui/features/orders/payment_confirmation_page.dart b/apps/user_app/lib/ui/features/orders/payment_confirmation_page.dart new file mode 100644 index 0000000..9b2b028 --- /dev/null +++ b/apps/user_app/lib/ui/features/orders/payment_confirmation_page.dart @@ -0,0 +1,570 @@ +// 功能描述:图19订单支付确认、余额校验、渠道选择与真实支付提交;版本:1.0.0。 +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../data/repositories/client_repository.dart'; +import '../../../data/services/payment_launcher.dart'; +import '../../../domain/models/client_models.dart'; +import '../../../domain/models/gas_order_detail.dart'; +import '../../../domain/models/recharge.dart'; +import '../../../domain/models/shop_order_detail.dart'; +import '../../core/payment_pin_field.dart'; + +/// 支付页始终重读订单和钱包,金额、余额及允许动作均以服务端为准。 +class PaymentConfirmationPage extends StatefulWidget { + const PaymentConfirmationPage({ + required this.repository, + required this.business, + required this.identity, + this.launchPayment, + super.key, + }); + + final ClientRepository repository; + final String business, identity; + final Future Function(Map payment)? launchPayment; + + @override + State createState() => _PaymentConfirmationPageState(); +} + +class _PaymentData { + const _PaymentData(this.order, this.wallet, this.options); + final ShopOrderDetail order; + final WalletSummary wallet; + final RechargeOptions options; +} + +class _PaymentConfirmationPageState extends State { + late Future<_PaymentData> _future = _load(); + final _password = TextEditingController(); + String _channel = 'wallet'; + String _requestNo = const Uuid().v7(); + bool _submitting = false; + String? _error; + + Future<_PaymentData> _load() async { + if (!const {'shop', 'gas'}.contains(widget.business)) { + throw const FormatException('订单类型不支持支付'); + } + final values = await Future.wait([ + widget.business == 'gas' + ? widget.repository.gasOrderDetail(widget.identity) + : widget.repository.shopOrderDetail(widget.identity), + widget.repository.wallet(), + widget.repository.rechargeOptions(), + ]); + return _PaymentData( + values[0] as ShopOrderDetail, + values[1] as WalletSummary, + values[2] as RechargeOptions, + ); + } + + /// 订单支付复用服务端支付渠道就绪状态,缺少商户配置时直接显示首期真实边界。 + bool _channelAvailable(_PaymentData data, String channel) { + if (channel == 'wallet') return true; + final configured = data.options.channels.where((item) => item.name == channel); + if (configured.isEmpty) return false; + final item = configured.first; + return kIsWeb ? item.wap : item.app; + } + + void _channelNotOpen(String channel) => showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('${_channelName(channel)}暂未开放'), + content: const Text('当前环境尚未配置可用的支付商户通道,请使用余额支付。'), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('知道了'))], + ), + ); + + Future _refresh() async { + final future = _load(); + setState(() => _future = future); + await future; + } + + void _notOpen() => showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('优惠券暂未开放'), + content: const Text('当前订单优惠券能力尚未接入,暂时无法选择。'), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('知道了'))], + ), + ); + + /// 真正扣款前再次确认订单号、渠道和金额,未知结果保留同一幂等号。 + Future _pay(_PaymentData data) async { + final order = data.order; + if (!order.actions.contains('pay')) { + setState(() => _error = '订单状态已变化,当前不可支付'); + return; + } + if (_channel == 'wallet' && data.wallet.balance < order.payableAmount) { + setState(() => _error = '余额不足,请选择其他支付方式或先充值'); + return; + } + if (!_channelAvailable(data, _channel)) { + _channelNotOpen(_channel); + return; + } + if (_channel == 'wallet' && !RegExp(r'^\d{6}$').hasMatch(_password.text)) { + setState(() => _error = '请输入6位支付密码'); + return; + } + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('确认支付'), + content: Text( + '订单号:${order.orderNo}\n支付方式:${_channelName(_channel)}\n支付金额:${moneyText(order.payableAmount)}', + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('返回检查')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('确认支付')), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() { + _submitting = true; + _error = null; + }); + try { + final payment = await widget.repository.payOrder( + business: widget.business, + identity: widget.identity, + requestNo: _requestNo, + channel: _channel, + payType: _channel == 'wallet' + ? 'balance' + : kIsWeb + ? (_channel == 'alipay' ? 'wap' : 'jsapi') + : 'app', + openid: kIsWeb && _channel == 'wechat' ? Uri.base.queryParameters['openid'] ?? '' : '', + paymentPassword: _channel == 'wallet' ? _password.text : '', + ); + if (!mounted) return; + if (_channel == 'wallet') { + _requestNo = const Uuid().v7(); + _password.clear(); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('支付成功'))); + context.go( + widget.business == 'gas' + ? '/gas/orders/${Uri.encodeComponent(widget.identity)}' + : '/shop/orders/${Uri.encodeComponent(widget.identity)}', + ); + } else { + await (widget.launchPayment ?? PaymentLauncher().launch)(payment); + if (mounted) { + setState(() => _submitting = false); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('支付结果确认中,请稍后刷新'))); + } + } + } catch (error) { + if (mounted) { + setState(() { + _submitting = false; + _error = error.toString(); + }); + } + } + } + + @override + void dispose() { + _password.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('确认支付'), + centerTitle: true, + leading: IconButton( + tooltip: '关闭', + onPressed: () => context.canPop() ? context.pop() : context.go('/orders'), + icon: const Icon(Icons.close), + ), + ), + body: FutureBuilder<_PaymentData>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center( + child: TextButton(onPressed: _refresh, child: const Text('支付信息加载失败,点击重试')), + ); + } + final data = snapshot.data!; + final order = data.order; + final item = order.items.isEmpty ? null : order.items.first; + final canPay = order.actions.contains('pay'); + return Column( + children: [ + Expanded( + child: RefreshIndicator( + onRefresh: _refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(14, 6, 14, 14), + children: [ + _amountCard(order, item), + const SizedBox(height: 8), + _methodCard(data), + const SizedBox(height: 8), + _actionCard( + icon: Icons.confirmation_number_outlined, + iconColor: const Color(0xFFF97316), + title: '优惠券', + trailing: '暂未开放', + onTap: _notOpen, + ), + const SizedBox(height: 8), + _securityCard(), + const SizedBox(height: 8), + _detailCard(order), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 10), + child: Text(_error!, style: const TextStyle(color: Color(0xFFC7352A))), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(14, 8, 14, 14), + child: Column( + children: [ + const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.verified_user_outlined, color: Color(0xFF777F8D), size: 18), + SizedBox(width: 6), + Text('支付由平台安全保障', style: TextStyle(color: Color(0xFF777F8D))), + ], + ), + const SizedBox(height: 8), + FilledButton( + key: const Key('confirm-payment'), + style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48)), + onPressed: _submitting || !canPay ? null : () => _pay(data), + child: Text( + !canPay + ? '订单当前不可支付' + : _submitting + ? '提交中…' + : '确认支付 ${moneyText(order.payableAmount)}', + ), + ), + ], + ), + ), + ], + ); + }, + ), + ); + + Widget _amountCard(ShopOrderDetail order, ShopOrderLine? item) => Container( + padding: const EdgeInsets.all(12), + decoration: _box(), + child: Row( + children: [ + Container( + width: 56, + height: 68, + decoration: BoxDecoration( + color: const Color(0xFFEAF1FF), + borderRadius: BorderRadius.circular(8), + ), + child: item?.imageUrl.isNotEmpty == true + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network(item!.imageUrl, fit: BoxFit.cover), + ) + : (widget.business == 'gas' || (item?.name.contains('液化气') ?? false)) + ? Image.asset('assets/products/lpg-cylinder.png', fit: BoxFit.contain) + : const Icon(Icons.inventory_2_outlined, color: Color(0xFF2563EB), size: 38), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('应付金额', style: TextStyle(fontSize: 15)), + Text( + moneyText(order.payableAmount), + style: const TextStyle( + color: Color(0xFF2563EB), + fontSize: 32, + fontWeight: FontWeight.w700, + ), + ), + Text( + item?.name.isNotEmpty == true + ? item!.name + : (widget.business == 'gas' ? '供气订单' : '商城订单'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFF4B5563)), + ), + Text('订单号:${order.orderNo}', style: const TextStyle(color: Color(0xFF777F8D))), + ], + ), + ), + ], + ), + ); + + Widget _methodCard(_PaymentData data) => Material( + color: Colors.white, + shape: _cardShape(), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('支付方式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + RadioGroup( + groupValue: _channel, + onChanged: (value) { + if (value == null) return; + if (!_channelAvailable(data, value)) { + _channelNotOpen(value); + return; + } + setState(() { + _channel = value; + _error = null; + }); + }, + child: Column( + children: [ + _channelTile( + 'wallet', + '余额支付', + '余额 ${moneyText(data.wallet.balance)}', + Icons.currency_yen, + const Color(0xFF2563EB), + available: true, + ), + _channelTile( + 'wechat', + '微信支付', + _channelAvailable(data, 'wechat') ? '' : '暂未开放', + Icons.chat_bubble, + const Color(0xFF16A34A), + available: _channelAvailable(data, 'wechat'), + ), + _channelTile( + 'alipay', + '支付宝', + _channelAvailable(data, 'alipay') ? '' : '暂未开放', + Icons.currency_exchange, + const Color(0xFF1677FF), + available: _channelAvailable(data, 'alipay'), + divider: false, + ), + ], + ), + ), + ], + ), + ), + ); + + Widget _channelTile( + String value, + String title, + String subtitle, + IconData icon, + Color color, { + required bool available, + bool divider = true, + }) => Column( + children: [ + SizedBox( + height: 48, + child: RadioListTile( + value: value, + enabled: available, + dense: true, + visualDensity: const VisualDensity(horizontal: -2, vertical: -4), + controlAffinity: ListTileControlAffinity.trailing, + contentPadding: EdgeInsets.zero, + secondary: CircleAvatar( + radius: 16, + backgroundColor: color, + child: Icon(icon, color: Colors.white, size: 19), + ), + title: Text(title), + subtitle: subtitle.isEmpty + ? null + : Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ), + if (divider) const Divider(height: 1), + ], + ); + + Widget _actionCard({ + required IconData icon, + required Color iconColor, + required String title, + required String trailing, + required VoidCallback onTap, + }) => Material( + color: Colors.white, + shape: _cardShape(), + child: ListTile( + dense: true, + minTileHeight: 48, + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + onTap: onTap, + leading: Icon(icon, color: iconColor), + title: Text(title, style: const TextStyle(fontSize: 17)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + trailing, + style: TextStyle( + color: trailing.contains('暂未开放') ? const Color(0xFF777F8D) : const Color(0xFFC7352A), + ), + ), + const Icon(Icons.chevron_right), + ], + ), + ), + ); + + Widget _securityCard() => Container( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 10), + decoration: _box(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('安全验证', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + SizedBox( + height: 32, + child: TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 4), + visualDensity: VisualDensity.compact, + ), + onPressed: () => context.push('/settings/payment-password'), + child: const Text('忘记密码'), + ), + ), + ], + ), + if (_channel == 'wallet') ...[ + const Text('支付密码'), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 22), + child: PaymentPinField( + controller: _password, + enabled: !_submitting, + fieldKey: const Key('payment-password'), + onChanged: () { + if (_error != null) setState(() => _error = null); + }, + ), + ), + ] else + Text( + '${_channelName(_channel)}将在安全页面完成验证', + style: const TextStyle(color: Color(0xFF777F8D)), + ), + ], + ), + ); + + Widget _detailCard(ShopOrderDetail order) { + final delivery = order is GasOrderDetail ? order.deliveryFee : 0; + return Container( + padding: const EdgeInsets.all(12), + decoration: _box(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('订单明细', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + const SizedBox(height: 6), + _moneyRow( + widget.business == 'gas' ? '气费(${_gasItemSummary(order)})' : '商品金额', + order.productAmount, + ), + if (order is GasOrderDetail) + _moneyRow('押金(可退押金 × ${_itemQuantity(order)})', order.depositAmount), + _moneyRow('配送费', delivery), + if (order.discountAmount > 0) _moneyRow('优惠', -order.discountAmount), + const Divider(), + _moneyRow('合计', order.payableAmount, strong: true), + ], + ), + ); + } + + Widget _moneyRow(String label, int amount, {bool strong = false}) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: TextStyle(fontWeight: strong ? FontWeight.w700 : FontWeight.w400), + ), + ), + const SizedBox(width: 8), + Text( + '${amount < 0 ? '-' : ''}${moneyText(amount.abs())}', + style: TextStyle( + color: strong ? const Color(0xFF2563EB) : null, + fontWeight: strong ? FontWeight.w700 : FontWeight.w400, + ), + ), + ], + ), + ); + + int _itemQuantity(ShopOrderDetail order) => + order.items.fold(0, (total, item) => total + item.quantity); + + String _gasItemSummary(ShopOrderDetail order) { + if (order.items.isEmpty) return '气瓶'; + final item = order.items.first; + return '${item.name} × ${item.quantity}'; + } +} + +String _channelName(String value) => switch (value) { + 'wallet' => '余额支付', + 'wechat' => '微信支付', + 'alipay' => '支付宝', + _ => '支付渠道', +}; + +BoxDecoration _box() => BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE5E7EB)), +); + +ShapeBorder _cardShape() => RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: Color(0xFFE5E7EB)), +); diff --git a/apps/user_app/lib/ui/features/orders/shop_order_detail_page.dart b/apps/user_app/lib/ui/features/orders/shop_order_detail_page.dart new file mode 100644 index 0000000..be931b4 --- /dev/null +++ b/apps/user_app/lib/ui/features/orders/shop_order_detail_page.dart @@ -0,0 +1,521 @@ +// 功能描述:订单详情、历史收货快照、费用及服务端允许操作;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/client_models.dart'; +import '../../../domain/models/shop_order_detail.dart'; +import '../../../domain/models/gas_order_detail.dart'; +import '../../core/async_content.dart'; +import '../shop/shop_page.dart'; +import 'order_action_handler.dart'; +import 'gas_order_sections.dart'; + +class ShopOrderDetailPage extends StatefulWidget { + const ShopOrderDetailPage({ + required this.repository, + required this.identity, + this.business = 'shop', + super.key, + }); + final ClientRepository repository; + final String identity; + final String business; + @override + State createState() => _ShopOrderDetailPageState(); +} + +/// 下拉刷新保留历史内容;动作先重读订单,后端仍执行状态与所有权校验。 +class _ShopOrderDetailPageState extends State { + final _content = GlobalKey>(); + late final OrderActionHandler _actions; + bool _busy = false; + ShopOrderDetail? _order; + int _generation = 0; + Future _read() => widget.business == 'gas' + ? widget.repository.gasOrderDetail(widget.identity) + : widget.repository.shopOrderDetail(widget.identity); + Future _load() async { + final generation = ++_generation; + final order = await _read(); + if (mounted && generation == _generation) setState(() => _order = order); + return order; + } + + @override + void initState() { + super.initState(); + _actions = OrderActionHandler( + repository: widget.repository, + onChanged: (_) { + if (mounted) _content.currentState?.refresh(); + }, + ); + } + + Future _act(String? action) async { + if (_busy) return; + setState(() => _busy = true); + try { + final fresh = await _read(); + if (!mounted) return; + if (action != null && !fresh.actions.contains(action)) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('订单状态已变化,请查看最新信息'))); + return; + } + await _actions.run(context, fresh.summary, widget.business, selectedAction: action); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString()))); + } + } finally { + if (mounted) { + await _content.currentState?.refresh(); + if (mounted) setState(() => _busy = false); + } + } + } + + Future _copy(String value) async { + try { + await Clipboard.setData(ClipboardData(text: value)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已复制订单号'))); + } + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('复制失败,请重试'))); + } + } + } + + /// 未接入的首期能力保留入口并说明真实边界;明确二期的能力使用“即将开放”。 + Future _showPending(String title, String message) => showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('知道了')), + ], + ), + ); + + String _time(DateTime value) => value.toLocal().toString().substring(0, 16); + Widget _section(String title, List children) => Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE5E7EB)), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title.isNotEmpty) ...[ + Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + const SizedBox(height: 5), + ], + ...children, + ], + ), + ); + Widget _row(String label, String value, {bool blue = false}) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle(color: Color(0xFF6B7280), fontSize: 13, height: 1.15), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + value, + textAlign: TextAlign.right, + style: TextStyle( + color: blue ? const Color(0xFF2563EB) : null, + fontWeight: blue ? FontWeight.w600 : null, + fontSize: 13, + height: 1.15, + ), + ), + ), + ], + ), + ); + + Widget _infoActionRow(String label, String value, String action, VoidCallback onTap) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Text(label, style: const TextStyle(color: Color(0xFF6B7280), fontSize: 13)), + const SizedBox(width: 12), + Expanded( + child: Text( + value, + textAlign: TextAlign.right, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13), + ), + ), + const SizedBox(width: 8), + InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Text( + action, + style: const TextStyle( + color: Color(0xFF2563EB), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ); + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('订单详情'), + leading: BackButton( + onPressed: () => + context.canPop() ? context.pop() : context.go('/orders?tab=${widget.business}'), + ), + ), + body: AsyncContent( + key: _content, + load: _load, + builder: (context, order) => ListView( + padding: const EdgeInsets.fromLTRB(14, 8, 14, 8), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + _section('', [ + Row( + children: [ + const Icon(Icons.local_shipping_outlined, color: Color(0xFF2563EB), size: 32), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + order.statusName, + style: const TextStyle( + color: Color(0xFF2563EB), + fontSize: 21, + fontWeight: FontWeight.w600, + ), + ), + if (order is GasOrderDetail) + Text( + order.staffName.isEmpty ? '配送员暂未分配' : '配送员:${order.staffName}', + style: const TextStyle(color: Color(0xFF6B7280)), + ), + ], + ), + ), + if (order is GasOrderDetail) + SizedBox( + width: 96, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 38), + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + onPressed: () => context.push( + '/gas/orders/${Uri.encodeComponent(order.identity)}/delivery', + ), + child: const Text('查看配送'), + ), + ), + ], + ), + const SizedBox(height: 6), + if (order is! GasOrderDetail && order.createdAt != null) + _row('已下单', _time(order.createdAt!)), + if (order is GasOrderDetail) GasOrderTimeline(order: order), + if (order.paidAt != null) _row('已付款', _time(order.paidAt!)), + if (order.shippedAt != null) _row('已发货', _time(order.shippedAt!)), + if (order.receivedAt != null) _row('已收货', _time(order.receivedAt!)), + ]), + _section('', [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.location_on, color: Color(0xFF2563EB)), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${order.contactName} ${order.contactPhone.length == 11 ? order.contactPhone.replaceRange(3, 7, '****') : order.contactPhone}', + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + order.address.isEmpty ? '暂无收货地址快照' : order.address, + style: const TextStyle(fontSize: 13, height: 1.25), + ), + ], + ), + ), + ], + ), + ]), + if (order is GasOrderDetail) _gasProduct(order), + if (order is! GasOrderDetail) + _section('商品明细', [ + if (order.items.isEmpty) const Text('暂无历史商品明细'), + for (final item in order.items) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + SizedBox( + width: 76, + height: 88, + child: DefaultTextStyle.merge( + style: const TextStyle(fontSize: 12), + child: ProductImage(url: item.imageUrl), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name.isEmpty ? '历史商品资料缺失' : item.name, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + _row('单价', moneyText(item.unitAmount)), + _row('数量', '× ${item.quantity}'), + _row('小计', moneyText(item.amount)), + ], + ), + ), + ], + ), + ), + ]), + _section('费用明细', [ + _row(order is GasOrderDetail ? '气费' : '商品金额', moneyText(order.productAmount)), + if (order is GasOrderDetail) _row('可退押金', moneyText(order.depositAmount)), + if (order is GasOrderDetail) _row('配送费', moneyText(order.deliveryFee)), + if (order is! GasOrderDetail) _row('优惠金额', '-${moneyText(order.discountAmount)}'), + const Divider(), + _row( + order is GasOrderDetail ? '实付金额' : '应付金额', + moneyText(order.payableAmount), + blue: true, + ), + ]), + _section('订单信息', [ + _infoActionRow('订单号', order.orderNo, '复制', () => _copy(order.orderNo)), + if (order.createdAt != null) _row('下单时间', _time(order.createdAt!)), + if (order is GasOrderDetail && order.payments.isNotEmpty) + _row('支付方式', order.payments.first.channelName), + if (order is GasOrderDetail && order.contractNo.isNotEmpty) + _infoActionRow('供气合同编号', order.contractNo, '复制', () => _copy(order.contractNo)), + if (order.logisticsCompany.isNotEmpty) _row('物流公司', order.logisticsCompany), + if (order.logisticsNo.isNotEmpty) _row('物流单号', order.logisticsNo), + if (order.remark.isNotEmpty) _row('订单备注', order.remark), + ]), + if (order is GasOrderDetail) _gasServices(order), + if (order.statusName == '已完成') _invoiceEntry(order), + ], + ), + ), + bottomNavigationBar: _order == null ? null : _footer(_order!), + ); + + /// 供气订单按设计稿展示气站与气瓶商品,不伪造缺失的规格或图片。 + Widget _gasProduct(GasOrderDetail order) => _section('', [ + for (final item in order.items) + Row( + children: [ + SizedBox( + width: 72, + height: 78, + child: item.imageUrl.isEmpty + ? const Icon(Icons.image_not_supported_outlined, color: Color(0xFF9CA3AF), size: 30) + : ProductImage(url: item.imageUrl), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + order.stationName.isEmpty ? '暂无气站资料' : order.stationName, + style: const TextStyle(color: Color(0xFF6B7280)), + ), + const SizedBox(height: 5), + Text( + '${item.name.isEmpty ? '历史商品资料缺失' : item.name} × ${item.quantity}', + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + ), + ], + ), + ), + Text( + moneyText(item.amount), + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + ), + ], + ), + ]); + + Widget _gasServices(GasOrderDetail order) => _section('', [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _serviceAction( + Icons.phone_outlined, + '联系气站', + () => _showPending('联系气站暂未开放', '订单接口尚未提供可拨打的气站服务电话。'), + ), + _serviceAction( + Icons.phone_in_talk_outlined, + '联系配送员', + () => _showPending('联系配送员暂未开放', '为保护员工隐私,当前订单接口尚未提供受控呼叫能力。'), + ), + _serviceAction( + Icons.description_outlined, + '查看合同', + order.contractIdentity.isEmpty + ? () => _showPending('合同暂不可查看', '当前订单没有关联可读取的供气合同。') + : () => showGasContract(context, widget.repository, order.contractIdentity), + ), + _serviceAction( + Icons.verified_user_outlined, + '申请售后', + () => _showPending('申请售后即将开放', '订单售后已明确属于后续版本,当前暂不能提交申请。'), + ), + ], + ), + ]); + + /// 二期电子发票保留真实订单入口,进入后只展示事实和“即将开放”状态。 + Widget _invoiceEntry(ShopOrderDetail order) => _section('', [ + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.receipt_long_outlined, color: Color(0xFF2563EB)), + title: const Text('电子发票'), + subtitle: const Text('二期功能 · 即将开放'), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push( + '/invoice/${Uri.encodeComponent(widget.business)}/${Uri.encodeComponent(order.identity)}', + ), + ), + ]); + + Widget _serviceAction(IconData icon, String label, VoidCallback onTap) => Expanded( + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + children: [ + Icon(icon, color: const Color(0xFF2563EB), size: 27), + const SizedBox(height: 7), + Text(label, textAlign: TextAlign.center, style: const TextStyle(fontSize: 13)), + ], + ), + ), + ), + ); + + Widget _footer(ShopOrderDetail order) => order.actions.isEmpty + ? const SizedBox.shrink() + : SafeArea( + top: false, + child: Material( + color: Colors.white, + child: Padding( + padding: EdgeInsets.fromLTRB(12, order is GasOrderDetail ? 6 : 12, 12, 6), + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth >= 280 + ? (constraints.maxWidth - 12) / 2 + : constraints.maxWidth; + final buttons = Wrap( + spacing: 12, + runSpacing: 6, + children: [ + if (order is GasOrderDetail && order.actions.contains('confirm_receipt')) + SizedBox( + width: width, + child: OutlinedButton( + onPressed: _busy + ? null + : () => _showPending( + '申请售后即将开放', + '订单售后已明确属于后续版本,当前暂不能提交申请。', + ), + child: const Text('申请售后'), + ), + ), + if (order.actions.contains('cancel')) + SizedBox( + width: width, + child: OutlinedButton( + onPressed: _busy ? null : () => _act('cancel'), + child: const Text('取消订单'), + ), + ), + if (order.actions.contains('refund')) + SizedBox( + width: width, + child: OutlinedButton( + onPressed: _busy ? null : () => _act('refund'), + child: const Text('申请退款'), + ), + ), + if (order.actions.contains('pay')) + SizedBox( + width: width, + child: FilledButton( + onPressed: _busy ? null : () => _act(null), + child: const Text('去支付'), + ), + ), + if (order.actions.contains('confirm_receipt')) + SizedBox( + width: width, + child: FilledButton( + onPressed: _busy ? null : () => _act('confirm_receipt'), + child: const Text('确认收货'), + ), + ), + ], + ); + if (order is! GasOrderDetail || !order.actions.contains('confirm_receipt')) { + return buttons; + } + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + buttons, + const SizedBox(height: 3), + const Text( + '收货时请核对气瓶封签和编号', + style: TextStyle(fontSize: 11, color: Color(0xFF6B7280)), + ), + ], + ); + }, + ), + ), + ), + ); +} diff --git a/apps/user_app/lib/ui/features/profile/device_groups_page.dart b/apps/user_app/lib/ui/features/profile/device_groups_page.dart new file mode 100644 index 0000000..702702c --- /dev/null +++ b/apps/user_app/lib/ui/features/profile/device_groups_page.dart @@ -0,0 +1,387 @@ +// 功能:图08设备分组资料管理与本人设备归组;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../data/repositories/client_repository.dart'; +import '../../../data/services/api_client.dart'; +import '../../../domain/models/client_models.dart'; +import '../../../domain/models/device_group.dart'; +import '../../core/async_content.dart'; +import '../../core/feature_entry.dart'; + +class DeviceGroupsPage extends StatefulWidget { + const DeviceGroupsPage({required this.repository, super.key}); + final ClientRepository repository; + + @override + State createState() => _DeviceGroupsPageState(); +} + +/// 分组资料可真实维护;设备遥测、批量控制和安全检查缺少服务端能力时明确阻止操作。 +class _DeviceGroupsPageState extends State { + final _contentKey = GlobalKey>(); + + Future _load() async { + final values = await Future.wait([ + widget.repository.devices(), + widget.repository.deviceGroups(), + ]); + return DeviceCatalog( + devices: values[0] as List, + groups: values[1] as List, + ); + } + + void _message(String text) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); + } + + Future _saveGroup([DeviceGroup? group]) async { + var draft = group?.name ?? ''; + var error = ''; + final name = await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: Text(group == null ? '新建分组' : '编辑分组'), + content: TextFormField( + autofocus: true, + initialValue: group?.name ?? '', + maxLength: 32, + decoration: InputDecoration( + labelText: '分组名称', + hintText: '例如:厨房', + errorText: error.isEmpty ? null : error, + ), + onChanged: (value) => draft = value, + onFieldSubmitted: (_) { + final value = draft.trim(); + if (value.isEmpty) { + setDialogState(() => error = '请输入分组名称'); + } else { + Navigator.pop(context, value); + } + }, + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')), + FilledButton( + onPressed: () { + final value = draft.trim(); + if (value.isEmpty) { + setDialogState(() => error = '请输入分组名称'); + return; + } + Navigator.pop(context, value); + }, + child: const Text('保存'), + ), + ], + ), + ), + ); + if (name == null) return; + try { + await widget.repository.saveDeviceGroup( + identity: group?.identity, + name: name, + requestNo: const Uuid().v7(), + ); + _message(group == null ? '分组已创建' : '分组已更新'); + await _contentKey.currentState?.refresh(); + } on ApiException catch (error) { + _message(error.message); + } + } + + Future _deleteGroup(DeviceGroup group) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('删除分组'), + content: Text('删除“${group.name}”后,组内设备将回到未分组。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('取消')), + TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('删除')), + ], + ), + ); + if (confirmed != true) return; + try { + await widget.repository.deleteDeviceGroup(group.identity); + _message('分组已删除,设备已移至未分组'); + await _contentKey.currentState?.refresh(); + } on ApiException catch (error) { + _message(error.message); + } + } + + DeviceGroup? _currentGroup(ClientRecord device, List groups) { + for (final group in groups) { + if (group.deviceIdentities.contains(device.identity)) return group; + } + return null; + } + + Future _assign(ClientRecord device, String identity) async { + try { + await widget.repository.assignDeviceGroup(device.identity, identity); + if (mounted) Navigator.of(context).pop(); + _message('设备分组已更新'); + await _contentKey.currentState?.refresh(); + } on ApiException catch (error) { + _message(error.message); + } + } + + void _manageDevices(DeviceCatalog catalog, {DeviceGroup? filter}) { + final devices = filter == null + ? catalog.devices + : catalog.devices + .where((device) => filter.deviceIdentities.contains(device.identity)) + .toList(); + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => SafeArea( + child: SizedBox( + height: 460, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: Text( + filter == null ? '管理设备归组' : '${filter.name}设备', + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700), + ), + ), + if (devices.isEmpty) + const Expanded(child: Center(child: Text('暂无可分组设备'))) + else + Expanded( + child: ListView.separated( + itemCount: devices.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final device = devices[index]; + final current = _currentGroup(device, catalog.groups); + return ListTile( + leading: Icon( + device.raw['kind'] == 'valve' + ? Icons.propane_tank_outlined + : Icons.sensors, + color: const Color(0xFF0868F7), + ), + title: Text(device.title), + subtitle: Text('当前分组:${current?.name ?? '未分组'}'), + trailing: PopupMenuButton( + tooltip: '更改分组', + onSelected: (identity) { + if (identity != (current?.identity ?? '')) _assign(device, identity); + }, + itemBuilder: (_) => [ + const PopupMenuItem(value: '', child: Text('未分组')), + for (final group in catalog.groups) + PopupMenuItem(value: group.identity, child: Text(group.name)), + ], + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [Text('更改'), Icon(Icons.chevron_right)], + ), + ), + ); + }, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _groupCard(DeviceGroup group, DeviceCatalog catalog) { + final devices = catalog.devices + .where((device) => group.deviceIdentities.contains(device.identity)) + .toList(); + return Container( + margin: const EdgeInsets.only(bottom: 14), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFD7DAE4)), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.kitchen_outlined, color: Color(0xFF0868F7)), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + group.name, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + Text( + '${devices.length}台设备 · 在线状态暂未开放', + style: const TextStyle(color: Color(0xFF697184)), + ), + ], + ), + ), + PopupMenuButton( + tooltip: '分组操作', + onSelected: (value) => value == 'edit' ? _saveGroup(group) : _deleteGroup(group), + itemBuilder: (_) => const [ + PopupMenuItem( + value: 'edit', + child: ListTile(leading: Icon(Icons.edit_outlined), title: Text('编辑分组')), + ), + PopupMenuItem( + value: 'delete', + child: ListTile( + leading: Icon(Icons.delete_outline, color: Colors.red), + title: Text('删除分组', style: TextStyle(color: Colors.red)), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 14), + InkWell( + onTap: () => _manageDevices(catalog, filter: group), + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 11), + child: Row( + children: [ + Icon(Icons.health_and_safety_outlined, color: Color(0xFF697184)), + SizedBox(width: 10), + Text('安全状态:暂未开放'), + Spacer(), + Text('查看设备列表', style: TextStyle(color: Color(0xFF697184))), + Icon(Icons.chevron_right, color: Color(0xFF697184)), + ], + ), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => showUnavailableFeature(context, '分组全部关闭'), + icon: const Icon(Icons.power_settings_new), + label: const Text('全部关闭'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + onPressed: () => showUnavailableFeature(context, '安全检查后开启'), + icon: const Icon(Icons.verified_user_outlined), + label: const Text('安全检查后开启'), + ), + ), + ], + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: const Color(0xFFF8F9FC), + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new), + tooltip: '返回', + onPressed: () => context.pop(), + ), + title: const Text('设备分组'), + centerTitle: true, + actions: [TextButton(onPressed: _saveGroup, child: const Text('新建分组'))], + ), + body: AsyncContent( + key: _contentKey, + load: _load, + builder: (context, catalog) => ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 18, 16, 22), + children: [ + const Text('将设备按功能或区域分组,方便查看和管理', style: TextStyle(color: Color(0xFF697184))), + const SizedBox(height: 18), + if (catalog.groups.isEmpty) + Container( + margin: const EdgeInsets.only(bottom: 14), + padding: const EdgeInsets.symmetric(vertical: 30), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFD7DAE4)), + borderRadius: BorderRadius.circular(12), + ), + child: const Center(child: Text('暂无设备分组')), + ) + else + for (final group in catalog.groups) _groupCard(group, catalog), + Container( + margin: const EdgeInsets.only(top: 2, bottom: 14), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFFAF1), + border: Border.all(color: const Color(0xFFFFC875)), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.warning_amber_rounded, color: Color(0xFFE36B00)), + SizedBox(width: 9), + Expanded( + child: Text( + '分组开关及安全检查暂未开放;当前可使用分组资料与设备归组。', + style: TextStyle(color: Color(0xFFC85E00)), + ), + ), + ], + ), + ), + Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFD7DAE4)), + borderRadius: BorderRadius.circular(10), + ), + child: Material( + type: MaterialType.transparency, + child: ListTile( + leading: const Icon(Icons.settings_outlined, color: Color(0xFF0868F7)), + title: const Text('管理分组'), + subtitle: const Text('新建、重命名、删除分组或调整设备'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _manageDevices(catalog), + ), + ), + ), + ], + ), + ), + ); +} diff --git a/apps/user_app/lib/ui/features/profile/devices_page.dart b/apps/user_app/lib/ui/features/profile/devices_page.dart new file mode 100644 index 0000000..7ae14b9 --- /dev/null +++ b/apps/user_app/lib/ui/features/profile/devices_page.dart @@ -0,0 +1,335 @@ +// 功能:图34本人设备档案、分组展示、搜索和分类;版本:1.1.1。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/client_models.dart'; +import '../../../domain/models/device_group.dart'; +import '../../core/async_content.dart'; +import '../../core/feature_entry.dart'; + +class DevicesPage extends StatefulWidget { + const DevicesPage({required this.repository, super.key}); + final ClientRepository repository; + + @override + State createState() => _DevicesPageState(); +} + +/// 分类和搜索只作用于本人完整分页结果,未知遥测不计为离线或零告警。 +class _DevicesPageState extends State { + final _contentKey = GlobalKey>(); + String _search = '', _kind = ''; + + Future _load() async { + final values = await Future.wait([ + widget.repository.devices(), + widget.repository.deviceGroups(), + ]); + return DeviceCatalog( + devices: values[0] as List, + groups: values[1] as List, + ); + } + + Widget _panel(Widget child, {EdgeInsetsGeometry padding = const EdgeInsets.all(12)}) => Container( + margin: const EdgeInsets.only(bottom: 12), + padding: padding, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFD7DAE4)), + borderRadius: BorderRadius.circular(12), + ), + child: Material(type: MaterialType.transparency, child: child), + ); + + Widget _metric(IconData icon, Color color, String value, String label) => Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 18, color: color), + const SizedBox(width: 7), + Text(value, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w600)), + ], + ), + const SizedBox(height: 4), + Text(label, style: const TextStyle(fontSize: 12)), + ], + ), + ); + + String _kindName(ClientRecord device) => device.raw['kind'] == 'valve' ? '智能角阀' : '报警器'; + + void _details(ClientRecord device, String groupName) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(device.title), + content: SelectableText( + '设备编号:${device.subtitle}\n设备类型:${_kindName(device)}\n所在分组:$groupName\n设备状态:暂不可查询\n远程控制:暂未开放', + ), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('关闭'))], + ), + ); + } + + Widget _deviceRow(ClientRecord device, String groupName) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: InkWell( + onTap: () => _details(device, groupName), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + device.raw['kind'] == 'valve' ? Icons.propane_tank_outlined : Icons.sensors, + color: const Color(0xFF0868F7), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + device.title, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 3), + Text( + '$groupName | 编号 ${device.subtitle}', + style: const TextStyle(fontSize: 12, color: Color(0xFF697184)), + ), + const SizedBox(height: 3), + const Text('更新时间 暂不可查询', style: TextStyle(fontSize: 12, color: Color(0xFF697184))), + ], + ), + ), + const SizedBox(width: 6), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const Text('状态暂不可查询', style: TextStyle(fontSize: 11, color: Color(0xFF697184))), + if (device.raw['kind'] == 'valve') + TextButton( + onPressed: () => showUnavailableFeature(context, '设备控制'), + child: const Text('控制'), + ), + ], + ), + const Icon(Icons.chevron_right, color: Color(0xFF697184)), + ], + ), + ), + ); + + Widget _groupCard(String name, List devices) => _panel( + Column( + children: [ + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.meeting_room_outlined, color: Color(0xFF0868F7)), + ), + const SizedBox(width: 10), + Text(name, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), + const SizedBox(width: 10), + Text('${devices.length}台设备', style: const TextStyle(color: Color(0xFF697184))), + const Spacer(), + const Icon(Icons.keyboard_arrow_up), + ], + ), + for (var index = 0; index < devices.length; index++) ...[ + const Divider(height: 18), + _deviceRow(devices[index], name), + ], + ], + ), + ); + + List _groupedCards(List devices, List groups) { + final result = []; + final assigned = {}; + for (final group in groups) { + final values = devices + .where((device) => group.deviceIdentities.contains(device.identity)) + .toList(); + if (values.isEmpty) continue; + assigned.addAll(values.map((device) => device.identity)); + result.add(_groupCard(group.name, values)); + } + final ungrouped = devices.where((device) => !assigned.contains(device.identity)).toList(); + if (ungrouped.isNotEmpty) result.add(_groupCard('未分组', ungrouped)); + return result; + } + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: const Color(0xFFF8F9FC), + appBar: AppBar( + title: const Text('我的设备'), + centerTitle: true, + leading: IconButton( + tooltip: '返回', + icon: const Icon(Icons.arrow_back_ios_new), + onPressed: () => context.canPop() ? context.pop() : context.go('/me'), + ), + actions: [ + IconButton( + tooltip: '扫码添加 · 暂未开放', + icon: const Icon(Icons.qr_code_scanner), + onPressed: () => showUnavailableFeature(context, '扫码添加'), + ), + ], + ), + body: AsyncContent( + key: _contentKey, + load: _load, + builder: (context, catalog) { + final shown = catalog.devices + .where( + (device) => + (_kind.isEmpty || device.raw['kind'] == _kind) && + ('${device.title} ${device.subtitle}').toLowerCase().contains( + _search.toLowerCase(), + ), + ) + .toList(); + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 4, 16, 18), + children: [ + SizedBox( + height: 42, + child: TextField( + decoration: const InputDecoration( + prefixIcon: Icon(Icons.search), + hintText: '搜索设备名称或编号', + isDense: true, + contentPadding: EdgeInsets.symmetric(vertical: 8), + ), + onChanged: (value) => setState(() => _search = value.trim()), + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 6, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final entry in {'': '全部', 'valve': '角阀', 'alarm': '报警器'}.entries) ...[ + ChoiceChip( + label: Text(entry.value), + selected: _kind == entry.key, + showCheckmark: false, + visualDensity: const VisualDensity(vertical: -2), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + labelPadding: const EdgeInsets.symmetric(horizontal: 6), + selectedColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + side: BorderSide( + color: _kind == entry.key ? const Color(0xFF0064FF) : const Color(0xFFE0E3EA), + ), + onSelected: (_) => setState(() => _kind = entry.key), + ), + ], + TextButton.icon( + onPressed: () => showUnavailableFeature(context, '设备状态筛选'), + iconAlignment: IconAlignment.end, + icon: const Icon(Icons.keyboard_arrow_down, size: 18), + label: const Text('全部状态'), + style: TextButton.styleFrom( + visualDensity: const VisualDensity(vertical: -2), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ), + const SizedBox(height: 10), + _panel( + Row( + children: [ + _metric( + Icons.devices_other_outlined, + const Color(0xFF0868F7), + '${catalog.devices.length}', + '台设备', + ), + const SizedBox(height: 42, child: VerticalDivider(width: 1)), + _metric(Icons.circle, const Color(0xFF26BE70), '—', '台在线'), + const SizedBox(height: 42, child: VerticalDivider(width: 1)), + _metric(Icons.circle, const Color(0xFFFF7A00), '—', '台离线'), + const SizedBox(height: 42, child: VerticalDivider(width: 1)), + _metric(Icons.notifications_active, const Color(0xFFFF3B30), '—', '条告警'), + ], + ), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), + ), + const Padding( + padding: EdgeInsets.only(bottom: 12), + child: Text( + '在线、离线与告警状态暂未开放。', + style: TextStyle(fontSize: 12, color: Color(0xFF707887)), + ), + ), + if (shown.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 28), + child: Center(child: Text(catalog.devices.isEmpty ? '暂无设备' : '没有符合条件的设备')), + ) + else + ..._groupedCards(shown, catalog.groups), + ], + ); + }, + ), + bottomNavigationBar: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 12), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 52, + child: OutlinedButton.icon( + onPressed: () async { + await context.push('/device-groups'); + _contentKey.currentState?.refresh(); + }, + icon: const Icon(Icons.grid_view), + label: const Text('设备分组'), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: SizedBox( + height: 52, + child: FilledButton.icon( + onPressed: () => showUnavailableFeature(context, '添加设备'), + icon: const Icon(Icons.add), + label: const Text('添加设备'), + ), + ), + ), + ], + ), + ), + ), + ); +} diff --git a/apps/user_app/lib/ui/features/profile/emergency_contacts_page.dart b/apps/user_app/lib/ui/features/profile/emergency_contacts_page.dart new file mode 100644 index 0000000..6aa2613 --- /dev/null +++ b/apps/user_app/lib/ui/features/profile/emergency_contacts_page.dart @@ -0,0 +1,360 @@ +// 功能:图10联系人资料管理,未接通权限独立提示;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:uuid/uuid.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../data/services/api_client.dart'; +import '../../../domain/models/client_models.dart'; +import '../../core/async_content.dart'; +import '../../core/feature_entry.dart'; + +class EmergencyContactsPage extends StatefulWidget { + const EmergencyContactsPage({required this.repository, super.key}); + final ClientRepository repository; + @override + State createState() => _EmergencyContactsPageState(); +} + +/// 列表只有服务端保存成功才刷新,错误保留表单和同一个新增请求号。 +class _EmergencyContactsPageState extends State { + int _revision = 0; + void _reload() { + if (mounted) setState(() => _revision++); + } + + Future _edit([ClientRecord? contact]) async { + final changed = await showDialog( + context: context, + builder: (_) => _ContactDialog(repository: widget.repository, contact: contact), + ); + if (changed == true) _reload(); + } + + Future _call(ClientRecord contact) async { + try { + if (!await launchUrl(Uri(scheme: 'tel', path: contact.subtitle))) throw StateError('dialer'); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('当前设备无法打开拨号器'))); + } + } + } + + Widget _panel(Widget child) => Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFD3D7E4)), + borderRadius: BorderRadius.circular(12), + ), + child: child, + ); + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('紧急联系人'), + centerTitle: true, + leading: IconButton( + tooltip: '返回', + icon: const Icon(Icons.arrow_back), + onPressed: () => context.canPop() ? context.pop() : context.go('/me'), + ), + actions: [TextButton(onPressed: () => _edit(), child: const Text('添加'))], + ), + body: AsyncContent>( + key: ValueKey(_revision), + load: widget.repository.emergencyContacts, + builder: (context, contacts) => ListView( + padding: const EdgeInsets.all(18), + children: [ + _panel( + const Row( + children: [ + Icon(Icons.shield_outlined, color: Color(0xFF0064FF), size: 32), + SizedBox(width: 12), + Expanded( + child: Text( + '告警自动通知暂未开放。当前可管理联系人资料。', + style: TextStyle(fontSize: 14, height: 1.6), + ), + ), + ], + ), + ), + if (contacts.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 28), + child: Center(child: Text('暂无紧急联系人')), + ), + for (var i = 0; i < contacts.length; i++) + _panel( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 22, + height: 22, + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xFF0064FF), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${i + 1}', + style: const TextStyle(fontSize: 15, color: Colors.white), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + contacts[i].title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + ), + if ((contacts[i].raw['relationship'] as String? ?? '').isNotEmpty) + Flexible( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFEAF0FF), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + contacts[i].raw['relationship'] as String, + style: const TextStyle(color: Color(0xFF0064FF)), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + contacts[i].subtitle.replaceRange(3, 7, '****'), + style: const TextStyle(fontSize: 16, color: Color(0xFF626A7A)), + ), + const SizedBox(height: 16), const Text('权限 · 暂未开放'), + // 权限和联系操作按原图并列;小屏大字改为分行,保留完整点击区域。 + LayoutBuilder( + builder: (context, constraints) { + final stacked = + constraints.maxWidth < 300 || + MediaQuery.textScalerOf(context).scale(12) > 14; + final permissions = Row( + children: [ + for (final label in ['接收告警', '查看设备', '远程控制']) + Expanded( + child: TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 2), + ), + onPressed: () => showUnavailableFeature(context, label), + child: Text(label, style: const TextStyle(fontSize: 11)), + ), + ), + ], + ); + final actions = Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + color: const Color(0xFF0064FF), + tooltip: '拨打联系人电话', + onPressed: () => _call(contacts[i]), + icon: const Icon(Icons.phone_outlined), + ), + IconButton( + color: const Color(0xFF0064FF), + tooltip: '编辑联系人', + onPressed: () => _edit(contacts[i]), + icon: const Icon(Icons.edit_outlined), + ), + ], + ); + return stacked + ? Column( + children: [ + permissions, + Align(alignment: Alignment.centerRight, child: actions), + ], + ) + : Row( + children: [ + Expanded(child: permissions), + actions, + ], + ); + }, + ), + ], + ), + ), + _panel( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('通知规则', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + for (final label in ['危险告警通知', '未接听顺延通知', '远程控制确认']) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Text('$label:暂未开放'), + ), + ], + ), + ), + const SizedBox(height: 12), + const Center(child: Text('最多可添加 5 位联系人')), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: contacts.length >= 5 ? null : () => _edit(), + icon: const Icon(Icons.add), + label: const Text('添加紧急联系人'), + ), + ], + ), + ), + ); +} + +class _ContactDialog extends StatefulWidget { + const _ContactDialog({required this.repository, this.contact}); + final ClientRepository repository; + final ClientRecord? contact; + @override + State<_ContactDialog> createState() => _ContactDialogState(); +} + +/// 模态编辑隔离草稿,删除需要明确确认,不触发电话或短信发送。 +class _ContactDialogState extends State<_ContactDialog> { + final _key = GlobalKey(); + final _requestNo = const Uuid().v4(); + late final _name = TextEditingController(text: widget.contact?.title ?? ''); + late final _phone = TextEditingController(text: widget.contact?.subtitle ?? ''); + late final _relation = TextEditingController( + text: widget.contact?.raw['relationship'] as String? ?? '', + ); + bool _busy = false; + String? _error; + @override + void dispose() { + _name.dispose(); + _phone.dispose(); + _relation.dispose(); + super.dispose(); + } + + Future _save() async { + if (!_key.currentState!.validate()) return; + await _run( + () => widget.repository.saveEmergencyContact( + identity: widget.contact?.identity, + name: _name.text.trim(), + phone: _phone.text.trim(), + relationship: _relation.text.trim(), + requestNo: _requestNo, + ), + ); + } + + Future _run(Future Function() work, {bool deleting = false}) async { + setState(() { + _busy = true; + _error = null; + }); + try { + await work(); + if (mounted) Navigator.pop(context, true); + } catch (error) { + // 只有服务端明确拒绝才显示业务原因;超时不能推断为人数超限或号码重复。 + const messages = { + 2501: '最多可添加5位联系人,请先移除不再使用的联系人。', + 2502: '该手机号已在联系人列表中,请勿重复添加。', + 2503: '该新增请求已处理,请关闭表单并刷新联系人列表。', + 1112: '联系人已不存在,请关闭表单并刷新列表。', + }; + if (mounted) { + setState( + () => _error = error is ApiException && messages.containsKey(error.code) + ? messages[error.code] + : deleting + ? '未能确认删除结果,请稍后重试。' + : '未能确认保存结果,请稍后重试。', + ); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _delete() async { + final agreed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('删除联系人?'), + content: const Text('该联系人将从列表移除。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('取消')), + TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('删除')), + ], + ), + ); + if (agreed == true && mounted) { + await _run( + () => widget.repository.deleteEmergencyContact(widget.contact!.identity), + deleting: true, + ); + } + } + + @override + Widget build(BuildContext context) => PopScope( + canPop: !_busy, + child: AlertDialog( + title: Text(widget.contact == null ? '添加紧急联系人' : '编辑联系人'), + content: SingleChildScrollView( + child: Form( + key: _key, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _name, + enabled: !_busy, + maxLength: 64, + decoration: const InputDecoration(labelText: '姓名'), + validator: (v) => v!.trim().isEmpty ? '请输入姓名' : null, + ), + TextFormField( + controller: _phone, + enabled: !_busy, + keyboardType: TextInputType.phone, + decoration: const InputDecoration(labelText: '手机号'), + validator: (v) => + RegExp(r'^1[3-9][0-9]{9}$').hasMatch(v!.trim()) ? null : '请输入正确的11位手机号', + ), + const SizedBox(height: 12), + TextFormField( + controller: _relation, + enabled: !_busy, + maxLength: 32, + decoration: const InputDecoration(labelText: '关系(选填)'), + ), + if (_error != null) + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + ), + ), + ), + actions: [ + if (widget.contact != null) + TextButton(onPressed: _busy ? null : _delete, child: const Text('删除')), + TextButton(onPressed: _busy ? null : () => Navigator.pop(context), child: const Text('取消')), + FilledButton(onPressed: _busy ? null : _save, child: Text(_busy ? '处理中' : '保存')), + ], + ), + ); +} diff --git a/apps/user_app/lib/ui/features/profile/family_sharing_page.dart b/apps/user_app/lib/ui/features/profile/family_sharing_page.dart new file mode 100644 index 0000000..3082b83 --- /dev/null +++ b/apps/user_app/lib/ui/features/profile/family_sharing_page.dart @@ -0,0 +1,811 @@ +// 功能:家庭成员邀请、设备范围授权、撤销及设计稿布局;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/family_sharing.dart'; +import '../../core/async_content.dart'; + +class FamilySharingPage extends StatefulWidget { + const FamilySharingPage({required this.repository, super.key}); + final ClientRepository repository; + + @override + State createState() => _FamilySharingPageState(); +} + +/// 管理家庭页刷新、邀请和撤销操作,只有服务端确认后改变画面。 +class _FamilySharingPageState extends State { + final _contentKey = GlobalKey>(); + FamilyDashboardData? _latest; + + Future _invite(FamilyDashboardData data) async { + final changed = await showDialog( + context: context, + builder: (_) => _InviteFamilyDialog(repository: widget.repository, devices: data.devices), + ); + if (changed == true) await _contentKey.currentState?.refresh(); + } + + Future _edit(FamilyDashboardData data, FamilyMember member) async { + final changed = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => + _PermissionSheet(repository: widget.repository, member: member, devices: data.devices), + ); + if (changed == true) await _contentKey.currentState?.refresh(); + } + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: const Color(0xFFF7F9FC), + appBar: AppBar( + backgroundColor: const Color(0xFFF7F9FC), + title: const Text('家庭共享', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)), + actions: [ + TextButton( + onPressed: () async { + final data = _latest; + if (data != null) await _invite(data); + }, + child: const Text('添加', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ), + ], + ), + body: AsyncContent( + key: _contentKey, + load: widget.repository.familyDashboard, + builder: (context, data) { + _latest = data; + return ListView( + padding: const EdgeInsets.fromLTRB(16, 2, 16, 76), + children: [ + _homeCard(data), + if (data.incomingInvitations.isNotEmpty) ...[ + const SizedBox(height: 8), + _incomingCard(data), + ], + const SizedBox(height: 8), + _membersCard(data), + const SizedBox(height: 8), + _devicesCard(data), + const SizedBox(height: 8), + _rulesCard(), + ], + ); + }, + ), + bottomNavigationBar: SafeArea( + minimum: const EdgeInsets.fromLTRB(16, 4, 16, 6), + child: SizedBox( + height: 44, + child: FilledButton.icon( + onPressed: () async { + final data = _latest; + if (data != null) await _invite(data); + }, + icon: const Icon(Icons.add), + label: const Text('邀请家庭成员', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ), + ), + ), + ); + + Widget _homeCard(FamilyDashboardData data) => _card( + Padding( + padding: const EdgeInsets.all(10), + child: Column( + children: [ + Row( + children: [ + Container( + width: 42, + height: 42, + decoration: const BoxDecoration(color: Color(0xFFEAF1FF), shape: BoxShape.circle), + child: const Icon(Icons.home_outlined, color: Color(0xFF1268F3), size: 26), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.householdName, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 3), + Text( + '房主:${data.ownerName}', + style: const TextStyle(color: Color(0xFF636C7D), fontSize: 14), + ), + ], + ), + ), + const Icon(Icons.chevron_right, color: Color(0xFF969CAA)), + ], + ), + const SizedBox(height: 9), + Row( + children: [ + Expanded(child: _summary(Icons.people_outline, '${data.memberCount} 位成员')), + Container(width: 1, height: 24, color: const Color(0xFFE2E6ED)), + Expanded(child: _summary(Icons.devices_other_outlined, '${data.deviceCount} 台设备')), + ], + ), + ], + ), + ), + ); + + Widget _summary(IconData icon, String text) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: const Color(0xFF1268F3), size: 20), + const SizedBox(width: 7), + Text(text, style: const TextStyle(fontSize: 14)), + ], + ); + + /// 受邀账号可在同一页面接受或拒绝,确认前不获得任何设备权限。 + Widget _incomingCard(FamilyDashboardData data) => _card( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(12, 9, 12, 4), + child: Text('收到的邀请', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + ), + for (final invitation in data.incomingInvitations) + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 8, 8), + child: Row( + children: [ + const CircleAvatar( + radius: 18, + backgroundColor: Color(0xFFEAF1FF), + child: Icon(Icons.home_outlined, size: 20, color: Color(0xFF1268F3)), + ), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${invitation.ownerName}邀请你加入家庭', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + Text( + '身份:${invitation.relationship.isEmpty ? '家人' : invitation.relationship} · 有效期至 ${_date(invitation.expiresAt)}', + style: const TextStyle(fontSize: 11, color: Color(0xFF697386)), + ), + ], + ), + ), + TextButton( + onPressed: () => _respondInvitation(invitation, false), + child: const Text('拒绝'), + ), + FilledButton( + onPressed: () => _respondInvitation(invitation, true), + style: FilledButton.styleFrom( + minimumSize: const Size(54, 34), + padding: const EdgeInsets.symmetric(horizontal: 10), + ), + child: const Text('接受'), + ), + ], + ), + ), + ], + ), + ); + + Future _respondInvitation(FamilyInvitation invitation, bool accept) async { + try { + await widget.repository.respondFamilyInvitation(invitation.identity, accept); + await _contentKey.currentState?.refresh(); + } catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('操作失败:$error'))); + } + } + } + + Widget _membersCard(FamilyDashboardData data) => _card( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 8, 4), + child: Row( + children: [ + const Expanded( + child: Text('家庭成员', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + ), + InkWell( + onTap: () => _showRules(context), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 4, vertical: 3), + child: Row( + children: [ + Text('共享规则', style: TextStyle(color: Color(0xFF697386), fontSize: 12)), + SizedBox(width: 3), + Icon(Icons.info_outline, size: 16, color: Color(0xFF697386)), + ], + ), + ), + ), + ], + ), + ), + if (data.members.isEmpty) + const Padding( + padding: EdgeInsets.all(18), + child: Center(child: Text('暂无家庭成员')), + ), + for (var index = 0; index < data.members.length; index++) ...[ + if (index > 0) const Divider(height: 1, indent: 72), + _memberRow(data, data.members[index]), + ], + ], + ), + ); + + Widget _memberRow(FamilyDashboardData data, FamilyMember member) { + final chips = {}; + if (member.isOwner) { + chips.add('全部权限'); + } else if (!member.pending) { + for (final item in member.permissions) { + if (item.canView) chips.add('查看设备'); + if (item.canAlert) chips.add('接收告警'); + if (item.canControl) chips.add('远程控制'); + } + } + return InkWell( + onTap: member.isOwner ? null : () => _edit(data, member), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 21, + backgroundColor: const Color(0xFFE9EEF7), + child: Text( + member.name.isEmpty ? '?' : member.name.substring(0, 1), + style: const TextStyle(fontSize: 17, color: Color(0xFF30425F)), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + member.name, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: 8), + _tag( + member.isOwner + ? '房主' + : member.pending + ? '待确认' + : (member.relationship.isEmpty ? '家人' : member.relationship), + pending: member.pending, + ), + ], + ), + if (member.phoneMasked.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + member.phoneMasked, + style: const TextStyle(fontSize: 12, color: Color(0xFF667085)), + ), + ], + if (member.pending) ...[ + const SizedBox(height: 3), + Text( + '邀请有效期至 ${_date(member.expiresAt)}', + style: const TextStyle(fontSize: 11, color: Color(0xFFFF7A00)), + ), + ] else if (chips.isNotEmpty) ...[ + const SizedBox(height: 3), + Wrap( + spacing: 5, + runSpacing: 4, + children: chips + .map((label) => _permissionTag(label, owner: member.isOwner)) + .toList(), + ), + ], + ], + ), + ), + if (!member.isOwner) + member.pending + ? OutlinedButton( + onPressed: () => _confirmRevoke(member), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFFF6A00), + side: const BorderSide(color: Color(0xFFFF6A00)), + minimumSize: const Size(60, 34), + padding: const EdgeInsets.symmetric(horizontal: 10), + ), + child: const Text('撤回'), + ) + : const Padding( + padding: EdgeInsets.only(top: 8), + child: Icon(Icons.edit_outlined, color: Color(0xFF1268F3)), + ), + ], + ), + ), + ); + } + + Widget _devicesCard(FamilyDashboardData data) => _card( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Row( + children: [ + const Expanded( + child: Text('共享设备', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + ), + Text('${data.deviceCount} 台设备', style: const TextStyle(color: Color(0xFF677083))), + const Icon(Icons.chevron_right, color: Color(0xFF969CAA)), + ], + ), + ), + if (data.devices.isEmpty) + const Padding( + padding: EdgeInsets.fromLTRB(16, 12, 16, 20), + child: Center( + child: Text('暂无可共享设备', style: TextStyle(color: Color(0xFF737B8C))), + ), + ), + for (var index = 0; index < data.devices.length; index++) ...[ + if (index > 0) const Divider(height: 1, indent: 58), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Icon( + data.devices[index].kind == 'valve' + ? Icons.settings_input_component_outlined + : Icons.sensors_outlined, + color: const Color(0xFF1268F3), + size: 22, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.devices[index].name, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + Text( + data.devices[index].kind == 'valve' ? '智能角阀' : '燃气报警器', + style: const TextStyle(fontSize: 11, color: Color(0xFF687184)), + ), + ], + ), + ), + Text( + '已共享 ${data.devices[index].shareCount} 人', + style: const TextStyle(fontSize: 12, color: Color(0xFF737B8C)), + ), + const SizedBox(width: 8), + Icon( + Icons.circle, + size: 9, + color: data.devices[index].mappingConfigured + ? const Color(0xFF18B778) + : const Color(0xFFB4BAC4), + ), + const SizedBox(width: 5), + Text( + data.devices[index].mappingConfigured ? '可连接' : '未映射', + style: const TextStyle(fontSize: 12, color: Color(0xFF687184)), + ), + ], + ), + ), + ], + ], + ), + ); + + Widget _rulesCard() => _card( + const Padding( + padding: EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('安全规则', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + SizedBox(height: 7), + _Rule(icon: Icons.verified_user_outlined, text: '远程控制需二次确认,保障用气安全'), + SizedBox(height: 5), + _Rule(icon: Icons.notifications_none, text: '危险告警将通知已授权成员'), + SizedBox(height: 5), + _Rule(icon: Icons.manage_accounts_outlined, text: '房主可随时调整权限或移除成员'), + ], + ), + ), + ); + + Widget _card(Widget child) => DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE5E8EE)), + borderRadius: BorderRadius.circular(8), + ), + child: child, + ); + Widget _tag(String text, {bool pending = false}) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: pending ? const Color(0xFFFFF1E5) : const Color(0xFFEAF1FF), + borderRadius: BorderRadius.circular(5), + ), + child: Text( + text, + style: TextStyle( + fontSize: 11, + color: pending ? const Color(0xFFFF7900) : const Color(0xFF1268F3), + ), + ), + ); + Widget _permissionTag(String text, {bool owner = false}) => Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: owner ? const Color(0xFFE8F8F1) : const Color(0xFFEAF1FF), + borderRadius: BorderRadius.circular(5), + ), + child: Text( + text, + style: TextStyle( + fontSize: 10, + color: owner ? const Color(0xFF0B9A61) : const Color(0xFF1268F3), + ), + ), + ); + String _date(DateTime? value) => value == null + ? '--' + : '${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}'; + + Future _confirmRevoke(FamilyMember member) async { + final confirmed = + await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text(member.pending ? '撤回邀请' : '移除成员'), + content: Text(member.pending ? '撤回后,对方将无法接受本次邀请。' : '移除后,该成员的全部设备权限立即失效。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('取消')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('确认')), + ], + ), + ) ?? + false; + if (!confirmed || !mounted) return; + try { + await widget.repository.revokeFamilyMember(member.identity); + await _contentKey.currentState?.refresh(); + } catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('操作失败:$error'))); + } + } + } + + void _showRules(BuildContext context) => showModalBottomSheet( + context: context, + builder: (_) => const SafeArea( + child: Padding( + padding: EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('共享规则', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)), + SizedBox(height: 14), + Text('成员必须使用受邀手机号登录并确认邀请,权限才会生效。房主可按设备授予查看、告警和控制权限,也可随时撤销。远程控制仍需在控制页面二次确认。'), + ], + ), + ), + ), + ); +} + +class _Rule extends StatelessWidget { + const _Rule({required this.icon, required this.text}); + final IconData icon; + final String text; + @override + Widget build(BuildContext context) => Row( + children: [ + Icon(icon, size: 19, color: const Color(0xFF1268F3)), + const SizedBox(width: 8), + Expanded( + child: Text(text, style: const TextStyle(fontSize: 12, color: Color(0xFF4E586A))), + ), + ], + ); +} + +/// 邀请表单同时配置设备权限;无设备时仍可邀请成员。 +class _InviteFamilyDialog extends StatefulWidget { + const _InviteFamilyDialog({required this.repository, required this.devices}); + final ClientRepository repository; + final List devices; + @override + State<_InviteFamilyDialog> createState() => _InviteFamilyDialogState(); +} + +class _InviteFamilyDialogState extends State<_InviteFamilyDialog> { + final _form = GlobalKey(), + _name = TextEditingController(), + _phone = TextEditingController(), + _relationship = TextEditingController(); + late final String _requestNo = const Uuid().v7(); + final Map> _permissions = {}; + bool _busy = false; + String? _error; + @override + void dispose() { + _name.dispose(); + _phone.dispose(); + _relationship.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_form.currentState!.validate() || _busy) return; + setState(() { + _busy = true; + _error = null; + }); + final values = widget.devices.where((d) => _permissions[d.identity]?.isNotEmpty == true).map(( + d, + ) { + final set = _permissions[d.identity]!; + return FamilyDevicePermission( + deviceIdentity: d.identity, + deviceName: d.name, + deviceKind: d.kind, + canView: true, + canAlert: set.contains('alert'), + canControl: set.contains('control'), + ); + }).toList(); + try { + await widget.repository.inviteFamilyMember( + name: _name.text.trim(), + phone: _phone.text.trim(), + relationship: _relationship.text.trim(), + requestNo: _requestNo, + permissions: values, + ); + if (mounted) Navigator.pop(context, true); + } catch (error) { + if (mounted) { + setState(() { + _busy = false; + _error = error.toString(); + }); + } + } + } + + @override + Widget build(BuildContext context) => AlertDialog( + title: const Text('邀请家庭成员'), + content: SizedBox( + width: 360, + child: Form( + key: _form, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _name, + enabled: !_busy, + decoration: const InputDecoration(labelText: '成员称呼'), + validator: (v) => v == null || v.trim().isEmpty ? '请输入成员称呼' : null, + ), + const SizedBox(height: 10), + TextFormField( + controller: _phone, + enabled: !_busy, + keyboardType: TextInputType.phone, + decoration: const InputDecoration(labelText: '手机号'), + validator: (v) => + RegExp(r'^1[3-9][0-9]{9}$').hasMatch(v?.trim() ?? '') ? null : '请输入正确的11位手机号', + ), + const SizedBox(height: 10), + TextFormField( + controller: _relationship, + enabled: !_busy, + decoration: const InputDecoration(labelText: '关系(选填)'), + ), + if (widget.devices.isNotEmpty) ...[ + const SizedBox(height: 14), + const Align( + alignment: Alignment.centerLeft, + child: Text('预设设备权限', style: TextStyle(fontWeight: FontWeight.w600)), + ), + for (final device in widget.devices) + CheckboxListTile( + contentPadding: EdgeInsets.zero, + value: _permissions[device.identity]?.contains('view') == true, + title: Text(device.name), + subtitle: const Text('勾选后允许查看;告警和控制可接受后再调整'), + onChanged: _busy + ? null + : (checked) => setState(() { + if (checked == true) { + _permissions[device.identity] = {'view'}; + } else { + _permissions.remove(device.identity); + } + }), + ), + ], + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 10), + child: Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ), + ), + ), + ), + actions: [ + TextButton(onPressed: _busy ? null : () => Navigator.pop(context), child: const Text('取消')), + FilledButton(onPressed: _busy ? null : _submit, child: Text(_busy ? '发送中' : '发送邀请')), + ], + ); +} + +/// 权限面板按设备控制查看、告警和远程控制,并支持移除成员。 +class _PermissionSheet extends StatefulWidget { + const _PermissionSheet({required this.repository, required this.member, required this.devices}); + final ClientRepository repository; + final FamilyMember member; + final List devices; + @override + State<_PermissionSheet> createState() => _PermissionSheetState(); +} + +class _PermissionSheetState extends State<_PermissionSheet> { + late final Map _values = { + for (final item in widget.member.permissions) item.deviceIdentity: item, + }; + bool _busy = false; + void _set(FamilyDevice device, String key, bool value) { + final old = + _values[device.identity] ?? + FamilyDevicePermission( + deviceIdentity: device.identity, + deviceName: device.name, + deviceKind: device.kind, + canView: false, + canAlert: false, + canControl: false, + ); + setState(() { + final view = key == 'view' ? value : old.canView || value; + _values[device.identity] = FamilyDevicePermission( + deviceIdentity: old.deviceIdentity, + deviceName: old.deviceName, + deviceKind: old.deviceKind, + canView: view, + canAlert: key == 'alert' ? value : old.canAlert && view, + canControl: key == 'control' ? value : old.canControl && view, + ); + }); + } + + Future _save() async { + setState(() => _busy = true); + try { + await widget.repository.updateFamilyPermissions( + widget.member.identity, + _values.values.where((e) => e.canView).toList(), + ); + if (mounted) Navigator.pop(context, true); + } catch (error) { + if (mounted) { + setState(() => _busy = false); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存失败:$error'))); + } + } + } + + @override + Widget build(BuildContext context) => Padding( + padding: EdgeInsets.fromLTRB(18, 12, 18, MediaQuery.viewInsetsOf(context).bottom + 18), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '编辑 ${widget.member.name} 的权限', + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + const Text('查看权限是告警和控制权限的前提。', style: TextStyle(color: Color(0xFF697386))), + const SizedBox(height: 12), + if (widget.devices.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: Text('暂无可授权设备')), + ), + for (final device in widget.devices) + Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(device.name, style: const TextStyle(fontWeight: FontWeight.w600)), + Row( + children: [ + _check( + '查看', + _values[device.identity]?.canView == true, + (v) => _set(device, 'view', v), + ), + _check( + '告警', + _values[device.identity]?.canAlert == true, + (v) => _set(device, 'alert', v), + ), + _check( + '控制', + _values[device.identity]?.canControl == true, + (v) => _set(device, 'control', v), + ), + ], + ), + ], + ), + ), + ), + SizedBox( + width: double.infinity, + child: FilledButton(onPressed: _busy ? null : _save, child: Text(_busy ? '保存中' : '保存权限')), + ), + ], + ), + ); + Widget _check(String label, bool value, ValueChanged onChanged) => Expanded( + child: CheckboxListTile( + dense: true, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + value: value, + title: Text(label, style: const TextStyle(fontSize: 13)), + onChanged: _busy ? null : (v) => onChanged(v == true), + ), + ); +} diff --git a/apps/user_app/lib/ui/features/profile/message_center_page.dart b/apps/user_app/lib/ui/features/profile/message_center_page.dart new file mode 100644 index 0000000..c85fe2f --- /dev/null +++ b/apps/user_app/lib/ui/features/profile/message_center_page.dart @@ -0,0 +1,355 @@ +// 功能描述:实现图30消息中心,展示真实订单、服务与公告消息及服务端已读状态;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/message_center.dart'; +import '../../core/async_content.dart'; + +class MessageCenterPage extends StatefulWidget { + const MessageCenterPage({required this.repository, super.key}); + final ClientRepository repository; + + @override + State createState() => _MessageCenterPageState(); +} + +class _MessageCenterPageState extends State { + final _contentKey = GlobalKey>(); + String _filter = 'all'; + bool _saving = false; + + Future _markAll(MessageCenterData data) async { + final keys = data.items.where((item) => !item.read).map((item) => item.key).toList(); + if (keys.isEmpty || _saving) return; + setState(() => _saving = true); + try { + await widget.repository.markMessagesRead(keys); + await _contentKey.currentState?.refresh(); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _open(UserMessage item) async { + if (!item.read) { + await widget.repository.markMessagesRead([item.key]); + } + if (!mounted) return; + final path = switch (item.target) { + 'gas_order' => '/gas/orders/${item.targetIdentity}', + 'shop_order' => '/shop/orders/${item.targetIdentity}', + 'ticket' => '/tickets/${item.targetIdentity}', + 'content' => '/contents/${item.targetIdentity}', + _ => null, + }; + if (path != null) await context.push(path); + if (mounted) await _contentKey.currentState?.refresh(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('消息中心', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)), + centerTitle: true, + actions: [ + AsyncContent( + load: widget.repository.messages, + builder: (_, data) => TextButton( + onPressed: _saving || (data.counts['unread'] ?? 0) == 0 ? null : () => _markAll(data), + child: const Text('全部已读'), + ), + ), + ], + ), + body: AsyncContent( + key: _contentKey, + load: widget.repository.messages, + builder: (context, data) { + final shown = data.items + .where( + (item) => + _filter == 'all' || (_filter == 'unread' ? !item.read : item.category == _filter), + ) + .toList(); + return ListView( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 24), + children: [ + Row( + children: [ + _summary(data, 'safety', '安全告警', Icons.gpp_maybe_outlined, const Color(0xFFEF3B3B)), + _summary(data, 'order', '订单通知', Icons.assignment_outlined, const Color(0xFF1768E5)), + _summary(data, 'service', '服务消息', Icons.support_agent, const Color(0xFF34B77B)), + _summary(data, 'notice', '平台公告', Icons.campaign_outlined, const Color(0xFFFF8A28)), + ], + ), + const SizedBox(height: 16), + _filters(), + const SizedBox(height: 14), + if (shown.isEmpty) + _empty() + else + DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(9), + border: Border.all(color: const Color(0xFFE2E6ED)), + ), + child: Column( + children: [ + for (final entry in shown.indexed) + _message(entry.$2, entry.$1 != shown.length - 1), + ], + ), + ), + const SizedBox(height: 18), + InkWell( + onTap: () => context.push('/settings'), + child: const Text.rich( + TextSpan( + children: [ + WidgetSpan(child: Icon(Icons.info, size: 16, color: Color(0xFF7A8496))), + TextSpan(text: ' 您可以在 '), + TextSpan( + text: '「设置-消息通知」', + style: TextStyle(color: Color(0xFF1768E5)), + ), + TextSpan(text: ' 中管理通知方式'), + ], + ), + textAlign: TextAlign.center, + style: TextStyle(color: Color(0xFF697386), fontSize: 13), + ), + ), + ], + ); + }, + ), + ); + + Widget _summary( + MessageCenterData data, + String category, + String label, + IconData icon, + Color color, + ) { + final total = data.counts[category] ?? 0; + final unread = data.items.where((item) => item.category == category && !item.read).length; + // 字体放大时只增加必要高度,默认比例仍与390宽产品稿一致。 + final cardHeight = (108 + (MediaQuery.textScalerOf(context).scale(1) - 1) * 110).ceilToDouble(); + return Expanded( + child: Padding( + padding: EdgeInsets.only(right: category == 'notice' ? 0 : 7), + child: InkWell( + borderRadius: BorderRadius.circular(9), + onTap: () => setState(() => _filter = category), + child: Container( + height: cardHeight, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 3), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(9), + border: Border.all(color: const Color(0xFFE2E6ED)), + ), + child: Column( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Icon(icon, size: 38, color: color), + if (unread > 0) Positioned(right: -9, top: -7, child: _badge(unread)), + ], + ), + const Spacer(), + Text( + label, + maxLines: 1, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), + ), + const SizedBox(height: 4), + Text( + unread > 0 + ? '$unread条未读' + : total > 0 + ? '全部已读' + : '暂无消息', + style: const TextStyle(fontSize: 12, color: Color(0xFF697386)), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _badge(int count) => Container( + constraints: const BoxConstraints(minWidth: 20, minHeight: 20), + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 4), + decoration: const BoxDecoration(color: Color(0xFFEF3030), shape: BoxShape.circle), + child: Text( + '$count', + style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700), + ), + ); + + Widget _filters() { + const options = {'all': '全部', 'unread': '未读', 'safety': '安全', 'order': '订单', 'service': '服务'}; + return SizedBox( + height: 48, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(9), + border: Border.all(color: const Color(0xFFE2E6ED)), + ), + child: Row( + children: options.entries + .map( + (entry) => Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: ChoiceChip( + showCheckmark: false, + label: Text(entry.value), + selected: _filter == entry.key, + selectedColor: const Color(0xFF1768E5), + backgroundColor: Colors.white, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + labelStyle: TextStyle( + color: _filter == entry.key ? Colors.white : const Color(0xFF59647A), + fontSize: 13, + ), + side: _filter == entry.key + ? BorderSide.none + : const BorderSide(color: Color(0xFFE1E5EC)), + onSelected: (_) => setState(() => _filter = entry.key), + ), + ), + ), + ) + .toList(), + ), + ), + ); + } + + Widget _message(UserMessage item, bool divider) => InkWell( + onTap: () => _open(item), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 15, 10, 0), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: _color(item.category).withValues(alpha: .1), + shape: BoxShape.circle, + ), + child: Icon(_icon(item.category), color: _color(item.category), size: 27), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + _time(item.occurredAt), + style: const TextStyle(fontSize: 13, color: Color(0xFF697386)), + ), + ), + Text( + item.statusText, + style: TextStyle(fontSize: 13, color: _statusColor(item.statusText)), + ), + ], + ), + const SizedBox(height: 7), + Text( + item.title, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + Text( + item.summary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13, height: 1.45, color: Color(0xFF697386)), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(top: 35), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!item.read) const Icon(Icons.circle, size: 9, color: Color(0xFF1768E5)), + const SizedBox(width: 6), + const Icon(Icons.chevron_right, color: Color(0xFF697386)), + ], + ), + ), + ], + ), + SizedBox(height: 14, child: divider ? const Divider(height: 14) : null), + ], + ), + ), + ); + + Widget _empty() => Container( + padding: const EdgeInsets.symmetric(vertical: 54), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(9), + border: Border.all(color: const Color(0xFFE2E6ED)), + ), + child: const Column( + children: [ + Icon(Icons.notifications_none, size: 42, color: Color(0xFF9AA3B2)), + SizedBox(height: 10), + Text('暂无符合条件的消息', style: TextStyle(color: Color(0xFF697386))), + ], + ), + ); + + IconData _icon(String category) => switch (category) { + 'safety' => Icons.warning_amber_rounded, + 'order' => Icons.assignment_outlined, + 'service' => Icons.build_outlined, + _ => Icons.campaign_outlined, + }; + Color _color(String category) => switch (category) { + 'safety' => const Color(0xFFEF3B3B), + 'order' => const Color(0xFF1768E5), + 'service' => const Color(0xFF34B77B), + _ => const Color(0xFFFF8A28), + }; + Color _statusColor(String value) => value.contains('完成') + ? const Color(0xFF20A66A) + : value.contains('取消') || value.contains('异常') + ? const Color(0xFFEF3B3B) + : const Color(0xFF1768E5); + String _time(DateTime time) { + final value = time.toLocal(), now = DateTime.now(); + if (DateUtils.isSameDay(value, now)) return '今天 ${_two(value.hour)}:${_two(value.minute)}'; + if (DateUtils.isSameDay(value, now.subtract(const Duration(days: 1)))) { + return '昨天 ${_two(value.hour)}:${_two(value.minute)}'; + } + return '${_two(value.month)}-${_two(value.day)} ${_two(value.hour)}:${_two(value.minute)}'; + } + + String _two(int value) => value.toString().padLeft(2, '0'); +} diff --git a/apps/user_app/lib/ui/features/profile/profile_avatar.dart b/apps/user_app/lib/ui/features/profile/profile_avatar.dart index 72f4d1d..02d0ace 100644 --- a/apps/user_app/lib/ui/features/profile/profile_avatar.dart +++ b/apps/user_app/lib/ui/features/profile/profile_avatar.dart @@ -20,11 +20,13 @@ class ProfileAvatar extends StatelessWidget { const ProfileAvatar({ required this.name, required this.imageBytes, + this.size = 56, super.key, }); final String name; final Uint8List? imageBytes; + final double size; /// 从用户名生成占位字符,空名称统一显示“用”。 String get _fallbackText { @@ -51,8 +53,8 @@ class ProfileAvatar extends StatelessWidget { label: '${name.trim().isEmpty ? '用户' : name.trim()}头像', child: ExcludeSemantics( child: Container( - width: 56, - height: 56, + width: size, + height: size, decoration: BoxDecoration( shape: BoxShape.circle, color: Theme.of(context).colorScheme.primaryContainer, @@ -63,8 +65,8 @@ class ProfileAvatar extends StatelessWidget { : Image( key: const Key('profile-avatar-image'), image: MemoryImage(bytes), - width: 56, - height: 56, + width: size, + height: size, fit: BoxFit.cover, frameBuilder: (context, child, frame, wasSynchronouslyLoaded) => wasSynchronouslyLoaded || frame != null ? child : fallback, diff --git a/apps/user_app/lib/ui/features/profile/profile_edit_page.dart b/apps/user_app/lib/ui/features/profile/profile_edit_page.dart new file mode 100644 index 0000000..969fd93 --- /dev/null +++ b/apps/user_app/lib/ui/features/profile/profile_edit_page.dart @@ -0,0 +1,422 @@ +// 功能描述:个人资料编辑、相机或相册选图、上传预览及服务端保存。 +// 版本:1.0.0。 + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../../../data/repositories/client_repository.dart'; +import '../../../data/services/api_client.dart'; +import '../../../domain/models/client_models.dart'; +import '../../core/async_content.dart'; +import '../../core/feature_entry.dart'; +import '../../core/widgets.dart'; +import 'profile_avatar.dart'; + +/// 提供可注入的选图操作,测试可验证取消、错误和成功流程而不调用平台 UI。 +typedef AvatarPicker = Future Function(ImageSource source); + +/// 图 41 的资料编辑页;只有服务端确认保存后才返回成功。 +class ProfileEditPage extends StatefulWidget { + const ProfileEditPage({required this.repository, this.pickImage, super.key}); + final ClientRepository repository; + final AvatarPicker? pickImage; + + @override + State createState() => _ProfileEditPageState(); +} + +/// 保留编辑草稿与已上传 URI,保存重试不会重复上传同一图片。 +class _ProfileEditPageState extends State { + final _name = TextEditingController(); + final _form = GlobalKey(); + UserProfile? _profile; + Uint8List? _avatar; + XFile? _selected; + String? _uploadedUri; + String? _error; + bool _saving = false; + bool _picking = false; + bool _changed = false; + + @override + void dispose() { + _name.dispose(); + super.dispose(); + } + + /// 只初始化一次草稿;附属信息失败不影响头像和昵称的保存。 + Future _load() async { + // 下拉刷新不能覆盖未保存的昵称或已选图片,也不能打断上传中的草稿。 + if (_profile != null && (_changed || _saving || _picking)) return _profile!; + final profile = await widget.repository.profile(); + _profile = profile; + _name.text = profile.name; + if (profile.avatar.isNotEmpty) { + _avatar = await loadProfileAvatarSafely(widget.repository.profileAvatar); + } else { + // 服务端清空头像后,刷新必须同步清除旧图片,不能继续显示缓存。 + _avatar = null; + } + return profile; + } + + /// 打开系统照片来源选择,取消时保持原头像和草稿。 + Future _chooseAvatar() async { + if (_saving || _picking) return; + final source = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (kIsWeb || + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS) + ListTile( + leading: const Icon(Icons.camera_alt_outlined), + title: const Text('拍照'), + onTap: () => Navigator.pop(context, ImageSource.camera), + ), + ListTile( + leading: const Icon(Icons.photo_library_outlined), + title: const Text('从相册选择'), + onTap: () => Navigator.pop(context, ImageSource.gallery), + ), + ListTile( + title: const Center(child: Text('取消')), + onTap: () => Navigator.pop(context), + ), + ], + ), + ), + ); + if (source == null || !mounted) return; + setState(() { + _picking = true; + _error = null; + }); + try { + final picked = + await (widget.pickImage ?? + ((source) => ImagePicker().pickImage( + source: source, + maxWidth: 2048, + maxHeight: 2048, + imageQuality: 90, + requestFullMetadata: false, + )))(source); + if (picked == null) return; + if (await picked.length() > 2 * 1024 * 1024) { + throw const ApiException(422, '图片超过 2MB,请选择较小的照片'); + } + final bytes = await picked.readAsBytes(); + // 检查真实文件头;后端仍执行完整解码、尺寸及 MIME 校验。 + final png = + bytes.length > 8 && listEquals(bytes.take(8).toList(), [137, 80, 78, 71, 13, 10, 26, 10]); + final jpeg = bytes.length > 3 && bytes[0] == 255 && bytes[1] == 216 && bytes[2] == 255; + if (!png && !jpeg) throw const ApiException(422, '请选择 JPG 或 PNG 图片'); + if (mounted) { + setState(() { + _avatar = bytes; + _selected = XFile.fromData(bytes, name: png ? 'avatar.png' : 'avatar.jpg'); + _uploadedUri = null; + _changed = true; + }); + } + } on PlatformException { + if (mounted) setState(() => _error = '无法访问相机或相册,请在系统设置中允许访问后重试'); + } catch (error) { + if (mounted) setState(() => _error = error is ApiException ? error.message : '照片读取失败,请重新选择'); + } finally { + if (mounted) setState(() => _picking = false); + } + } + + /// 上传和保存分开确认;失败保留草稿,下一次保存复用已上传的资源。 + Future _save() async { + if (_saving || _picking || _profile == null || !(_form.currentState?.validate() ?? false)) { + return; + } + setState(() { + _saving = true; + _error = null; + }); + try { + if (_selected != null && _uploadedUri == null) { + _uploadedUri = await widget.repository.uploadAvatar(_avatar!, _selected!.name); + } + await widget.repository.updateProfile(_name.text, avatar: _uploadedUri); + if (!mounted) return; + setState(() => _changed = false); + context.pop(true); + } on SessionExpiredException { + return; + } catch (error) { + if (mounted) setState(() => _error = error is ApiException ? error.message : '保存失败,请重试'); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + /// 离开前确认丢弃未保存草稿;保存期间防止误退出。 + Future _leave() async { + if (_saving || _picking) return; + if (_changed) { + final discard = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('放弃未保存的修改?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('继续编辑')), + TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('放弃修改')), + ], + ), + ); + if (discard != true || !mounted) return; + } + setState(() => _changed = false); + context.pop(); + } + + @override + Widget build(BuildContext context) => PopScope( + canPop: !_changed && !_saving && !_picking, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) _leave(); + }, + child: Scaffold( + appBar: AppBar( + title: const Text('个人资料'), + leading: IconButton( + tooltip: '返回', + onPressed: _leave, + icon: const Icon(Icons.arrow_back_ios_new), + ), + actions: [ + TextButton(onPressed: _saving || _picking ? null : _save, child: const Text('保存')), + ], + ), + body: AsyncContent( + load: _load, + builder: (context, profile) => Form( + key: _form, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 24), + children: [ + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + _error!, + key: const Key('profile-save-error'), + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + SurfaceSection( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + const SizedBox(height: 4), + InkWell( + onTap: _chooseAvatar, + customBorder: const CircleBorder(), + child: ProfileAvatar(name: _name.text, imageBytes: _avatar, size: 88), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _picking || _saving ? null : _chooseAvatar, + icon: const Icon(Icons.camera_alt_outlined), + label: Text(_picking ? '正在读取…' : '更换头像'), + style: OutlinedButton.styleFrom(minimumSize: const Size(110, 36)), + ), + const SizedBox(height: 8), + Text( + '支持拍照或从相册选择 JPG、PNG,最大 2MB', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + const SizedBox(height: 8), + SurfaceSection( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Column( + children: [ + Row( + children: [ + const Icon(Icons.person_outline), + const SizedBox(width: 12), + const Text('昵称'), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + key: const Key('profile-name-input'), + controller: _name, + textAlign: TextAlign.end, + enabled: !_saving, + maxLength: 64, + decoration: const InputDecoration( + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + counterText: '', + hintText: '请输入昵称', + ), + validator: (value) => + value == null || value.trim().isEmpty ? '昵称不能为空' : null, + onChanged: (_) => setState(() => _changed = true), + ), + ), + ], + ), + const Divider(height: 1), + _row(Icons.phone_iphone, '手机号', _maskedPhone(profile.phone)), + const Divider(height: 1), + _row(Icons.person_outline, '性别', '尚未开放'), + const Divider(height: 1), + _row(Icons.calendar_month_outlined, '生日', '尚未开放'), + ], + ), + ), + const SizedBox(height: 8), + SurfaceSection( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _heading(Icons.verified_user_outlined, '实名认证'), + const SizedBox(height: 12), + if (profile.realName.isNotEmpty) + Row( + children: [ + const Icon(Icons.verified, size: 18, color: Color(0xFF19B978)), + const SizedBox(width: 6), + Expanded( + child: Text( + '已认证 ${profile.realName}', + style: const TextStyle(color: Color(0xFF16875D)), + ), + ), + ], + ) + else + const Text('尚未认证,认证功能暂未开放'), + ], + ), + ), + const SizedBox(height: 8), + _ProfileRelations(repository: widget.repository), + const SizedBox(height: 12), + FilledButton( + onPressed: _saving || _picking ? null : _save, + child: Text(_saving ? '正在保存…' : '保存修改'), + ), + ], + ), + ), + ), + ), + ); + + /// 未接入的字段显式提示,不能将设计图里的演示资料当作用户事实。 + Widget _row(IconData icon, String label, String value) => ListTile( + minTileHeight: 48, + contentPadding: EdgeInsets.zero, + leading: Icon(icon, color: Theme.of(context).colorScheme.primary), + title: Text(label), + trailing: Text(value), + onTap: () => showUnavailableFeature(context, '$label修改'), + ); + + Widget _heading(IconData icon, String title) => Row( + children: [ + Icon(icon, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 10), + Text(title, style: Theme.of(context).textTheme.titleMedium), + ], + ); + + String _maskedPhone(String phone) => + phone.replaceFirstMapped(RegExp(r'^(\d{3})\d{4}(\d{4})$'), (m) => '${m[1]}****${m[2]}'); +} + +/// 独立加载服务归属与地址,出错提供重试,不阻止资料编辑。 +class _ProfileRelations extends StatefulWidget { + const _ProfileRelations({required this.repository}); + final ClientRepository repository; + @override + State<_ProfileRelations> createState() => _ProfileRelationsState(); +} + +class _ProfileRelationsState extends State<_ProfileRelations> { + late Future<(Map?, List)> _data = _load(); + Future<(Map?, List)> _load() async { + final values = await Future.wait([ + widget.repository.serviceRelation(), + widget.repository.addresses(), + ]); + return (values[0] as Map?, values[1] as List); + } + + @override + Widget build(BuildContext context) => FutureBuilder( + future: _data, + builder: (context, snapshot) { + if (snapshot.hasError) { + return TextButton( + onPressed: () => setState(() => _data = _load()), + child: const Text('服务关系与地址加载失败,点击重试'), + ); + } + if (!snapshot.hasData) return const LinearProgressIndicator(); + final (relation, addresses) = snapshot.data!; + final defaults = addresses.where((a) => a.raw['is_default'] == true); + return Column( + children: [ + SurfaceSection( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('默认服务关系', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + _detail('所属气站', relation?['gas_name'] as String? ?? '尚未绑定'), + _detail('服务配送点', relation?['delivery_name'] as String? ?? '尚未绑定'), + ], + ), + ), + const SizedBox(height: 8), + SurfaceSection( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('常用地址', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + InkWell( + onTap: () async { + await context.push('/addresses'); + if (mounted) setState(() => _data = _load()); + }, + child: _detail('默认地址', defaults.isEmpty ? '尚未设置 ›' : '${defaults.first.title} ›'), + ), + ], + ), + ), + ], + ); + }, + ); + Widget _detail(String label, String value) => Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label), + const SizedBox(width: 16), + Expanded(child: Text(value.isEmpty ? '尚未绑定' : value, textAlign: TextAlign.end)), + ], + ), + ); +} diff --git a/apps/user_app/lib/ui/features/profile/profile_page.dart b/apps/user_app/lib/ui/features/profile/profile_page.dart index 2e21b05..9c48fb0 100644 --- a/apps/user_app/lib/ui/features/profile/profile_page.dart +++ b/apps/user_app/lib/ui/features/profile/profile_page.dart @@ -4,21 +4,27 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:uuid/uuid.dart'; import '../../../app/dependencies.dart'; import '../../../data/repositories/client_repository.dart'; -import '../../../data/services/api_client.dart'; import '../../../domain/models/client_models.dart'; import '../../core/widgets.dart'; +import '../../core/async_content.dart'; +import '../../core/feature_entry.dart'; import 'profile_avatar.dart'; /// 用户端个人中心页面。 class ProfilePage extends StatefulWidget { - const ProfilePage({required this.session, required this.repository, super.key}); + const ProfilePage({ + required this.session, + required this.repository, + this.openRepair = false, + super.key, + }); final UserSession session; final ClientRepository repository; + final bool openRepair; @override State createState() => _ProfilePageState(); @@ -26,208 +32,362 @@ class ProfilePage extends StatefulWidget { /// 管理个人资料加载及账户服务操作。 class _ProfilePageState extends State { - late Future<(UserProfile, WalletSummary, Uint8List?)> _future; + final _contentKey = + GlobalKey>(); + bool _creatingTicket = false; + + /// 保存成功后重新读取账户和受保护头像,避免显示旧缓存。 + Future _editProfile() async { + final saved = await context.push('/profile/edit'); + if (mounted && saved == true) await _contentKey.currentState?.refresh(); + } @override void initState() { super.initState(); - _future = _load(); + if (widget.openRepair) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _openRequestedRepair(); + }); + } + } + + @override + void didUpdateWidget(covariant ProfilePage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.openRepair && !oldWidget.openRepair) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _openRequestedRepair(); + }); + } + } + + /// 消费一次性报修查询参数,保证再次从首页进入时仍可打开表单。 + void _openRequestedRepair() { + if (!mounted) return; + context.go('/me'); + _createTicket(); } /// 并行加载资料和钱包,并在资料存在头像时读取受保护图片内容。 - Future<(UserProfile, WalletSummary, Uint8List?)> _load() async { + Future<(UserProfile, WalletSummary, Uint8List?, int?)> _load() async { final profileFuture = widget.repository.profile(); final walletFuture = widget.repository.wallet(); + final contactCountFuture = _loadContactCount(); final profile = await profileFuture; final avatarFuture = profile.avatar.trim().isEmpty ? Future.value() : loadProfileAvatarSafely(widget.repository.profileAvatar); - return (profile, await walletFuture, await avatarFuture); + return (profile, await walletFuture, await avatarFuture, await contactCountFuture); + } + + /// 联系人数量是辅助信息,读取失败不应阻断个人中心主资料。 + Future _loadContactCount() async { + try { + return (await widget.repository.emergencyContacts()).length; + } catch (_) { + return null; + } } Future _addAddress() async { - final controller = TextEditingController(); - final address = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('新增地址'), - content: TextField( - controller: controller, - decoration: const InputDecoration(labelText: '详细地址'), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')), - FilledButton( - onPressed: () => Navigator.pop(context, controller.text.trim()), - child: const Text('保存'), - ), - ], - ), - ); - controller.dispose(); - if (address == null || address.isEmpty) return; - try { - await widget.repository.addAddress(address, isDefault: true); - } on SessionExpiredException { - return; - } - if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('地址已保存'))); + await context.push('/addresses'); } Future _createTicket() async { - final controller = TextEditingController(); - final description = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('申请维修'), - content: TextField( - controller: controller, - maxLines: 4, - decoration: const InputDecoration(labelText: '问题描述'), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')), - FilledButton( - onPressed: () => Navigator.pop(context, controller.text.trim()), - child: const Text('提交'), - ), - ], - ), - ); - controller.dispose(); - if (description == null || description.isEmpty) return; + if (_creatingTicket) return; + _creatingTicket = true; try { - await widget.repository.createTicket( - requestNo: const Uuid().v7(), - category: 'repair', - description: description, - ); - } on SessionExpiredException { - return; + await context.push('/repair'); + } finally { + _creatingTicket = false; } - if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('工单已提交'))); } @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('我的')), - body: FutureBuilder<(UserProfile, WalletSummary, Uint8List?)>( - future: _future, - builder: (context, snapshot) { - if (!snapshot.hasData) { - if (snapshot.hasError) { - if (snapshot.error is SessionExpiredException) { - return const SizedBox.shrink(); - } - return EmptyState( - title: '个人信息加载失败', - description: '请检查网络后重新进入本页', - onRetry: () => setState(() => _future = _load()), - ); - } - return const Center(child: CircularProgressIndicator()); - } - final (profile, wallet, avatarBytes) = snapshot.data!; + appBar: AppBar( + title: const Text( + '个人中心', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600), + ), + centerTitle: false, + actions: [ + IconButton( + tooltip: '设置', + onPressed: () => context.push('/settings'), + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: '消息', + onPressed: () => context.push('/messages'), + icon: const Icon(Icons.sms_outlined), + ), + ], + ), + body: AsyncContent<(UserProfile, WalletSummary, Uint8List?, int?)>( + key: _contentKey, + load: _load, + builder: (context, data) { + final (profile, wallet, avatarBytes, contactCount) = data; return ListView( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(14, 4, 14, 20), children: [ SurfaceSection( - child: Row( - children: [ - ProfileAvatar( - name: profile.name, - imageBytes: avatarBytes, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(profile.name, style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 4), - Text( - profile.phone, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + padding: const EdgeInsets.all(12), + child: InkWell( + onTap: _editProfile, + child: Row( + children: [ + ProfileAvatar( + name: profile.name, + imageBytes: avatarBytes, + size: 68, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile.name, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), ), - ), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 28), - Text('资产概览', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 12), - SurfaceSection( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('钱包余额', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 4), - Text( - '以服务端资金流水为准', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + const SizedBox(height: 4), + Text( + profile.phone.replaceFirstMapped( + RegExp(r'^(\d{3})\d{4}(\d{4})$'), + (m) => '${m[1]}****${m[2]}', + ), + style: const TextStyle(fontSize: 13).copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + if (profile.realName.isNotEmpty) ...[ + const SizedBox(height: 5), + const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.verified_user, size: 16, color: Color(0xFF19B978)), + SizedBox(width: 4), + Text( + '已认证', + style: TextStyle(fontSize: 13, color: Color(0xFF19B978)), + ), + ], + ), + ], + ], ), - ], + ), + const Icon(Icons.chevron_right), + ], + ), + ), + ), + const SizedBox(height: 8), + SurfaceSection( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _assetSummary( + '账户余额', + moneyText(wallet.balance), + Icons.account_balance_wallet_outlined, + () => context.push('/wallet'), + ), ), - Text( - moneyText(wallet.balance), - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, + Expanded( + child: _assetSummary( + '可退押金', + '查看明细', + Icons.shield_outlined, + () => context.push('/deposits'), + ), + ), + Expanded( + child: _assetSummary( + '优惠券', + '尚未开放', + Icons.confirmation_number_outlined, + () => showUnavailableFeature(context, '优惠券'), ), ), ], ), ), - const SizedBox(height: 28), - Text('账户与服务', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 12), - SurfaceSection( - padding: EdgeInsets.zero, - child: Column( + const SizedBox(height: 8), + _group('我的服务', [ + Row( children: [ - _item(Icons.location_on_outlined, '地址管理', _addAddress), - const Divider(indent: 56), - _item( - Icons.description_outlined, - '供气合同', - () => context.push('/records/contracts'), + Expanded( + child: FeatureEntry( + icon: Icons.propane_tank_outlined, + label: '气瓶订单', + onTap: () => context.go('/orders?tab=gas'), + ), ), - const Divider(indent: 56), - _item( - Icons.account_balance_wallet_outlined, - '钱包流水', - () => context.push('/records/wallet'), + Expanded( + child: FeatureEntry( + icon: Icons.shopping_cart_outlined, + label: '商城订单', + onTap: () => context.go('/orders?tab=shop'), + ), + ), + Expanded( + child: FeatureEntry( + icon: Icons.build_outlined, + label: '报修工单', + onTap: () => context.go('/orders?tab=tickets'), + ), + ), + Expanded( + child: FeatureEntry( + icon: Icons.router_outlined, + label: '我的设备', + onTap: () => context.push('/devices'), + ), ), - const Divider(indent: 56), - _item(Icons.build_outlined, '申请维修', _createTicket), ], ), - ), - const SizedBox(height: 24), - TextButton.icon( - style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error), - onPressed: widget.session.logout, - icon: const Icon(Icons.logout), - label: const Text('退出登录'), - ), + ]), + const SizedBox(height: 8), + _group('安全与家庭', [ + _item( + Icons.people_outline, + '紧急联系人', + () => context.push('/safety/contacts'), + trailingText: contactCount == null ? null : '$contactCount人', + ), + _item(Icons.home_outlined, '家庭成员与设备共享', () => context.push('/family')), + _item( + Icons.description_outlined, + '供气合同', + () => context.push('/records/contracts'), + ), + _item(Icons.location_on_outlined, '地址管理', _addAddress), + ]), + const SizedBox(height: 8), + _group('常用功能', [ + _item( + Icons.history_outlined, + '我的记录', + () => context.push('/records'), + minHeight: 30, + ), + _item( + Icons.bar_chart_outlined, + '用气统计', + () => context.push('/usage'), + minHeight: 30, + ), + _item( + Icons.star_outline, + '我的收藏', + () => context.push('/favorites'), + minHeight: 30, + ), + _pending(Icons.receipt_outlined, '电子发票', minHeight: 30), + _pending(Icons.shield_outlined, '押金管理', minHeight: 30), + _pending(Icons.headset_mic_outlined, '客服与帮助', minHeight: 30), + _pending(Icons.info_outline, '关于我们', minHeight: 30), + ]), ], ); }, ), ); - Widget _item(IconData icon, String title, VoidCallback onTap) => ListTile( - leading: Icon(icon), - title: Text(title), - trailing: const Icon(Icons.chevron_right), + /// 尚无资金接口的摘要显示未开放,禁止把缺失数据伪装为零。 + Widget _assetSummary(String label, String value, IconData icon, VoidCallback onTap) => InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + children: [ + Icon( + icon, + color: icon == Icons.shield_outlined + ? const Color(0xFF58B643) + : icon == Icons.confirmation_number_outlined + ? const Color(0xFFFF982F) + : Theme.of(context).colorScheme.primary, + size: 22, + ), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontSize: 12)), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: value == '尚未开放' ? 12 : 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), + ); + + /// 保留后续业务的可见入口。 + Widget _pending(IconData icon, String title, {double minHeight = 34}) => _item( + icon, + title, + () => showUnavailableFeature(context, title), + unavailable: true, + minHeight: minHeight, + ); + + Widget _item( + IconData icon, + String title, + VoidCallback onTap, { + bool unavailable = false, + String? trailingText, + double minHeight = 34, + }) => ListTile( + minTileHeight: minHeight, + minVerticalPadding: 0, + contentPadding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + leading: Icon(icon, color: Theme.of(context).colorScheme.primary, size: 22), + title: Text(title, style: const TextStyle(fontSize: 14)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (trailingText != null) + Text(trailingText, style: const TextStyle(fontSize: 12, color: Color(0xFF626A7A))) + else if (unavailable) + const Text('暂未开放', style: TextStyle(fontSize: 12, color: Color(0xFF777F8D))), + const Icon(Icons.chevron_right, color: Color(0xFF999DA5), size: 22), + ], + ), onTap: onTap, ); + + /// 按设计图将分组标题与行列表置于同一白色区域,保持顺序及分隔线。 + Widget _group(String title, List children) => SurfaceSection( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + for (var index = 0; index < children.length; index++) ...[ + if (index > 0) const Divider(height: 1), + children[index], + ], + ], + ), + ); } diff --git a/apps/user_app/lib/ui/features/profile/usage_statistics_page.dart b/apps/user_app/lib/ui/features/profile/usage_statistics_page.dart new file mode 100644 index 0000000..bc2501e --- /dev/null +++ b/apps/user_app/lib/ui/features/profile/usage_statistics_page.dart @@ -0,0 +1,417 @@ +// 功能描述:实现图27用气统计,展示服务端可追溯计量数据及缺失边界;版本:1.0.0。 +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/client_models.dart'; +import '../../../domain/models/usage_statistics.dart'; +import '../../core/async_content.dart'; + +class UsageStatisticsPage extends StatefulWidget { + const UsageStatisticsPage({required this.repository, super.key}); + final ClientRepository repository; + + @override + State createState() => _UsageStatisticsPageState(); +} + +class _UsageStatisticsPageState extends State { + final _contentKey = GlobalKey>(); + String _period = 'month'; + String? _deviceIdentity; + DateTime? _anchor; + + Future<_UsagePageData> _load() async { + final devices = await widget.repository.devices(); + if (devices.isEmpty) return const _UsagePageData(devices: [], statistics: null); + final selected = devices.any((item) => item.identity == _deviceIdentity) + ? _deviceIdentity! + : devices.first.identity; + _deviceIdentity = selected; + final statistics = await widget.repository.usageStatistics( + deviceIdentity: selected, + period: _period, + anchor: _anchor, + ); + return _UsagePageData(devices: devices, statistics: statistics); + } + + Future _reload({String? period, String? device}) async { + if (period != null) _period = period; + if (device != null) _deviceIdentity = device; + await _contentKey.currentState?.refresh(); + } + + // 选择统计基准日期,切换周期或设备时沿用该日期;取消不发起查询。 + Future _selectDate() async { + final today = DateUtils.dateOnly(DateTime.now()); + final selected = await showDatePicker( + context: context, + initialDate: _anchor ?? today, + firstDate: DateTime(2000), + lastDate: today, + helpText: '选择统计日期', + cancelText: '取消', + confirmText: '确定', + ); + if (!mounted || selected == null || selected == _anchor) return; + setState(() => _anchor = selected); + await _reload(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('用气统计', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + centerTitle: true, + actions: [ + IconButton( + tooltip: '选择日期', + onPressed: _selectDate, + icon: const Icon(Icons.calendar_month_outlined), + ), + ], + ), + body: AsyncContent<_UsagePageData>( + key: _contentKey, + load: _load, + builder: (context, data) => ListView( + padding: const EdgeInsets.fromLTRB(14, 6, 14, 18), + children: [ + _deviceSelector(data.devices), + const SizedBox(height: 8), + _periodSelector(), + const SizedBox(height: 8), + if (data.statistics == null) + _unavailable('尚无可统计设备', '请先在后台完成本人设备归属和计量映射。') + else if (!data.statistics!.available) + _unavailable('用气数据暂未开放', data.statistics!.unavailableReason) + else + ..._statistics(data.statistics!), + ], + ), + ), + ); + + Widget _deviceSelector(List devices) => _card( + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: devices.isEmpty ? null : _deviceIdentity, + hint: const Text('暂无可用设备'), + icon: const Icon(Icons.keyboard_arrow_down), + items: devices + .map( + (item) => DropdownMenuItem( + value: item.identity, + child: Row( + children: [ + const Icon(Icons.propane_tank_outlined, color: Color(0xFF1768E5), size: 27), + const SizedBox(width: 10), + Expanded( + child: Text( + '${item.title} ${item.subtitle}', + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ) + .toList(), + onChanged: devices.isEmpty + ? null + : (value) => value == null ? null : _reload(device: value), + ), + ), + ); + + Widget _periodSelector() => _card( + padding: const EdgeInsets.all(5), + child: SegmentedButton( + showSelectedIcon: false, + segments: const [ + ButtonSegment(value: 'day', label: Text('日')), + ButtonSegment(value: 'week', label: Text('周')), + ButtonSegment(value: 'month', label: Text('月')), + ButtonSegment(value: 'year', label: Text('年')), + ], + selected: {_period}, + onSelectionChanged: (value) => _reload(period: value.first), + ), + ); + + List _statistics(UsageStatistics statistics) => [ + _overview(statistics), + const SizedBox(height: 8), + _chart(statistics), + const SizedBox(height: 8), + _composition(statistics), + const SizedBox(height: 8), + _unavailable('安全趋势暂未开放', '角阀操作和异常事件尚无统一统计接口。'), + const SizedBox(height: 8), + _card( + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.description_outlined, color: Color(0xFF1768E5)), + title: const Text('用气明细', style: TextStyle(fontWeight: FontWeight.w700)), + subtitle: Text('数据来源:${_sourceName(statistics.source)} · 口径 ${statistics.calcVersion}'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showDetails(statistics), + ), + ), + ]; + + Widget _overview(UsageStatistics value) => _card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('本期用气总览', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _metric('${value.totalUsage.toStringAsFixed(1)} ${value.unit}', '本期用气量'), + ), + _divider(), + Expanded( + child: _metric('${value.averageUsage.toStringAsFixed(2)} ${value.unit}', '有数据日均'), + ), + _divider(), + Expanded(child: _metric('${value.points.length} 天', '计量天数')), + ], + ), + const SizedBox(height: 10), + Text( + '${_date(value.periodStart)} – ${_date(value.periodEnd)} · 更新 ${_updated(value.updatedAt)}', + style: const TextStyle(fontSize: 12, color: Color(0xFF697386)), + ), + ], + ), + ); + + Widget _chart(UsageStatistics value) { + final peak = value.points.fold(0, (current, point) => math.max(current, point.usage)); + return _card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: Text( + '每日用气量(kg)', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + ), + ), + Text( + '峰值 ${peak.toStringAsFixed(2)}kg', + style: const TextStyle(color: Color(0xFFFF7A00)), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 180, + width: double.infinity, + child: CustomPaint(painter: _UsageChartPainter(value.points)), + ), + ], + ), + ); + } + + Widget _composition(UsageStatistics value) { + final items = [ + ('早餐', value.composition.breakfast, Icons.wb_twilight_outlined), + ('午餐', value.composition.lunch, Icons.light_mode_outlined), + ('晚餐', value.composition.dinner, Icons.dark_mode_outlined), + ]; + final total = items.fold(0, (sum, item) => sum + item.$2); + return _card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('用气构成(本期)', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), + const SizedBox(height: 14), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Row( + children: [ + for (var index = 0; index < items.length; index++) + Expanded( + flex: total == 0 ? 1 : math.max(1, (items[index].$2 / total * 1000).round()), + child: Container( + height: 12, + color: [ + const Color(0xFFB6DAFF), + const Color(0xFF65AFFF), + const Color(0xFF1768E5), + ][index], + ), + ), + ], + ), + ), + const SizedBox(height: 14), + Row( + children: [ + for (final item in items) + Expanded( + child: Column( + children: [ + Icon(item.$3, color: const Color(0xFF1768E5)), + Text(item.$1), + Text( + '${item.$2.toStringAsFixed(1)}kg', + style: const TextStyle(color: Color(0xFF1768E5)), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + Widget _unavailable(String title, String description) => _card( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.info_outline, color: Color(0xFFFF8A00)), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.w700)), + const SizedBox(height: 5), + Text(description, style: const TextStyle(color: Color(0xFF697386), height: 1.4)), + ], + ), + ), + ], + ), + ), + ); + + Future _showDetails(UsageStatistics value) => showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => SafeArea( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + children: [ + const Text('用气明细', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), + const SizedBox(height: 10), + for (final point in value.points.reversed) + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(_date(point.date)), + trailing: Text('${point.usage.toStringAsFixed(3)} ${value.unit}'), + ), + ], + ), + ), + ); + + Widget _metric(String value, String label) => Column( + children: [ + FittedBox( + child: Text(value, style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700)), + ), + const SizedBox(height: 5), + Text( + label, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12, color: Color(0xFF586274)), + ), + ], + ); + + Widget _divider() => Container(width: 1, height: 56, color: const Color(0xFFE1E6ED)); + + Widget _card({required Widget child, EdgeInsets padding = const EdgeInsets.all(12)}) => Material( + color: Colors.white, + shape: RoundedRectangleBorder( + side: const BorderSide(color: Color(0xFFE1E6ED)), + borderRadius: BorderRadius.circular(8), + ), + child: Padding(padding: padding, child: child), + ); + + String _date(DateTime value) => + '${value.month.toString().padLeft(2, '0')}月${value.day.toString().padLeft(2, '0')}日'; + String _updated(DateTime? value) => value == null + ? '时间待补充' + : '${_date(value)} ${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}'; + String _sourceName(String value) => switch (value) { + 'meter' => '计量表', + 'device' => '设备上报', + 'manual' => '人工导入', + _ => '未标注', + }; +} + +class _UsagePageData { + const _UsagePageData({required this.devices, required this.statistics}); + final List devices; + final UsageStatistics? statistics; +} + +class _UsageChartPainter extends CustomPainter { + const _UsageChartPainter(this.points); + final List points; + + @override + void paint(Canvas canvas, Size size) { + if (points.isEmpty) return; + final grid = Paint() + ..color = const Color(0xFFD9E0EA) + ..strokeWidth = 1; + for (var row = 0; row <= 4; row++) { + final y = size.height * row / 4; + canvas.drawLine(Offset.zero.translate(0, y), Offset(size.width, y), grid); + } + final peak = points.fold(0.01, (current, point) => math.max(current, point.usage)); + final path = Path(); + for (var index = 0; index < points.length; index++) { + final x = points.length == 1 ? size.width / 2 : size.width * index / (points.length - 1); + final y = size.height - points[index].usage / peak * (size.height - 10); + if (index == 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + canvas.drawPath( + path, + Paint() + ..color = const Color(0xFF1768E5) + ..strokeWidth = 2.5 + ..style = PaintingStyle.stroke, + ); + for (var index = 0; index < points.length; index++) { + final x = points.length == 1 ? size.width / 2 : size.width * index / (points.length - 1); + final y = size.height - points[index].usage / peak * (size.height - 10); + canvas.drawCircle(Offset(x, y), 3, Paint()..color = Colors.white); + canvas.drawCircle( + Offset(x, y), + 3, + Paint() + ..color = const Color(0xFF1768E5) + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); + } + } + + @override + bool shouldRepaint(covariant _UsageChartPainter oldDelegate) => oldDelegate.points != points; +} diff --git a/apps/user_app/lib/ui/features/profile/user_records_page.dart b/apps/user_app/lib/ui/features/profile/user_records_page.dart new file mode 100644 index 0000000..931e61d --- /dev/null +++ b/apps/user_app/lib/ui/features/profile/user_records_page.dart @@ -0,0 +1,356 @@ +// 功能描述:图26我的记录,聚合本人用气与报修事实,并标明首期和二期能力边界;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/client_models.dart'; +import '../../core/async_content.dart'; +import '../../core/feature_entry.dart'; + +class UserRecordsPage extends StatefulWidget { + const UserRecordsPage({required this.repository, super.key}); + final ClientRepository repository; + + @override + State createState() => _UserRecordsPageState(); +} + +class _UserRecordsPageState extends State { + String _filter = '全部'; + + Future<_UserRecordData> _load() async { + final values = await Future.wait([widget.repository.gasOrders(), widget.repository.tickets()]); + final items = <_UserRecordItem>[ + for (final record in values[0]) _gasItem(record), + for (final record in values[1]) _ticketItem(record), + ]..sort((a, b) => b.occurredAt.compareTo(a.occurredAt)); + final now = DateTime.now(); + bool inMonth(_UserRecordItem item) => + item.occurredAt.year == now.year && item.occurredAt.month == now.month; + return _UserRecordData( + items: items, + gasCount: items.where((item) => item.kind == '用气' && inMonth(item)).length, + serviceCount: items.where((item) => item.kind == '报修' && inMonth(item)).length, + ); + } + + _UserRecordItem _gasItem(ClientRecord record) { + final raw = record.raw; + final orderNo = _text(raw, 'order_no', record.title); + final status = _text(raw, 'status_name', '状态待更新'); + final product = _firstProduct(raw); + return _UserRecordItem( + kind: '用气', + title: product.isEmpty ? '气瓶配送记录' : '$product配送', + subtitle: '订单号:$orderNo', + status: status, + occurredAt: _date(raw['created_at']), + icon: Icons.propane_tank_outlined, + onOpen: (context) => context.push('/gas/orders/${Uri.encodeComponent(record.identity)}'), + ); + } + + _UserRecordItem _ticketItem(ClientRecord record) { + final raw = record.raw; + final number = _text(raw, 'ticket_no', record.title); + final status = _text(raw, 'status_name', '状态待更新'); + return _UserRecordItem( + kind: '报修', + title: '报修工单', + subtitle: '工单号:$number', + status: status, + occurredAt: _date(raw['created_at']), + icon: Icons.build_outlined, + onOpen: (context) => context.push('/tickets/${Uri.encodeComponent(record.identity)}'), + ); + } + + Future _selectFilter() async { + final value = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const ListTile( + title: Text('筛选记录', style: TextStyle(fontWeight: FontWeight.w700)), + ), + RadioGroup( + groupValue: _filter, + onChanged: (next) => Navigator.pop(context, next), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final item in ['全部', '用气', '报修']) + RadioListTile( + value: item, + title: Text(item == '全部' ? '全部记录' : '$item记录'), + ), + ], + ), + ), + ], + ), + ), + ); + if (value != null && mounted) setState(() => _filter = value); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('我的记录', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + centerTitle: true, + actions: [ + TextButton.icon( + onPressed: _selectFilter, + icon: const Icon(Icons.filter_alt_outlined), + label: Text(_filter == '全部' ? '筛选' : _filter), + ), + ], + ), + body: AsyncContent<_UserRecordData>( + load: _load, + builder: (context, data) { + final items = _filter == '全部' + ? data.items + : data.items.where((item) => item.kind == _filter).toList(); + final now = DateTime.now(); + final first = DateTime(now.year, now.month); + final last = DateTime(now.year, now.month + 1, 0); + return ListView( + padding: const EdgeInsets.fromLTRB(14, 4, 14, 16), + children: [ + _overview(data, first, last), + const SizedBox(height: 8), + _categories(), + const SizedBox(height: 8), + _recent(items), + ], + ); + }, + ), + ); + + Widget _overview(_UserRecordData data, DateTime first, DateTime last) => _card([ + Row( + children: [ + const Expanded( + child: Text('本月概览', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), + ), + Text( + '${_monthDay(first)}–${_monthDay(last)}', + style: const TextStyle(color: Color(0xFF697386)), + ), + const SizedBox(width: 6), + const Icon(Icons.calendar_month_outlined, color: Color(0xFF697386), size: 20), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _metric( + Icons.description_outlined, + '${data.gasCount}', + '用气记录', + const Color(0xFF1768E5), + ), + ), + _divider(), + Expanded(child: _metric(Icons.tune_outlined, '—', '设备操作', const Color(0xFF23B15D))), + _divider(), + Expanded( + child: _metric( + Icons.headset_mic_outlined, + '${data.serviceCount}', + '服务记录', + const Color(0xFFFF981A), + ), + ), + ], + ), + ]); + + Widget _categories() => _card([ + GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisExtent: 70, + ), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: 6, + itemBuilder: (context, index) => [ + _category(Icons.bar_chart_outlined, '用气记录', () => setState(() => _filter = '用气')), + _category(Icons.tune_outlined, '角阀操作', () => showUnavailableFeature(context, '角阀操作')), + _category( + Icons.notifications_active_outlined, + '告警记录', + () => showUnavailableFeature(context, '告警记录'), + ), + _category(Icons.build_outlined, '报修记录', () => setState(() => _filter = '报修')), + _category(Icons.shield_outlined, '押金记录', () => context.push('/deposits')), + _category( + Icons.receipt_long_outlined, + '发票记录', + () => showUnavailableFeature(context, '发票记录', stage: UnavailableStage.secondPhase), + ), + ][index], + ), + ]); + + Widget _recent(List<_UserRecordItem> items) => _card([ + const Text('最近记录', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), + const SizedBox(height: 10), + if (items.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 28), + child: Center( + child: Text('暂无符合条件的记录', style: TextStyle(color: Color(0xFF697386))), + ), + ) + else + for (final item in items.take(8)) _recordRow(item), + if (items.isNotEmpty) ...[ + const Divider(height: 8), + Center( + child: TextButton.icon( + onPressed: () => setState(() => _filter = '全部'), + label: const Text('查看全部记录'), + iconAlignment: IconAlignment.end, + icon: const Icon(Icons.chevron_right, size: 18), + ), + ), + ], + ]); + + Widget _recordRow(_UserRecordItem item) => InkWell( + onTap: () => item.onOpen(context), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 52, + child: Text( + _recordTime(item.occurredAt), + style: const TextStyle(color: Color(0xFF586274), height: 1.35), + ), + ), + Container( + width: 32, + height: 32, + decoration: const BoxDecoration(color: Color(0xFFEAF1FF), shape: BoxShape.circle), + child: Icon(item.icon, color: const Color(0xFF1768E5), size: 20), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14), + ), + const SizedBox(height: 4), + Text(item.subtitle, style: const TextStyle(color: Color(0xFF697386), fontSize: 12)), + ], + ), + ), + const SizedBox(width: 4), + Text(item.status, style: const TextStyle(color: Color(0xFF1768E5), fontSize: 11)), + const Icon(Icons.chevron_right, color: Color(0xFF8A93A3), size: 20), + ], + ), + ), + ); + + Widget _metric(IconData icon, String value, String label, Color color) => Column( + children: [ + Icon(icon, color: color, size: 23), + const SizedBox(height: 5), + Text(value, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + Text(label, style: const TextStyle(color: Color(0xFF586274), fontSize: 12)), + ], + ); + + Widget _divider() => Container(width: 1, height: 62, color: const Color(0xFFE2E7EE)); + + Widget _category(IconData icon, String label, VoidCallback onTap) => InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: const Color(0xFF1768E5), size: 25), + const SizedBox(height: 6), + Text(label), + ], + ), + ); + + Widget _card(List children) => Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE2E7EE)), + borderRadius: BorderRadius.circular(8), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children), + ); + + String _text(Map data, String key, String fallback) { + final value = data[key]?.toString().trim() ?? ''; + return value.isEmpty ? fallback : value; + } + + String _firstProduct(Map data) { + final items = data['items']; + if (items is! List || items.isEmpty || items.first is! Map) return ''; + final first = items.first as Map; + return (first['product_type_name'] ?? first['name'])?.toString().trim() ?? ''; + } + + DateTime _date(Object? value) => + DateTime.tryParse(value?.toString() ?? '')?.toLocal() ?? DateTime(1970); + String _monthDay(DateTime value) => + '${value.month.toString().padLeft(2, '0')}月${value.day.toString().padLeft(2, '0')}日'; + String _recordTime(DateTime value) { + final now = DateTime.now(); + final day = DateTime(value.year, value.month, value.day); + final today = DateTime(now.year, now.month, now.day); + final label = day == today + ? '今天' + : day == today.subtract(const Duration(days: 1)) + ? '昨天' + : '${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}'; + return '$label\n${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}'; + } +} + +class _UserRecordData { + const _UserRecordData({required this.items, required this.gasCount, required this.serviceCount}); + final List<_UserRecordItem> items; + final int gasCount, serviceCount; +} + +class _UserRecordItem { + const _UserRecordItem({ + required this.kind, + required this.title, + required this.subtitle, + required this.status, + required this.occurredAt, + required this.icon, + required this.onOpen, + }); + final String kind, title, subtitle, status; + final DateTime occurredAt; + final IconData icon; + final void Function(BuildContext context) onOpen; +} diff --git a/apps/user_app/lib/ui/features/settings/login_password_page.dart b/apps/user_app/lib/ui/features/settings/login_password_page.dart new file mode 100644 index 0000000..0ebf53a --- /dev/null +++ b/apps/user_app/lib/ui/features/settings/login_password_page.dart @@ -0,0 +1,132 @@ +// 功能描述:当前密码验证、新密码二次确认与服务端成功后重新登录;版本:1.0.0。 +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../../app/dependencies.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../data/services/api_client.dart'; + +class LoginPasswordPage extends StatefulWidget { + const LoginPasswordPage({required this.repository, required this.session, super.key}); + final ClientRepository repository; + final UserSession session; + @override + State createState() => _LoginPasswordPageState(); +} + +/// 密码仅保留在当前表单内存;失败不清空草稿,成功后清空并退出本机登录。 +class _LoginPasswordPageState extends State { + final _form = GlobalKey(); + final _current = TextEditingController(), + _next = TextEditingController(), + _repeat = TextEditingController(); + final _visible = [false, false, false]; + bool _busy = false; + String? _error; + @override + void dispose() { + _current.dispose(); + _next.dispose(); + _repeat.dispose(); + super.dispose(); + } + + Future _save() async { + if (_busy || !(_form.currentState?.validate() ?? false)) return; + setState(() { + _busy = true; + _error = null; + }); + try { + await widget.repository.changeLoginPassword(_current.text, _next.text); + if (!mounted) return; + _current.clear(); + _next.clear(); + _repeat.clear(); + await widget.session.logout(); + } on SessionExpiredException { + return; + } catch (error) { + if (mounted) setState(() => _error = error is ApiException ? error.message : '修改失败,请重试'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Widget _input( + String label, + TextEditingController controller, + int index, + String? Function(String?) validator, + ) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: TextFormField( + key: ValueKey('password-$index'), + controller: controller, + enabled: !_busy, + obscureText: !_visible[index], + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.visiblePassword, + validator: validator, + decoration: InputDecoration( + labelText: label, + suffixIcon: IconButton( + tooltip: _visible[index] ? '隐藏$label' : '显示$label', + onPressed: _busy ? null : () => setState(() => _visible[index] = !_visible[index]), + icon: Icon(_visible[index] ? Icons.visibility_off_outlined : Icons.visibility_outlined), + ), + ), + ), + ); + + @override + Widget build(BuildContext context) => PopScope( + canPop: !_busy, + child: Scaffold( + appBar: AppBar( + title: const Text('登录密码'), + centerTitle: true, + leading: IconButton( + tooltip: '返回', + icon: const Icon(Icons.arrow_back), + onPressed: _busy + ? null + : () => context.canPop() ? context.pop() : context.go('/settings'), + ), + ), + body: Form( + key: _form, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _input('当前密码', _current, 0, (value) => (value ?? '').isEmpty ? '请输入当前密码' : null), + _input('新密码', _next, 1, (value) { + final password = value ?? ''; + if (password.runes.length < 6) return '新密码至少6个字符'; + if (utf8.encode(password).length > 72) return '新密码过长,请缩短后重试'; + if (password == _current.text) return '新密码不能与当前密码相同'; + return null; + }), + _input('确认新密码', _repeat, 2, (value) => value != _next.text ? '两次新密码不一致' : null), + if (_error != null) + Semantics( + liveRegion: true, + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ), + FilledButton( + onPressed: _busy ? null : _save, + child: Text(_busy ? '正在修改…' : '确认修改并重新登录'), + ), + ], + ), + ), + ), + ); +} diff --git a/apps/user_app/lib/ui/features/settings/payment_password_page.dart b/apps/user_app/lib/ui/features/settings/payment_password_page.dart new file mode 100644 index 0000000..579a41f --- /dev/null +++ b/apps/user_app/lib/ui/features/settings/payment_password_page.dart @@ -0,0 +1,251 @@ +// 功能描述:支付密码首次设置、旧密码修改及验证码找回;版本:1.0.0。 +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../data/services/api_client.dart'; +import '../../../domain/models/client_models.dart'; +import '../../core/async_content.dart'; + +/// 只操作当前用户的钱包密码,余额和提现数据不允许由页面写入。 +class PaymentPasswordPage extends StatefulWidget { + const PaymentPasswordPage({required this.repository, super.key}); + final ClientRepository repository; + @override + State createState() => _PaymentPasswordPageState(); +} + +class _PaymentPasswordPageState extends State { + final _form = GlobalKey(); + final _old = TextEditingController(), + _next = TextEditingController(), + _repeat = TextEditingController(), + _code = TextEditingController(); + bool _busy = false, _useCode = false; + bool? _hasPassword; + WalletSummary? _loaded; + PaymentCodeRequest? _request; + String? _error, _notice; + Timer? _timer; + DateTime? _resendAt; + int get _remaining => + _resendAt == null ? 0 : _resendAt!.difference(DateTime.now()).inSeconds.clamp(0, 3600); + + @override + void dispose() { + _timer?.cancel(); + _old.dispose(); + _next.dispose(); + _repeat.dispose(); + _code.dispose(); + super.dispose(); + } + + /// 服务端状态缺失时进入可重试错误态,不能自行选择首次设置流程。 + Future _load() async { + if (_busy && _loaded != null) return _loaded!; + final wallet = await widget.repository.wallet(); + if (!mounted) return wallet; + if (_busy && _loaded != null) return _loaded!; + if (wallet.paymentPasswordSet == null) throw const ApiException(1714, '支付密码状态读取失败'); + _loaded = wallet; + if (_hasPassword != wallet.paymentPasswordSet) { + _hasPassword = wallet.paymentPasswordSet; + _useCode = !_hasPassword!; + _clearCode(); + _old.clear(); + } + return wallet; + } + + void _clearCode() { + _request = null; + _code.clear(); + } + + /// 不把Mock的验证码记录误报为短信已发送。 + Future _sendCode() async { + if (_busy || _remaining > 0) return; + setState(() { + _busy = true; + _error = null; + _notice = null; + }); + try { + final request = await widget.repository.requestPaymentPasswordCode( + resetting: _hasPassword == true, + ); + if (!mounted) return; + _request = request; + _code.clear(); + _resendAt = DateTime.now().add(Duration(seconds: request.retryAfter + 1)); + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted || _remaining == 0) timer.cancel(); + if (mounted) setState(() {}); + }); + setState( + () => + _notice = request.delivered ? '验证码已发送至 ${request.maskedPhone}' : '验证码请求已建立,但当前环境未发送短信', + ); + } catch (error) { + if (mounted) setState(() => _error = error is ApiException ? error.message : '验证码请求失败,请重试'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _save() async { + if (_busy || !(_form.currentState?.validate() ?? false)) return; + if (_useCode && _request == null) { + setState(() => _error = '请先获取验证码'); + return; + } + setState(() { + _busy = true; + _error = null; + _notice = null; + }); + try { + final cleared = await widget.repository.setPaymentPassword( + newPassword: _next.text, + currentPassword: _useCode ? null : _old.text, + requestIdentity: _useCode ? _request!.identity : null, + code: _useCode ? _code.text : null, + ); + if (!mounted) return; + _old.clear(); + _next.clear(); + _repeat.clear(); + _clearCode(); + _timer?.cancel(); + _resendAt = null; + setState(() { + _hasPassword = true; + _useCode = false; + _notice = cleared ? '支付密码已更新' : '支付密码已更新,安全锁定可能仍在生效,请稍后再试'; + }); + } on SessionExpiredException { + return; + } catch (error) { + if (mounted) setState(() => _error = error is ApiException ? error.message : '修改结果未确认,请稍后重试'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Widget _pin( + String label, + TextEditingController controller, + String key, { + bool confirmation = false, + }) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: TextFormField( + key: ValueKey(key), + controller: controller, + enabled: !_busy, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + decoration: InputDecoration(labelText: label), + validator: (value) { + if (!RegExp(r'^\d{6}$').hasMatch(value ?? '')) return '请输入6位数字支付密码'; + if (confirmation && value != _next.text) return '两次支付密码不一致'; + return null; + }, + ), + ); + + @override + Widget build(BuildContext context) => PopScope( + canPop: !_busy, + child: Scaffold( + appBar: AppBar( + title: const Text('支付密码'), + centerTitle: true, + leading: IconButton( + tooltip: '返回', + icon: const Icon(Icons.arrow_back), + onPressed: _busy + ? null + : () => context.canPop() ? context.pop() : context.go('/settings'), + ), + ), + body: AsyncContent( + load: _load, + builder: (context, wallet) => Form( + key: _form, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + _hasPassword == true ? (_useCode ? '找回支付密码' : '修改支付密码') : '设置支付密码', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 20), + if (_useCode) ...[ + TextFormField( + key: const ValueKey('payment-code'), + controller: _code, + enabled: !_busy, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(8), + ], + decoration: const InputDecoration(labelText: '手机验证码'), + validator: (v) => (v ?? '').isEmpty ? '请输入手机验证码' : null, + ), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: _busy || _remaining > 0 ? null : _sendCode, + child: Text(_remaining > 0 ? '$_remaining秒后重新获取' : '获取验证码'), + ), + ), + const SizedBox(height: 12), + ] else + _pin('当前支付密码', _old, 'payment-old'), + _pin('新支付密码(6位数字)', _next, 'payment-next'), + _pin('确认支付密码', _repeat, 'payment-repeat', confirmation: true), + if (_error != null || _notice != null) + Semantics( + liveRegion: true, + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + _error ?? _notice!, + style: TextStyle( + color: _error == null ? null : Theme.of(context).colorScheme.error, + ), + ), + ), + ), + FilledButton(onPressed: _busy ? null : _save, child: Text(_busy ? '正在处理…' : '确认')), + if (_hasPassword == true) + TextButton( + onPressed: _busy + ? null + : () => setState(() { + _useCode = !_useCode; + _clearCode(); + _old.clear(); + _error = null; + _notice = null; + }), + child: Text(_useCode ? '使用当前支付密码修改' : '忘记支付密码?'), + ), + ], + ), + ), + ), + ), + ); +} diff --git a/apps/user_app/lib/ui/features/settings/settings_page.dart b/apps/user_app/lib/ui/features/settings/settings_page.dart new file mode 100644 index 0000000..c59bdf8 --- /dev/null +++ b/apps/user_app/lib/ui/features/settings/settings_page.dart @@ -0,0 +1,277 @@ +// 功能描述:按图31组织账户设置、协议、版本、图片缓存和退出操作;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../../app/dependencies.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../data/services/app_settings_service.dart'; +import '../../core/feature_entry.dart'; +import '../auth/login_support.dart'; + +class SettingsPage extends StatefulWidget { + const SettingsPage({required this.repository, required this.session, this.service, super.key}); + final ClientRepository repository; + final UserSession session; + final AppSettingsService? service; + @override + State createState() => _SettingsPageState(); +} + +/// 附属资料读取失败不阻塞清缓存或退出,未接入能力保持真实的未知状态。 +class _SettingsPageState extends State { + late final AppSettingsService _service = widget.service ?? AppSettingsService(); + String _phone = '读取中', _version = '读取中'; + String? _notice; + bool _busy = false, _phoneFailed = false; + @override + void initState() { + super.initState(); + _loadPhone(); + _loadVersion(); + } + + Future _loadPhone() async { + try { + final profile = await widget.repository.profile(); + if (mounted) { + setState(() { + _phone = profile.phone.replaceFirstMapped( + RegExp(r'^(\d{3})\d{4}(\d{4})$'), + (m) => '${m[1]}****${m[2]}', + ); + _phoneFailed = false; + }); + } + } catch (_) { + if (mounted) { + setState(() { + _phone = '读取失败,点击重试'; + _phoneFailed = true; + }); + } + } + } + + Future _loadVersion() async { + try { + final version = await _service.version(); + if (mounted) setState(() => _version = version); + } catch (error) { + // 仅诊断非敏感的构建元数据错误,不输出会话或账号资料。 + debugPrint('构建版本读取失败:$error'); + if (mounted) setState(() => _version = '读取失败,点击重试'); + } + } + + Future _confirm(bool logout) async { + if (_busy) return; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(logout ? '退出登录?' : '清除图片缓存?'), + content: Text(logout ? '退出后再次办理业务需要登录。' : '清除可重新加载的图片缓存。账户资料、报修草稿和已下载文件会保留。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('取消')), + TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('确认')), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _busy = true); + try { + if (logout) { + await widget.session.logout(); + } else { + _service.clearImageCache(); + if (mounted) setState(() => _notice = '图片缓存已清除'); + } + } catch (_) { + if (mounted) setState(() => _notice = '操作失败,请重试'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Widget _row(IconData icon, String title, {String? value, VoidCallback? action, Color? color}) => + InkWell( + onTap: _busy ? null : action ?? () => showUnavailableFeature(context, title), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: LayoutBuilder( + builder: (context, constraints) { + // 窄屏或放大文字时上下排列,避免手机号和设置名称拆成单字。 + final stacked = + constraints.maxWidth < 290 || MediaQuery.textScalerOf(context).scale(14) > 16; + return Row( + children: [ + Icon(icon, color: color ?? const Color(0xFF0064FF), size: 24), + const SizedBox(width: 10), + Expanded( + flex: 5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: TextStyle(fontSize: 14, color: color)), + if (stacked && value != null) ...[ + const SizedBox(height: 4), + Text( + value, + style: const TextStyle(fontSize: 12, color: Color(0xFF777F8D)), + ), + ], + ], + ), + ), + if (value != null && !stacked) ...[ + const SizedBox(width: 8), + Expanded( + flex: 4, + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontSize: 12, color: Color(0xFF777F8D)), + ), + ), + ], + const SizedBox(width: 4), + const Icon(Icons.chevron_right, color: Color(0xFF9CA3AF), size: 20), + ], + ); + }, + ), + ), + ), + ); + + Widget _group(String title, List rows) => Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE5E7EB)), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + color: Color(0xFF777F8D), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + for (var i = 0; i < rows.length; i++) ...[if (i > 0) const Divider(height: 1), rows[i]], + ], + ), + ); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('设置'), + centerTitle: true, + leading: IconButton( + tooltip: '返回', + icon: const Icon(Icons.arrow_back), + onPressed: () => context.canPop() ? context.pop() : context.go('/me'), + ), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 24), + children: [ + _group('账号与安全', [ + _row(Icons.verified_user_outlined, '实名认证', value: '暂未开放'), + _row(Icons.lock_outline, '登录密码', action: () => context.push('/settings/password')), + _row( + Icons.lock_outline, + '支付密码', + action: () => context.push('/settings/payment-password'), + ), + _row( + Icons.phone_android, + '手机号', + value: _phone, + action: () => _phoneFailed ? _loadPhone() : showUnavailableFeature(context, '更换手机号'), + ), + _row(Icons.desktop_windows_outlined, '设备登录管理', value: '暂未开放'), + ]), + _group('消息通知', [ + // 尚无真实通知通道,不能用“不可关闭”暗示已经开启并正常送达。 + _row(Icons.notifications_none, '安全告警通知', value: '暂未开放', color: Colors.red), + _row(Icons.sms_outlined, '订单通知', value: '暂未开放'), + _row(Icons.mail_outline, '服务通知', value: '暂未开放'), + _row(Icons.volume_up_outlined, '声音与振动', value: '暂未开放'), + ]), + _group('设备与权限', [ + _row(Icons.bluetooth, '蓝牙权限', value: '暂未开放'), + _row(Icons.location_on_outlined, '定位权限', value: '暂未开放'), + _row(Icons.lock_outline, '远程控制二次确认', value: '暂未开放'), + ]), + _group('通用', [ + _row( + Icons.language, + '语言', + value: '简体中文', + action: () => showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('语言'), + content: const Text('简体中文'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('确定')), + ], + ), + ), + ), + _row(Icons.shield_outlined, '隐私设置', value: '暂未开放'), + _row( + Icons.delete_outline, + '清除缓存', + value: '${(_service.imageCacheBytes / 1024 / 1024).toStringAsFixed(1)}MB 图片缓存', + action: () => _confirm(false), + ), + _row(Icons.info_outline, '版本', value: _version, action: _loadVersion), + ]), + _group('帮助与协议', [ + _row( + Icons.description_outlined, + '用户协议', + action: () => showPublishedAgreements(context, repository: widget.repository), + ), + _row( + Icons.privacy_tip_outlined, + '隐私政策', + action: () => showPublishedAgreements(context, repository: widget.repository), + ), + _row(Icons.menu_book_outlined, '安全内容', action: () => context.push('/contents')), + _row(Icons.person_off_outlined, '注销账号', value: '暂未开放', color: Colors.red), + ]), + if (_notice != null) + Semantics( + liveRegion: true, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text(_notice!), + ), + ), + OutlinedButton( + onPressed: _busy ? null : () => _confirm(true), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + side: const BorderSide(color: Colors.red), + ), + child: const Text('退出登录'), + ), + const SizedBox(height: 16), + Text( + '当前版本 $_version', + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFF777F8D), fontSize: 12), + ), + ], + ), + ); +} diff --git a/apps/user_app/lib/ui/features/shared/record_list_page.dart b/apps/user_app/lib/ui/features/shared/record_list_page.dart index 87dd390..0837131 100644 --- a/apps/user_app/lib/ui/features/shared/record_list_page.dart +++ b/apps/user_app/lib/ui/features/shared/record_list_page.dart @@ -15,6 +15,7 @@ class RecordListPage extends StatefulWidget { this.onRecordTap, this.embedded = false, this.refreshToken = 0, + this.query = '', super.key, }); @@ -27,6 +28,7 @@ class RecordListPage extends StatefulWidget { final ValueChanged? onRecordTap; final bool embedded; final int refreshToken; + final String query; @override State createState() => _RecordListPageState(); @@ -78,6 +80,15 @@ class _RecordListPageState extends State onRetry: state.load, ); } + final records = widget.query.isEmpty + ? state.records + : state.records + .where( + (record) => + record.title.contains(widget.query) || + record.subtitle.contains(widget.query), + ) + .toList(); return RefreshIndicator( onRefresh: state.load, child: CustomScrollView( @@ -103,10 +114,10 @@ class _RecordListPageState extends State ), ), ), - if (state.records.isEmpty) + if (records.isEmpty) const SliverFillRemaining( hasScrollBody: false, - child: EmptyState(title: '暂无记录', description: '服务端还没有可展示的数据'), + child: EmptyState(title: '暂无记录', description: '没有符合条件的记录'), ) else SliverPadding( @@ -116,14 +127,14 @@ class _RecordListPageState extends State padding: EdgeInsets.zero, child: Column( children: [ - for (var index = 0; index < state.records.length; index++) ...[ + for (var index = 0; index < records.length; index++) ...[ RecordCard( - record: state.records[index], + record: records[index], onTap: widget.onRecordTap == null ? null - : () => widget.onRecordTap!(state.records[index]), + : () => widget.onRecordTap!(records[index]), ), - if (index < state.records.length - 1) + if (index < records.length - 1) const Divider(indent: 16, endIndent: 16), ], ], diff --git a/apps/user_app/lib/ui/features/shop/cart_page.dart b/apps/user_app/lib/ui/features/shop/cart_page.dart new file mode 100644 index 0000000..9f4b4f1 --- /dev/null +++ b/apps/user_app/lib/ui/features/shop/cart_page.dart @@ -0,0 +1,518 @@ +// 功能描述:购物车真实数量、勾选、管理删除和多商品结算;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../data/services/api_client.dart'; +import '../../../domain/models/cart_item.dart'; +import '../../../domain/models/client_models.dart'; +import 'shop_page.dart'; +import 'product_recommendations.dart'; + +class CartPage extends StatefulWidget { + const CartPage({required this.repository, super.key}); + final ClientRepository repository; + @override + State createState() => _CartPageState(); +} + +/// 所有控件等待服务端确认;失败后刷新真实状态,禁止乐观伪造成功。 +class _CartPageState extends State { + List _items = []; + final Set _deleting = {}; + bool _loading = true, _changing = false, _managing = false, _recommendationBusy = false; + bool get _busy => _changing || _recommendationBusy; + String? _error; + Map? _relation; + List get _valid => _items.where((i) => i.purchasable).toList(); + List get _selected => _valid.where((i) => i.selected).toList(); + bool get _all => _managing + ? _items.isNotEmpty && _deleting.length == _items.length + : _valid.isNotEmpty && _valid.every((i) => i.selected); + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final results = await Future.wait([ + widget.repository.cart(), + widget.repository.serviceRelation().catchError((_) => null), + ]); + final items = results[0] as List; + if (mounted) { + setState(() { + _items = items; + _relation = results[1] as Map?; + _deleting.removeWhere((id) => !items.any((i) => i.product.identity == id)); + }); + } + } on SessionExpiredException { + // 根路由处理会话失效。 + } catch (e) { + if (mounted) setState(() => _error = e is ApiException ? e.message : '购物车加载失败,请重试'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + /// 批量勾选允许逐项成功;任意失败后重读,明确展示部分完成后的服务端状态。 + Future _change(List items, {int? quantity, bool? selected}) async { + if (_busy) return; + setState(() { + _changing = true; + _error = null; + }); + try { + for (final item in items) { + await widget.repository.setCartItem( + item, + quantity: quantity ?? item.quantity, + selected: selected ?? item.selected, + ); + } + } on SessionExpiredException { + // 不自动重放写请求。 + } catch (e) { + if (mounted) setState(() => _error = e is ApiException ? e.message : '操作结果待确认,请刷新购物车'); + } finally { + if (mounted) { + await _load(); + if (mounted) setState(() => _changing = false); + } + } + } + + Future _delete() async { + final accepted = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('删除选中商品?'), + content: Text('将从购物车移除 ${_deleting.length} 件商品'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('取消')), + TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('删除')), + ], + ), + ); + if (accepted == true && mounted) { + await _change( + _items.where((i) => _deleting.contains(i.product.identity)).toList(), + quantity: 0, + ); + } + } + + Widget _check(bool value, VoidCallback action, String label) => Semantics( + label: label, + child: Checkbox( + shape: const CircleBorder(), + value: value, + onChanged: _busy ? null : (_) => action(), + ), + ); + + /// 未接通的首期能力保留入口,并明确当前状态。 + Future _unavailable(String title) => showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: const Text('该功能暂未开放'), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('知道了'))], + ), + ); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('购物车'), + leading: BackButton(onPressed: () => context.canPop() ? context.pop() : context.go('/shop')), + actions: [ + TextButton( + onPressed: _busy + ? null + : () => setState(() { + _managing = !_managing; + _deleting.clear(); + }), + child: Text(_managing ? '完成' : '管理'), + ), + ], + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: () async { + if (!_busy) { + setState(() => _error = null); + await _load(); + } + }, + child: ListView( + padding: const EdgeInsets.all(16), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + children: [ + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + TextButton( + onPressed: _busy + ? null + : () { + setState(() => _error = null); + _load(); + }, + child: const Text('重新加载'), + ), + ], + ), + ), + if (_items.isEmpty && _error == null) + Padding( + padding: const EdgeInsets.symmetric(vertical: 64), + child: Column( + children: [ + const Icon( + Icons.shopping_cart_outlined, + size: 56, + color: Color(0xFF6B7280), + ), + const SizedBox(height: 16), + const Text('购物车还是空的'), + TextButton(onPressed: () => context.go('/shop'), child: const Text('去逛逛')), + ], + ), + ), + if (_items.isNotEmpty) ...[ + _deliveryCard(), + const SizedBox(height: 12), + ], + if (_items.isNotEmpty) + Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE5E7EB)), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + children: [ + for (var index = 0; index < _items.length; index++) ...[ + if (index > 0) const Divider(height: 1, indent: 12, endIndent: 12), + _item(_items[index]), + ], + ], + ), + ), + if (_items.isNotEmpty) ...[ + const SizedBox(height: 12), + _protectionCard(), + ], + const SizedBox(height: 16), + ProductRecommendationSection( + repository: widget.repository, + source: 'cart', + enabled: !_busy && !_managing, + onCartChanged: _load, + onActivityChanged: (busy) { + if (mounted) setState(() => _recommendationBusy = busy); + }, + ), + ], + ), + ), + bottomNavigationBar: SafeArea( + child: Material( + color: Colors.white, + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 10, 16, 12), + child: LayoutBuilder( + builder: (context, constraints) { + // 窄屏或大字体将金额单独成行,金额数字不可被底栏挤成逐字换行。 + final stacked = + constraints.maxWidth < 335 || MediaQuery.textScalerOf(context).scale(1) > 1.1; + final total = Text( + '合计:${moneyText(_selected.fold(0, (sum, i) => sum + i.amount))}', + textAlign: TextAlign.right, + style: const TextStyle(color: Color(0xFF2563EB), fontWeight: FontWeight.w600), + ); + final amount = Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + total, + const Text('押金暂未开放', style: TextStyle(fontSize: 11, color: Color(0xFF8A94A3))), + ], + ); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (stacked && !_managing) Align(alignment: Alignment.centerRight, child: amount), + Row( + children: [ + _check(_all, () { + if (_managing) { + setState(() { + if (_all) { + _deleting.clear(); + } else { + _deleting.addAll(_items.map((i) => i.product.identity)); + } + }); + } else { + _change(_valid, selected: !_all); + } + }, '全选商品'), + const Text('全选'), + const SizedBox(width: 8), + Expanded( + child: _managing || stacked ? const SizedBox() : amount, + ), + const SizedBox(width: 12), + SizedBox( + width: 124, + child: FilledButton( + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + ), + onPressed: + _busy || + _error != null || + (_managing ? _deleting.isEmpty : _selected.isEmpty) + ? null + : () async { + if (_managing) { + await _delete(); + } else { + await context.push('/cart/checkout'); + if (mounted) await _load(); + } + }, + child: Text( + _busy + ? '处理中' + : _managing + ? '删除(${_deleting.length})' + : '去结算(${_selected.length})', + ), + ), + ), + ], + ), + ], + ); + }, + ), + ), + ), + ), + ); + + Widget _deliveryCard() { + final gasName = _relation?['gas_name']?.toString().trim(); + final deliveryName = _relation?['delivery_name']?.toString().trim(); + return Material( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () => _unavailable('预计送达'), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.local_shipping_outlined, color: Color(0xFF2563EB)), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + (gasName == null || gasName.isEmpty) ? '服务气站暂未配置' : '$gasName 官方配送', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + (deliveryName == null || deliveryName.isEmpty) ? '气站直接服务' : deliveryName, + style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280)), + ), + ], + ), + ), + const Text( + '预计送达\n暂未开放', + textAlign: TextAlign.right, + style: TextStyle(fontSize: 12, color: Color(0xFF6B7280)), + ), + const Icon(Icons.chevron_right, size: 18, color: Color(0xFF9CA3AF)), + ], + ), + ), + ), + ); + } + + Widget _protectionCard() => Container( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 12), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('服务保障', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + Row( + children: [ + _protection(Icons.verified_user_outlined, '正品保障'), + _protection(Icons.home_repair_service_outlined, '专业安装'), + _protection(Icons.support_agent_outlined, '售后服务'), + ], + ), + const SizedBox(height: 8), + const Align( + alignment: Alignment.center, + child: Text('服务标准暂未开放', style: TextStyle(fontSize: 11, color: Color(0xFF8A94A3))), + ), + ], + ), + ); + + Widget _protection(IconData icon, String label) => Expanded( + child: InkWell( + onTap: () => _unavailable(label), + child: Column( + children: [ + Icon(icon, color: const Color(0xFF2563EB)), + const SizedBox(height: 5), + Text(label, style: const TextStyle(fontSize: 12)), + ], + ), + ), + ); + + Widget _item(CartItem item) => Padding( + padding: const EdgeInsets.fromLTRB(0, 16, 12, 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _check( + _managing ? _deleting.contains(item.product.identity) : item.selected, + () { + if (_managing) { + setState(() { + if (!_deleting.add(item.product.identity)) _deleting.remove(item.product.identity); + }); + } else if (item.purchasable || item.selected) { + _change([item], selected: !item.selected); + } + }, + '选择 ${item.product.name}', + ), + SizedBox( + width: 72, + height: 100, + child: DefaultTextStyle.merge( + style: const TextStyle(fontSize: 12), + child: ProductImage(url: item.product.imageUrl), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: _busy + ? null + : () async { + await context.push( + '/products/${Uri.encodeComponent(item.product.identity)}', + ); + if (mounted) await _load(); + }, + child: Row( + children: [ + Expanded( + child: Text( + item.product.name, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + const Icon(Icons.chevron_right, size: 18), + ], + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + if (item.product.category.isNotEmpty) _tag(item.product.category), + _tag(item.specification.isEmpty ? '规格暂未配置' : item.specification), + ], + ), + const SizedBox(height: 5), + const Text('押金暂未开放', style: TextStyle(fontSize: 11, color: Color(0xFF8A94A3))), + if (!item.purchasable) + Text( + item.available ? '库存不足,请调整数量' : '商品已失效', + style: const TextStyle(color: Color(0xFFC7352A)), + ), + Text( + moneyText(item.product.price), + style: const TextStyle( + color: Color(0xFF2563EB), + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + tooltip: '减少 ${item.product.name}', + onPressed: _busy || item.quantity <= 1 + ? null + : () => _change([item], quantity: item.quantity - 1), + icon: const Icon(Icons.remove), + ), + Text('${item.quantity}'), + IconButton( + tooltip: '增加 ${item.product.name}', + onPressed: + _busy || + !item.available || + item.quantity >= item.product.stock || + item.quantity >= 999 + ? null + : () => _change([item], quantity: item.quantity + 1), + icon: const Icon(Icons.add), + ), + ], + ), + ], + ), + ), + ], + ), + ); + + Widget _tag(String text) => Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFF3F4F6), + borderRadius: BorderRadius.circular(4), + ), + child: Text(text, style: const TextStyle(fontSize: 11, color: Color(0xFF6B7280))), + ); +} diff --git a/apps/user_app/lib/ui/features/shop/checkout_page.dart b/apps/user_app/lib/ui/features/shop/checkout_page.dart new file mode 100644 index 0000000..5e6bfc5 --- /dev/null +++ b/apps/user_app/lib/ui/features/shop/checkout_page.dart @@ -0,0 +1,572 @@ +// 功能描述:提交订单页面,管理地址、数量、备注及确认金额,网络重试保留订单请求。 +// 版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:uuid/uuid.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../data/repositories/primary_repository.dart'; +import '../../../data/services/api_client.dart'; +import '../../../domain/models/primary_models.dart'; +import '../../../domain/models/shipping_address.dart'; +import '../../../domain/models/client_models.dart'; +import '../../../domain/models/cart_item.dart'; +import 'shop_page.dart'; + +/// 通过公开标识重读商品,支持登录回跳和页面重新打开。 +class CheckoutPage extends StatefulWidget { + const CheckoutPage({ + required this.repository, + required this.productIdentity, + this.initialQuantity = 1, + this.fromCart = false, + super.key, + }); + final int initialQuantity; + final bool fromCart; + final ClientRepository repository; + final String productIdentity; + @override + State createState() => _CheckoutPageState(); +} + +class _CheckoutPageState extends State { + ProductSummary? _product; + List _cartItems = []; + ShippingAddress? _address; + WalletSummary? _wallet; + String _stationName = ''; + final _remark = TextEditingController(); + final _request = const Uuid().v7(); + int _quantity = 1; + bool _loading = true, _saving = false, _attempted = false; + String? _error; + int get _amount => widget.fromCart + ? _cartItems.fold(0, (sum, item) => sum + item.amount) + : (_product?.price ?? 0) * _quantity; + @override + void initState() { + super.initState(); + _quantity = widget.initialQuantity.clamp(1, 999); + _load(); + } + + @override + void dispose() { + _remark.dispose(); + super.dispose(); + } + + /// 首次读取默认地址;刷新价格时保持已经选择的收货人和数量。 + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + try { + ProductSummary? found; + if (widget.fromCart) { + _cartItems = (await widget.repository.cart()) + .where((i) => i.selected && i.quantity > 0) + .toList(); + if (_cartItems.any((i) => !i.purchasable)) { + _product = null; + throw const ApiException(2402, '购物车中有失效或库存不足的商品,请返回调整'); + } + found = _cartItems.firstOrNull?.product; + } else { + final products = await PrimaryRepository(widget.repository).products(); + found = products.where((p) => p.identity == widget.productIdentity).firstOrNull; + } + if (found == null) { + if (mounted) setState(() => _product = null); + throw const ApiException(1112, '商品已下架或暂时无货'); + } + final addresses = await widget.repository.shippingAddresses(); + WalletSummary? wallet; + Map? relation; + try { + wallet = await widget.repository.wallet(); + } catch (_) { + // 钱包读取失败不阻断下单,支付页会再次校验。 + } + try { + relation = await widget.repository.serviceRelation(); + } catch (_) { + // 服务归属只用于页面展示,服务端仍保持下单边界。 + } + if (!mounted) return; + setState(() { + _product = found; + _wallet = wallet; + _stationName = relation?['gas_name'] as String? ?? ''; + _address ??= addresses + .where((a) => a.isDefault && a.contactName.isNotEmpty && a.contactPhone.isNotEmpty) + .firstOrNull; + }); + } catch (error) { + if (mounted) setState(() => _error = error is ApiException ? error.message : '加载失败,请重试'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _chooseAddress() async { + final selected = await context.push('/addresses?select=1'); + if (mounted && selected != null) setState(() => _address = selected); + } + + /// 首次尝试后冻结请求内容,结果未知时重试不会变成另一笔订单。 + Future _submit() async { + if (_saving || _loading || _product == null) return; + if (_address == null) { + await _chooseAddress(); + return; + } + if (!widget.fromCart && _quantity > _product!.stock) { + setState(() => _error = '商品库存不足,请调整数量'); + return; + } + setState(() { + _saving = true; + _attempted = true; + _error = null; + }); + try { + late final Map result; + if (widget.fromCart) { + result = await widget.repository.submitCartOrder( + requestNo: _request, + items: _cartItems, + address: _address!, + expectedAmount: _amount, + remark: _remark.text, + ); + } else { + result = await widget.repository.submitShopOrder( + requestNo: _request, + productIdentity: _product!.identity, + address: _address!, + quantity: _quantity, + expectedAmount: _amount, + remark: _remark.text, + ); + } + final identity = result['identity']; + if (identity is! String || identity.isEmpty) { + throw const ApiException(500, '订单已提交,但返回的订单标识无效'); + } + if (mounted) { + context.go('/payment/shop/${Uri.encodeComponent(identity)}'); + } + } on SessionExpiredException { + // 根路由负责重新登录,禁止自动重放写入。 + } on ApiException catch (error) { + if (!mounted) return; + if (error.code == 2401 || error.code == 2402 || error.code == 2403) { + setState(() => _attempted = false); + await _load(); + } + if ([1704, 1711, 1112].contains(error.code)) setState(() => _attempted = false); + if (mounted) setState(() => _error = error.message); + } catch (_) { + if (mounted) setState(() => _error = '提交结果尚未确认,请查看订单或重试'); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('提交订单'), + leading: BackButton( + onPressed: () => context.canPop() + ? context.pop() + : context.go( + widget.fromCart + ? '/cart' + : '/products/${Uri.encodeComponent(widget.productIdentity)}', + ), + ), + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 18), + children: [ + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + if (_product == null) + Center( + child: TextButton(onPressed: _load, child: const Text('重新加载')), + ), + if (_product case final product?) ...[ + _addressSection(), + _deliverySection(), + _productSection(product), + _feeSection(), + _serviceSection(), + _paymentSection(), + TextButton( + onPressed: _saving ? null : () => context.go('/orders?tab=shop'), + child: const Text('查看订单'), + ), + ], + ], + ), + bottomNavigationBar: SafeArea( + minimum: const EdgeInsets.fromLTRB(16, 10, 16, 16), + child: Row( + children: [ + Expanded( + child: Text( + '应付: ${moneyText(_amount)}', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 148, + child: FilledButton( + onPressed: _loading || _saving || _product == null ? null : _submit, + child: Text( + _saving + ? '提交中…' + : _attempted + ? '重试提交' + : '提交订单', + ), + ), + ), + ], + ), + ), + ); + + Widget _addressSection() => _section( + InkWell( + onTap: _attempted ? null : _chooseAddress, + child: Row( + children: [ + const Icon(Icons.location_on_outlined, color: Color(0xFF2563EB), size: 28), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _address == null + ? '选择收货地址' + : '${_address!.contactName} ${_address!.maskedPhone}', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 3), + Text( + _address == null ? '下单前需确认收货人' : _address!.address.replaceAll('\n', ' '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFF4B5563)), + ), + ], + ), + ), + const Icon(Icons.chevron_right), + ], + ), + ), + ); + + Widget _deliverySection() => _section( + InkWell( + onTap: () => _showAvailability( + '配送时间暂未开放', + '当前订单暂不支持选择预约时段,客服会按实际履约情况联系。', + ), + child: const Row( + children: [ + Icon(Icons.schedule_outlined, color: Color(0xFF2563EB), size: 27), + SizedBox(width: 12), + Text('配送时间', style: TextStyle(fontWeight: FontWeight.w600)), + Spacer(), + Text('暂未开放', style: TextStyle(color: Color(0xFF777F8D))), + Icon(Icons.chevron_right), + ], + ), + ), + ); + + Widget _productSection(ProductSummary product) => _section( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_stationName.isNotEmpty) ...[ + Text(_stationName, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + const SizedBox(height: 2), + ], + if (widget.fromCart) + for (final item in _cartItems) + Padding( + padding: EdgeInsets.zero, + child: _productRow( + item.product, + item.quantity, + item.amount, + ), + ) + else ...[ + _productRow(product, _quantity, _amount), + const SizedBox(height: 4), + Row( + children: [ + const Text('购买数量'), + const Spacer(), + IconButton( + tooltip: '减少数量', + visualDensity: VisualDensity.compact, + onPressed: _attempted || _quantity <= 1 ? null : () => setState(() => _quantity--), + icon: const Icon(Icons.remove), + ), + Text('$_quantity', key: const Key('checkout-quantity')), + IconButton( + tooltip: '增加数量', + visualDensity: VisualDensity.compact, + onPressed: _attempted || _quantity >= product.stock || _quantity >= 999 + ? null + : () => setState(() => _quantity++), + icon: const Icon(Icons.add), + ), + ], + ), + ], + ], + ), + ); + + Widget _productRow(ProductSummary product, int quantity, int amount) => Row( + children: [ + SizedBox(width: 48, height: 52, child: ProductImage(url: product.imageUrl)), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(product.name, style: const TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Text('${product.category.isEmpty ? '商品' : product.category} ×$quantity'), + ], + ), + ), + Text(moneyText(amount)), + ], + ); + + Widget _feeSection() => _section( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('费用明细', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), + const SizedBox(height: 6), + _valueRow('商品金额', moneyText(_amount)), + _valueRow( + '气瓶押金', + '暂未开放', + labelIcon: Icons.info_outline, + valueColor: const Color(0xFF777F8D), + onTap: () => _showAvailability( + '气瓶押金暂未开放', + '当前商品和订单接口未返回押金规则,不会在客户端伪造押金金额。', + ), + ), + _valueRow('配送费', moneyText(0)), + _valueRow( + '优惠', + '暂未开放', + valueColor: const Color(0xFFC7352A), + onTap: () => _showAvailability( + '优惠暂未开放', + '优惠券与活动价接口尚未接入,当前应付金额只使用服务端商品价格。', + ), + ), + const Divider(height: 14), + _valueRow('合计', moneyText(_amount), strong: true), + const SizedBox(height: 4), + const Text( + '押金规则接入后,将以服务端订单金额为准。', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: Color(0xFF777F8D), fontSize: 11), + ), + ], + ), + ); + + Widget _serviceSection() => _section( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('订单服务', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), + _serviceRow( + icon: Icons.receipt_long_outlined, + title: '电子发票', + trailing: '即将开放', + onTap: () => _showAvailability( + '电子发票即将开放', + '电子发票已明确属于后续版本,当前订单暂无法申请开票。', + ), + ), + const Divider(height: 1), + _serviceRow( + icon: Icons.edit_note_outlined, + title: '配送备注', + trailing: _remark.text.trim().isEmpty ? '请提前电话联系' : '已填写', + chevron: true, + onTap: _attempted ? null : _editRemark, + ), + ], + ), + ); + + Widget _serviceRow({ + required IconData icon, + required String title, + required String trailing, + required VoidCallback? onTap, + bool chevron = false, + }) => InkWell( + onTap: onTap, + child: SizedBox( + height: 40, + child: Row( + children: [ + Icon(icon, color: const Color(0xFF2563EB), size: 22), + const SizedBox(width: 12), + Text(title), + const Spacer(), + Flexible( + child: Text( + trailing, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFF777F8D)), + ), + ), + if (chevron) const Icon(Icons.chevron_right), + ], + ), + ), + ); + + Widget _paymentSection() => _section( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('支付方式', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), + ListTile( + dense: true, + visualDensity: const VisualDensity(vertical: -4), + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.account_balance_wallet_outlined, color: Color(0xFF2563EB)), + title: const Text('提交后选择'), + subtitle: Text( + _wallet == null ? '支付页会重新读取余额' : '钱包余额 ${moneyText(_wallet!.balance)}', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showAvailability( + '提交后选择支付方式', + '订单创建成功后会直接进入支付确认页,可选择余额、微信或支付宝。', + ), + ), + ], + ), + ); + + Widget _valueRow( + String label, + String value, { + bool strong = false, + IconData? labelIcon, + Color? valueColor, + VoidCallback? onTap, + }) => InkWell( + onTap: onTap, + child: Padding( + padding: EdgeInsets.zero, + child: Row( + children: [ + Text(label, style: TextStyle(fontWeight: strong ? FontWeight.w700 : FontWeight.w400)), + if (labelIcon != null) ...[ + const SizedBox(width: 5), + Icon(labelIcon, size: 17, color: const Color(0xFF777F8D)), + ], + const Spacer(), + Text( + value, + style: TextStyle( + color: valueColor ?? (strong ? const Color(0xFF2563EB) : null), + fontSize: strong ? 18 : null, + fontWeight: strong ? FontWeight.w700 : FontWeight.w400, + ), + ), + ], + ), + ), + ); + + Future _showAvailability(String title, String message) => showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('知道了'))], + ), + ); + + Future _editRemark() async { + final controller = TextEditingController(text: _remark.text); + final value = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('配送备注'), + content: TextField( + key: const Key('checkout-remark'), + controller: controller, + autofocus: true, + maxLength: 2000, + minLines: 2, + maxLines: 4, + decoration: const InputDecoration(hintText: '例如:请提前电话联系'), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')), + FilledButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + child: const Text('确定'), + ), + ], + ), + ); + controller.dispose(); + if (mounted && value != null) setState(() => _remark.text = value); + } + + Widget _section(Widget child) => Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE5E7EB)), + borderRadius: BorderRadius.circular(10), + ), + child: Material(type: MaterialType.transparency, child: child), + ); +} diff --git a/apps/user_app/lib/ui/features/shop/favorite_button.dart b/apps/user_app/lib/ui/features/shop/favorite_button.dart new file mode 100644 index 0000000..5e07840 --- /dev/null +++ b/apps/user_app/lib/ui/features/shop/favorite_button.dart @@ -0,0 +1,152 @@ +// 功能描述:各商品入口共用的真实收藏按钮与跨页面状态同步;版本:1.0.0。 +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../data/services/api_client.dart'; +import '../../../domain/models/product_favorite.dart'; + +/// 从服务端读取初始状态;游客登录回跳不自动执行收藏写入。 +class FavoriteButton extends StatefulWidget { + const FavoriteButton({ + required this.repository, + required this.identity, + required this.redirect, + this.authenticated = true, + this.name = '', + super.key, + }); + final ClientRepository repository; + final String identity, redirect, name; + final bool authenticated; + @override + State createState() => _FavoriteButtonState(); +} + +class _FavoriteButtonState extends State { + ProductFavoriteState? _state; + bool _busy = false; + int _generation = 0; + String? _error; + StreamSubscription? _subscription; + @override + void initState() { + super.initState(); + _connect(); + } + + void _connect() { + _subscription = widget.repository.favoriteChanges.listen((value) { + if (mounted && widget.authenticated && value.productIdentity == widget.identity) { + _generation++; + setState(() { + _state = value; + _busy = false; + _error = null; + }); + } + }); + if (widget.authenticated) _load(); + } + + @override + void didUpdateWidget(covariant FavoriteButton old) { + super.didUpdateWidget(old); + if (old.identity != widget.identity || + old.authenticated != widget.authenticated || + old.repository != widget.repository) { + _generation++; + _subscription?.cancel(); + _state = null; + _error = null; + _busy = false; + _connect(); + } + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + /// 重新读取后再允许操作,不依据旧图标猜测服务器状态。 + Future _load() async { + final identity = widget.identity; + final generation = ++_generation; + setState(() => _busy = true); + try { + final value = await widget.repository.favoriteState(identity); + if (mounted && + generation == _generation && + widget.identity == identity && + widget.authenticated) { + setState(() { + _state = value; + _error = null; + }); + } + } on SessionExpiredException { + // 根路由负责登录失效。 + } catch (e) { + if (mounted && generation == _generation) { + setState(() => _error = e is ApiException ? e.message : '收藏状态读取失败'); + } + } finally { + if (mounted && generation == _generation) setState(() => _busy = false); + } + } + + Future _tap() async { + if (!widget.authenticated) { + context.go('/login?redirect=${Uri.encodeComponent(widget.redirect)}'); + return; + } + if (_state == null || _error != null) { + await _load(); + return; + } + final state = _state!; + final generation = _generation; + setState(() => _busy = true); + try { + final value = await widget.repository.setFavorite(state, !state.active); + if (mounted && generation == _generation && widget.authenticated) { + setState(() => _state = value); + } + } on SessionExpiredException { + // 禁止自动重放写入。 + } catch (e) { + if (mounted && generation == _generation) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(16, 0, 16, 120), + content: Text(e is ApiException ? e.message : '收藏结果待确认,请刷新'), + ), + ); + await _load(); + } + } finally { + if (mounted && generation == _generation) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) => IconButton( + tooltip: _error != null + ? '重试读取收藏状态' + : _state?.active == true + ? '取消收藏 ${widget.name}' + : '收藏 ${widget.name}', + onPressed: _busy ? null : _tap, + icon: Icon( + _error != null + ? Icons.refresh + : _state?.active == true + ? Icons.favorite + : Icons.favorite_border, + color: _state?.active == true ? const Color(0xFF2563EB) : null, + ), + ); +} diff --git a/apps/user_app/lib/ui/features/shop/favorites_page.dart b/apps/user_app/lib/ui/features/shop/favorites_page.dart new file mode 100644 index 0000000..d9e7faa --- /dev/null +++ b/apps/user_app/lib/ui/features/shop/favorites_page.dart @@ -0,0 +1,365 @@ +// 功能描述:收藏分类、下架保留、取消收藏及真实加购;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../data/services/api_client.dart'; +import '../../../domain/models/product_favorite.dart'; +import '../../../domain/models/cart_item.dart'; +import '../../../domain/models/client_models.dart'; +import '../../core/async_content.dart'; +import 'shop_page.dart'; +import 'product_recommendations.dart'; + +class FavoritesPage extends StatefulWidget { + const FavoritesPage({required this.repository, super.key}); + final ClientRepository repository; + @override + State createState() => _FavoritesPageState(); +} + +/// 收藏和购物车独立维护,取消收藏不删除已加入的购物车商品。 +class _FavoritesPageState extends State { + final _content = GlobalKey>>(); + final _selected = {}; + String? _category; + bool _managing = false, _busy = false; + final _pending = {}; + + Future> _load() async { + final list = await widget.repository.favorites(); + _selected.removeWhere((id) => !list.any((i) => i.product.identity == id)); + if (_category != null && !list.any((i) => i.product.category == _category)) _category = null; + return list; + } + + void _notice(String value) => + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(value))); + + /// 每次失败后重读,批量取消的局部成功只以服务端当前列表展示。 + Future _remove(List items, {bool confirm = false}) async { + if (_busy || items.isEmpty) return; + if (confirm) { + final accepted = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('取消选中收藏?'), + content: Text('将取消 ${items.length} 件商品的收藏'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('保留')), + TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('取消收藏')), + ], + ), + ); + if (accepted != true || !mounted) return; + } + setState(() => _busy = true); + try { + for (final item in items) { + await widget.repository.setFavorite(item.state, false); + } + } on SessionExpiredException { + // 会话失效由路由处理。 + } catch (e) { + if (mounted) _notice(e is ApiException ? e.message : '取消结果待确认,请刷新收藏'); + } finally { + if (mounted) { + await _content.currentState?.refresh(); + if (mounted) setState(() => _busy = false); + } + } + } + + /// 保留结果未知时的原加购目标,重试不得读取新数量后重复累加。 + Future _add(ProductFavorite item) async { + if (_busy || !item.available) return; + setState(() => _busy = true); + final id = item.product.identity; + try { + if (!_pending.containsKey(id)) { + final cart = await widget.repository.cartItem(id); + if (cart.quantity + 1 > cart.product.stock || cart.quantity >= 999) { + throw const ApiException(2402, '购物车数量已达库存上限'); + } + _pending[id] = (item: cart, quantity: cart.quantity + 1); + } + final target = _pending[id]!; + await widget.repository.setCartItem(target.item, quantity: target.quantity, selected: true); + _pending.remove(id); + if (mounted) _notice('已加入购物车'); + } on SessionExpiredException { + // 不自动重放写入。 + } catch (e) { + if (e is ApiException && [2402, 2403, 1704, 1711, 1112].contains(e.code)) _pending.remove(id); + if (mounted) _notice(e is ApiException ? e.message : '加入结果待确认,请重试或查看购物车'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('我的收藏'), + leading: BackButton(onPressed: () => context.canPop() ? context.pop() : context.go('/me')), + actions: [ + TextButton( + onPressed: _busy + ? null + : () => setState(() { + _managing = !_managing; + _selected.clear(); + }), + child: Text(_managing ? '完成' : '管理'), + ), + ], + ), + body: AsyncContent>( + key: _content, + load: _load, + builder: (context, list) { + final categories = list.map((i) => i.product.category).toSet(); + final shown = list + .where((i) => _category == null || i.product.category == _category) + .toList(); + return ListView( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 24), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _filter('全部 ${list.length}', null), + for (final category in categories) + _filter( + '${category.isEmpty ? '未分类' : category} ${list.where((i) => i.product.category == category).length}', + category, + ), + ], + ), + ), + const SizedBox(height: 14), + if (list.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 64), + child: Column( + children: [ + const Icon(Icons.favorite_border, size: 56, color: Color(0xFF6B7280)), + const SizedBox(height: 16), + const Text('暂无收藏商品'), + TextButton(onPressed: () => context.go('/shop'), child: const Text('去商城逛逛')), + ], + ), + ), + for (final item in shown) _card(item), + if (_managing && list.isNotEmpty) + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Checkbox( + value: + shown.isNotEmpty && + shown.every((i) => _selected.contains(i.product.identity)), + onChanged: _busy + ? null + : (value) => setState(() { + if (value == true) { + _selected.addAll(shown.map((i) => i.product.identity)); + } else { + _selected.removeAll(shown.map((i) => i.product.identity)); + } + }), + ), + const Text('全选当前分类'), + TextButton( + onPressed: _busy || _selected.isEmpty + ? null + : () => _remove( + list.where((i) => _selected.contains(i.product.identity)).toList(), + confirm: true, + ), + child: Text('取消收藏(${_selected.length})'), + ), + ], + ), + const SizedBox(height: 12), + ListTile( + tileColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + leading: const Icon(Icons.favorite, color: Color(0xFF2563EB)), + title: const Text('猜你喜欢'), + subtitle: const Text('发现更多优质商品'), + trailing: const Icon(Icons.chevron_right), + onTap: _busy + ? null + : () async { + await showFavoriteRecommendations(context, widget.repository); + if (mounted) await _content.currentState?.refresh(); + }, + ), + ], + ); + }, + ), + ); + + Widget _filter(String label, String? value) => Padding( + padding: const EdgeInsets.only(right: 10), + child: ChoiceChip( + label: Text(label), + shape: const StadiumBorder(), + selectedColor: const Color(0xFF2563EB), + backgroundColor: Colors.white, + labelStyle: TextStyle( + fontSize: 15, + color: _category == value ? Colors.white : const Color(0xFF111827), + ), + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), + selected: _category == value, + showCheckmark: false, + onSelected: _busy ? null : (_) => setState(() => _category = value), + ), + ); + + Widget _card(ProductFavorite item) => Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.fromLTRB(10, 7, 10, 7), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE5E7EB)), + borderRadius: BorderRadius.circular(10), + ), + child: Stack( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (_managing) + Checkbox( + value: _selected.contains(item.product.identity), + onChanged: _busy + ? null + : (value) => setState(() { + if (value == true) { + _selected.add(item.product.identity); + } else { + _selected.remove(item.product.identity); + } + }), + ), + SizedBox( + width: _managing ? 62 : 92, + height: 84, + child: ProductImage(url: item.product.imageUrl), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(right: _managing ? 0 : 38), + child: InkWell( + onTap: _busy + ? null + : () async { + await context.push( + '/products/${Uri.encodeComponent(item.product.identity)}', + ); + if (mounted) await _content.currentState?.refresh(); + }, + child: Text( + item.product.name, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFF3F4F6), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + item.specifications.isEmpty ? '规格暂未配置' : item.specifications.join(' · '), + style: const TextStyle(fontSize: 11, color: Color(0xFF6B7280)), + ), + ), + const SizedBox(height: 7), + _purchase(item), + ], + ), + ), + ], + ), + if (!_managing) + Positioned( + right: 0, + top: 0, + child: IconButton( + constraints: const BoxConstraints.tightFor(width: 40, height: 40), + padding: EdgeInsets.zero, + tooltip: '取消收藏 ${item.product.name}', + onPressed: _busy ? null : () => _remove([item]), + icon: Icon( + item.available ? Icons.favorite : Icons.favorite_border, + color: item.available ? const Color(0xFF2563EB) : const Color(0xFF6B7280), + ), + ), + ), + ], + ), + ); + + /// 常规手机宽度对齐价格与加购按钮;窄屏或大字体自然换行,保留可读性。 + Widget _purchase(ProductFavorite item) => LayoutBuilder( + builder: (context, constraints) { + final price = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + moneyText(item.product.price), + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: item.available ? const Color(0xFF2563EB) : const Color(0xFF6B7280), + ), + ), + if (!item.available) const Text('暂不可售', style: TextStyle(color: Color(0xFF6B7280))), + if (item.available) + const Text( + '月售暂未开放', + style: TextStyle(fontSize: 12, color: Color(0xFF8A94A3)), + ), + ], + ); + final action = item.available + ? OutlinedButton.icon( + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 36), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + ), + onPressed: _busy ? null : () => _add(item), + icon: const Icon(Icons.add_shopping_cart, size: 18), + label: Text(_pending.containsKey(item.product.identity) ? '重试加入' : '加入购物车'), + ) + : TextButton(onPressed: _busy ? null : () => _remove([item]), child: const Text('删除')); + if (constraints.maxWidth >= 180 && MediaQuery.textScalerOf(context).scale(1) <= 1.1) { + return Row( + children: [ + Expanded(child: price), + const SizedBox(width: 8), + action, + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + price, + Align(alignment: Alignment.centerRight, child: action), + ], + ); + }, + ); +} diff --git a/apps/user_app/lib/ui/features/shop/product_detail_page.dart b/apps/user_app/lib/ui/features/shop/product_detail_page.dart new file mode 100644 index 0000000..98c0b00 --- /dev/null +++ b/apps/user_app/lib/ui/features/shop/product_detail_page.dart @@ -0,0 +1,433 @@ +// 功能描述:真实商品详情、图片翻页预览、参数和数量选择;购买前转入独立结算。 +// 版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/product_detail.dart'; +import '../../../domain/models/cart_item.dart'; +import '../../../data/services/api_client.dart'; +import '../../../domain/models/client_models.dart'; +import '../../core/async_content.dart'; +import '../../core/feature_entry.dart'; +import 'shop_page.dart'; +import 'favorite_button.dart'; + +class ProductDetailPage extends StatefulWidget { + const ProductDetailPage({ + required this.repository, + required this.identity, + this.authenticated = true, + super.key, + }); + final bool authenticated; + final ClientRepository repository; + final String identity; + @override + State createState() => _ProductDetailPageState(); +} + +/// 公开详情不自动登录和下单;受保护结算路由保留商品与数量供登录回跳。 +class _ProductDetailPageState extends State { + final _content = GlobalKey>(); + int _quantity = 1, _picture = 0; + bool _opening = false; + String _stationName = ''; + CartItem? _pendingCart; + int _pendingQuantity = 0; + + /// 未知结果保留同一目标数量;重试不重新读取后再次累加。 + Future _add() async { + if (_opening) return; + if (!widget.authenticated) { + context.go( + '/login?redirect=${Uri.encodeComponent('/products/${Uri.encodeComponent(widget.identity)}')}', + ); + return; + } + setState(() => _opening = true); + try { + if (_pendingCart == null) { + final item = await widget.repository.cartItem(widget.identity); + final target = item.quantity + _quantity; + if (target > 999 || target > item.product.stock) { + throw const ApiException(2402, '加入后的数量超过库存'); + } + _pendingCart = item; + _pendingQuantity = target; + } + await widget.repository.setCartItem( + _pendingCart!, + quantity: _pendingQuantity, + selected: true, + ); + _pendingCart = null; + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.fromLTRB(16, 0, 16, 120), + content: Text('已加入购物车'), + ), + ); + } + } on SessionExpiredException { + _pendingCart = null; + } catch (e) { + if (e is ApiException && [2402, 2403, 1704, 1711, 1112].contains(e.code)) _pendingCart = null; + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(16, 0, 16, 120), + content: Text(e is ApiException ? e.message : '加入结果待确认,请重试或查看购物车'), + ), + ); + } + } finally { + if (mounted) setState(() => _opening = false); + } + } + + Future _buy(ProductDetail product) async { + if (_opening || product.stock < 1) return; + if (!widget.authenticated) { + context.go( + '/login?redirect=${Uri.encodeComponent('/checkout/${Uri.encodeComponent(product.identity)}?quantity=$_quantity')}', + ); + return; + } + setState(() => _opening = true); + try { + await context.push('/checkout/${Uri.encodeComponent(product.identity)}?quantity=$_quantity'); + if (mounted) await _content.currentState?.refresh(); + } finally { + if (mounted) setState(() => _opening = false); + } + } + + Future _load() async { + final detail = await widget.repository.productDetail(widget.identity); + if (widget.authenticated) { + try { + final relation = await widget.repository.serviceRelation(); + if (mounted) _stationName = relation?['gas_name']?.toString().trim() ?? ''; + } catch (_) { + // 商品公开详情不因用户服务归属读取失败而整体失败。 + } + } + _quantity = _quantity.clamp(1, detail.stock.clamp(1, 999)); + _picture = 0; + return detail; + } + + Future _share() async { + await Clipboard.setData(ClipboardData(text: Uri.base.toString())); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('商品链接已复制'))); + } + } + + Future _pending(String title, String message) => showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('知道了')), + ], + ), + ); + + Widget _card(List children) => Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE2E5EC)), + borderRadius: BorderRadius.circular(8), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children), + ); + + /// 原始商品图支持缩放,加载失败仍提供明确状态。 + void _preview(String url) => showDialog( + context: context, + builder: (context) => Dialog( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Align( + alignment: Alignment.centerRight, + child: IconButton( + tooltip: '关闭图片', + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close), + ), + ), + Flexible( + child: InteractiveViewer(child: ProductImage(url: url)), + ), + ], + ), + ), + ); + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('商品详情'), + leading: BackButton(onPressed: () => context.canPop() ? context.pop() : context.go('/shop')), + actions: [ + IconButton(tooltip: '分享商品', onPressed: _share, icon: const Icon(Icons.ios_share_outlined)), + FavoriteButton( + repository: widget.repository, + identity: widget.identity, + redirect: '/products/${Uri.encodeComponent(widget.identity)}', + authenticated: widget.authenticated, + name: '商品', + ), + ], + ), + body: AsyncContent( + key: _content, + load: _load, + builder: (context, product) => Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(14, 8, 14, 8), + children: [ + Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE2E5EC)), + ), + child: AspectRatio( + aspectRatio: 1.5, + child: product.images.isEmpty + ? const Center(child: Text('暂无商品图片')) + : Stack( + children: [ + PageView.builder( + key: ValueKey(product.images.join('|')), + itemCount: product.images.length, + onPageChanged: (value) => setState(() => _picture = value), + itemBuilder: (_, index) => InkWell( + onTap: () => _preview(product.images[index]), + child: ProductImage(url: product.images[index]), + ), + ), + if (product.images.length > 1) + Positioned( + right: 12, + bottom: 8, + child: Text( + '${_picture + 1}/${product.images.length}', + style: const TextStyle(backgroundColor: Colors.white), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + _card([ + Text( + product.name, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + if (product.category.isNotEmpty) ...[ + const SizedBox(height: 3), + Text(product.category, style: const TextStyle(color: Color(0xFF7D8492))), + ], + const SizedBox(height: 7), + Row( + children: [ + Expanded( + child: Text( + moneyText(product.price), + style: const TextStyle( + fontSize: 23, + fontWeight: FontWeight.w600, + color: Color(0xFF1762F4), + ), + ), + ), + Text(product.stock == 0 ? '暂时售罄' : '库存 ${product.stock}'), + ], + ), + ]), + _card([ + const Text('规格', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 40), + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + onPressed: null, + child: Text( + product.attributes + .where((item) => item.name == '规格') + .map((item) => item.value) + .firstOrNull ?? + '暂未配置', + ), + ), + ), + const SizedBox(width: 8), + IconButton( + tooltip: '减少数量', + visualDensity: VisualDensity.compact, + onPressed: _quantity > 1 && !_opening + ? () => setState(() => _quantity--) + : null, + icon: const Icon(Icons.remove_circle_outline), + ), + SizedBox(width: 24, child: Text('$_quantity', textAlign: TextAlign.center)), + IconButton( + tooltip: '增加数量', + visualDensity: VisualDensity.compact, + onPressed: _quantity < product.stock && _quantity < 999 && !_opening + ? () => setState(() => _quantity++) + : null, + icon: const Icon(Icons.add_circle_outline), + ), + ], + ), + ]), + _card([ + InkWell( + onTap: () => _pending('配送服务暂未开放', '预约送达时间和配送前联系能力尚未接入用户端。'), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.local_shipping_outlined, color: Color(0xFF1762F4)), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '配送服务', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 5), + Text('配送至 ${_stationName.isEmpty ? '服务范围待确认' : '$_stationName服务范围'}'), + const Text('预计送达 暂未开放', style: TextStyle(color: Color(0xFF7D8492))), + const Text('配送前联系 暂未开放', style: TextStyle(color: Color(0xFF7D8492))), + ], + ), + ), + const Icon(Icons.chevron_right, color: Color(0xFF7D8492)), + ], + ), + ), + ]), + _card([ + Row( + children: [ + _promise(Icons.verified_user_outlined, '正规气源'), + _promise(Icons.fact_check_outlined, '气瓶检测'), + _promise(Icons.handyman_outlined, '入户安装'), + _promise(Icons.health_and_safety_outlined, '售后保障'), + ], + ), + ]), + _card([ + const Text('商品说明', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + if (product.attributes.where((item) => item.name != '规格').isEmpty) + const Text('商品参数暂未配置', style: TextStyle(color: Color(0xFF7D8492))), + for (final attribute in product.attributes.where((item) => item.name != '规格')) + Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Text('• ${attribute.name}:${attribute.value}'), + ), + ]), + ], + ), + ), + Material( + color: Colors.white, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + child: Row( + children: [ + _bottomIcon( + Icons.headset_mic_outlined, + '客服', + () => showUnavailableFeature(context, '商品客服'), + ), + _bottomIcon( + Icons.shopping_cart_outlined, + '购物车', + () => widget.authenticated + ? context.push('/cart') + : context.go('/login?redirect=%2Fcart'), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 12), + ), + onPressed: product.stock < 1 || _opening ? null : _add, + child: Text(_pendingCart == null ? '加入购物车' : '重试加入'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton( + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 12), + ), + onPressed: product.stock < 1 || _opening ? null : () => _buy(product), + child: Text(product.stock < 1 ? '暂时售罄' : '立即购买'), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + + Widget _promise(IconData icon, String label) => Expanded( + child: InkWell( + onTap: () => _pending('$label暂未开放', '$label服务标准尚未完成后台配置。'), + child: Column( + children: [ + Icon(icon, color: const Color(0xFF1762F4), size: 25), + const SizedBox(height: 4), + Text(label, textAlign: TextAlign.center, style: const TextStyle(fontSize: 12)), + ], + ), + ), + ); + + Widget _bottomIcon(IconData icon, String label, VoidCallback onTap) => SizedBox( + width: 48, + child: InkWell( + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 22), + const SizedBox(height: 2), + Text(label, style: const TextStyle(fontSize: 11)), + ], + ), + ), + ); +} diff --git a/apps/user_app/lib/ui/features/shop/product_recommendations.dart b/apps/user_app/lib/ui/features/shop/product_recommendations.dart new file mode 100644 index 0000000..9d8531a --- /dev/null +++ b/apps/user_app/lib/ui/features/shop/product_recommendations.dart @@ -0,0 +1,283 @@ +// 功能描述:真实推荐商品、换一批、详情及条件加购;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../data/services/api_client.dart'; +import '../../../domain/models/cart_item.dart'; +import '../../../domain/models/client_models.dart'; +import '../../../domain/models/primary_models.dart'; +import '../../../domain/models/product_recommendations.dart'; +import 'shop_page.dart'; + +/// 内嵌推荐区,失败不阻断上方购物车/收藏的正常操作。 +class ProductRecommendationSection extends StatefulWidget { + const ProductRecommendationSection({ + required this.repository, + required this.source, + this.onCartChanged, + this.onActivityChanged, + this.enabled = true, + super.key, + }); + final ClientRepository repository; + final String source; + final Future Function()? onCartChanged; + final ValueChanged? onActivityChanged; + final bool enabled; + @override + State createState() => _ProductRecommendationSectionState(); +} + +class _ProductRecommendationSectionState extends State { + ProductRecommendations? _data; + String? _error, _notice; + bool _loading = true, _adding = false; + int _generation = 0; + final _pending = {}; + @override + void initState() { + super.initState(); + _load(1); + } + + @override + void didUpdateWidget(covariant ProductRecommendationSection old) { + super.didUpdateWidget(old); + if (old.source != widget.source || old.repository != widget.repository) { + _data = null; + _pending.clear(); + _load(1); + } + } + + Future _load(int page) async { + final generation = ++_generation; + setState(() { + _loading = true; + _error = null; + }); + try { + final data = await widget.repository.recommendations(source: widget.source, page: page); + if (mounted && generation == _generation) setState(() => _data = data); + } on SessionExpiredException { + // 根路由处理登录失效。 + } catch (e) { + if (mounted && generation == _generation) { + setState(() => _error = e is ApiException ? e.message : '推荐商品加载失败'); + } + } finally { + if (mounted && generation == _generation) setState(() => _loading = false); + } + } + + /// 失败重试复用原绝对目标;成功后重新读取推荐和购物车,不伪造数量变化。 + Future _add(ProductSummary product) async { + if (_adding || !widget.enabled) return; + setState(() { + _adding = true; + _error = null; + _notice = null; + }); + widget.onActivityChanged?.call(true); + try { + if (!_pending.containsKey(product.identity)) { + final item = await widget.repository.cartItem(product.identity); + if (item.quantity >= 999 || item.quantity + 1 > item.product.stock) { + throw const ApiException(2402, '商品库存不足'); + } + _pending[product.identity] = (item: item, quantity: item.quantity + 1); + } + final target = _pending[product.identity]!; + await widget.repository.setCartItem(target.item, quantity: target.quantity, selected: true); + _pending.remove(product.identity); + if (mounted) setState(() => _notice = '已加入购物车'); + await widget.onCartChanged?.call(); + if (mounted) await _load(1); + } on SessionExpiredException { + // 禁止自动重放写请求。 + } catch (e) { + if (e is ApiException && [2402, 2403, 1704, 1711, 1112].contains(e.code)) { + _pending.remove(product.identity); + } + if (mounted) setState(() => _error = e is ApiException ? e.message : '加入结果待确认,请重试或查看购物车'); + } finally { + widget.onActivityChanged?.call(false); + if (mounted) setState(() => _adding = false); + } + } + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE5E7EB)), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: Text('猜你喜欢', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), + ), + TextButton.icon( + onPressed: + _loading || + _adding || + !widget.enabled || + (_data?.page == 1 && _data?.hasMore == false) + ? null + : () { + setState(() => _notice = null); + _load(_data?.hasMore == true ? _data!.page + 1 : 1); + }, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('换一换'), + ), + ], + ), + if (_loading) const LinearProgressIndicator(minHeight: 2), + if (_notice != null) Text(_notice!, style: const TextStyle(color: Color(0xFF16875D))), + if (_error != null) + Row( + children: [ + Expanded( + child: Text(_error!, style: const TextStyle(color: Color(0xFFC7352A))), + ), + TextButton( + onPressed: _loading || _adding ? null : () => _load(_data?.page ?? 1), + child: const Text('重新加载'), + ), + ], + ), + if (!_loading && _error == null && _data?.items.isEmpty == true) + const Padding(padding: EdgeInsets.symmetric(vertical: 16), child: Text('暂无可推荐商品')), + if (_data != null && _data!.items.isNotEmpty) + LayoutBuilder( + builder: (context, constraints) { + final two = + constraints.maxWidth >= 310 && MediaQuery.textScalerOf(context).scale(1) <= 1.1; + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final product in _data!.items) + SizedBox( + width: two ? (constraints.maxWidth - 8) / 2 : constraints.maxWidth, + child: _product(product), + ), + ], + ); + }, + ), + ], + ), + ); + + Widget _product(ProductSummary product) => Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E7EB)), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: _adding + ? null + : () async { + await context.push('/products/${Uri.encodeComponent(product.identity)}'); + if (mounted) await widget.onCartChanged?.call(); + if (mounted) await _load(1); + }, + child: Row( + children: [ + SizedBox( + width: 54, + height: 64, + child: DefaultTextStyle.merge( + style: const TextStyle(fontSize: 11), + child: ProductImage(url: product.imageUrl), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.name, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + if (product.category.isNotEmpty) + Text( + product.category, + style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280)), + ), + ], + ), + ), + ], + ), + ), + Row( + children: [ + Expanded( + child: Text( + moneyText(product.price), + style: const TextStyle( + color: Color(0xFF2563EB), + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + tooltip: _pending.containsKey(product.identity) + ? '重试加入 ${product.name}' + : '加入购物车 ${product.name}', + onPressed: _adding || _loading || !widget.enabled || product.stock < 1 + ? null + : () => _add(product), + icon: Icon( + _pending.containsKey(product.identity) ? Icons.refresh : Icons.add_circle, + color: const Color(0xFF2563EB), + ), + ), + ], + ), + ], + ), + ); +} + +/// 收藏页按原图保留推荐入口;打开弹层后读取真实商品,不提前产生购物车写入。 +Future showFavoriteRecommendations(BuildContext context, ClientRepository repository) => + showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + useSafeArea: true, + builder: (context) => FractionallySizedBox( + heightFactor: .75, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + child: Column( + children: [ + Align( + alignment: Alignment.centerRight, + child: IconButton( + tooltip: '关闭推荐', + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close), + ), + ), + ProductRecommendationSection(repository: repository, source: 'favorites'), + ], + ), + ), + ), + ); diff --git a/apps/user_app/lib/ui/features/shop/shop_page.dart b/apps/user_app/lib/ui/features/shop/shop_page.dart index 3eb37cc..179af64 100644 --- a/apps/user_app/lib/ui/features/shop/shop_page.dart +++ b/apps/user_app/lib/ui/features/shop/shop_page.dart @@ -1,131 +1,202 @@ -// 功能描述:展示商城商品并处理用户确认后的下单流程。 -// 版本:1.1.0 +// 功能描述:商城搜索、分类、真实商品图片及兼容单品下单流程。 +// 版本:2.0.0 import 'package:flutter/material.dart'; -import 'package:uuid/uuid.dart'; - +import 'package:flutter/gestures.dart'; +import 'package:go_router/go_router.dart'; import '../../../data/repositories/client_repository.dart'; -import '../../../data/services/api_client.dart'; +import '../../../data/repositories/primary_repository.dart'; import '../../../domain/models/client_models.dart'; -import '../../core/widgets.dart'; +import '../../../domain/models/primary_models.dart'; +import '../../core/async_content.dart'; +import '../../core/reference_image.dart'; +import 'favorite_button.dart'; +/// 商城一级页面;价格、库存和图片均由后台资源提供。 class ShopPage extends StatefulWidget { - const ShopPage({required this.repository, super.key}); - + const ShopPage({required this.repository, this.authenticated = true, super.key}); final ClientRepository repository; - + final bool authenticated; @override State createState() => _ShopPageState(); } +/// 管理搜索和分类;购买操作统一从商品详情进入结算。 class _ShopPageState extends State { - late Future> _future; - - @override - void initState() { - super.initState(); - _future = widget.repository.products(); - } - - Future _buy(ClientRecord product) async { - final profile = await widget.repository.profile(); - final addresses = await widget.repository.addresses(); - if (!mounted) return; - if (addresses.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请先在“我的”中添加收货地址'))); - return; - } - final confirmed = await showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - builder: (context) => SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 4, 20, 20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('确认下单', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 20), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.shopping_bag_outlined), - title: Text(product.title), - subtitle: Text(product.subtitle), - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.location_on_outlined), - title: const Text('配送地址'), - subtitle: Text(addresses.first.title), - ), - const SizedBox(height: 20), - FilledButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('提交订单'), - ), - ], - ), - ), - ), - ); - if (confirmed != true) return; - try { - await widget.repository.createShopOrder( - requestNo: const Uuid().v7(), - productIdentity: product.identity, - addressIdentity: addresses.first.identity, - contactName: profile.name, - contactPhone: profile.phone, - ); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('订单已创建,请前往订单页支付'))); - } - } catch (error) { - if (error is SessionExpiredException) return; - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString()))); - } - } - } + final _contentKey = GlobalKey>>(); + String _search = '', _category = ''; @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('安全商城')), - body: FutureBuilder>( - future: _future, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError) { - if (snapshot.error is SessionExpiredException) { - return const SizedBox.shrink(); - } - return EmptyState(title: '商品加载失败', description: snapshot.error.toString()); - } - final products = snapshot.data ?? const []; + appBar: AppBar(title: const Text('燃气商城')), + body: AsyncContent>( + key: _contentKey, + load: () => PrimaryRepository(widget.repository).products(), + builder: (context, products) { + final categories = products.map((p) => p.category).where((s) => s.isNotEmpty).toSet(); + final shown = products + .where( + (p) => + (_category.isEmpty || p.category == _category) && + p.name.toLowerCase().contains(_search.toLowerCase()), + ) + .toList(); return ListView( - padding: const EdgeInsets.only(bottom: 32), + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), children: [ - const PageIntro(eyebrow: '品质保障', title: '燃气安全商城', description: '价格和库存以服务端结算为准'), - if (products.isEmpty) - const EmptyState(title: '暂无商品', description: '目前没有上架且有库存的商品') - else - AppGutter( - child: SurfaceSection( - padding: EdgeInsets.zero, - child: Column( - children: [ - for (var index = 0; index < products.length; index++) ...[ - _ProductRow(product: products[index], onBuy: () => _buy(products[index])), - if (index < products.length - 1) const Divider(indent: 92, endIndent: 16), - ], - ], + Row( + children: [ + Expanded( + child: TextField( + decoration: const InputDecoration( + hintText: '搜索气瓶、配件、服务', + prefixIcon: Icon(Icons.search), + ), + onChanged: (value) => setState(() => _search = value.trim()), ), ), + IconButton( + tooltip: '购物车', + onPressed: () => widget.authenticated + ? context.push('/cart') + : context.go('/login?redirect=%2Fcart'), + icon: const Icon(Icons.shopping_cart_outlined), + ), + ], + ), + const SizedBox(height: 16), + ScrollConfiguration( + // 桌面预览允许鼠标按住拖动分类,同时保留触屏与触控板手势。 + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + ...ScrollConfiguration.of(context).dragDevices, + PointerDeviceKind.mouse, + }, ), + child: SingleChildScrollView( + key: const ValueKey('shop-category-scroll'), + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final category in ['', ...categories]) + Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + shape: const StadiumBorder(), + showCheckmark: false, + selectedColor: Theme.of(context).colorScheme.primary, + labelStyle: TextStyle( + color: _category == category + ? Colors.white + : Theme.of(context).colorScheme.onSurface, + ), + label: Text(category.isEmpty ? '全部' : category), + selected: _category == category, + onSelected: (_) => setState(() => _category = category), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 18), + if (_search.isEmpty && _category.isEmpty) ...[ + const ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(12)), + child: ReferenceImage.shopBanner(), + ), + const SizedBox(height: 16), + ], + Text('在售商品', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + if (shown.isEmpty) + const Padding(padding: EdgeInsets.all(32), child: Text('暂无符合条件的商品,试试其他关键词或分类')), + LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth > 600 ? 3 : 2; + final width = (constraints.maxWidth - 12 * (columns - 1)) / columns; + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final product in shown) + SizedBox( + width: width, + child: Card( + margin: EdgeInsets.zero, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: Color(0xFFE0E3EA)), + ), + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () => context.push( + '/products/${Uri.encodeComponent(product.identity)}', + ), + child: AspectRatio( + aspectRatio: 1.35, + child: ProductImage(url: product.imageUrl), + ), + ), + const SizedBox(height: 10), + InkWell( + onTap: () => context.push( + '/products/${Uri.encodeComponent(product.identity)}', + ), + child: Text( + product.name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + if (product.category.isNotEmpty) + Text( + product.category, + style: Theme.of(context).textTheme.labelSmall, + ), + const SizedBox(height: 8), + Text( + moneyText(product.price), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + product.stock == 0 ? '暂时售罄' : '库存 ${product.stock}', + style: Theme.of(context).textTheme.labelSmall, + ), + ), + FavoriteButton( + repository: widget.repository, + identity: product.identity, + redirect: '/shop', + authenticated: widget.authenticated, + name: product.name, + ), + ], + ), + ], + ), + ), + ), + ), + ], + ); + }, + ), ], ); }, @@ -133,55 +204,28 @@ class _ShopPageState extends State { ); } -class _ProductRow extends StatelessWidget { - const _ProductRow({required this.product, required this.onBuy}); - - final ClientRecord product; - final VoidCallback onBuy; - +/// 显示后台商品图片;无图片或下载失败均给出可读状态。 +class ProductImage extends StatelessWidget { + const ProductImage({required this.url, super.key}); + final String url; @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 12, 16), - child: Row( - children: [ - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(16), - ), - child: Icon( - Icons.local_fire_department_outlined, - color: Theme.of(context).colorScheme.primary, - semanticLabel: '燃气安全商品', - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(product.title, style: Theme.of(context).textTheme.titleMedium), - if (product.subtitle.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - product.subtitle, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ], - ), - ), - const SizedBox(width: 8), - IconButton.filled( - tooltip: '购买 ${product.title}', - onPressed: onBuy, - icon: const Icon(Icons.add_shopping_cart_outlined), - ), - ], - ), - ); + Widget build(BuildContext context) { + final uri = Uri.tryParse(url); + if (url.isEmpty || uri == null || !['https', 'http'].contains(uri.scheme)) { + return const Center( + child: Icon(Icons.image_not_supported_outlined, color: Color(0xFF9CA3AF)), + ); + } + return Image.network( + // 迁移旧API未按Origin区分的24小时图片缓存,仅针对本站公开上传路径。 + uri.path.startsWith('/uploads/product-images/') + ? uri.replace(queryParameters: {...uri.queryParameters, 'image_cache': '2'}).toString() + : url, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => + const Center(child: Icon(Icons.broken_image_outlined, color: Color(0xFF9CA3AF))), + loadingBuilder: (_, child, progress) => + progress == null ? child : const Center(child: CircularProgressIndicator()), + ); + } } diff --git a/apps/user_app/lib/ui/features/tickets/repair_layout.dart b/apps/user_app/lib/ui/features/tickets/repair_layout.dart new file mode 100644 index 0000000..d3af672 --- /dev/null +++ b/apps/user_app/lib/ui/features/tickets/repair_layout.dart @@ -0,0 +1,226 @@ +// 功能描述:图05报修步骤、表单分区与联系人预览,布局不持有业务状态。 +// 版本:1.0.0。 +import 'package:flutter/material.dart'; +import '../../../domain/models/shipping_address.dart'; + +/// 三步进度随当前步骤展示,文字放大时允许换行。 +class RepairStepHeader extends StatelessWidget { + const RepairStepHeader({required this.step, super.key}); + final int step; + @override + Widget build(BuildContext context) => Row( + children: [ + for (var index = 0; index < 3; index++) ...[ + if (index > 0) const SizedBox(width: 12, child: Divider(color: Color(0xFFDFE2E9))), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + Container( + width: 22, + height: 22, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: index == step ? const Color(0xFF1762F4) : Colors.transparent, + border: Border.all( + color: index == step ? const Color(0xFF1762F4) : const Color(0xFFD2D6E0), + ), + ), + child: Text( + '${index + 1}', + style: TextStyle( + fontSize: 14, + color: index == step ? Colors.white : const Color(0xFF8D929E), + ), + ), + ), + const SizedBox(width: 5), + Expanded( + child: Text( + ['故障信息', '联系与地址', '确认提交'][index], + style: TextStyle( + fontSize: 13, + color: index == step ? const Color(0xFF1762F4) : const Color(0xFF818694), + fontWeight: index == step ? FontWeight.w600 : FontWeight.normal, + ), + ), + ), + ], + ), + ), + ), + ], + ], + ); +} + +/// 原设计中的连续表单分区;内部组件不再各自套卡片。 +class RepairSection extends StatelessWidget { + const RepairSection({required this.child, super.key}); + final Widget child; + @override + Widget build(BuildContext context) => Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFDFE2E9)), + ), + child: Material(color: Colors.transparent, child: child), + ); +} + +/// 必填标记有明确文字标签,不依赖颜色单独表达含义。 +class RequiredRepairLabel extends StatelessWidget { + const RequiredRepairLabel(this.label, {super.key}); + final String label; + @override + Widget build(BuildContext context) => Text.rich( + TextSpan( + children: [ + TextSpan(text: label), + const TextSpan( + text: ' *', + style: TextStyle(color: Color(0xFFE53935)), + ), + ], + ), + semanticsLabel: '$label,必填', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ); +} + +/// 只显示实际选中地址,无数据时提供选取入口,不伪造定位成功。 +class RepairContactPreview extends StatelessWidget { + const RepairContactPreview({required this.address, required this.onChoose, super.key}); + final ShippingAddress? address; + final VoidCallback onChoose; + @override + Widget build(BuildContext context) => RepairSection( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text.rich( + TextSpan( + children: [ + TextSpan( + text: '联系与地址', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + TextSpan( + text: ' (下一步填写)', + style: TextStyle(fontSize: 12, color: Color(0xFF8D929E)), + ), + ], + ), + ), + const SizedBox(height: 4), + _row( + icon: Icons.phone_outlined, + title: '联系电话', + subtitle: address?.contactName, + trailing: address?.maskedPhone ?? '请选择', + ), + const Divider(height: 1, color: Color(0xFFDFE2E9)), + _row( + icon: Icons.location_on_outlined, + title: '报修地址', + subtitle: address?.address ?? '选择报修地址', + ), + ], + ), + ); + + Widget _row({ + required IconData icon, + required String title, + String? subtitle, + String? trailing, + }) => InkWell( + onTap: onChoose, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + _icon(icon), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, style: const TextStyle(fontSize: 14)), + if (subtitle != null) + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: Color(0xFF626A7A)), + ), + ], + ), + ), + if (trailing != null) + Text(trailing, style: const TextStyle(fontSize: 13, color: Color(0xFF626A7A))), + const Icon(Icons.chevron_right, size: 20), + ], + ), + ), + ); + + Widget _icon(IconData icon) => Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: const Color(0xFFEDF3FF), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 23, color: const Color(0xFF1762F4)), + ); +} + +/// 紧急情况提示保持与设计稿同层级,拨号只在用户明确点击后触发。 +class RepairEmergencyNotice extends StatelessWidget { + const RepairEmergencyNotice({required this.onCall, super.key}); + final VoidCallback onCall; + + @override + Widget build(BuildContext context) => Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(10, 8, 4, 8), + decoration: BoxDecoration( + color: const Color(0xFFFFF7E8), + border: Border.all(color: const Color(0xFFFFD591)), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.warning_amber_rounded, color: Color(0xFFEB7B16), size: 19), + const SizedBox(width: 6), + const Expanded( + child: Text( + '若发生燃气泄漏,请先关闭阀门、开窗通风并远离明火', + style: TextStyle(fontSize: 10, color: Color(0xFF9A4D00), height: 1.4), + ), + ), + TextButton( + onPressed: onCall, + style: TextButton.styleFrom( + foregroundColor: const Color(0xFFD65A00), + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 6), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('紧急电话', style: TextStyle(fontSize: 11)), + Icon(Icons.chevron_right, size: 15), + ], + ), + ), + ], + ), + ); +} diff --git a/apps/user_app/lib/ui/features/tickets/repair_page.dart b/apps/user_app/lib/ui/features/tickets/repair_page.dart new file mode 100644 index 0000000..4f8fa8e --- /dev/null +++ b/apps/user_app/lib/ui/features/tickets/repair_page.dart @@ -0,0 +1,788 @@ +// 功能描述:报修故障、联系地址、确认提交三步表单;未知结果重试复用原请求。 +// 版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:uuid/uuid.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../data/services/api_client.dart'; +import '../../../data/services/repair_draft_store.dart'; +import '../../../domain/models/shipping_address.dart'; +import 'repair_photos.dart'; +import 'repair_layout.dart'; +import 'repair_speech_button.dart'; + +typedef RepairEmergencyLauncher = Future Function(Uri uri); + +class RepairPage extends StatefulWidget { + const RepairPage({ + required this.repository, + this.pickPhoto, + this.draftStore, + this.launchEmergency, + super.key, + }); + final ClientRepository repository; + final RepairPhotoPicker? pickPhoto; + final RepairDraftStore? draftStore; + final RepairEmergencyLauncher? launchEmergency; + @override + State createState() => _RepairPageState(); +} + +class _RepairPageState extends State { + bool _dictating = false; + final _description = TextEditingController(); + String _requestNo = const Uuid().v7(); + final _photos = []; + bool _picking = false; + static const _faults = {'valve': '阀门故障', 'alarm': '报警器故障', 'leak': '燃气泄漏', 'other': '其他问题'}; + String _fault = 'valve'; + ShippingAddress? _address; + DateTime? _appointment; + int _step = 0; + bool _busy = false, _attempted = false, _leaving = false; + String? _error; + String? _draftOwner; + bool _initializing = false, _draftLoadFailed = false; + bool _savingDraft = false; + + @override + void initState() { + super.initState(); + if (widget.draftStore != null) { + _initializing = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _loadDraft(); + }); + } + } + + /// 账户确认后才读取草稿;恢复需要用户选择,绝不自动重放提交。 + Future _loadDraft() async { + setState(() { + _initializing = true; + _draftLoadFailed = false; + _error = null; + }); + try { + _draftOwner = await widget.repository.repairDraftOwner(); + final draft = await widget.draftStore!.read(_draftOwner!); + if (!mounted || draft == null) return; + final pending = draft['attempted'] == true; + final choice = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: const Text('发现报修草稿'), + content: Text(pending ? '上次提交结果待确认。恢复后可使用原请求号重试,或先查看工单。' : '继续填写上次暂存的故障、照片和联系地址?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, pending ? 'orders' : 'discard'), + child: Text(pending ? '查看工单' : '删除草稿'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, 'restore'), + child: const Text('恢复草稿'), + ), + ], + ), + ); + if (!mounted) return; + if (choice == 'orders') { + _leaving = true; + context.go('/orders?tab=tickets'); + return; + } + if (choice == 'discard') { + await widget.draftStore!.delete(_draftOwner!); + return; + } + if (choice != 'restore') throw const FormatException('草稿尚未恢复'); + final request = draft['request_no'], + description = draft['description'], + fault = draft['fault_type'], + step = draft['step']; + if (request is! String || + request.isEmpty || + request.length > 128 || + description is! String || + description.length > 2000 || + fault is! String || + !_faults.containsKey(fault) || + step is! int || + step < 0 || + step > 2) { + throw const FormatException('草稿字段无效'); + } + final rawPhotos = draft['photos']; + if (rawPhotos is! List || rawPhotos.length > 3) throw const FormatException('草稿照片无效'); + final photos = []; + for (final item in rawPhotos) { + if (item is! Map || + item['uri'] is! String || + !['camera', 'gallery'].contains(item['source'])) { + throw const FormatException('草稿照片无效'); + } + final uri = item['uri'] as String; + final bytes = await widget.repository.uploadedTicketPhoto(uri); + if (bytes == null) throw const ApiException(404, '草稿照片已不可读取'); + photos.add( + RepairPhoto( + bytes: bytes, + filename: uri.endsWith('.png') ? 'repair.png' : 'repair.jpg', + source: item['source'] as String, + addedAt: DateTime.parse(item['added_at'] as String), + )..uri = uri, + ); + } + final rawAddress = draft['address']; + var address = rawAddress is Map + ? ShippingAddress.fromJson(Map.from(rawAddress)) + : null; + if ((step == 2 || pending) && (address == null || photos.isEmpty)) { + throw const FormatException('提交草稿不完整'); + } + var restoredStep = step; + // 未发出的草稿使用最新本人地址;未知提交保留首次请求快照以便幂等查询。 + if (!pending && address != null) { + final identity = address.identity; + final current = await widget.repository.shippingAddresses(); + address = null; + for (final candidate in current) { + if (candidate.identity == identity) address = candidate; + } + if (address == null && restoredStep > 1) restoredStep = 1; + } + final appointment = draft['appointment_at'] is String + ? DateTime.parse(draft['appointment_at'] as String).toLocal() + : null; + if (!mounted) return; + setState(() { + _requestNo = request; + _description.text = description; + _fault = fault; + _step = restoredStep; + _attempted = pending; + _photos + ..clear() + ..addAll(photos); + _address = address; + _appointment = appointment; + }); + } catch (error) { + if (mounted && error is! SessionExpiredException) { + setState(() { + _draftLoadFailed = true; + _error = '草稿读取失败,请重试或先查看工单。'; + }); + } + } finally { + if (mounted) setState(() => _initializing = false); + } + } + + /// 保存前上传照片,仅持久化小体积受控URI与稳定请求号。 + Future _persistDraft() async { + if (widget.draftStore == null) return; + if (_draftOwner == null || await widget.repository.repairDraftOwner() != _draftOwner) { + throw const ApiException(1104, '草稿账户已变化,请重新打开页面'); + } + for (final photo in _photos) { + photo.uri ??= await widget.repository.uploadTicketPhoto(photo.bytes, photo.filename); + } + await widget.draftStore!.write(_draftOwner!, { + 'schema': 1, + 'request_no': _requestNo, + 'description': _description.text.trim(), + 'fault_type': _fault, + 'step': _step, + 'attempted': _attempted, + 'address': _address == null ? null : {..._address!.toJson(), 'identity': _address!.identity}, + 'appointment_at': _appointment?.toUtc().toIso8601String(), + 'photos': _photos.map((p) => p.toJson()).toList(), + }); + } + + Future _saveAndExit() async { + if (_dictating) return; + if (_busy || _picking || _initializing) return; + setState(() { + _busy = true; + _savingDraft = true; + _error = null; + }); + try { + await _persistDraft(); + if (mounted) { + _leaving = true; + context.go(_attempted ? '/orders?tab=tickets' : '/me'); + } + } catch (error) { + if (mounted) setState(() => _error = '草稿保存失败,内容仍在当前页面,请重试。'); + } finally { + if (mounted) { + setState(() { + _busy = false; + _savingDraft = false; + }); + } + } + } + + /// 损坏草稿只有用户明确选择才清除,避免自动丢弃可能已提交的请求号。 + Future _discardUnreadableDraft() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('清除无法读取的草稿?'), + content: const Text('若之前提交结果未知,请先检查报修工单,避免重新创建相同申请。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('返回')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('清除草稿')), + ], + ), + ); + if (confirmed != true || !mounted) return; + try { + await widget.draftStore!.delete(_draftOwner!); + if (mounted) { + setState(() { + _draftLoadFailed = false; + _error = null; + _description.clear(); + _photos.clear(); + _address = null; + _appointment = null; + _step = 0; + _attempted = false; + _requestNo = const Uuid().v7(); + }); + } + } catch (_) { + if (mounted) setState(() => _error = '草稿清理失败,请重试。'); + } + } + + @override + void dispose() { + _description.dispose(); + super.dispose(); + } + + /// 选择用户自己的地址,必须具有完整联系人,以便服务端生成独立快照。 + Future _chooseAddress() async { + if (_dictating) return; + final value = await context.push('/addresses?select=1'); + if (value != null && mounted) setState(() => _address = value); + } + + Future _chooseTime() async { + final now = DateTime.now(); + final date = await showDatePicker( + context: context, + initialDate: _appointment ?? now, + firstDate: DateTime(now.year, now.month, now.day), + lastDate: DateTime(now.year + 1, now.month, now.day), + ); + if (date == null || !mounted) return; + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_appointment ?? now.add(const Duration(hours: 1))), + ); + if (time == null || !mounted) return; + final value = DateTime(date.year, date.month, date.day, time.hour, time.minute); + setState(() { + if (value.isBefore(DateTime.now())) { + _error = '请选择将来的预约时间'; + } else { + _appointment = value; + _error = null; + } + }); + } + + /// 未提交前可修改,结果未知时冻结原请求内容,避免同一请求号对应不同业务。 + Future _next() async { + if (_dictating) return; + if (_busy || _picking) return; + if (_step == 0 && _description.text.trim().isEmpty) { + setState(() => _error = '请描述故障现象'); + return; + } + if (_step == 0 && _photos.isEmpty) { + setState(() => _error = '请添加至少一张现场照片'); + return; + } + if (_step == 1 && + (_address == null || _address!.contactName.isEmpty || _address!.contactPhone.isEmpty)) { + setState(() => _error = '请选择并补齐联系人、电话和地址'); + return; + } + if (_step < 2) { + setState(() { + _step++; + _error = null; + }); + return; + } + final previousAttempted = _attempted; + var sent = false; + setState(() { + _busy = true; + _attempted = true; + _error = null; + }); + try { + if (widget.draftStore != null) { + await _persistDraft(); + } else { + for (final photo in _photos) { + photo.uri ??= await widget.repository.uploadTicketPhoto(photo.bytes, photo.filename); + } + } + if (!mounted) return; + sent = true; + final identity = await widget.repository.submitRepair( + requestNo: _requestNo, + description: _description.text.trim(), + faultType: _fault, + addressIdentity: _address!.identity, + appointment: _appointment, + photos: _photos.map((photo) => photo.toJson()).toList(), + ); + if (!mounted) return; + try { + if (_draftOwner != null) await widget.draftStore?.delete(_draftOwner!); + } catch (_) { + /* 已成功提交,保留原请求号仍可幂等恢复。 */ + } + if (!mounted) return; + setState(() => _leaving = true); + context.go('/tickets/${Uri.encodeComponent(identity)}'); + } catch (error) { + if (mounted && error is! SessionExpiredException) { + setState(() { + _error = error is ApiException ? error.message : '提交结果待确认,请重试或查看工单'; + if (!sent) { + _attempted = previousAttempted; + _error = '提交前保存失败,尚未发送工单,请重试。'; + } + // 明确校验拒绝时可修正输入;网络未知结果保持同一份请求。 + if (error is ApiException && [1711, 1112, 1704].contains(error.code)) _attempted = false; + }); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _back() async { + if (_dictating) return; + if (_busy || _picking) return; + if (_step > 0 && !_attempted) { + setState(() { + _step--; + _error = null; + }); + return; + } + if (widget.draftStore != null && + (_description.text.isNotEmpty || _photos.isNotEmpty || _attempted)) { + final choice = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('保存报修草稿?'), + content: Text(_attempted ? '提交结果待确认,将保留原请求号供下次查看。' : '保存故障、照片与地址,下次可继续填写。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('继续填写')), + if (!_attempted) + TextButton( + onPressed: () => Navigator.pop(context, 'discard'), + child: const Text('不保存'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, 'save'), + child: const Text('暂存并退出'), + ), + ], + ), + ); + if (!mounted || choice == null) return; + if (choice == 'save') { + await _saveAndExit(); + return; + } + try { + await widget.draftStore!.delete(_draftOwner!); + } catch (_) { + if (mounted) setState(() => _error = '草稿清理失败,请重试'); + return; + } + if (mounted) { + _leaving = true; + context.go('/me'); + } + return; + } + final leave = + _description.text.trim().isEmpty || + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('离开报修页面?'), + content: Text(_attempted ? '提交结果待确认,离开后请在报修工单中查看。' : '尚未提交的内容将丢失。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('继续填写'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('离开'), + ), + ], + ), + ) == + true; + if (leave && mounted) { + setState(() => _leaving = true); + context.go(_attempted ? '/orders?tab=tickets' : '/me'); + } + } + + /// 用户主动点击后打开系统拨号器;无法启动时保留明确的人工拨号提示。 + Future _callEmergency() async { + final launcher = + widget.launchEmergency ?? (uri) => launchUrl(uri, mode: LaunchMode.externalApplication); + try { + final opened = await launcher(Uri(scheme: 'tel', path: '119')); + if (!opened) throw StateError('dialer unavailable'); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('当前设备无法打开拨号,请拨打119')), + ); + } + } + } + + /// 查看工单前保存当前首期草稿,避免从顶部菜单离开时丢失填写内容。 + Future _openRepairOrders() async { + if (_busy || _picking || _dictating) return; + final hasContent = + _description.text.trim().isNotEmpty || + _photos.isNotEmpty || + _address != null || + _appointment != null; + if (hasContent && widget.draftStore != null) { + setState(() { + _busy = true; + _savingDraft = true; + _error = null; + }); + try { + await _persistDraft(); + } catch (_) { + if (mounted) setState(() => _error = '草稿保存失败,内容仍在当前页面,请重试。'); + return; + } finally { + if (mounted) { + setState(() { + _busy = false; + _savingDraft = false; + }); + } + } + } + if (!mounted) return; + setState(() => _leaving = true); + context.go('/orders?tab=tickets'); + } + + Future _showRepairHelp() => showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('报修说明'), + content: const Text('请描述故障并上传1至3张现场照片。燃气泄漏等紧急情况请先撤离到安全区域,再拨打119。'), + actions: [ + FilledButton(onPressed: () => Navigator.pop(context), child: const Text('我知道了')), + ], + ), + ); + + @override + Widget build(BuildContext context) => PopScope( + canPop: _leaving, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _back(); + }, + child: Scaffold( + appBar: AppBar( + title: const Text('一键报修'), + leading: BackButton(onPressed: _busy || _dictating ? null : _back), + actions: [ + PopupMenuButton( + tooltip: '更多', + icon: const Icon(Icons.more_horiz), + enabled: !_busy && !_dictating, + onSelected: (value) { + if (value == 'orders') _openRepairOrders(); + if (value == 'help') _showRepairHelp(); + }, + itemBuilder: (context) => const [ + PopupMenuItem(value: 'orders', child: Text('查看报修工单')), + PopupMenuItem(value: 'help', child: Text('报修说明')), + ], + ), + ], + ), + body: _initializing + ? const Center(child: Text('正在读取草稿…')) + : _draftLoadFailed + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_error!), + TextButton(onPressed: _loadDraft, child: const Text('重试读取草稿')), + TextButton( + onPressed: () => context.go('/orders?tab=tickets'), + child: const Text('查看报修工单'), + ), + if (_draftOwner != null) + TextButton(onPressed: _discardUnreadableDraft, child: const Text('清除无法读取的草稿')), + ], + ), + ) + : ListView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + children: [ + RepairStepHeader(step: _step), + const SizedBox(height: 2), + if (_step == 0) ...[ + RepairSection( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 34, + height: 34, + margin: const EdgeInsets.only(right: 10), + decoration: BoxDecoration( + color: const Color(0xFFEDF3FF), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.settings_input_component_outlined, + size: 22, + color: Color(0xFF1762F4), + ), + ), + const Expanded(child: RequiredRepairLabel('故障类型')), + Flexible( + child: DropdownButtonFormField( + initialValue: _fault, + isExpanded: true, + decoration: const InputDecoration( + isDense: true, + filled: false, + border: InputBorder.none, + enabledBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + icon: const Icon(Icons.chevron_right), + items: _faults.entries + .map( + (e) => DropdownMenuItem( + value: e.key, + child: Text(e.value, style: const TextStyle(fontSize: 14)), + ), + ) + .toList(), + onChanged: _dictating + ? null + : (value) { + if (value != null) setState(() => _fault = value); + }, + ), + ), + ], + ), + const Divider(height: 30, color: Color(0xFFDFE2E9)), + const RequiredRepairLabel('请描述故障现象'), + const SizedBox(height: 10), + Container( + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFDFE2E9)), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _description, + readOnly: _dictating, + minLines: 2, + maxLines: 4, + maxLength: 2000, + style: const TextStyle(fontSize: 13), + decoration: const InputDecoration( + hintText: '请详细描述故障情况,如:什么时候发生、具体现象等…', + counterText: '', + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.all(10), + ), + ), + RepairSpeechButton( + controller: _description, + enabled: !_busy && !_picking, + onBusy: (busy) { + if (mounted) setState(() => _dictating = busy); + }, + ), + ], + ), + ), + const SizedBox(height: 10), + AbsorbPointer( + absorbing: _dictating, + child: RepairPhotos( + photos: _photos, + pickImage: widget.pickPhoto, + onChanged: () => setState(() {}), + onBusy: (value) => setState(() => _picking = value), + ), + ), + ], + ), + ), + const SizedBox(height: 10), + RepairContactPreview(address: _address, onChoose: _chooseAddress), + const SizedBox(height: 10), + RepairEmergencyNotice(onCall: _callEmergency), + ], + if (_step == 1) ...[ + Material( + color: Colors.transparent, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.location_on_outlined), + title: Text(_address?.address ?? '选择报修地址'), + subtitle: Text( + _address == null + ? '联系与地址' + : '${_address!.contactName} ${_address!.maskedPhone}', + ), + trailing: const Icon(Icons.chevron_right), + onTap: _chooseAddress, + ), + ), + const Divider(), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.schedule), + title: const Text('预约时间(选填)'), + subtitle: Text(_appointment?.toString().substring(0, 16) ?? '与服务人员协商'), + onTap: _chooseTime, + ), + if (_appointment != null) + TextButton( + onPressed: () => setState(() => _appointment = null), + child: const Text('清除预约时间'), + ), + ], + if (_step == 2) ...[ + _summary('故障类型', _faults[_fault]!), + _summary('故障描述', _description.text.trim()), + _summary('现场照片', '${_photos.length}张'), + _summary('联系人', '${_address!.contactName} ${_address!.maskedPhone}'), + _summary('报修地址', _address!.address), + _summary('预约时间', _appointment?.toString().substring(0, 16) ?? '与服务人员协商'), + ], + if (_fault == 'leak' && _step > 0) + const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Text( + '紧急情况请先撤离至安全区域,再联系当地燃气抢险服务。', + style: TextStyle(color: Color(0xFFB45309)), + ), + ), + const SizedBox(height: 16), + ], + ), + bottomNavigationBar: _initializing || _draftLoadFailed + ? null + : SafeArea( + top: false, + child: Container( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 8), + decoration: const BoxDecoration( + color: Colors.white, + border: Border(top: BorderSide(color: Color(0xFFEAECF1))), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + _error!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 13, + ), + textAlign: TextAlign.center, + ), + ), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _busy || _picking || _dictating ? null : _next, + child: Text( + _busy + ? _savingDraft + ? '暂存中…' + : '提交中…' + : _step < 2 + ? '下一步' + : _attempted + ? '重试提交' + : '确认提交', + ), + ), + ), + if (_attempted) + TextButton( + onPressed: _busy ? null : () => context.go('/orders?tab=tickets'), + child: const Text('查看报修工单'), + ), + if (widget.draftStore != null) + TextButton( + onPressed: _busy || _picking || _dictating ? null : _saveAndExit, + child: const Text('暂存并退出'), + ), + ], + ), + ), + ), + ), + ); + + Widget _summary(String label, String value) => Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: 6), + Text(value), + ], + ), + ); +} diff --git a/apps/user_app/lib/ui/features/tickets/repair_photos.dart b/apps/user_app/lib/ui/features/tickets/repair_photos.dart new file mode 100644 index 0000000..2f0de30 --- /dev/null +++ b/apps/user_app/lib/ui/features/tickets/repair_photos.dart @@ -0,0 +1,217 @@ +// 功能描述:报修现场照片选择、删除与预览,保留上传结果供提交重试复用。 +// 版本:1.0.0。 +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'repair_layout.dart'; + +typedef RepairPhotoPicker = Future Function(ImageSource source); + +class RepairPhoto { + RepairPhoto({ + required this.bytes, + required this.filename, + required this.source, + required this.addedAt, + }); + final Uint8List bytes; + final String filename, source; + final DateTime addedAt; + String? uri; + Map toJson() => { + 'uri': uri!, + 'added_at': addedAt.toUtc().toIso8601String(), + 'source': source, + }; +} + +class RepairPhotos extends StatefulWidget { + const RepairPhotos({ + required this.photos, + required this.onChanged, + required this.onBusy, + this.pickImage, + super.key, + }); + final List photos; + final VoidCallback onChanged; + final ValueChanged onBusy; + final RepairPhotoPicker? pickImage; + @override + State createState() => _RepairPhotosState(); +} + +class _RepairPhotosState extends State { + bool _picking = false; + String? _error; + + /// 取消系统选择不修改草稿;读取成功后才加入列表,最多三张。 + Future _pick() async { + if (_picking || widget.photos.length >= 3) return; + setState(() => _picking = true); + widget.onBusy(true); + try { + final source = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.camera_alt_outlined), + title: const Text('拍照'), + onTap: () => Navigator.pop(context, ImageSource.camera), + ), + ListTile( + leading: const Icon(Icons.photo_library_outlined), + title: const Text('从相册选择'), + onTap: () => Navigator.pop(context, ImageSource.gallery), + ), + ], + ), + ), + ); + if (source == null || !mounted) return; + final file = + await (widget.pickImage ?? + ((source) => ImagePicker().pickImage( + source: source, + maxWidth: 2048, + maxHeight: 2048, + imageQuality: 90, + requestFullMetadata: false, + )))(source); + if (file == null || !mounted) return; + if (await file.length() > 2 * 1024 * 1024) throw Exception('每张图片不能超过2MB'); + final bytes = await file.readAsBytes(); + final png = + bytes.length > 8 && bytes[0] == 137 && bytes[1] == 80 && bytes[2] == 78 && bytes[3] == 71; + final jpg = bytes.length > 3 && bytes[0] == 255 && bytes[1] == 216 && bytes[2] == 255; + if (!png && !jpg) throw Exception('请选择JPG或PNG图片'); + if (widget.photos.any((photo) => listEquals(photo.bytes, bytes))) throw Exception('这张照片已添加'); + if (!mounted) return; + widget.photos.add( + RepairPhoto( + bytes: bytes, + filename: png ? 'repair.png' : 'repair.jpg', + source: source == ImageSource.camera ? 'camera' : 'gallery', + addedAt: DateTime.now(), + ), + ); + setState(() => _error = null); + widget.onChanged(); + } catch (error) { + if (mounted) setState(() => _error = '无法添加照片:$error'); + } finally { + if (mounted) { + setState(() => _picking = false); + widget.onBusy(false); + } + } + } + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: RequiredRepairLabel('现场照片'), + ), + Text('${widget.photos.length}/3'), + ], + ), + const SizedBox(height: 12), + GridView.count( + crossAxisCount: 3, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisSpacing: 10, + mainAxisSpacing: 10, + children: [ + for (final photo in widget.photos) + Stack( + fit: StackFit.expand, + children: [ + InkWell( + onTap: () => showRepairPhoto(context, photo.bytes), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.memory( + photo.bytes, + fit: BoxFit.cover, + errorBuilder: (_, e, s) => const Icon(Icons.broken_image_outlined), + ), + ), + ), + Positioned( + top: 0, + right: 0, + child: IconButton.filledTonal( + tooltip: '删除照片', + onPressed: _picking + ? null + : () { + widget.photos.remove(photo); + widget.onChanged(); + }, + icon: const Icon(Icons.close), + ), + ), + ], + ), + for (var slot = widget.photos.length; slot < 3; slot++) + OutlinedButton( + onPressed: _picking ? null : _pick, + style: OutlinedButton.styleFrom( + padding: EdgeInsets.zero, + foregroundColor: const Color(0xFF8AAAF4), + side: const BorderSide(color: Color(0xFFCDD3DF)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.camera_alt_outlined, size: 28), + SizedBox(height: 4), + Text('添加照片', style: TextStyle(fontSize: 12)), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + '记录添加时间;原拍摄时间与位置待核实', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + if (_picking) const Text('正在选择照片…'), + if (_error != null) + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + ); +} + +/// 展示已加载字节,支持缩放,不暴露未鉴权图片链接。 +Future showRepairPhoto(BuildContext context, Uint8List bytes) => showDialog( + context: context, + builder: (context) => Dialog( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Align( + alignment: Alignment.centerRight, + child: IconButton( + tooltip: '关闭预览', + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close), + ), + ), + Flexible( + child: InteractiveViewer(child: Image.memory(bytes, fit: BoxFit.contain)), + ), + ], + ), + ), +); diff --git a/apps/user_app/lib/ui/features/tickets/repair_speech_button.dart b/apps/user_app/lib/ui/features/tickets/repair_speech_button.dart new file mode 100644 index 0000000..a1e0531 --- /dev/null +++ b/apps/user_app/lib/ui/features/tickets/repair_speech_button.dart @@ -0,0 +1,180 @@ +// 功能描述:报修语音输入按钮,保护已有文字与选区,处理中阻止发单,离页终止识别。 +// 版本:1.0.0。 +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../../../data/services/repair_speech.dart'; + +class RepairSpeechButton extends StatefulWidget { + const RepairSpeechButton({ + required this.controller, + required this.onBusy, + this.speech, + this.enabled = true, + super.key, + }); + final bool enabled; + final TextEditingController controller; + final ValueChanged onBusy; + final RepairSpeech? speech; + @override + State createState() => _RepairSpeechButtonState(); +} + +/// 临时识别结果始终覆盖同一选区;用户手动修改时停止本轮,避免覆盖新输入。 +class _RepairSpeechButtonState extends State with WidgetsBindingObserver { + late final RepairSpeech _speech = widget.speech ?? SystemRepairSpeech.instance; + bool _active = false, _starting = false; + int _generation = 0; + String _before = '', _after = '', _lastText = ''; + String? _error; + Timer? _deadline; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // 系统权限弹窗可能暂时 inactive;真正切后台仍应立即终止采集。 + if (state != AppLifecycleState.resumed && + _active && + !(state == AppLifecycleState.inactive && _starting)) { + _finish(); + unawaited(_cancel()); + } + } + + Future _cancel() async { + try { + await _speech.cancel(); + } catch (_) { + /* 离页后不再将平台错误写回界面。 */ + } + } + + void _finish() { + if (!mounted) return; + _deadline?.cancel(); + ++_generation; + setState(() { + _active = false; + _starting = false; + }); + widget.onBusy(false); + } + + Future _toggle() async { + if (_active) { + try { + await _speech.stop(); + } catch (_) { + if (mounted) setState(() => _error = '语音识别已中断,已识别文字保留。'); + } + if (mounted) _finish(); + await _cancel(); + return; + } + final text = widget.controller.text, selection = widget.controller.selection; + final start = selection.isValid ? selection.start.clamp(0, text.length) : text.length; + final end = selection.isValid ? selection.end.clamp(start, text.length) : text.length; + _before = text.substring(0, start); + _after = text.substring(end); + _lastText = text; + if ((_before + _after).characters.length >= 2000) { + setState(() => _error = '故障描述已满2000字,请先删减。'); + return; + } + final generation = ++_generation; + setState(() { + _active = true; + _starting = true; + _error = null; + }); + widget.onBusy(true); + // 兜底超时也覆盖权限窗口或平台没有结束回调的情况。 + _deadline = Timer(const Duration(seconds: 50), () { + if (mounted && generation == _generation) { + _finish(); + unawaited(_cancel()); + } + }); + try { + final available = await _speech.start( + onWords: (words) { + if (!mounted || generation != _generation) return; + if (widget.controller.text != _lastText) { + _finish(); + unawaited(_cancel()); + return; + } + final capacity = 2000 - (_before + _after).characters.length; + final insertion = words.characters.take(capacity).toString(); + _lastText = '$_before$insertion$_after'; + widget.controller.value = TextEditingValue( + text: _lastText, + selection: TextSelection.collapsed(offset: (_before + insertion).length), + ); + }, + onError: (code) { + if (!mounted || generation != _generation) return; + setState(() => _error = _message(code)); + _finish(); + unawaited(_cancel()); + }, + onDone: () { + if (mounted && generation == _generation) _finish(); + }, + ); + if (!mounted || generation != _generation) return; + if (!available) { + setState(() => _error = '无法使用语音识别,请检查麦克风权限或改用文字输入。'); + _finish(); + } else { + setState(() => _starting = false); + } + } catch (_) { + if (mounted && generation == _generation) { + setState(() => _error = '语音识别启动失败,请重试或输入文字。'); + _finish(); + await _cancel(); + } + } + } + + String _message(String code) { + if (code.contains('permission') || code.contains('not_allowed')) { + return '未获得麦克风或语音识别权限,请在系统设置中开启,或输入文字。'; + } + if (code.contains('no_match') || code.contains('speech_timeout')) return '没有识别到语音,请重试。'; + if (code.contains('network')) return '语音服务连接失败,已识别文字保留,请重试。'; + return '语音识别已中断,已识别文字保留,请重试。'; + } + + @override + void dispose() { + _deadline?.cancel(); + ++_generation; + WidgetsBinding.instance.removeObserver(this); + if (_active) unawaited(_cancel()); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: widget.enabled ? _toggle : null, + icon: Icon(_active ? Icons.stop_circle_outlined : Icons.mic_none, size: 22), + label: Text(_active ? (_starting ? '取消启动' : '结束识别') : '语音输入'), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + ), + ), + if (_error != null) + Text(_error!, style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.error)), + ], + ); +} diff --git a/apps/user_app/lib/ui/features/tickets/ticket_detail_page.dart b/apps/user_app/lib/ui/features/tickets/ticket_detail_page.dart new file mode 100644 index 0000000..194b8de --- /dev/null +++ b/apps/user_app/lib/ui/features/tickets/ticket_detail_page.dart @@ -0,0 +1,215 @@ +// 功能描述:展示本人报修详情、实际处理记录,以及确认后执行的取消和完成动作。 +// 版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../data/services/api_client.dart'; +import '../../../domain/models/service_ticket.dart'; +import '../../core/async_content.dart'; +import 'ticket_photos.dart'; + +class TicketDetailPage extends StatefulWidget { + const TicketDetailPage({required this.repository, required this.identity, super.key}); + final ClientRepository repository; + final String identity; + @override + State createState() => _TicketDetailPageState(); +} + +class _TicketDetailPageState extends State { + final _content = GlobalKey>(); + bool _busy = false; + bool _sending = false; + + /// 用户确认后才调用动作;响应失败保留详情,重试由服务端保证幂等。 + Future _act(String action) async { + if (_busy) return; + setState(() => _busy = true); + final cancelling = action == 'cancel'; + try { + final accepted = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(cancelling ? '取消这张工单?' : '确认问题已处理完成?'), + content: Text(cancelling ? '取消后本次服务申请将终止。' : '请确认处理结果与现场情况一致,完成后工单将关闭。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('返回')), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(cancelling ? '确认取消' : '确认完成'), + ), + ], + ), + ); + if (accepted != true || !mounted) return; + setState(() => _sending = true); + if (cancelling) { + await widget.repository.cancelTicket(widget.identity); + } else { + await widget.repository.confirmTicket(widget.identity); + } + if (!mounted) return; + await _content.currentState?.refresh(); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(cancelling ? '工单已取消' : '工单已完成'))); + } + } catch (error) { + if (mounted && error is! SessionExpiredException) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString()))); + } + } finally { + if (mounted) { + setState(() { + _busy = false; + _sending = false; + }); + } + } + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('报修工单详情'), + leading: BackButton( + onPressed: () => context.canPop() ? context.pop() : context.go('/orders?tab=tickets'), + ), + actions: [ + IconButton( + tooltip: '刷新工单', + onPressed: _busy ? null : () => _content.currentState?.refresh(), + icon: const Icon(Icons.refresh), + ), + ], + ), + body: AsyncContent( + key: _content, + load: () async => ServiceTicket(await widget.repository.ticket(widget.identity)), + builder: (context, ticket) => ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + children: [ + _section( + children: [ + Row( + children: [ + const Icon(Icons.build_circle, size: 42, color: Color(0xFF2563EB)), + const SizedBox(width: 12), + Expanded( + child: Text( + ticket.state, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(color: const Color(0xFF2563EB)), + ), + ), + ], + ), + const SizedBox(height: 12), + SelectableText('工单号:${ticket.number}'), + ], + ), + _section( + title: '故障信息', + children: [ + _field(Icons.build_outlined, '故障类型', ticket.fault), + _field(Icons.chat_bubble_outline, '故障描述', ticket.description), + _field(Icons.schedule, '提交时间', ticket.time('created_at')), + _field(Icons.location_on_outlined, '报修地址', ticket.address), + if (ticket.text('contact_name').isNotEmpty) + _field(Icons.person_outline, '联系人', ticket.text('contact_name')), + if (ticket.text('contact_phone').isNotEmpty) + _field(Icons.phone_outlined, '联系电话', ticket.text('contact_phone')), + ], + ), + _section( + title: '预约信息', + children: [ + _field( + Icons.schedule, + '预约时间', + ticket.text('appointment_at').isEmpty ? '未预约' : ticket.time('appointment_at'), + ), + ], + ), + if (ticket.photos.isNotEmpty) + _section( + children: [ + TicketPhotos( + repository: widget.repository, + ticketIdentity: ticket.identity, + photos: ticket.photos, + ), + ], + ), + _section( + title: '处理记录', + children: [ + _field(Icons.check_circle_outline, '已提交', ticket.time('created_at')), + if (ticket.text('started_at').isNotEmpty) + _field(Icons.engineering_outlined, '开始处理', ticket.time('started_at')), + if (ticket.result.isNotEmpty) + _field(Icons.assignment_turned_in_outlined, '处理结果', ticket.result), + if (ticket.result.isEmpty) + const Padding(padding: EdgeInsets.symmetric(vertical: 8), child: Text('暂无处理结果')), + if (ticket.text('completed_at').isNotEmpty) + _field(Icons.check_circle, '确认完成', ticket.time('completed_at')), + ], + ), + if (_sending) const LinearProgressIndicator(), + if (ticket.actions.contains('confirm')) ...[ + FilledButton( + onPressed: _busy ? null : () => _act('confirm'), + child: const Text('确认处理完成'), + ), + const SizedBox(height: 12), + ], + if (ticket.actions.contains('cancel')) + OutlinedButton( + onPressed: _busy ? null : () => _act('cancel'), + child: const Text('取消工单'), + ), + ], + ), + ), + ); + + /// 按设计稿的信息分组展示;窄屏和长地址自然换行。 + Widget _section({String? title, required List children}) => Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFE0E5EE)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) ...[ + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + ], + ...children, + ], + ), + ); + + Widget _field(IconData icon, String label, String value) => Padding( + padding: const EdgeInsets.symmetric(vertical: 9), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: const Color(0xFF2563EB), size: 20), + const SizedBox(width: 9), + SizedBox(width: 72, child: Text(label, style: Theme.of(context).textTheme.bodyMedium)), + const SizedBox(width: 8), + Expanded( + child: SelectableText(value, textAlign: TextAlign.right), + ), + ], + ), + ); +} diff --git a/apps/user_app/lib/ui/features/tickets/ticket_photos.dart b/apps/user_app/lib/ui/features/tickets/ticket_photos.dart new file mode 100644 index 0000000..09a560e --- /dev/null +++ b/apps/user_app/lib/ui/features/tickets/ticket_photos.dart @@ -0,0 +1,107 @@ +// 功能描述:读取本人工单照片并展示可重试缩略图,文件不使用公开URL。 +// 版本:1.0.0。 +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import '../../../data/repositories/client_repository.dart'; +import 'repair_photos.dart'; + +class TicketPhotos extends StatelessWidget { + const TicketPhotos({ + required this.repository, + required this.ticketIdentity, + required this.photos, + super.key, + }); + final ClientRepository repository; + final String ticketIdentity; + final List> photos; + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('现场照片(${photos.length}张)', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + GridView.count( + crossAxisCount: 3, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 10, + crossAxisSpacing: 10, + children: [ + for (final photo in photos) + _Photo( + repository: repository, + ticketIdentity: ticketIdentity, + identity: photo['identity'] as String, + ), + ], + ), + const SizedBox(height: 8), + for (var index = 0; index < photos.length; index++) + Text( + '照片${index + 1}添加时间:${DateTime.tryParse(photos[index]['added_at'] as String? ?? '')?.toLocal().toString().substring(0, 16) ?? '未记录'}', + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + const Text('拍摄时间与位置未核实', style: TextStyle(fontSize: 12, color: Colors.grey)), + ], + ); +} + +class _Photo extends StatefulWidget { + const _Photo({required this.repository, required this.ticketIdentity, required this.identity}); + final ClientRepository repository; + final String ticketIdentity, identity; + @override + State<_Photo> createState() => _PhotoState(); +} + +class _PhotoState extends State<_Photo> { + late Future _image; + @override + void initState() { + super.initState(); + _load(); + } + + void _load() { + _image = widget.repository.ticketPhoto(widget.ticketIdentity, widget.identity); + } + + @override + void didUpdateWidget(covariant _Photo oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.identity != widget.identity || + oldWidget.ticketIdentity != widget.ticketIdentity) { + _load(); + } + } + + @override + Widget build(BuildContext context) => FutureBuilder( + future: _image, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (!snapshot.hasData || snapshot.hasError) { + return IconButton( + tooltip: '重新加载照片', + onPressed: () => setState(_load), + icon: const Icon(Icons.broken_image_outlined), + ); + } + return InkWell( + onTap: () => showRepairPhoto(context, snapshot.data!), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.memory( + snapshot.data!, + fit: BoxFit.cover, + semanticLabel: '查看现场照片', + errorBuilder: (_, e, s) => const Icon(Icons.broken_image_outlined), + ), + ), + ); + }, + ); +} diff --git a/apps/user_app/lib/ui/features/wallet/bank_cards_page.dart b/apps/user_app/lib/ui/features/wallet/bank_cards_page.dart new file mode 100644 index 0000000..fa65181 --- /dev/null +++ b/apps/user_app/lib/ui/features/wallet/bank_cards_page.dart @@ -0,0 +1,465 @@ +// 功能描述:图40银行卡列表、绑定与安全解绑;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/wallet_account.dart'; + +/// 银行卡页面只显示脱敏字段,敏感信息仅在提交绑定时短暂存在于表单。 +class BankCardsPage extends StatefulWidget { + const BankCardsPage({required this.repository, super.key}); + final ClientRepository repository; + + @override + State createState() => _BankCardsPageState(); +} + +class _BankCardsPageState extends State { + late Future> _future = widget.repository.walletBanks(); + + /// 重新读取服务端银行卡事实。 + Future _refresh() async { + final future = widget.repository.walletBanks(); + setState(() => _future = future); + await future; + } + + /// 打开完整绑定表单,成功后重新读取脱敏列表。 + Future _add() async { + final changed = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => _BindBankSheet(repository: widget.repository), + ); + if (changed == true && mounted) await _refresh(); + } + + /// 解绑前再次收取支付密码,并由服务端检查待处理提现。 + Future _unbind(WalletBank bank) async { + final controller = TextEditingController(); + var submitting = false; + String? error; + final changed = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: const Text('解除绑定'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${bank.bankName} ${bank.maskedNumber}'), + const SizedBox(height: 12), + TextField( + controller: controller, + obscureText: true, + keyboardType: TextInputType.number, + maxLength: 6, + decoration: InputDecoration(labelText: '支付密码', errorText: error), + ), + ], + ), + actions: [ + TextButton( + onPressed: submitting ? null : () => Navigator.pop(context, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: submitting + ? null + : () async { + if (!RegExp(r'^\d{6}$').hasMatch(controller.text)) { + setDialogState(() => error = '请输入6位支付密码'); + return; + } + setDialogState(() { + submitting = true; + error = null; + }); + try { + await widget.repository.unbindWalletBank(bank.identity, controller.text); + if (dialogContext.mounted) Navigator.pop(dialogContext, true); + } catch (e) { + setDialogState(() { + submitting = false; + error = e.toString(); + }); + } + }, + child: Text(submitting ? '提交中…' : '确认解绑'), + ), + ], + ), + ), + ); + controller.dispose(); + if (changed == true && mounted) await _refresh(); + } + + /// 默认到账卡切换以服务端结果为准。 + Future _setDefault(WalletBank bank) async { + try { + await widget.repository.setDefaultWalletBank(bank.identity); + if (mounted) await _refresh(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString()))); + } + } + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('银行卡管理'), + centerTitle: true, + leading: IconButton( + tooltip: '返回', + onPressed: () => context.canPop() ? context.pop() : context.go('/wallet'), + icon: const Icon(Icons.arrow_back), + ), + actions: [TextButton(onPressed: _add, child: const Text('添加'))], + ), + body: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center( + child: TextButton(onPressed: _refresh, child: const Text('银行卡加载失败,点击重试')), + ); + } + final banks = snapshot.data!; + return RefreshIndicator( + onRefresh: _refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(14), + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: _box(), + child: const Row( + children: [ + Icon(Icons.verified_user_outlined, color: Color(0xFF2563EB)), + SizedBox(width: 8), + Expanded( + child: Text( + '银行卡仅用于余额提现,必须为本人实名账户', + style: TextStyle(color: Color(0xFF777F8D)), + ), + ), + ], + ), + ), + const Padding( + padding: EdgeInsets.fromLTRB(2, 14, 2, 8), + child: Text('我的银行卡', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)), + ), + if (banks.isEmpty) + Container( + padding: const EdgeInsets.symmetric(vertical: 36, horizontal: 16), + decoration: _box(), + child: const Column( + children: [ + Icon(Icons.credit_card_off_outlined, size: 42, color: Color(0xFF9CA3AF)), + SizedBox(height: 12), + Text('暂无银行卡'), + SizedBox(height: 4), + Text( + '添加本人储蓄卡后可申请提现', + style: TextStyle(color: Color(0xFF777F8D), fontSize: 13), + ), + ], + ), + ), + for (final bank in banks) + _BankCard( + bank: bank, + onDefault: () => _setDefault(bank), + onUnbind: () => _unbind(bank), + ), + const SizedBox(height: 8), + _SecurityPanel(), + const SizedBox(height: 8), + _NoticePanel(), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _add, + icon: const Icon(Icons.add_card), + label: const Text('添加银行卡'), + ), + ], + ), + ); + }, + ), + ); +} + +/// 银行卡列表项只展示服务端确认的默认状态。 +class _BankCard extends StatelessWidget { + const _BankCard({required this.bank, required this.onDefault, required this.onUnbind}); + final WalletBank bank; + final VoidCallback onDefault, onUnbind; + + @override + Widget build(BuildContext context) => Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: _box(), + child: Column( + children: [ + Row( + children: [ + const CircleAvatar( + radius: 18, + backgroundColor: Color(0xFFEAF1FF), + child: Icon(Icons.account_balance, color: Color(0xFF2563EB)), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + bank.bankName, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + if (bank.isDefault) + const Icon(Icons.check_circle, color: Color(0xFF2563EB), size: 20), + ], + ), + const SizedBox(height: 5), + Text( + '${bank.typeName}${bank.typeName.isEmpty ? '' : ' '}${bank.maskedNumber}', + style: const TextStyle(color: Color(0xFF4B5563)), + ), + if (bank.owner.isNotEmpty) + Text('持卡人 ${bank.owner}', style: const TextStyle(color: Color(0xFF777F8D))), + ], + ), + ), + ], + ), + const Divider(height: 8), + SizedBox( + height: 48, + child: Row( + children: [ + Expanded( + child: TextButton( + onPressed: bank.isDefault ? null : onDefault, + child: Text(bank.isDefault ? '默认到账卡' : '设为默认卡'), + ), + ), + const SizedBox(height: 24, child: VerticalDivider(width: 1)), + Expanded( + child: TextButton(onPressed: onUnbind, child: const Text('解除绑定')), + ), + ], + ), + ), + ], + ), + ); +} + +class _SecurityPanel extends StatelessWidget { + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: _box(), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('资金安全', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), + SizedBox(height: 4), + ListTile( + dense: true, + visualDensity: VisualDensity(vertical: -2), + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.badge_outlined, color: Color(0xFF2563EB)), + title: Text('实名认证'), + subtitle: Text('持卡人信息必须与实名资料一致'), + ), + Divider(height: 1), + ListTile( + dense: true, + visualDensity: VisualDensity(vertical: -2), + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.lock_outline, color: Color(0xFF2563EB)), + title: Text('支付密码'), + subtitle: Text('绑定和解绑均需验证'), + ), + ], + ), + ); +} + +class _NoticePanel extends StatelessWidget { + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: _box(), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('添加银行卡须知', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), + SizedBox(height: 6), + Text( + '• 仅支持本人名下储蓄卡,不支持信用卡\n• 持卡人姓名需与实名认证信息一致\n• 银行卡验证结果以银行回执为准', + style: TextStyle(height: 1.55, color: Color(0xFF777F8D), fontSize: 12), + ), + ], + ), + ); +} + +/// 绑定表单提交前完成客户端格式检查,最终身份和密码仍由服务端验证。 +class _BindBankSheet extends StatefulWidget { + const _BindBankSheet({required this.repository}); + final ClientRepository repository; + + @override + State<_BindBankSheet> createState() => _BindBankSheetState(); +} + +class _BindBankSheetState extends State<_BindBankSheet> { + final _form = GlobalKey(); + final _bank = TextEditingController(); + final _card = TextEditingController(); + final _owner = TextEditingController(); + final _idCard = TextEditingController(); + final _phone = TextEditingController(); + final _password = TextEditingController(); + bool _busy = false; + String? _error; + + /// 保存绑定请求,失败时保留用户输入便于修正。 + Future _submit() async { + if (!_form.currentState!.validate()) { + return; + } + setState(() { + _busy = true; + _error = null; + }); + try { + await widget.repository.bindWalletBank( + cardNo: _card.text, + bankName: _bank.text, + cardOwner: _owner.text, + idCard: _idCard.text, + phone: _phone.text, + paymentPassword: _password.text, + ); + if (mounted) Navigator.pop(context, true); + } catch (e) { + if (mounted) { + setState(() { + _busy = false; + _error = e.toString(); + }); + } + } + } + + @override + void dispose() { + for (final controller in [_bank, _card, _owner, _idCard, _phone, _password]) { + controller.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) => Padding( + padding: EdgeInsets.fromLTRB(20, 16, 20, MediaQuery.viewInsetsOf(context).bottom + 20), + child: Form( + key: _form, + child: ListView( + shrinkWrap: true, + children: [ + Row( + children: [ + const Expanded( + child: Text('添加银行卡', style: TextStyle(fontSize: 21, fontWeight: FontWeight.w700)), + ), + IconButton( + tooltip: '关闭', + onPressed: _busy ? null : () => Navigator.pop(context), + icon: const Icon(Icons.close), + ), + ], + ), + const Text('敏感信息经服务端加密保存,页面仅展示卡号末四位。', style: TextStyle(color: Color(0xFF777F8D))), + const SizedBox(height: 16), + _field(_bank, '开户银行'), + _field( + _card, + '银行卡号', + keyboard: TextInputType.number, + validator: (v) => RegExp(r'^\d{12,32}$').hasMatch((v ?? '').replaceAll(' ', '')) + ? null + : '请输入12至32位银行卡号', + ), + _field(_owner, '持卡人姓名'), + _field(_idCard, '身份证号'), + _field( + _phone, + '银行预留手机号', + keyboard: TextInputType.phone, + validator: (v) => RegExp(r'^1\d{10}$').hasMatch(v ?? '') ? null : '请输入11位手机号', + ), + _field( + _password, + '6位支付密码', + keyboard: TextInputType.number, + obscure: true, + validator: (v) => RegExp(r'^\d{6}$').hasMatch(v ?? '') ? null : '请输入6位支付密码', + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text(_error!, style: const TextStyle(color: Color(0xFFC7352A))), + ), + FilledButton(onPressed: _busy ? null : _submit, child: Text(_busy ? '提交中…' : '确认添加')), + ], + ), + ), + ); + + /// 统一首期银行卡输入校验。 + Widget _field( + TextEditingController controller, + String label, { + TextInputType? keyboard, + bool obscure = false, + String? Function(String?)? validator, + }) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextFormField( + controller: controller, + keyboardType: keyboard, + obscureText: obscure, + maxLength: obscure ? 6 : null, + decoration: InputDecoration(labelText: label), + validator: validator ?? (value) => (value ?? '').trim().isEmpty ? '请填写$label' : null, + ), + ); +} + +BoxDecoration _box() => BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFE5E7EB)), +); diff --git a/apps/user_app/lib/ui/features/wallet/deposit_page.dart b/apps/user_app/lib/ui/features/wallet/deposit_page.dart new file mode 100644 index 0000000..da61d46 --- /dev/null +++ b/apps/user_app/lib/ui/features/wallet/deposit_page.dart @@ -0,0 +1,243 @@ +// 功能描述:展示真实押金汇总、状态筛选、规则与退瓶入口;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/client_models.dart'; +import '../../../domain/models/deposit.dart'; +import '../../core/async_content.dart'; + +class DepositPage extends StatefulWidget { + const DepositPage({required this.repository, super.key}); + final ClientRepository repository; + + @override + State createState() => _DepositPageState(); +} + +class _DepositPageState extends State { + int _filter = 0; + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('押金管理'), + leading: BackButton(onPressed: () => context.canPop() ? context.pop() : context.go('/me')), + ), + body: AsyncContent( + load: widget.repository.deposits, + builder: (context, summary) { + final items = summary.items + .where((item) => _filter == 0 || item.status == [0, 10, 20, 23][_filter]) + .toList(); + return ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + _summary(summary), + const SizedBox(height: 12), + _filters(), + const SizedBox(height: 12), + if (items.isEmpty) _empty(), + for (final item in items) _record(item), + _rules(summary.ruleText), + ], + ); + }, + ), + ); + + Widget _summary(DepositSummary summary) => Container( + padding: const EdgeInsets.symmetric(vertical: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFE5E7EB)), + ), + child: Row( + children: [ + Expanded(child: _metric('可退押金', moneyText(summary.refundableAmount), '押金退至余额账户')), + Container(width: 1, height: 64, color: const Color(0xFFE5E7EB)), + Expanded(child: _metric('在押气瓶', '${summary.usingCount} 个', '以服务端绑定为准')), + ], + ), + ); + + Widget _metric(String title, String value, String hint) => Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(title), + const SizedBox(width: 4), + const Icon(Icons.help_outline, size: 16, color: Color(0xFF6B7280)), + ], + ), + const SizedBox(height: 8), + Text( + value, + style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w600, color: Color(0xFF2563EB)), + ), + const SizedBox(height: 5), + Text( + hint, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 11, color: Color(0xFF6B7280)), + ), + ], + ); + + Widget _filters() => Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFE5E7EB)), + ), + child: Row( + children: [ + for (final (index, label) in ['全部', '使用中', '退款中', '已退回'].indexed) + Expanded( + child: TextButton( + onPressed: () => setState(() => _filter = index), + style: TextButton.styleFrom( + foregroundColor: _filter == index + ? const Color(0xFF2563EB) + : const Color(0xFF374151), + side: _filter == index + ? const BorderSide( + color: Color(0xFF2563EB), + width: 0, + strokeAlign: BorderSide.strokeAlignOutside, + ) + : null, + ), + child: Text(label), + ), + ), + ], + ), + ); + + Widget _empty() => Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(vertical: 38), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)), + child: const Column( + children: [ + Icon(Icons.inventory_2_outlined, size: 42, color: Color(0xFF9CA3AF)), + SizedBox(height: 10), + Text('暂无押金记录'), + SizedBox(height: 4), + Text('完成含押金的气瓶订单后将在这里显示', style: TextStyle(fontSize: 12, color: Color(0xFF6B7280))), + ], + ), + ); + + Widget _record(DepositRecord item) => Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFE5E7EB)), + ), + child: Column( + children: [ + Row( + children: [ + const Icon(Icons.propane_tank_outlined, size: 34, color: Color(0xFFF05A24)), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.productName.isEmpty ? '气瓶' : item.productName, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + Text( + '押金编号:${item.depositNo}', + style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280)), + ), + ], + ), + ), + _status(item), + ], + ), + const Divider(height: 18), + _line('押金金额', moneyText(item.amount)), + _line('绑定气瓶编号', item.productCode), + _line('缴纳日期', _date(item.paidAt)), + if (item.status == 20) _line('退款进度', '预计1-3个工作日到账'), + if (item.returnStatusName.isNotEmpty) _line('退瓶进度', item.returnStatusName), + if (item.refundedAt != null) _line('退款日期', _date(item.refundedAt!)), + if (item.allowedActions.contains('return_bottle')) + Align( + alignment: Alignment.centerRight, + child: SizedBox( + width: 116, + height: 36, + child: OutlinedButton( + onPressed: () => + context.push('/deposits/return?deposit=${Uri.encodeComponent(item.identity)}'), + child: const Text('申请退瓶'), + ), + ), + ), + ], + ), + ); + + Widget _status(DepositRecord item) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: item.status == 10 + ? const Color(0xFFE8F7EF) + : item.status == 20 + ? const Color(0xFFFFF2E8) + : const Color(0xFFF3F4F6), + borderRadius: BorderRadius.circular(16), + ), + child: Text( + item.statusName, + style: TextStyle( + color: item.status == 10 + ? const Color(0xFF16875D) + : item.status == 20 + ? const Color(0xFFB86400) + : const Color(0xFF6B7280), + ), + ), + ); + Widget _line(String label, String value) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + Expanded( + child: Text(label, style: const TextStyle(color: Color(0xFF6B7280))), + ), + Text(value, style: TextStyle(color: label == '退款进度' ? const Color(0xFFEF6C00) : null)), + ], + ), + ); + String _date(DateTime value) => + '${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}'; + + Widget _rules(String text) => ListTile( + tileColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + leading: const Icon(Icons.verified_user_outlined, color: Color(0xFF2563EB)), + title: const Text('押金规则'), + subtitle: Text(text.isEmpty ? '押金规则暂未配置' : text, maxLines: 2, overflow: TextOverflow.ellipsis), + trailing: const Icon(Icons.chevron_right), + onTap: () => showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('押金规则'), + content: Text(text.isEmpty ? '押金规则暂未配置' : text), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('知道了'))], + ), + ), + ); +} diff --git a/apps/user_app/lib/ui/features/wallet/deposit_return_page.dart b/apps/user_app/lib/ui/features/wallet/deposit_return_page.dart new file mode 100644 index 0000000..d648a64 --- /dev/null +++ b/apps/user_app/lib/ui/features/wallet/deposit_return_page.dart @@ -0,0 +1,513 @@ +// 功能描述:按产品稿完成选瓶、上门回收、预约、状态确认和退款申请;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../data/repositories/client_repository.dart'; +import '../../../data/services/api_client.dart'; +import '../../../domain/models/client_models.dart'; +import '../../../domain/models/deposit.dart'; +import '../../../domain/models/shipping_address.dart'; +import '../../core/async_content.dart'; + +class DepositReturnPage extends StatefulWidget { + const DepositReturnPage({required this.repository, this.depositIdentity = '', super.key}); + + final ClientRepository repository; + final String depositIdentity; + + @override + State createState() => _DepositReturnPageState(); +} + +class _DepositReturnPageState extends State { + DepositRecord? _deposit; + List _addresses = const []; + ShippingAddress? _address; + late DateTime _start; + late DateTime _end; + bool _intact = false, + _valveSafe = false, + _stopped = false, + _accepted = false, + _submitting = false; + + @override + void initState() { + super.initState(); + final tomorrow = DateTime.now().add(const Duration(days: 1)); + _start = DateTime(tomorrow.year, tomorrow.month, tomorrow.day, 14); + _end = _start.add(const Duration(hours: 2)); + } + + Future<(DepositSummary, List)> _load() async { + final values = await Future.wait([ + widget.repository.deposits(), + widget.repository.shippingAddresses(), + ]); + final summary = values[0] as DepositSummary; + _addresses = values[1] as List; + final eligible = summary.items + .where((item) => item.allowedActions.contains('return_bottle')) + .toList(); + _deposit = eligible.cast().firstWhere( + (item) => widget.depositIdentity.isEmpty || item?.identity == widget.depositIdentity, + orElse: () => null, + ); + _address = _addresses + .where((item) => item.isDefault) + .cast() + .firstWhere( + (item) => true, + orElse: () => _addresses.isEmpty ? null : _addresses.first, + ); + return (summary, _addresses); + } + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: const Color(0xFFF7F8FA), + appBar: AppBar( + title: const Text('退瓶退押金'), + leading: BackButton( + onPressed: () => context.canPop() ? context.pop() : context.go('/deposits'), + ), + ), + body: AsyncContent<(DepositSummary, List)>( + load: _load, + builder: (context, data) => _deposit == null ? _empty() : _content(data.$1), + ), + ); + + Widget _empty() => Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.propane_tank_outlined, size: 54, color: Color(0xFF9CA3AF)), + const SizedBox(height: 12), + const Text('暂无可退气瓶', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + const SizedBox(height: 6), + const Text('只有“使用中”且已缴纳押金的气瓶可申请回收', textAlign: TextAlign.center), + const SizedBox(height: 18), + FilledButton(onPressed: () => context.go('/deposits'), child: const Text('返回押金管理')), + ], + ), + ), + ); + + Widget _content(DepositSummary summary) => ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + children: [ + _steps(), + const SizedBox(height: 7), + _card('可退气瓶', _bottle()), + _card('回收方式', _pickup()), + _card('预约时间', _appointment()), + _card( + '气瓶状态确认', + Column( + children: [ + _check('瓶体完整,无变形、锈蚀', _intact, (v) => _intact = v), + _check('阀门无缺失,无泄漏', _valveSafe, (v) => _valveSafe = v), + _check('已停止使用并关闭阀门', _stopped, (v) => _stopped = v), + ], + ), + ), + _card('退款信息', _refundInfo()), + Material( + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: Color(0xFFE5E7EB)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 4, 10, 9), + child: Column( + children: [ + _check('我已阅读《退瓶规则》', _accepted, (value) => _accepted = value), + SizedBox( + width: double.infinity, + height: 44, + child: FilledButton( + onPressed: _canSubmit ? _submit : null, + child: Text( + _submitting ? '提交中…' : '提交退瓶申请', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + ), + ], + ), + ), + ), + if (summary.ruleText.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 10), + child: Text( + '押金规则尚未由后台配置,提交前请联系客服确认。', + textAlign: TextAlign.center, + style: TextStyle(color: Color(0xFFB45309), fontSize: 12), + ), + ), + ], + ); + + bool get _canSubmit => + !_submitting && + _deposit != null && + _address != null && + _intact && + _valveSafe && + _stopped && + _accepted; + + Widget _steps() => const Row( + children: [ + Expanded( + child: _Step(number: '1', label: '选择气瓶', active: true), + ), + Expanded( + child: _Step(number: '2', label: '确认回收'), + ), + Expanded( + child: _Step(number: '3', label: '退款到账'), + ), + ], + ); + + Widget _card(String title, Widget child) => Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Material( + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: Color(0xFFE1E5EB)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 7, 12, 7), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + child, + ], + ), + ), + ), + ); + + Widget _bottle() => Row( + children: [ + Container( + width: 54, + height: 60, + alignment: Alignment.center, + child: const Icon(Icons.propane_tank, size: 46, color: Color(0xFFF05A24)), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _deposit!.productName, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + Text( + '绑定编号:${_deposit!.productCode}', + style: const TextStyle(color: Color(0xFF6B7280), fontSize: 12), + ), + Text( + '押金金额:${moneyText(_deposit!.amount)}', + style: const TextStyle(color: Color(0xFF6B7280), fontSize: 12), + ), + ], + ), + ), + const Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _Tag('使用中'), + SizedBox(height: 7), + Text('最近安检记录暂未开放', style: TextStyle(fontSize: 10, color: Color(0xFF6B7280))), + ], + ), + ], + ); + + Widget _pickup() => InkWell( + onTap: _chooseAddress, + child: Row( + children: [ + const Icon(Icons.local_shipping_outlined, size: 32, color: Color(0xFF1677FF)), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('上门回收', style: TextStyle(fontSize: 15)), + const SizedBox(height: 2), + Text( + _address?.address ?? '请选择上门地址', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFF6B7280), fontSize: 11), + ), + ], + ), + ), + const Text('修改', style: TextStyle(color: Color(0xFF1677FF), fontSize: 12)), + const Icon(Icons.chevron_right, color: Color(0xFF1677FF)), + ], + ), + ); + + Widget _appointment() => InkWell( + onTap: _chooseAppointment, + child: Row( + children: [ + const Icon(Icons.calendar_month_outlined, size: 32, color: Color(0xFF1677FF)), + const SizedBox(width: 10), + Expanded( + child: Text( + '${_dayLabel(_start)} ${_hm(_start)}-${_hm(_end)}', + style: const TextStyle(fontSize: 14), + ), + ), + const Text('修改', style: TextStyle(color: Color(0xFF1677FF), fontSize: 12)), + const Icon(Icons.chevron_right, color: Color(0xFF1677FF)), + ], + ), + ); + + Widget _check(String label, bool value, void Function(bool) update) => InkWell( + onTap: () => setState(() => update(!value)), + child: SizedBox( + height: 33, + child: Row( + children: [ + Checkbox( + value: value, + onChanged: (selected) => setState(() => update(selected == true)), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + const SizedBox(width: 4), + Expanded(child: Text(label, style: const TextStyle(fontSize: 13))), + ], + ), + ), + ); + + Widget _refundInfo() => Column( + children: [ + _info( + Icons.account_balance_wallet_outlined, + '预计退款', + moneyText(_deposit!.amount), + highlight: true, + ), + _info(Icons.account_balance_outlined, '退至账户', '余额账户'), + _info(Icons.schedule, '预计到账时间', '验收合格后1-3个工作日到账'), + Container( + width: double.infinity, + margin: const EdgeInsets.only(top: 5), + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 7), + color: const Color(0xFFFFF4E8), + child: const Text( + '损坏或配件缺失可能影响退款金额', + style: TextStyle(color: Color(0xFFEA7100), fontSize: 12), + ), + ), + ], + ); + + Widget _info(IconData icon, String label, String value, {bool highlight = false}) => Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + Icon(icon, color: const Color(0xFF1677FF), size: 21), + const SizedBox(width: 8), + Text(label, style: const TextStyle(color: Color(0xFF6B7280), fontSize: 12)), + const Spacer(), + Flexible( + child: Text( + value, + textAlign: TextAlign.right, + style: TextStyle( + color: highlight ? const Color(0xFF1677FF) : null, + fontSize: highlight ? 18 : 12, + fontWeight: highlight ? FontWeight.w600 : null, + ), + ), + ), + ], + ), + ); + + Future _chooseAddress() async { + if (_addresses.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('请先在地址管理中新增完整联系人地址'))); + return; + } + final selected = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const ListTile( + title: Text('选择上门地址', style: TextStyle(fontWeight: FontWeight.w600)), + ), + for (final address in _addresses) + ListTile( + leading: const Icon(Icons.location_on_outlined), + title: Text('${address.contactName} ${address.maskedPhone}'), + subtitle: Text(address.address), + onTap: () => Navigator.pop(context, address), + ), + ], + ), + ), + ); + if (selected != null) setState(() => _address = selected); + } + + Future _chooseAppointment() async { + final tomorrow = DateTime.now().add(const Duration(days: 1)); + final base = DateTime(tomorrow.year, tomorrow.month, tomorrow.day); + final selected = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const ListTile( + title: Text('选择上门时间', style: TextStyle(fontWeight: FontWeight.w600)), + ), + for (final hour in [10, 14, 16]) + ListTile( + leading: const Icon(Icons.schedule), + title: Text( + '明日 ${hour.toString().padLeft(2, '0')}:00-${(hour + 2).toString().padLeft(2, '0')}:00', + ), + onTap: () => Navigator.pop(context, base.add(Duration(hours: hour))), + ), + ], + ), + ), + ); + if (selected != null) { + setState(() { + _start = selected; + _end = selected.add(const Duration(hours: 2)); + }); + } + } + + Future _submit() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('确认提交退瓶申请'), + content: Text('工作人员将于${_dayLabel(_start)} ${_hm(_start)}-${_hm(_end)}上门回收,验收后退入余额账户。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('再检查一下')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('确认提交')), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _submitting = true); + try { + final result = await widget.repository.createDepositReturn( + depositIdentity: _deposit!.identity, + address: _address!, + appointmentStart: _start, + appointmentEnd: _end, + requestNo: 'deposit-return:${_deposit!.identity}:${DateTime.now().millisecondsSinceEpoch}', + ); + if (!mounted) return; + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('申请已提交'), + content: Text('当前状态:${result.statusName}'), + actions: [ + FilledButton(onPressed: () => Navigator.pop(context), child: const Text('知道了')), + ], + ), + ); + if (mounted) context.go('/deposits'); + } on ApiException catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.message))); + } + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + String _dayLabel(DateTime value) { + final tomorrow = DateTime.now().add(const Duration(days: 1)); + return value.year == tomorrow.year && value.month == tomorrow.month && value.day == tomorrow.day + ? '明日' + : '${value.month}月${value.day}日'; + } + + String _hm(DateTime value) => + '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}'; +} + +class _Step extends StatelessWidget { + const _Step({required this.number, required this.label, this.active = false}); + final String number, label; + final bool active; + @override + Widget build(BuildContext context) => Column( + children: [ + Row( + children: [ + Expanded( + child: Divider(color: active ? const Color(0xFF1677FF) : const Color(0xFFD1D5DB)), + ), + CircleAvatar( + radius: 14, + backgroundColor: active ? const Color(0xFF1677FF) : Colors.white, + foregroundColor: active ? Colors.white : const Color(0xFF6B7280), + child: Text(number), + ), + Expanded( + child: Divider(color: active ? const Color(0xFF1677FF) : const Color(0xFFD1D5DB)), + ), + ], + ), + const SizedBox(height: 5), + Text( + label, + style: TextStyle( + color: active ? const Color(0xFF1677FF) : const Color(0xFF6B7280), + fontSize: 12, + ), + ), + ], + ); +} + +class _Tag extends StatelessWidget { + const _Tag(this.text); + final String text; + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: const Color(0xFFE8F7EF), + borderRadius: BorderRadius.circular(16), + ), + child: Text(text, style: const TextStyle(color: Color(0xFF15945B))), + ); +} diff --git a/apps/user_app/lib/ui/features/wallet/recharge_page.dart b/apps/user_app/lib/ui/features/wallet/recharge_page.dart new file mode 100644 index 0000000..1111add --- /dev/null +++ b/apps/user_app/lib/ui/features/wallet/recharge_page.dart @@ -0,0 +1,308 @@ +// 功能描述:余额充值金额、渠道、协议和待确认请求恢复;版本:1.0.0。 +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:uuid/uuid.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../data/services/payment_launcher.dart'; +import '../../../data/services/recharge_draft_store.dart'; +import '../../../data/services/recharge_flow.dart'; +import '../../../domain/models/client_models.dart'; +import '../../../domain/models/recharge.dart'; + +/// 渠道返回后只查询到账事实;异常请求保留供继续支付或查询。 +class RechargePage extends StatefulWidget { + const RechargePage({required this.repository, this.store, this.launchPayment, super.key}); + final ClientRepository repository; + final RechargeDraftStore? store; + final Future Function(Map)? launchPayment; + @override + State createState() => _RechargePageState(); +} + +class _RechargePageState extends State { + final _amount = TextEditingController(text: '100'); + final _amountFocus = FocusNode(); + late final _store = widget.store ?? SecureRechargeDraftStore(); + late final _flow = RechargeFlow(widget.repository, _store); + RechargeOptions? _options; + WalletSummary? _wallet; + PendingRecharge? _pending; + String? _channel, _message; + bool _busy = true, _agreed = false, _hidden = false; + bool _available(RechargeChannel c) => kIsWeb ? c.wap : c.app; + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _amount.dispose(); + _amountFocus.dispose(); + super.dispose(); + } + + Future _load() async { + setState(() { + _busy = true; + _message = null; + }); + try { + final owner = await widget.repository.rechargeDraftOwner(); + final pending = await _store.read(owner); + final options = await widget.repository.rechargeOptions(); + final wallet = await widget.repository.wallet(); + if (await widget.repository.rechargeDraftOwner() != owner) throw StateError('账户已切换'); + if (!mounted) return; + setState(() { + _options = options; + _wallet = wallet; + _pending = pending; + _channel = pending?.channel; + for (final c in options.channels) { + if (_channel == null && _available(c)) _channel = c.name; + } + if (pending != null) _amount.text = (pending.amount / 100).toStringAsFixed(2); + }); + } catch (_) { + if (mounted) setState(() => _message = '充值信息加载失败,请重试'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _query() async { + setState(() { + _busy = true; + _message = null; + }); + try { + final result = await _flow.recover(); + if (!mounted) return; + setState(() { + _message = result?.statusText ?? '没有待确认充值'; + if (result == null || result.credited) { + _pending = null; + _agreed = false; + } + }); + if (result?.credited == true) { + // 余额刷新失败不能否认服务端已经确认的入账结果。 + try { + final wallet = await widget.repository.wallet(); + if (mounted) setState(() => _wallet = wallet); + } catch (_) { + if (mounted) setState(() => _message = '已到账,余额刷新失败,请重新进入页面刷新'); + } + } + } catch (_) { + if (mounted) setState(() => _message = '暂未确认充值结果,请稍后查询'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _pay() async { + final options = _options!; + final cents = parseRechargeAmount(_amount.text); + if (cents == null || cents < options.min || cents > options.max) { + setState(() => _message = '请输入${moneyText(options.min)}至${moneyText(options.max)}之间的金额'); + return; + } + final draft = + _pending ?? + PendingRecharge( + request: const Uuid().v4(), + amount: cents, + channel: _channel!, + payType: kIsWeb ? 'wap' : 'app', + ); + setState(() { + _busy = true; + _message = null; + }); + try { + await widget.repository.confirmRechargeAgreement(options.agreement!); + if (!mounted) return; + setState(() => _pending = draft); + final payment = await _flow.create(draft); + if (!mounted) return; + if (payment['recharge_status'] == 23) { + await _query(); + return; + } + await (widget.launchPayment ?? PaymentLauncher().launch)(payment); + if (mounted) setState(() => _message = '请完成支付后查询到账结果'); + } catch (_) { + if (mounted) setState(() => _message = '支付未完成确认,可查询结果或继续原订单支付'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + void _agreement() { + final agreement = _options?.agreement; + if (agreement == null) return; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(agreement.title), + content: SingleChildScrollView(child: SelectableText(agreement.body)), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('关闭'))], + ), + ); + } + + @override + Widget build(BuildContext context) { + final options = _options; + final enabled = + !_busy && + options?.agreement != null && + _agreed && + options!.channels.any((c) => c.name == _channel && _available(c)); + return Scaffold( + appBar: AppBar( + title: const Text('余额充值'), + centerTitle: true, + leading: IconButton( + tooltip: '返回', + icon: const Icon(Icons.arrow_back), + onPressed: () => context.canPop() ? context.pop() : context.go('/wallet'), + ), + actions: [ + TextButton( + onPressed: () => context.push('/wallet/recharge-records'), + child: const Text('充值记录'), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + if (_busy) const LinearProgressIndicator(), + if (_message != null) + Padding(padding: const EdgeInsets.symmetric(vertical: 12), child: Text(_message!)), + if (options == null) + TextButton(onPressed: _busy ? null : _load, child: const Text('重新加载')), + if (options != null) ...[ + Row( + children: [ + const Expanded(child: Text('当前账户余额')), + IconButton( + tooltip: _hidden ? '显示余额' : '隐藏余额', + onPressed: () => setState(() => _hidden = !_hidden), + icon: Icon(_hidden ? Icons.visibility_off_outlined : Icons.visibility_outlined), + ), + ], + ), + Text( + _hidden ? '••••' : moneyText(_wallet!.balance), + style: const TextStyle(fontSize: 30, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 24), + const Text('充值金额'), + const SizedBox(height: 12), + Wrap( + spacing: 10, + runSpacing: 8, + children: [ + for (final value in ['100', '300', '500']) + ChoiceChip( + label: Text('$value元'), + selected: _amount.text == value, + onSelected: _busy || _pending != null + ? null + : (_) => setState(() => _amount.text = value), + ), + ChoiceChip( + label: const Text('自定义'), + selected: !['100', '300', '500'].contains(_amount.text), + onSelected: _busy || _pending != null + ? null + : (_) { + setState(() => _amount.clear()); + _amountFocus.requestFocus(); + }, + ), + ], + ), + TextField( + controller: _amount, + focusNode: _amountFocus, + onChanged: (_) => setState(() {}), + enabled: !_busy && _pending == null, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: '自定义金额', + prefixText: '¥ ', + suffixIcon: IconButton( + tooltip: '清空金额', + icon: const Icon(Icons.cancel_outlined), + onPressed: _busy || _pending != null + ? null + : () => setState(() => _amount.clear()), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text('单笔充值 ${moneyText(options.min)}–${moneyText(options.max)}'), + ), + const SizedBox(height: 16), + const Text('支付方式'), + for (final channel in options.channels) + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(channel.title), + subtitle: _available(channel) ? null : const Text('暂不可用'), + leading: Icon( + channel.name == 'wechat' + ? Icons.chat_bubble_outline + : Icons.account_balance_wallet_outlined, + ), + trailing: Icon( + _channel == channel.name ? Icons.radio_button_checked : Icons.radio_button_off, + ), + onTap: !_busy && _pending == null && _available(channel) + ? () => setState(() => _channel = channel.name) + : null, + ), + const ListTile( + contentPadding: EdgeInsets.zero, + title: Text('余额支付'), + subtitle: Text('不可用于充值'), + enabled: false, + ), + const Text('到账时间以实际支付结果为准。'), + if (options.agreement == null) const Text('充值协议暂未发布,暂不能发起充值'), + Row( + children: [ + Checkbox( + value: _agreed, + onChanged: _busy || options.agreement == null + ? null + : (value) => setState(() => _agreed = value ?? false), + ), + const Flexible(child: Text('我已阅读并同意')), + TextButton(onPressed: _agreement, child: const Text('《充值协议》')), + ], + ), + FilledButton( + onPressed: enabled ? _pay : null, + child: Text( + _pending == null + ? '确认充值 ${moneyText(parseRechargeAmount(_amount.text) ?? 0)}' + : '继续原订单支付', + ), + ), + if (_pending != null) + OutlinedButton(onPressed: _busy ? null : _query, child: const Text('查询到账结果')), + ], + ], + ), + ); + } +} diff --git a/apps/user_app/lib/ui/features/wallet/recharge_records_page.dart b/apps/user_app/lib/ui/features/wallet/recharge_records_page.dart new file mode 100644 index 0000000..a4b9495 --- /dev/null +++ b/apps/user_app/lib/ui/features/wallet/recharge_records_page.dart @@ -0,0 +1,133 @@ +// 功能描述:充值记录分页及服务端到账状态刷新;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/recharge.dart'; + +/// 展示当前账号充值记录,刷新失败保留已有记录供核对。 +class RechargeRecordsPage extends StatefulWidget { + const RechargeRecordsPage({required this.repository, super.key}); + final ClientRepository repository; + @override + State createState() => _RechargeRecordsPageState(); +} + +class _RechargeRecordsPageState extends State { + final List _items = []; + String _cursor = ''; + bool _busy = false, _retryReset = true; + String? _error; + int _generation = 0; + + @override + void initState() { + super.initState(); + _load(reset: true); + } + + // 刷新覆盖续页请求;迟到结果不得覆盖最新账号请求的数据。 + Future _load({bool reset = false}) async { + if (_busy && !reset) return; + final generation = ++_generation; + setState(() { + _busy = true; + _error = null; + _retryReset = reset; + }); + try { + final page = await widget.repository.rechargeRecords(cursor: reset ? '' : _cursor); + if (!mounted || generation != _generation) return; + setState(() { + if (reset) _items.clear(); + final ids = _items.map((item) => item.identity).toSet(); + _items.addAll(page.items.where((item) => ids.add(item.identity))); + _cursor = page.nextCursor; + }); + } catch (_) { + if (mounted && generation == _generation) { + setState(() => _error = '充值记录加载失败,请重试'); + } + } finally { + if (mounted && generation == _generation) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('充值记录'), + centerTitle: true, + leading: IconButton( + tooltip: '返回', + icon: const Icon(Icons.arrow_back), + onPressed: () => context.canPop() ? context.pop() : context.go('/wallet'), + ), + actions: [ + IconButton( + tooltip: '刷新充值状态', + onPressed: _busy ? null : () => _load(reset: true), + icon: const Icon(Icons.refresh), + ), + ], + ), + body: Column( + children: [ + if (_busy) const LinearProgressIndicator(), + if (_error != null) + TextButton( + onPressed: _busy ? null : () => _load(reset: _retryReset), + child: Text(_error!), + ), + Expanded( + child: RefreshIndicator( + onRefresh: () => _load(reset: true), + child: ListView( + padding: const EdgeInsets.all(16), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + if (!_busy && _error == null && _items.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 80), + child: Center(child: Text('暂无充值记录')), + ), + for (final item in _items) ...[ + Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + item.amountText, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600), + ), + ), + Flexible(child: Text(item.statusText, textAlign: TextAlign.right)), + ], + ), + const SizedBox(height: 8), + Text(switch (item.channel) { + 'wechat' => '微信支付', + 'alipay' => '支付宝', + 'mock' => '测试充值', + _ => '其他渠道', + }), + Text('充值单号:${item.number}'), + Text('创建时间:${item.createdAt.toString().split('.').first}'), + ], + ), + ), + const Divider(height: 1), + ], + if (_cursor.isNotEmpty) + TextButton(onPressed: _busy ? null : () => _load(), child: const Text('加载更多')), + ], + ), + ), + ), + ], + ), + ); +} diff --git a/apps/user_app/lib/ui/features/wallet/wallet_bills_page.dart b/apps/user_app/lib/ui/features/wallet/wallet_bills_page.dart new file mode 100644 index 0000000..08db0c6 --- /dev/null +++ b/apps/user_app/lib/ui/features/wallet/wallet_bills_page.dart @@ -0,0 +1,196 @@ +// 功能描述:钱包账单收支筛选、游标续页及真实流水详情;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/client_models.dart'; +import '../../../domain/models/wallet_bill.dart'; + +class WalletBillsPage extends StatefulWidget { + const WalletBillsPage({required this.repository, super.key}); + final ClientRepository repository; + @override + State createState() => _WalletBillsPageState(); +} + +/// 筛选和刷新递增世代,忽略旧筛选的迟到结果;失败保留已加载账单。 +class _WalletBillsPageState extends State { + final List _items = []; + String _direction = '', _cursor = ''; + String? _error; + bool _busy = false; + bool _retryReset = true; + int _generation = 0; + @override + void initState() { + super.initState(); + _load(reset: true); + } + + Future _load({bool reset = false}) async { + if (!reset && _busy) return; + final generation = ++_generation; + setState(() { + _busy = true; + _error = null; + _retryReset = reset; + }); + try { + final page = await widget.repository.walletBills( + direction: _direction, + cursor: reset ? '' : _cursor, + ); + if (!mounted || generation != _generation) return; + setState(() { + if (reset) _items.clear(); + final identities = _items.map((i) => i.identity).toSet(); + _items.addAll(page.items.where((i) => identities.add(i.identity))); + _cursor = page.nextCursor; + }); + } catch (_) { + if (mounted && generation == _generation) setState(() => _error = '账单加载失败,请重试'); + } finally { + if (mounted && generation == _generation) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('钱包账单'), + centerTitle: true, + leading: IconButton( + tooltip: '返回', + icon: const Icon(Icons.arrow_back), + onPressed: () => context.canPop() ? context.pop() : context.go('/wallet'), + ), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Wrap( + spacing: 12, + children: [ + for (final (value, label) in [('', '全部'), ('income', '收入'), ('expense', '支出')]) + ChoiceChip( + label: Text(label), + selected: _direction == value, + onSelected: (_) { + if (_direction == value) return; + setState(() { + _direction = value; + _items.clear(); + _cursor = ''; + }); + _load(reset: true); + }, + ), + ], + ), + ), + if (_busy) const LinearProgressIndicator(), + if (_error != null) + TextButton( + onPressed: () => _load(reset: _retryReset), + child: Text(_error!), + ), + Expanded( + child: RefreshIndicator( + onRefresh: () => _load(reset: true), + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 14), + children: [ + if (!_busy && _error == null && _items.isEmpty) + const Padding( + padding: EdgeInsets.all(32), + child: Center(child: Text('暂无账单')), + ), + for (final item in _items) WalletBillTile(bill: item), + if (_cursor.isNotEmpty && _error == null) + TextButton(onPressed: _busy ? null : () => _load(), child: const Text('加载更多')), + ], + ), + ), + ), + ], + ), + ); +} + +/// 列表金额隐藏时,详情同样隐藏,避免通过点击绕过隐私开关。 +class WalletBillTile extends StatelessWidget { + const WalletBillTile({required this.bill, this.hidden = false, super.key}); + final WalletBill bill; + final bool hidden; + String get _date => + '${bill.createdAt.year}-${bill.createdAt.month.toString().padLeft(2, '0')}-${bill.createdAt.day.toString().padLeft(2, '0')}'; + @override + Widget build(BuildContext context) => InkWell( + onTap: () => showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(bill.title), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('流水号:${bill.number}'), + Text('记账时间:${bill.createdAt}'), + Text('收支金额:${hidden ? '••••' : bill.signedAmount}'), + Text('手续费:${hidden ? '••••' : moneyText(bill.fee)}'), + Text('记账后余额:${hidden ? '••••' : moneyText(bill.balanceAfter)}'), + ], + ), + ), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('关闭'))], + ), + ), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Color(0xFFE5E7EB))), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + bill.income ? Icons.add_card_outlined : Icons.account_balance_wallet_outlined, + color: bill.income ? const Color(0xFF16A34A) : const Color(0xFF0064FF), + size: 26, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_date, style: const TextStyle(fontSize: 12, color: Color(0xFF777F8D))), + const SizedBox(height: 4), + Text(bill.title), + ], + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + hidden ? '••••' : bill.signedAmount, + textAlign: TextAlign.end, + style: TextStyle( + fontWeight: FontWeight.w600, + color: bill.income ? const Color(0xFF16A34A) : null, + ), + ), + const SizedBox(height: 4), + const Text('已记账', style: TextStyle(fontSize: 12, color: Color(0xFF777F8D))), + ], + ), + ), + ], + ), + ), + ); +} diff --git a/apps/user_app/lib/ui/features/wallet/wallet_page.dart b/apps/user_app/lib/ui/features/wallet/wallet_page.dart new file mode 100644 index 0000000..3354013 --- /dev/null +++ b/apps/user_app/lib/ui/features/wallet/wallet_page.dart @@ -0,0 +1,297 @@ +// 功能描述:图28钱包余额、资金入口与最近账单;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/client_models.dart'; +import '../../../domain/models/wallet_bill.dart'; +import '../../core/async_content.dart'; +import '../../core/feature_entry.dart'; +import 'wallet_bills_page.dart'; + +/// 余额与账单分别加载,账单异常不隐藏已取得的余额。 +class WalletPage extends StatefulWidget { + const WalletPage({required this.repository, super.key}); + final ClientRepository repository; + @override + State createState() => _WalletPageState(); +} + +class _WalletPageState extends State { + bool _hidden = false; + final _balanceKey = GlobalKey>(); + final _billsKey = GlobalKey(); + String _amount(int cents) => _hidden ? '••••' : moneyText(cents); + + Widget _section(String? title, Widget child) => Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFE5E7EB)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (title != null) ...[ + Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + ], + child, + ], + ), + ); + + void _password() => context.push('/settings/payment-password'); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('我的钱包'), + centerTitle: true, + leading: IconButton( + tooltip: '返回', + icon: const Icon(Icons.arrow_back), + onPressed: () => context.canPop() ? context.pop() : context.go('/me'), + ), + actions: [ + TextButton(onPressed: () => context.push('/records/wallet'), child: const Text('账单')), + ], + ), + body: AsyncContent( + key: _balanceKey, + load: widget.repository.wallet, + builder: (context, wallet) => ListView( + padding: const EdgeInsets.all(14), + children: [ + _section( + null, + LayoutBuilder( + builder: (context, constraints) { + final compact = + constraints.maxWidth < 300 || MediaQuery.textScalerOf(context).scale(14) > 17; + final balance = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('账户余额'), + IconButton( + tooltip: _hidden ? '显示金额' : '隐藏金额', + onPressed: () => setState(() => _hidden = !_hidden), + icon: Icon( + _hidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, + size: 20, + ), + ), + ], + ), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + _amount(wallet.balance), + style: const TextStyle(fontSize: 34, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 8), + Text( + '余额可用于支付燃气订单与商城订单', + style: const TextStyle(fontSize: 12, color: Color(0xFF777F8D)), + ), + ], + ); + final actions = SizedBox( + width: compact ? double.infinity : 86, + child: Column( + children: [ + FilledButton( + onPressed: () => context.push('/wallet/recharge'), + child: const Text('充值'), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: () => context.push('/wallet/withdraw'), + child: const Text('提现'), + ), + ], + ), + ); + return compact + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [balance, const SizedBox(height: 16), actions], + ) + : Row( + children: [ + Expanded(child: balance), + const SizedBox(width: 8), + actions, + ], + ); + }, + ), + ), + _section( + '我的资产', + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final (icon, title) in [ + (Icons.shield_outlined, '可退押金'), + (Icons.confirmation_number_outlined, '优惠券'), + (Icons.currency_exchange, '待退款'), + ]) + Expanded( + child: InkWell( + onTap: () => showUnavailableFeature(context, title), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 2), + child: Row( + children: [ + Icon( + icon, + size: 22, + color: title == '可退押金' + ? const Color(0xFF16A34A) + : const Color(0xFFF97316), + ), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontSize: 12)), + const SizedBox(height: 4), + const Text( + '暂未开放', + style: TextStyle(fontSize: 11, color: Color(0xFF777F8D)), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + _section( + '快捷服务', + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: FeatureEntry( + icon: Icons.account_balance_wallet_outlined, + label: '充值', + onTap: () => context.push('/wallet/recharge'), + ), + ), + Expanded( + child: FeatureEntry( + icon: Icons.output, + label: '提现', + onTap: () => context.push('/wallet/withdraw'), + ), + ), + Expanded( + child: FeatureEntry(icon: Icons.lock_outline, label: '支付密码', onTap: _password), + ), + Expanded( + child: FeatureEntry( + icon: Icons.credit_card, + label: '银行卡', + onTap: () => context.push('/wallet/banks'), + ), + ), + ], + ), + ), + _section( + '最近账单', + WalletRecentBills(key: _billsKey, repository: widget.repository, hidden: _hidden), + ), + _section( + '资金安全', + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: FeatureEntry( + icon: Icons.badge_outlined, + label: '实名账户', + onTap: () => context.push('/profile/edit'), + ), + ), + Expanded( + child: FeatureEntry(icon: Icons.lock_outline, label: '支付密码', onTap: _password), + ), + const Expanded( + child: FeatureEntry(icon: Icons.verified_user_outlined, label: '异常风控'), + ), + ], + ), + ), + OutlinedButton( + onPressed: () => context.push('/records/wallet'), + child: const Text('查看全部账单'), + ), + TextButton( + onPressed: () async { + await Future.wait([ + _balanceKey.currentState!.refresh(), + _billsKey.currentState!.refresh(), + ]); + }, + child: const Text('刷新钱包'), + ), + ], + ), + ), + ); +} + +/// 最近账单独立重试,不将加载失败当作没有账单。 +class WalletRecentBills extends StatefulWidget { + const WalletRecentBills({required this.repository, required this.hidden, super.key}); + final ClientRepository repository; + final bool hidden; + @override + State createState() => WalletRecentBillsState(); +} + +class WalletRecentBillsState extends State { + late Future _future = widget.repository.walletBills(); + Future refresh() async { + final future = widget.repository.walletBills(); + setState(() { + _future = future; + }); + try { + await future; + } catch (_) { + /* FutureBuilder负责错误展示。 */ + } + } + + @override + Widget build(BuildContext context) => FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) return const LinearProgressIndicator(); + if (snapshot.hasError) { + return TextButton(onPressed: refresh, child: const Text('账单加载失败,点击重试')); + } + final items = snapshot.data!.items; + if (items.isEmpty) return const Padding(padding: EdgeInsets.all(12), child: Text('暂无账单')); + return Column( + children: [ + for (final bill in items.take(4)) WalletBillTile(bill: bill, hidden: widget.hidden), + ], + ); + }, + ); +} diff --git a/apps/user_app/lib/ui/features/wallet/withdrawal_page.dart b/apps/user_app/lib/ui/features/wallet/withdrawal_page.dart new file mode 100644 index 0000000..b294a5b --- /dev/null +++ b/apps/user_app/lib/ui/features/wallet/withdrawal_page.dart @@ -0,0 +1,546 @@ +// 功能描述:图39提现申请、到账卡选择和真实状态记录;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/client_models.dart'; +import '../../../domain/models/wallet_account.dart'; +import '../../core/payment_pin_field.dart'; + +/// 提现申请只展示服务端余额和银行卡,提交成功保持“待审核”事实。 +class WithdrawalPage extends StatefulWidget { + const WithdrawalPage({required this.repository, super.key}); + final ClientRepository repository; + + @override + State createState() => _WithdrawalPageState(); +} + +class _WithdrawalData { + const _WithdrawalData(this.wallet, this.banks, this.records); + final WalletSummary wallet; + final List banks; + final List records; +} + +class _WithdrawalPageState extends State { + late Future<_WithdrawalData> _future = _load(); + final _amount = TextEditingController(); + final _password = TextEditingController(); + String? _bankIdentity; + String _requestNo = const Uuid().v7(); + bool _hidden = false; + bool _busy = false; + String? _error; + + /// 并行读取提现页必需的资金事实。 + Future<_WithdrawalData> _load() async { + final values = await Future.wait([ + widget.repository.wallet(), + widget.repository.walletBanks(), + widget.repository.walletWithdrawals(), + ]); + final data = _WithdrawalData( + values[0] as WalletSummary, + values[1] as List, + values[2] as List, + ); + if (_bankIdentity == null && data.banks.isNotEmpty) { + _bankIdentity = data.banks + .firstWhere((bank) => bank.isDefault, orElse: () => data.banks.first) + .identity; + } + return data; + } + + /// 页面写入后必须从服务端重新读取余额和申请状态。 + Future _refresh() async { + final future = _load(); + setState(() => _future = future); + await future; + } + + /// 创建申请前先展示不可逆资金影响的确认信息。 + Future _submit(_WithdrawalData data) async { + final cents = parseWithdrawalAmount(_amount.text); + if (_bankIdentity == null) { + setState(() => _error = '请先添加到账银行卡'); + return; + } + if (cents == null || cents < 1000 || cents > data.wallet.withdrawalBalance) { + setState(() => _error = '请输入10元以上且不超过可提现余额的金额'); + return; + } + if (!RegExp(r'^\d{6}$').hasMatch(_password.text)) { + setState(() => _error = '请输入6位支付密码'); + return; + } + final bank = data.banks.firstWhere((item) => item.identity == _bankIdentity); + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('确认提现申请'), + content: Text( + '提现 ${moneyText(cents)} 至\n${bank.bankName} ${bank.maskedNumber}\n\n提交后将进入平台审核。', + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('返回检查')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('确认提交')), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() { + _busy = true; + _error = null; + }); + try { + final result = await widget.repository.createWalletWithdrawal( + bankIdentity: bank.identity, + amount: cents, + requestNo: _requestNo, + paymentPassword: _password.text, + ); + if (!mounted) return; + _password.clear(); + _amount.clear(); + _requestNo = const Uuid().v7(); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('提现申请已提交:${result.statusName}'))); + await _refresh(); + if (mounted) setState(() => _busy = false); + } catch (e) { + if (mounted) { + setState(() { + _busy = false; + _error = e.toString(); + }); + } + } + } + + /// 展示本人真实提现记录,不将待审核标成已到账。 + void _showRecords(List records) => showModalBottomSheet( + context: context, + useSafeArea: true, + showDragHandle: true, + builder: (context) => Padding( + padding: const EdgeInsets.fromLTRB(18, 0, 18, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('提现记录', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)), + const SizedBox(height: 12), + Expanded( + child: records.isEmpty + ? const Center(child: Text('暂无提现记录')) + : ListView.separated( + itemCount: records.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final item = records[index]; + return ListTile( + contentPadding: EdgeInsets.zero, + title: Text(moneyText(item.amount)), + subtitle: Text( + '${item.number}\n${item.createdAt.toString().substring(0, 16)}', + ), + trailing: Text(item.statusName), + ); + }, + ), + ), + ], + ), + ), + ); + + /// 到账卡切换使用移动端列表,不暴露内部标识。 + Future _selectBank(List banks) async { + final selected = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const ListTile( + title: Text('选择到账银行卡', style: TextStyle(fontWeight: FontWeight.w700)), + ), + RadioGroup( + groupValue: _bankIdentity, + onChanged: (value) => Navigator.pop(context, value), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final bank in banks) + RadioListTile( + value: bank.identity, + title: Text(bank.bankName), + subtitle: Text(bank.maskedNumber), + ), + ], + ), + ), + ], + ), + ), + ); + if (selected != null && mounted) { + setState(() { + _bankIdentity = selected; + _error = null; + }); + } + } + + @override + void dispose() { + _amount.dispose(); + _password.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('余额提现'), + centerTitle: true, + leading: IconButton( + tooltip: '返回', + onPressed: () => context.canPop() ? context.pop() : context.go('/wallet'), + icon: const Icon(Icons.arrow_back), + ), + actions: [ + TextButton( + onPressed: () async { + try { + final data = await _future; + if (mounted) _showRecords(data.records); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + const SnackBar(content: Text('提现记录加载失败,请稍后重试')), + ); + } + } + }, + child: const Text('提现记录'), + ), + ], + ), + body: FutureBuilder<_WithdrawalData>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center( + child: TextButton(onPressed: _refresh, child: const Text('提现信息加载失败,点击重试')), + ); + } + final data = snapshot.data!; + final selectedBank = data.banks.isEmpty + ? null + : data.banks.firstWhere( + (bank) => bank.identity == _bankIdentity, + orElse: () => data.banks.first, + ); + return ListView( + padding: const EdgeInsets.fromLTRB(14, 6, 14, 14), + children: [ + _section( + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('可提现余额', style: TextStyle(fontSize: 16)), + Tooltip( + message: _hidden ? '显示金额' : '隐藏金额', + child: InkResponse( + onTap: () => setState(() => _hidden = !_hidden), + radius: 20, + child: SizedBox( + width: 32, + height: 32, + child: Icon( + _hidden + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + size: 19, + ), + ), + ), + ), + ], + ), + Text( + _hidden ? '••••' : moneyText(data.wallet.withdrawalBalance), + style: const TextStyle(fontSize: 30, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + const Text('仅可提现服务端确认的可用余额', style: TextStyle(color: Color(0xFF777F8D))), + ], + ), + ), + Container( + width: 58, + height: 58, + decoration: BoxDecoration( + color: const Color(0xFFEAF1FF), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.account_balance_wallet, + color: Color(0xFF2563EB), + size: 34, + ), + ), + ], + ), + ), + _section( + title: '到账银行卡', + child: selectedBank == null + ? ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.credit_card_off_outlined), + title: const Text('暂未绑定银行卡'), + trailing: const Text('去添加', style: TextStyle(color: Color(0xFF2563EB))), + onTap: () async { + await context.push('/wallet/banks'); + if (mounted) await _refresh(); + }, + ) + : Semantics( + button: true, + label: '更换到账银行卡', + child: InkWell( + onTap: _busy ? null : () => _selectBank(data.banks), + borderRadius: BorderRadius.circular(8), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: Row( + children: [ + const CircleAvatar( + radius: 18, + backgroundColor: Color(0xFFEAF1FF), + child: Icon(Icons.account_balance, color: Color(0xFF2563EB)), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + selectedBank.bankName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + Text( + '${selectedBank.typeName} ${selectedBank.maskedNumber}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFF777F8D), + fontSize: 12, + ), + ), + ], + ), + ), + const Text( + '更换银行卡', + style: TextStyle(color: Color(0xFF2563EB), fontSize: 12), + ), + const Icon(Icons.chevron_right, size: 18, color: Color(0xFF9CA3AF)), + ], + ), + ), + ), + ), + ), + _section( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '提现金额', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + TextField( + key: const Key('withdrawal-amount'), + controller: _amount, + enabled: !_busy, + onChanged: (_) { + if (_error != null) setState(() => _error = null); + }, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + prefixText: '¥ ', + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + suffix: TextButton( + style: TextButton.styleFrom( + minimumSize: const Size(0, 40), + padding: const EdgeInsets.symmetric(horizontal: 8), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: _busy + ? null + : () => _amount.text = (data.wallet.withdrawalBalance / 100) + .toStringAsFixed(2), + child: const Text('全部提现'), + ), + ), + ), + const SizedBox(height: 4), + const Text( + '单笔最低10元,最高不超过可提现余额', + style: TextStyle(color: Color(0xFF777F8D), fontSize: 12), + ), + ], + ), + ), + ValueListenableBuilder( + valueListenable: _amount, + builder: (context, value, child) { + final amount = parseWithdrawalAmount(value.text) ?? 0; + return _section( + child: Column( + children: [ + const Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text('手续费'), Text('¥0.00')], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('实际到账'), + Text( + moneyText(amount), + style: const TextStyle(color: Color(0xFF2563EB)), + ), + ], + ), + const SizedBox(height: 6), + const Row( + children: [ + Text('预计到账时间'), + SizedBox(width: 8), + Expanded( + child: Text('预计1—3个工作日', textAlign: TextAlign.end), + ), + ], + ), + ], + ), + ); + }, + ), + _section( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '支付密码验证', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: PaymentPinField( + controller: _password, + enabled: !_busy, + fieldKey: const Key('withdrawal-password'), + onChanged: () { + if (_error != null) setState(() => _error = null); + }, + ), + ), + TextButton( + style: TextButton.styleFrom( + minimumSize: const Size(0, 40), + padding: const EdgeInsets.symmetric(horizontal: 8), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => context.push('/settings/payment-password'), + child: const Text('忘记密码'), + ), + ], + ), + ], + ), + ), + _section( + title: '提现规则', + child: const Text( + '• 仅支持可提现余额的提现\n• 提交后由平台审核,结果以提现记录为准\n• 退款、押金等受限资金不可提现', + style: TextStyle(height: 1.5, color: Color(0xFF777F8D), fontSize: 12), + ), + ), + _section( + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline, color: Color(0xFFF97316), size: 18), + SizedBox(width: 8), + Expanded( + child: Text( + '风险提示:请确认银行卡状态正常,审核中不可重复提交。', + style: TextStyle(color: Color(0xFF777F8D), fontSize: 12, height: 1.5), + ), + ), + ], + ), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text(_error!, style: const TextStyle(color: Color(0xFFC7352A))), + ), + FilledButton( + style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(44)), + onPressed: _busy || data.banks.isEmpty ? null : () => _submit(data), + child: Text(_busy ? '提交中…' : '提交提现申请'), + ), + const SizedBox(height: 18), + ], + ); + }, + ), + ); + + /// 统一提现页信息分组样式。 + Widget _section({String? title, required Widget child}) => Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFE5E7EB)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (title != null) ...[ + Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + const SizedBox(height: 8), + ], + child, + ], + ), + ); +} diff --git a/apps/user_app/pubspec.lock b/apps/user_app/pubspec.lock index 8776797..c093a1d 100644 --- a/apps/user_app/pubspec.lock +++ b/apps/user_app/pubspec.lock @@ -6,7 +6,7 @@ packages: description: name: args sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.7.0" async: @@ -14,7 +14,7 @@ packages: description: name: async sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.13.1" boolean_selector: @@ -22,7 +22,7 @@ packages: description: name: boolean_selector sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.2" characters: @@ -30,7 +30,7 @@ packages: description: name: characters sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.4.1" clock: @@ -38,7 +38,7 @@ packages: description: name: clock sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.2" code_assets: @@ -46,7 +46,7 @@ packages: description: name: code_assets sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.2.1" collection: @@ -54,15 +54,23 @@ packages: description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" crypto: dependency: transitive description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.7" cupertino_icons: @@ -70,15 +78,31 @@ packages: description: name: cupertino_icons sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.9" + dio: + dependency: transitive + description: + name: dio + sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1" + url: "https://pub.dev" + source: hosted + version: "5.11.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc" + url: "https://pub.dev" + source: hosted + version: "2.2.2" fake_async: dependency: transitive description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.3.3" ffi: @@ -86,7 +110,7 @@ packages: description: name: ffi sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.2.0" ffi_leak_tracker: @@ -94,15 +118,55 @@ packages: description: name: ffi_leak_tracker sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.1.2" + file_saver: + dependency: "direct main" + description: + name: file_saver + sha256: "68c9a085d9bb4546e0a31d1e583a48d7c17a6987d538788ea064f0043b1fc02d" + url: "https://pub.dev" + source: hosted + version: "0.4.0" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 + url: "https://pub.dev" + source: hosted + version: "0.9.5+1" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" fixnum: dependency: transitive description: name: fixnum sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.1" flutter: @@ -115,15 +179,28 @@ packages: description: name: flutter_lints sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" flutter_secure_storage: dependency: "direct main" description: name: flutter_secure_storage sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "10.3.1" flutter_secure_storage_darwin: @@ -131,31 +208,31 @@ packages: description: name: flutter_secure_storage_darwin sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.3.2" flutter_secure_storage_linux: dependency: transitive description: name: flutter_secure_storage_linux - sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 - url: "https://pub.flutter-io.cn" + sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546" + url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" flutter_secure_storage_platform_interface: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" - url: "https://pub.flutter-io.cn" + sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4" + url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.3" flutter_secure_storage_web: dependency: transitive description: name: flutter_secure_storage_web sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.1" flutter_secure_storage_windows: @@ -163,7 +240,7 @@ packages: description: name: flutter_secure_storage_windows sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.2.2" flutter_test: @@ -181,30 +258,30 @@ packages: description: name: fluwx sha256: "4b526d281be8560a490bd1b945373a23ab2a12088c8fc5997ffb31cc6fb41082" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.0.2" go_router: dependency: "direct main" description: name: go_router - sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a" - url: "https://pub.flutter-io.cn" + sha256: d7a3576cb312649eaa51f2356450aed686085fb58fcdebda5b359aa951eef7ea + url: "https://pub.dev" source: hosted - version: "17.3.0" + version: "17.5.0" heqi_design_system: dependency: "direct main" description: path: "../heqi_design_system" relative: true source: path - version: "0.1.0" + version: "0.1.1" hooks: dependency: transitive description: name: hooks sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.0.2" http: @@ -212,7 +289,7 @@ packages: description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.6.0" http_parser: @@ -220,31 +297,119 @@ packages: description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: f71f4f3a9c5dbbe39800b31839cb3b305aac403a7cfbddf8331cf179d813b72a + url: "https://pub.dev" + source: hosted + version: "0.8.13+21" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: ee3885b6fcd71958fbc79770dd194c63371439d536d69c47b279171a486482ae + url: "https://pub.dev" + source: hosted + version: "0.8.13+7" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" jni: dependency: transitive description: name: jni - sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f - url: "https://pub.flutter-io.cn" + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.3" jni_flutter: dependency: transitive description: name: jni_flutter - sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" - url: "https://pub.flutter-io.cn" + sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 + url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.3" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" leak_tracker: dependency: transitive description: name: leak_tracker sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "11.0.2" leak_tracker_flutter_testing: @@ -252,7 +417,7 @@ packages: description: name: leak_tracker_flutter_testing sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.10" leak_tracker_testing: @@ -260,7 +425,7 @@ packages: description: name: leak_tracker_testing sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.2" lints: @@ -268,7 +433,7 @@ packages: description: name: lints sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.1.0" logging: @@ -276,7 +441,7 @@ packages: description: name: logging sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.3.0" matcher: @@ -284,7 +449,7 @@ packages: description: name: matcher sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.12.19" material_color_utilities: @@ -292,7 +457,7 @@ packages: description: name: material_color_utilities sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.13.0" meta: @@ -300,31 +465,55 @@ packages: description: name: meta sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" objective_c: dependency: transitive description: name: objective_c - sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" - url: "https://pub.flutter-io.cn" + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" source: hosted - version: "9.4.1" + version: "9.5.0" package_config: dependency: transitive description: name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.flutter-io.cn" + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "3.0.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" + url: "https://pub.dev" + source: hosted + version: "10.2.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" path: dependency: transitive description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.9.1" path_provider: @@ -332,7 +521,7 @@ packages: description: name: path_provider sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.6" path_provider_android: @@ -340,7 +529,7 @@ packages: description: name: path_provider_android sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.3.1" path_provider_foundation: @@ -348,7 +537,7 @@ packages: description: name: path_provider_foundation sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.6.0" path_provider_linux: @@ -356,7 +545,7 @@ packages: description: name: path_provider_linux sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.2.2" path_provider_platform_interface: @@ -364,7 +553,7 @@ packages: description: name: path_provider_platform_interface sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.3" path_provider_windows: @@ -372,7 +561,7 @@ packages: description: name: path_provider_windows sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.3.0" platform: @@ -380,7 +569,7 @@ packages: description: name: platform sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.1.6" plugin_platform_interface: @@ -388,23 +577,23 @@ packages: description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.8" pub_semver: dependency: transitive description: name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.flutter-io.cn" + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.1" record_use: dependency: transitive description: name: record_use sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.6.0" sky_engine: @@ -417,15 +606,39 @@ packages: description: name: source_span sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.10.2" + speech_to_text: + dependency: "direct main" + description: + name: speech_to_text + sha256: "75587f7400f485fdf166beacd471549d98fe5d58e634f708916bb65dec05d6a4" + url: "https://pub.dev" + source: hosted + version: "7.4.0" + speech_to_text_platform_interface: + dependency: transitive + description: + name: speech_to_text_platform_interface + sha256: a7e16e02853853ed7534ac2bde9a1c4f39c8879970a7974ac6ff832d4bdaa4b0 + url: "https://pub.dev" + source: hosted + version: "2.4.0" + speech_to_text_windows: + dependency: transitive + description: + name: speech_to_text_windows + sha256: "2d1d10565b23262386b453b33656299608dc7a66784453735d6c1318f13f44d7" + url: "https://pub.dev" + source: hosted + version: "1.0.1" stack_trace: dependency: transitive description: name: stack_trace sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.12.1" stream_channel: @@ -433,7 +646,7 @@ packages: description: name: stream_channel sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.4" string_scanner: @@ -441,7 +654,7 @@ packages: description: name: string_scanner sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.4.1" term_glyph: @@ -449,7 +662,7 @@ packages: description: name: term_glyph sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.2.2" test_api: @@ -457,7 +670,7 @@ packages: description: name: test_api sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.7.11" tobias: @@ -465,7 +678,7 @@ packages: description: name: tobias sha256: "1cfd203bf57f8d4daa3e734d24ef97568eb5265af62d1498c32da4e69905c70f" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "5.3.4" typed_data: @@ -473,7 +686,7 @@ packages: description: name: typed_data sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.4.0" url_launcher: @@ -481,7 +694,7 @@ packages: description: name: url_launcher sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.3.2" url_launcher_android: @@ -489,7 +702,7 @@ packages: description: name: url_launcher_android sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.3.32" url_launcher_ios: @@ -497,7 +710,7 @@ packages: description: name: url_launcher_ios sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.4.1" url_launcher_linux: @@ -505,7 +718,7 @@ packages: description: name: url_launcher_linux sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.2.2" url_launcher_macos: @@ -513,7 +726,7 @@ packages: description: name: url_launcher_macos sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.2.5" url_launcher_platform_interface: @@ -521,7 +734,7 @@ packages: description: name: url_launcher_platform_interface sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.3.2" url_launcher_web: @@ -529,7 +742,7 @@ packages: description: name: url_launcher_web sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.3" url_launcher_windows: @@ -537,7 +750,7 @@ packages: description: name: url_launcher_windows sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.1.5" uuid: @@ -545,7 +758,7 @@ packages: description: name: uuid sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.6.0" vector_math: @@ -553,49 +766,49 @@ packages: description: name: vector_math sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.2.0" vm_service: dependency: transitive description: name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" - url: "https://pub.flutter-io.cn" + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" source: hosted - version: "15.2.0" + version: "15.3.0" web: dependency: transitive description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.1" win32: dependency: transitive description: name: win32 - sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 - url: "https://pub.flutter-io.cn" + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.4.0" xdg_directories: dependency: transitive description: name: xdg_directories sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.0" yaml: dependency: transitive description: name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.flutter-io.cn" + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.4" sdks: dart: ">=3.12.2 <4.0.0" flutter: ">=3.44.0" diff --git a/apps/user_app/pubspec.yaml b/apps/user_app/pubspec.yaml index 01f5be4..9de664b 100644 --- a/apps/user_app/pubspec.yaml +++ b/apps/user_app/pubspec.yaml @@ -30,6 +30,9 @@ environment: dependencies: flutter: sdk: flutter + # 使用 Flutter 官方中文日期、时间及系统控件文案。 + flutter_localizations: + sdk: flutter heqi_design_system: path: ../heqi_design_system @@ -38,11 +41,18 @@ dependencies: cupertino_icons: ^1.0.8 go_router: ^17.3.0 http: ^1.6.0 + image_picker: ^1.2.3 + # 将用户主动录入的故障语音转为可编辑文字。 + speech_to_text: ^7.4.0 flutter_secure_storage: ^10.3.1 uuid: ^4.6.0 fluwx: ^6.0.2 tobias: ^5.3.4 url_launcher: ^6.3.2 + # 本人合同PDF通过系统保存对话框或浏览器下载保存。 + file_saver: ^0.4.0 + # 从当前安装包或Web构建元数据读取版本,避免写死设计示例版本。 + package_info_plus: ^10.2.1 dev_dependencies: flutter_test: @@ -65,6 +75,10 @@ flutter: # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true + assets: + - assets/design/ + - assets/products/ + - assets/staff/ # To add assets to your application, add an assets section, like this: # assets: diff --git a/apps/user_app/test/app/router_test.dart b/apps/user_app/test/app/router_test.dart index dc06627..0a69ebe 100644 --- a/apps/user_app/test/app/router_test.dart +++ b/apps/user_app/test/app/router_test.dart @@ -9,6 +9,9 @@ import 'package:user_app/app/router.dart'; import 'package:user_app/data/repositories/client_repository.dart'; import 'package:user_app/data/services/api_client.dart'; import 'package:user_app/data/services/secure_session_store.dart'; +import 'package:user_app/ui/features/shop/checkout_page.dart'; +import 'package:user_app/ui/features/auth/login_page.dart'; +import '../support/a1_fixture.dart'; /// 提供无需平台插件的空会话存储。 class _EmptySessionStore implements SessionStore { @@ -46,6 +49,54 @@ class _RouterTestSession extends UserSession { /// 覆盖过期提示、查询参数保留和登录后回跳。 void main() { + testWidgets('设置深链需要登录,登录后返回设置', (tester) async { + final session = _RouterTestSession(authenticated: false, expired: false); + final router = createRouter( + AppDependencies(session: session, repository: A1FixtureRepository()), + initialLocation: '/settings/password', + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + expect(router.routeInformationProvider.value.uri.path, '/login'); + expect( + router.routeInformationProvider.value.uri.queryParameters['redirect'], + '/settings/password', + ); + session.authenticate(); + await tester.pumpAndSettle(); + expect(router.routeInformationProvider.value.uri.path, '/settings/password'); + expect(find.text('登录密码'), findsOneWidget); + }); + testWidgets('游客可看商品详情,购买登录回跳保留商品数量', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final session = _RouterTestSession(authenticated: false, expired: false); + final router = createRouter( + AppDependencies(session: session, repository: A1FixtureRepository()), + initialLocation: '/products/p1', + ); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + expect(router.routeInformationProvider.value.uri.path, '/products/p1'); + expect(find.text('商品详情'), findsOneWidget); + await tester.tap(find.byTooltip('增加数量')); + await tester.pump(); + await tester.tap(find.text('立即购买')); + await tester.pumpAndSettle(); + expect(find.byType(LoginPage), findsOneWidget); + expect( + tester.widget(find.byType(LoginPage)).redirectTarget, + '/checkout/p1?quantity=2', + ); + session.authenticate(); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(CheckoutPage)).initialQuantity, 2); + await tester.pumpWidget(const SizedBox()); + router.dispose(); + }); testWidgets('令牌失效后跳转登录并在登录后返回原目标页', (tester) async { final session = _RouterTestSession(authenticated: false, expired: true); final dependencies = AppDependencies( diff --git a/apps/user_app/test/data/avatar_upload_test.dart b/apps/user_app/test/data/avatar_upload_test.dart new file mode 100644 index 0000000..e86f8b9 --- /dev/null +++ b/apps/user_app/test/data/avatar_upload_test.dart @@ -0,0 +1,61 @@ +// 功能描述:验证 multipart 文件上传鉴权、会话过期与资源路径解析。 +// 版本:1.0.0。 +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:user_app/data/repositories/client_repository.dart'; +import 'package:user_app/data/services/api_client.dart'; + +void main() { + test('上传携带鉴权和文件内容,不使用 JSON 包装二进制', () async { + final api = ApiClient( + () => 'test-token', + baseUrl: 'https://api.example.com', + client: MockClient((request) async { + expect(request.headers['authorization'], 'test-token'); + expect(request.url.path, '/upload/avatar'); + expect(request.headers['content-type'], startsWith('multipart/form-data; boundary=')); + expect(request.body, contains('name="file"; filename="avatar.png"')); + return http.Response('{"code":0,"details":{"uri":"/uploads/avatars/test.png"}}', 200); + }), + ); + expect( + await ClientRepository(api).uploadAvatar(Uint8List.fromList([1, 2, 3]), 'avatar.png'), + '/uploads/avatars/test.png', + ); + }); + test('超过大小限制不会发起请求;上传过期使会话失效', () async { + var requests = 0; + final rejected = []; + final api = ApiClient( + () => 'expired', + onUnauthorized: rejected.add, + client: MockClient((request) async { + requests++; + return http.Response('', 401); + }), + ); + await expectLater( + api.uploadAvatar(Uint8List(2 * 1024 * 1024 + 1), 'x.png'), + throwsA(isA()), + ); + expect(requests, 0); + await expectLater( + api.uploadAvatar(Uint8List.fromList([1]), 'x.png'), + throwsA(isA()), + ); + expect(rejected, ['expired']); + }); + test('图片相对路径使用配置的 API 域名,拒绝非网络协议', () { + final repo = ClientRepository(ApiClient(() => '', baseUrl: 'https://api.example.com')); + expect( + repo.resolveImageUrl('/uploads/product.png'), + 'https://api.example.com/uploads/product.png', + ); + expect(repo.resolveImageUrl('https://cdn.example.com/a.png'), 'https://cdn.example.com/a.png'); + for (final value in ['//other.example.com/a', 'javascript:x', 'file:///secret']) { + expect(repo.resolveImageUrl(value), ''); + } + }); +} diff --git a/apps/user_app/test/data/cart_test.dart b/apps/user_app/test/data/cart_test.dart new file mode 100644 index 0000000..f0f984a --- /dev/null +++ b/apps/user_app/test/data/cart_test.dart @@ -0,0 +1,77 @@ +// 功能描述:购物车公开标识、整数金额及登录失效契约回归;版本:1.0.0。 +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:user_app/data/repositories/client_repository.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/cart_item.dart'; + +void main() { + test('购物车迟到响应不跨账号继续加购或更新界面', () async { + var token = 'old'; + final repo = ClientRepository( + ApiClient( + () => token, + client: MockClient((_) async { + token = 'new'; + return http.Response('{"code":0,"details":{}}', 200); + }), + ), + ); + await expectLater(repo.cartItem('p1'), throwsA(isA())); + }); + test('条件数量更新携带原版本,图片仅接受可信协议', () async { + final value = { + 'product_identity': 'p1', + 'name': '商品', + 'price_amount': 1999, + 'stock_quantity': 4, + 'quantity': 2, + 'selected': false, + 'available': true, + 'revision': 'revision', + 'image_url': 'javascript:alert(1)', + }; + final repo = ClientRepository( + ApiClient( + () => 'test-token', + client: MockClient((request) async { + expect(request.headers['authorization'], 'test-token'); + if (request.method == 'PUT') { + expect(jsonDecode(request.body), { + 'quantity': 2, + 'selected': false, + 'revision': 'revision', + }); + } + return http.Response( + jsonEncode({'code': 0, 'details': value}), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ), + ); + final item = await repo.cartItem('p1'); + expect(item.product.imageUrl, ''); + expect(item.amount, 3998); + expect((await repo.setCartItem(item, quantity: 2, selected: false)).selected, isFalse); + expect( + () => CartItem.fromJson({...value, 'price_amount': 1.5}, (s) => s), + throwsFormatException, + ); + }); + test('购物车接口401清理会话', () async { + var expired = false; + final repo = ClientRepository( + ApiClient( + () => 'test', + onUnauthorized: (_) => expired = true, + client: MockClient((_) async => http.Response('{"code":1301}', 401)), + ), + ); + await expectLater(repo.cart(), throwsA(isA())); + expect(expired, isTrue); + }); +} diff --git a/apps/user_app/test/data/change_login_password_test.dart b/apps/user_app/test/data/change_login_password_test.dart new file mode 100644 index 0000000..32ebcfa --- /dev/null +++ b/apps/user_app/test/data/change_login_password_test.dart @@ -0,0 +1,55 @@ +// 功能描述:改密请求契约、成功确认和迟到会话响应保护;版本:1.0.0。 +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:user_app/data/repositories/client_repository.dart'; +import 'package:user_app/data/services/api_client.dart'; + +void main() { + test('改密使用本人PUT请求,只有changed=true被认为成功', () async { + var success = true; + final repo = ClientRepository( + ApiClient( + () => 'test-token', + client: MockClient((request) async { + expect(request.method, 'PUT'); + expect(request.url.path, '/heqi/client/v1/user/auth/password'); + expect(jsonDecode(request.body), { + 'current_password': 'old-value', + 'new_password': 'new-value', + }); + return http.Response( + jsonEncode({ + 'code': 0, + 'details': {'changed': success}, + }), + 200, + ); + }), + ), + ); + await repo.changeLoginPassword('old-value', 'new-value'); + success = false; + await expectLater( + repo.changeLoginPassword('old-value', 'new-value'), + throwsA(isA()), + ); + }); + test('切换账户后丢弃旧改密响应', () async { + var token = 'first'; + final repo = ClientRepository( + ApiClient( + () => token, + client: MockClient((_) async { + token = 'second'; + return http.Response('{"code":0,"details":{"changed":true}}', 200); + }), + ), + ); + await expectLater( + repo.changeLoginPassword('old', 'next'), + throwsA(isA()), + ); + }); +} diff --git a/apps/user_app/test/data/favorite_test.dart b/apps/user_app/test/data/favorite_test.dart new file mode 100644 index 0000000..5528a66 --- /dev/null +++ b/apps/user_app/test/data/favorite_test.dart @@ -0,0 +1,126 @@ +// 功能描述:收藏版本契约、跨入口同步及账号切换迟到响应保护;版本:1.0.0。 +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:user_app/data/repositories/client_repository.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/product_favorite.dart'; +import 'package:user_app/ui/features/shop/favorite_button.dart'; + +http.Response _response(Object details) => http.Response( + jsonEncode({'code': 0, 'details': details}), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, +); + +void main() { + testWidgets('两个入口同步服务端收藏状态,失败读取后不反转图标', (tester) async { + var active = false, version = 0, fail = false; + final repo = ClientRepository( + ApiClient( + () => 'test', + client: MockClient((request) async { + if (request.method == 'PUT') { + final body = jsonDecode(request.body) as Map; + expect(body['revision'], version == 0 ? '' : 'f:$version'); + if (fail) return http.Response('{"code":500}', 200); + active = body['favorite'] as bool; + version++; + } + return _response({ + 'product_identity': 'p1', + 'favorite': active, + 'revision': version == 0 ? '' : 'f:$version', + }); + }), + ), + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Row( + children: [ + for (var i = 0; i < 2; i++) + FavoriteButton(repository: repo, identity: 'p1', redirect: '/shop', name: '商品'), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byTooltip('收藏 商品'), findsNWidgets(2)); + await tester.tap(find.byTooltip('收藏 商品').first); + await tester.pumpAndSettle(); + expect(find.byTooltip('取消收藏 商品'), findsNWidgets(2)); + fail = true; + await tester.tap(find.byTooltip('取消收藏 商品').first); + await tester.pumpAndSettle(); + expect(find.byTooltip('取消收藏 商品'), findsNWidgets(2)); + }); + test('账号切换后不广播前一账号收藏结果', () async { + var token = 'account-a'; + final pending = Completer(); + final events = []; + final repo = ClientRepository( + ApiClient(() => token, client: MockClient((_) => pending.future)), + ); + final subscription = repo.favoriteChanges.listen(events.add); + addTearDown(subscription.cancel); + final result = repo.setFavorite( + const ProductFavoriteState(productIdentity: 'p1', active: false, revision: ''), + true, + ); + final assertion = expectLater(result, throwsA(isA())); + token = 'account-b'; + pending.complete(_response({'product_identity': 'p1', 'favorite': true, 'revision': 'f:1'})); + await assertion; + expect(events, isEmpty); + }); + test('收藏分页、图片URL及金额数据校验', () async { + var reads = 0; + final repo = ClientRepository( + ApiClient( + () => 'test', + client: MockClient((request) async { + reads++; + final page = int.parse(request.url.queryParameters['page']!); + return _response({ + 'items': [ + { + 'product_identity': 'p$page', + 'favorite': true, + 'revision': 'r$page', + 'name': '商品$page', + 'price_amount': 16900, + 'stock_quantity': 0, + 'available': false, + 'category_name': '配件', + 'image_url': 'javascript:bad', + 'specifications': ['家用'], + }, + ], + 'has_more': page == 1, + 'page': page, + 'page_size': 30, + }); + }), + ), + ); + final items = await repo.favorites(); + expect(reads, 2); + expect(items.length, 2); + expect(items.first.product.imageUrl, ''); + expect(items.first.available, isFalse); + expect( + () => ProductFavoriteState.fromJson({ + 'product_identity': 'p1', + 'favorite': 'true', + 'revision': '', + }), + throwsFormatException, + ); + }); +} diff --git a/apps/user_app/test/data/gas_contract_pdf_test.dart b/apps/user_app/test/data/gas_contract_pdf_test.dart new file mode 100644 index 0000000..9acfb04 --- /dev/null +++ b/apps/user_app/test/data/gas_contract_pdf_test.dart @@ -0,0 +1,34 @@ +// 功能描述:PDF鉴权、缺失、错误页与跨会话保护;版本:1.0.0。 +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:user_app/data/repositories/client_repository.dart'; +import 'package:user_app/data/services/api_client.dart'; + +void main() { + test('PDF只接收有效字节并拒绝缺失、JSON错误及旧会话', () async { + var mode = 0; + var token = 'token'; + final repo = ClientRepository( + ApiClient( + () => token, + client: MockClient((request) async { + expect(request.headers['authorization'], 'token'); + expect(request.headers['accept'], 'application/pdf'); + expect(request.url.path.endsWith('/gas/contracts/c1/attachment'), isTrue); + if (mode == 1) return http.Response('', 404); + if (mode == 2) return http.Response('{"code":1112}', 200); + if (mode == 3) token = 'changed'; + return http.Response('%PDF-1.7\n%%EOF', 200); + }), + ), + ); + expect(String.fromCharCodes(await repo.gasContractPdf('c1')), startsWith('%PDF-')); + mode = 1; + await expectLater(repo.gasContractPdf('c1'), throwsA(isA())); + mode = 2; + await expectLater(repo.gasContractPdf('c1'), throwsA(isA())); + mode = 3; + await expectLater(repo.gasContractPdf('c1'), throwsA(isA())); + }); +} diff --git a/apps/user_app/test/data/gas_contracts_test.dart b/apps/user_app/test/data/gas_contracts_test.dart new file mode 100644 index 0000000..5f89eda --- /dev/null +++ b/apps/user_app/test/data/gas_contracts_test.dart @@ -0,0 +1,48 @@ +// 功能描述:合同列表类型、会话隔离及真实状态回归;版本:1.0.0。 +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:user_app/data/repositories/client_repository.dart'; +import 'package:user_app/data/services/api_client.dart'; + +void main() { + test('合同草稿保持真实状态并拦截跨会话迟到响应', () async { + var token = 'first'; + var changeSession = false; + final repo = ClientRepository( + ApiClient( + () => token, + client: MockClient((request) async { + expect(request.url.path.endsWith('/gas/contracts'), isTrue); + if (changeSession) token = 'second'; + return http.Response( + jsonEncode({ + 'code': 0, + 'details': [ + { + 'identity': 'c1', + 'contract_no': 'HT1', + 'title': '合同', + 'terms': '条款', + 'contract_status': 0, + 'station_name': '气站', + 'has_attachment': false, + 'signed_at': '0001-01-01T00:00:00Z', + }, + ], + }), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ), + ); + final contracts = await repo.gasContracts(); + expect(contracts.single.statusName, '草稿'); + expect(contracts.single.signedAt, isNull); + expect(contracts.single.stationName, '气站'); + changeSession = true; + await expectLater(repo.gasContracts(), throwsA(isA())); + }); +} diff --git a/apps/user_app/test/data/gas_order_detail_test.dart b/apps/user_app/test/data/gas_order_detail_test.dart new file mode 100644 index 0000000..9818012 --- /dev/null +++ b/apps/user_app/test/data/gas_order_detail_test.dart @@ -0,0 +1,69 @@ +// 功能描述:供气与合同鉴权、货币精度及签收请求契约回归;版本:1.0.0。 +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:user_app/data/repositories/client_repository.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/gas_order_detail.dart'; + +void main() { + final data = { + 'identity': 'gas', + 'order_no': 'G1', + 'status_name': '配送中', + 'allowed_actions': [], + 'items': [ + {'identity': 'line', 'quantity': 1, 'sale_amount': 12800}, + ], + 'delivery_fee': 500, + 'product_amount': 12800, + 'discount_amount': 0, + 'payable_amount': 13300, + 'timeline': [], + 'payments': [], + }; + test('供气详情不从履约状态推断支付,签收只发送原请求号', () async { + final repo = ClientRepository( + ApiClient( + () => 'token', + client: MockClient((request) async { + expect(request.headers['authorization'], 'token'); + if (request.method == 'POST') { + expect(request.url.path.endsWith('/gas/orders/gas/confirm-receipt'), isTrue); + expect(jsonDecode(request.body), {'request_no': 'request'}); + return http.Response('{"code":0,"details":{"confirmed":true}}', 200); + } + return http.Response( + jsonEncode({'code': 0, 'details': data}), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ), + ); + final value = await repo.gasOrderDetail('gas'); + expect(value.payments, isEmpty); + expect(value.deliveryFee, 500); + expect(value.items.single.quantity, 1); + await repo.confirmGasReceipt('gas', requestNo: 'request'); + await expectLater(repo.gasOrderDetail('other'), throwsA(isA())); + expect( + () => GasOrderDetail.fromJson({...data, 'delivery_fee': 1.2}, (s) => s), + throwsFormatException, + ); + }); + test('供气和合同读取401触发会话清理', () async { + var expired = 0; + final repo = ClientRepository( + ApiClient( + () => 'token', + onUnauthorized: (_) => expired++, + client: MockClient((_) async => http.Response('{"code":1301}', 401)), + ), + ); + await expectLater(repo.gasOrderDetail('gas'), throwsA(isA())); + await expectLater(repo.gasContractDetail('contract'), throwsA(isA())); + expect(expired, 2); + }); +} diff --git a/apps/user_app/test/data/payment_password_test.dart b/apps/user_app/test/data/payment_password_test.dart new file mode 100644 index 0000000..30cc2f6 --- /dev/null +++ b/apps/user_app/test/data/payment_password_test.dart @@ -0,0 +1,84 @@ +// 功能描述:支付密码状态和OTP申请/改密请求契约与会话保护;版本:1.0.0。 +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:user_app/data/repositories/client_repository.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/client_models.dart'; + +void main() { + test('缺失支付密码状态保持未知', () { + expect(WalletSummary.fromJson({}).paymentPasswordSet, isNull); + }); + test('验证码只发当前资料手机号,区分首次与重置,不把Mock报告为已发送', () async { + final purposes = []; + final repo = ClientRepository( + ApiClient( + () => 'token', + client: MockClient((r) async { + if (r.url.path.endsWith('/auth/profile')) { + return http.Response('{"code":0,"details":{"phone":"13800000001"}}', 200); + } + expect(r.url.path, '/heqi/client/v1/user/auth/verification-code'); + final body = jsonDecode(r.body) as Map; + expect(body['phone'], '13800000001'); + purposes.add(body['purpose'] as String); + return http.Response( + '{"code":0,"details":{"request_identity":"request","delivery_mode":"mock","delivery_status":"not_sent","retry_after":60}}', + 200, + ); + }), + ), + ); + final first = await repo.requestPaymentPasswordCode(resetting: false); + await repo.requestPaymentPasswordCode(resetting: true); + expect(first.delivered, false); + expect(first.maskedPhone, '138****0001'); + expect(purposes, ['set_payment_password', 'reset_payment_password']); + }); + test('改密必须确认服务端成功,缓存解锁失败独立返回', () async { + var changed = true; + final repo = ClientRepository( + ApiClient( + () => 'token', + client: MockClient((r) async { + expect(r.method, 'PUT'); + expect(r.url.path, '/heqi/client/v1/user/wallet/payment-password'); + expect(jsonDecode(r.body), {'new_password': '123456', 'current_password': '654321'}); + return http.Response( + jsonEncode({ + 'code': 0, + 'details': {'changed': changed, 'lock_cleared': false}, + }), + 200, + ); + }), + ), + ); + expect(await repo.setPaymentPassword(newPassword: '123456', currentPassword: '654321'), false); + changed = false; + await expectLater( + repo.setPaymentPassword(newPassword: '123456', currentPassword: '654321'), + throwsA(isA()), + ); + }); + test('切换会话后不继续为旧账户申请验证码', () async { + var token = 'first', calls = 0; + final repo = ClientRepository( + ApiClient( + () => token, + client: MockClient((r) async { + calls++; + token = 'second'; + return http.Response('{"code":0,"details":{"phone":"13800000001"}}', 200); + }), + ), + ); + await expectLater( + repo.requestPaymentPasswordCode(resetting: false), + throwsA(isA()), + ); + expect(calls, 1); + }); +} diff --git a/apps/user_app/test/data/primary_repository_test.dart b/apps/user_app/test/data/primary_repository_test.dart new file mode 100644 index 0000000..7494323 --- /dev/null +++ b/apps/user_app/test/data/primary_repository_test.dart @@ -0,0 +1,70 @@ +// 功能描述:验证新旧分页契约兼容、异常分页与非法金额拒绝。版本:1.0.0。 +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:user_app/data/repositories/client_repository.dart'; +import 'package:user_app/data/repositories/primary_repository.dart'; +import 'package:user_app/data/services/api_client.dart'; + +/// 创建返回受控响应的真实 Repository,覆盖 HTTP 解码到领域转换链路。 +ClientRepository source(Object? Function(http.Request) reply) => ClientRepository( + ApiClient( + () => '', + client: MockClient( + (request) async => http.Response( + jsonEncode({'code': 0, 'details': reply(request)}), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ), + ), + baseUrl: 'http://fixture.test', + ), +); + +void main() { + test('分页连续读取且公开商品不发送认证头', () async { + final pages = []; + final repository = source((request) { + expect(request.headers.containsKey('authorization'), isFalse); + final page = request.url.queryParameters['page']!; + pages.add(page); + return { + 'items': [ + {'identity': page, 'name': '商品$page', 'price_amount': 16900, 'stock_quantity': 1}, + ], + 'has_more': page == '1', + }; + }); + final values = await PrimaryRepository(repository).products(); + expect(pages, ['1', '2']); + expect(values.map((p) => p.identity), ['1', '2']); + expect(values.first.price, 16900); + }); + test('兼容旧数组响应', () async { + final repository = source( + (_) => [ + {'identity': 'legacy', 'name': '商品', 'price_amount': 100, 'stock_quantity': 2}, + ], + ); + expect((await PrimaryRepository(repository).products()).single.identity, 'legacy'); + }); + test('空页仍声明更多数据时返回异常而不是循环或空成功', () async { + final repository = source((_) => {'items': [], 'has_more': true}); + await expectLater( + repository.products(), + throwsA(isA().having((e) => e.code, 'code', 1714)), + ); + }); + test('浮点金额不转换为看似有效的整数分', () async { + final repository = source( + (_) => [ + {'identity': 'bad', 'price_amount': 1.25, 'stock_quantity': 1}, + ], + ); + await expectLater( + PrimaryRepository(repository).products(), + throwsA(isA().having((e) => e.code, 'code', 1714)), + ); + }); +} diff --git a/apps/user_app/test/data/product_detail_test.dart b/apps/user_app/test/data/product_detail_test.dart new file mode 100644 index 0000000..1f8e89e --- /dev/null +++ b/apps/user_app/test/data/product_detail_test.dart @@ -0,0 +1,75 @@ +// 功能描述:商品详情匿名请求、图片路径解析、错误映射和畸形金额拒绝。 +// 版本:1.0.0。 +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:user_app/data/repositories/client_repository.dart'; +import 'package:user_app/data/services/api_client.dart'; + +void main() { + test('公开详情不发送登录令牌,解析图片和参数', () async { + final repo = ClientRepository( + ApiClient( + () => 'private-token', + baseUrl: 'https://api.example.com', + client: MockClient((request) async { + expect(request.url.path, '/heqi/client/v1/user/public/products/p1'); + expect(request.headers.containsKey('Authorization'), false); + return http.Response( + jsonEncode({ + 'code': 0, + 'details': { + 'identity': 'p1', + 'name': '商品', + 'price_amount': 123, + 'stock_quantity': 1, + 'images': [ + {'image_url': '/product.png'}, + {'image_url': 'javascript:bad'}, + ], + 'attributes': [ + {'name': '规格', 'value': '15kg'}, + ], + }, + }), + 200, + headers: const {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ), + ); + final detail = await repo.productDetail('p1'); + expect(detail.images, ['https://api.example.com/product.png']); + expect(detail.attributes.single.value, '15kg'); + expect(detail.price, 123); + }); + test('不存在映射中文;错误价格不显示为可购买商品', () async { + var body = {'code': 1112, 'details': 'Record Not Found'}; + final repo = ClientRepository( + ApiClient( + () => '', + baseUrl: 'https://api.example.com', + client: MockClient( + (request) async => http.Response( + jsonEncode(body), + 200, + headers: const {'content-type': 'application/json; charset=utf-8'}, + ), + ), + ), + ); + await expectLater( + repo.productDetail('missing'), + throwsA(isA().having((error) => error.message, '提示', '商品已下架或不存在')), + ); + body = { + 'code': 0, + 'details': {'identity': 'p1', 'name': '商品', 'price_amount': -1, 'stock_quantity': 1}, + }; + await expectLater( + repo.productDetail('p1'), + throwsA(isA().having((error) => error.code, '代码', 1714)), + ); + }); +} diff --git a/apps/user_app/test/data/product_recommendations_test.dart b/apps/user_app/test/data/product_recommendations_test.dart new file mode 100644 index 0000000..755157d --- /dev/null +++ b/apps/user_app/test/data/product_recommendations_test.dart @@ -0,0 +1,92 @@ +// 功能描述:推荐分页、整数金额、会话归属与登录失效回归;版本:1.0.0。 +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:user_app/data/repositories/client_repository.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/product_recommendations.dart'; + +void main() { + final data = { + 'page': 2, + 'has_more': false, + 'items': [ + { + 'identity': 'p', + 'name': '商品', + 'price_amount': 1999, + 'stock_quantity': 2, + 'image_url': 'javascript:alert(1)', + }, + ], + }; + test('按入口分页,真实整数金额和图片协议校验', () async { + final repo = ClientRepository( + ApiClient( + () => 'test', + client: MockClient((request) async { + expect(request.url.queryParameters, { + 'source': 'favorites', + 'page': '2', + 'page_size': '2', + }); + expect(request.headers['authorization'], 'test'); + return http.Response( + jsonEncode({'code': 0, 'details': data}), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ), + ); + final result = await repo.recommendations(source: 'favorites', page: 2); + expect(result.items.single.price, 1999); + expect(result.items.single.imageUrl, ''); + expect( + () => ProductRecommendations.fromJson({ + ...data, + 'items': [ + {'identity': 'p', 'name': '商品', 'price_amount': 1.9, 'stock_quantity': 2}, + ], + }, (s) => s), + throwsFormatException, + ); + expect( + () => ProductRecommendations.fromJson({...data, 'items': [], 'has_more': true}, (s) => s), + throwsFormatException, + ); + }); + test('迟到的旧账号推荐被丢弃,401交由会话退出处理', () async { + var token = 'old', expired = false; + final repo = ClientRepository( + ApiClient( + () => token, + client: MockClient((_) async { + token = 'new'; + return http.Response( + jsonEncode({'code': 0, 'details': data}), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ), + ); + await expectLater( + repo.recommendations(source: 'cart', page: 2), + throwsA(isA()), + ); + final unauthorized = ClientRepository( + ApiClient( + () => token, + onUnauthorized: (_) => expired = true, + client: MockClient((_) async => http.Response('{"code":1301}', 401)), + ), + ); + await expectLater( + unauthorized.recommendations(source: 'cart'), + throwsA(isA()), + ); + expect(expired, isTrue); + }); +} diff --git a/apps/user_app/test/data/recharge_agreement_test.dart b/apps/user_app/test/data/recharge_agreement_test.dart new file mode 100644 index 0000000..a6675c0 --- /dev/null +++ b/apps/user_app/test/data/recharge_agreement_test.dart @@ -0,0 +1,45 @@ +// 功能描述:充值协议按实际版本提交并校验确认结果;版本:1.0.0。 +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:user_app/data/repositories/client_repository.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/recharge.dart'; +import 'package:user_app/domain/models/client_models.dart'; + +class _Repo extends ClientRepository { + _Repo(super.api); + @override + Future profile() async => + const UserProfile(identity: 'alice', name: '', phone: '', avatar: ''); +} + +void main() { + test('确认使用稳定账号版本请求号,拒绝错误版本响应', () async { + var version = 2; + final repo = _Repo( + ApiClient( + () => 'token', + client: MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/heqi/client/v1/user/contents/read-confirmations'); + final body = jsonDecode(request.body) as Map; + expect(body['content_version'], 2); + expect(body['request_no'], 'consent:alice:agreement:2'); + return http.Response( + jsonEncode({ + 'code': 0, + 'details': {'identity': 'read', 'content_version': version}, + }), + 200, + ); + }), + ), + ); + const agreement = RechargeAgreement('agreement', 2, '充值协议', '正文'); + await repo.confirmRechargeAgreement(agreement); + version = 3; + await expectLater(repo.confirmRechargeAgreement(agreement), throwsA(isA())); + }); +} diff --git a/apps/user_app/test/data/repair_draft_store_test.dart b/apps/user_app/test/data/repair_draft_store_test.dart new file mode 100644 index 0000000..0421e23 --- /dev/null +++ b/apps/user_app/test/data/repair_draft_store_test.dart @@ -0,0 +1,21 @@ +// 功能描述:安全存储中的账户/环境草稿隔离与独立清理。 +// 版本:1.0.0。 +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:user_app/data/services/repair_draft_store.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('账户和API环境分别隔离,删除不影响其他草稿', () async { + FlutterSecureStorage.setMockInitialValues({}); + final store = SecureRepairDraftStore(); + await store.write('dev#alice', {'schema': 1, 'request_no': 'a'}); + await store.write('dev#bob', {'schema': 1, 'request_no': 'b'}); + await store.write('other#alice', {'schema': 1, 'request_no': 'c'}); + expect((await store.read('dev#alice'))!['request_no'], 'a'); + await store.delete('dev#alice'); + expect(await store.read('dev#alice'), isNull); + expect((await store.read('dev#bob'))!['request_no'], 'b'); + expect((await store.read('other#alice'))!['request_no'], 'c'); + }); +} diff --git a/apps/user_app/test/data/shop_order_detail_test.dart b/apps/user_app/test/data/shop_order_detail_test.dart new file mode 100644 index 0000000..9f49f23 --- /dev/null +++ b/apps/user_app/test/data/shop_order_detail_test.dart @@ -0,0 +1,70 @@ +// 功能描述:订单详情价格精度、公开标识和会话归属校验;版本:1.0.0。 +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:user_app/data/repositories/client_repository.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/shop_order_detail.dart'; + +void main() { + final data = { + 'identity': 'order', + 'order_no': 'EC123', + 'status_name': '待付款', + 'allowed_actions': ['cancel'], + 'items': [ + { + 'identity': 'item', + 'name': '历史商品', + 'quantity': 2, + 'sale_amount': 1999, + 'image_url': 'javascript:alert(1)', + }, + ], + 'product_amount': 3998, + 'discount_amount': 0, + 'payable_amount': 3998, + }; + test('成交单价为整数分,详情标识不匹配不得展示', () async { + final repo = ClientRepository( + ApiClient( + () => 'token', + client: MockClient((request) async { + expect(request.headers['authorization'], 'token'); + return http.Response( + jsonEncode({'code': 0, 'details': data}), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ), + ); + final order = await repo.shopOrderDetail('order'); + expect(order.items.single.amount, 3998); + expect(order.items.single.imageUrl, ''); + expect(order.summary.items.single.identity, 'item'); + await expectLater(repo.shopOrderDetail('other'), throwsA(isA())); + expect( + () => ShopOrderDetail.fromJson({...data, 'payable_amount': 39.98}, (s) => s), + throwsFormatException, + ); + }); + test('切换账号丢弃迟到订单响应', () async { + var token = 'old'; + final repo = ClientRepository( + ApiClient( + () => token, + client: MockClient((_) async { + token = 'new'; + return http.Response( + jsonEncode({'code': 0, 'details': data}), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ), + ); + await expectLater(repo.shopOrderDetail('order'), throwsA(isA())); + }); +} diff --git a/apps/user_app/test/data/wallet_account_test.dart b/apps/user_app/test/data/wallet_account_test.dart new file mode 100644 index 0000000..2e82c31 --- /dev/null +++ b/apps/user_app/test/data/wallet_account_test.dart @@ -0,0 +1,93 @@ +// 功能描述:银行卡与提现接口契约、金额精度和会话隔离测试;版本:1.0.0。 +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:user_app/data/repositories/client_repository.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/wallet_account.dart'; + +void main() { + test('提现金额按整数分解析,不接受超两位小数和指数', () { + expect(parseWithdrawalAmount('300'), 30000); + expect(parseWithdrawalAmount('10.08'), 1008); + expect(parseWithdrawalAmount('1.001'), isNull); + expect(parseWithdrawalAmount('1e3'), isNull); + }); + + test('银行卡列表只读取服务端脱敏字段', () async { + var requestedPath = ''; + final repo = ClientRepository( + ApiClient( + () => 'token', + client: MockClient((request) async { + requestedPath = request.url.path; + return http.Response.bytes( + utf8.encode( + jsonEncode({ + 'code': 0, + 'details': [ + { + 'identity': 'b1', + 'card_no_masked': '**** **** **** 2868', + 'bank_name': '中国建设银行', + 'card_owner': '张先生', + 'bank_type': 'debit', + 'is_default': true, + }, + ], + }), + ), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ), + ); + final bank = (await repo.walletBanks()).single; + expect(requestedPath, '/heqi/client/v1/user/wallet/banks'); + expect(bank.maskedNumber, endsWith('2868')); + expect(bank.typeName, '储蓄卡'); + expect(bank.isDefault, isTrue); + }); + + test('提现请求使用公开银行卡标识、整数分和幂等号', () async { + final repo = ClientRepository( + ApiClient( + () => 'token', + client: MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/heqi/client/v1/user/wallet/withdrawals'); + expect(jsonDecode(request.body), { + 'bank_identity': 'b1', + 'amount': 30000, + 'request_no': 'request-1', + 'payment_password': '123456', + }); + return http.Response( + jsonEncode({ + 'code': 0, + 'details': { + 'identity': 'w1', + 'cash_no': 'WD1', + 'amount': 30000, + 'fee': 0, + 'apply_status': 10, + 'created_at': '2026-09-11T12:00:00+08:00', + }, + }), + 200, + ); + }), + ), + ); + final result = await repo.createWalletWithdrawal( + bankIdentity: 'b1', + amount: 30000, + requestNo: 'request-1', + paymentPassword: '123456', + ); + expect(result.statusName, '待审核'); + }); +} diff --git a/apps/user_app/test/data/wallet_bill_test.dart b/apps/user_app/test/data/wallet_bill_test.dart new file mode 100644 index 0000000..bd2cbeb --- /dev/null +++ b/apps/user_app/test/data/wallet_bill_test.dart @@ -0,0 +1,73 @@ +// 功能描述:账单金额严格解析、筛选请求和过期会话保护;版本:1.0.0。 +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:user_app/data/repositories/client_repository.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/wallet_bill.dart'; + +void main() { + test('钱包缺失或小数金额不能显示为零余额', () async { + for (final data in [ + '{}', + '{"balance":1.5,"withdrawal_balance":0}', + '{"balance":0,"withdrawal_balance":1}', + ]) { + final repo = ClientRepository( + ApiClient( + () => 't', + client: MockClient((_) async => http.Response('{"code":0,"details":$data}', 200)), + ), + ); + await expectLater(repo.wallet(), throwsA(isA())); + } + }); + final valid = { + 'identity': 'r1', + 'direction': 'expense', + 'amount': 100, + 'fee': 0, + 'balance_after': 500, + 'created_at': '2026-09-08T08:00:00Z', + }; + test('金额和收支不推测、不截断为整数', () { + expect(WalletBill.fromJson(valid).signedAmount, '-¥1.00'); + for (final patch in [ + {'amount': 1.5}, + {'amount': -1}, + {'fee': '0'}, + {'direction': 'unknown'}, + {'created_at': ''}, + ]) { + expect(() => WalletBill.fromJson({...valid, ...patch}), throwsFormatException); + } + }); + test('账单筛选和游标传递,不将错误响应当空列表', () async { + final repo = ClientRepository( + ApiClient( + () => 't', + client: MockClient((r) async { + expect(r.url.queryParameters, {'direction': 'income', 'cursor': '50'}); + return http.Response('{"code":0,"details":{}}', 200); + }), + ), + ); + await expectLater( + repo.walletBills(direction: 'income', cursor: '50'), + throwsA(isA()), + ); + }); + test('切换账户后迟到的账单不可显示', () async { + var token = 'alice'; + final repo = ClientRepository( + ApiClient( + () => token, + client: MockClient((r) async { + token = 'bob'; + return http.Response('{"code":0,"details":{"items":[],"next_cursor":""}}', 200); + }), + ), + ); + await expectLater(repo.walletBills(), throwsA(isA())); + }); +} diff --git a/apps/user_app/test/domain/client_models_test.dart b/apps/user_app/test/domain/client_models_test.dart index c2fbb26..ac3ce38 100644 --- a/apps/user_app/test/domain/client_models_test.dart +++ b/apps/user_app/test/domain/client_models_test.dart @@ -2,6 +2,17 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:user_app/domain/models/client_models.dart'; void main() { + test('用户资料保留服务端实名认证事实', () { + final profile = UserProfile.fromJson(const { + 'identity': 'user', + 'name': '张先生', + 'phone': '13800005678', + 'avatar': '', + 'real_name': '张实名', + }); + expect(profile.realName, '张实名'); + }); + group('moneyText', () { test('formats minor currency units', () { expect(moneyText(12345), '¥123.45'); diff --git a/apps/user_app/test/domain/recharge_draft_test.dart b/apps/user_app/test/domain/recharge_draft_test.dart new file mode 100644 index 0000000..278562b --- /dev/null +++ b/apps/user_app/test/domain/recharge_draft_test.dart @@ -0,0 +1,39 @@ +// 功能描述:充值待确认请求的存储隔离和损坏保护;版本:1.0.0。 +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:user_app/data/services/recharge_draft_store.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const draft = PendingRecharge( + request: 'request-1', + amount: 29, + channel: 'alipay', + payType: 'wap', + ); + test('重开存储恢复同一请求,环境和账号分别隔离', () async { + FlutterSecureStorage.setMockInitialValues({}); + final store = SecureRechargeDraftStore(); + await store.write('api-a#alice', draft); + expect((await SecureRechargeDraftStore().read('api-a#alice'))?.request, 'request-1'); + expect((await store.read('api-a#alice'))?.amount, 29); + expect(await store.read('api-a#bob'), isNull); + expect(await store.read('api-b#alice'), isNull); + await store.delete('api-a#bob'); + expect(await store.read('api-a#alice'), isNotNull); + await store.delete('api-a#alice'); + expect(await store.read('api-a#alice'), isNull); + }); + test('损坏请求不能静默当作未创建,禁止Mock渠道和无效金额', () { + for (final patch in >[ + {'schema': 2}, + {'request': ''}, + {'amount': 0}, + {'amount': 1.5}, + {'channel': 'mock'}, + {'channel': 'wechat', 'pay_type': 'wap'}, + ]) { + expect(() => PendingRecharge.fromJson({...draft.toJson(), ...patch}), throwsFormatException); + } + }); +} diff --git a/apps/user_app/test/domain/recharge_flow_test.dart b/apps/user_app/test/domain/recharge_flow_test.dart new file mode 100644 index 0000000..b9517f1 --- /dev/null +++ b/apps/user_app/test/domain/recharge_flow_test.dart @@ -0,0 +1,121 @@ +// 功能描述:充值先存后发和未确认订单保留;版本:1.0.0。 +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/data/services/recharge_draft_store.dart'; +import 'package:user_app/data/services/recharge_flow.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/recharge.dart'; +import '../support/a1_fixture.dart'; + +class _Store implements RechargeDraftStore { + PendingRecharge? value; + bool fail = false; + @override + Future read(String owner) async => value; + @override + Future write(String owner, PendingRecharge draft) async { + if (fail) throw StateError('存储失败'); + value = draft; + } + + @override + Future delete(String owner) async { + value = null; + } +} + +class _Repo extends A1FixtureRepository { + _Repo(this.store); + final _Store store; + int calls = 0, status = 10; + int? queryError; + String owner = 'api#alice'; + bool changeOwnerOnQuery = false; + @override + Future rechargeDraftOwner() async => owner; + @override + Future> createRecharge({ + required String request, + required int amount, + required String channel, + required String payType, + }) async { + expect(store.value?.request, request); + calls++; + throw StateError('响应丢失'); + } + + @override + Future rechargeResult(String request) async { + if (changeOwnerOnQuery) owner = 'api#bob'; + if (queryError != null) throw ApiException(queryError!, '查询失败'); + return RechargeRecord( + identity: 'order', + number: request, + amount: 100, + status: status, + channel: 'alipay', + createdAt: DateTime(2026), + ); + } +} + +void main() { + test('明确不存在可原号重发,网络错误和账号切换不能发单', () async { + const draft = PendingRecharge( + request: 'request-1', + amount: 100, + channel: 'alipay', + payType: 'wap', + ); + for (final code in [1112, 500]) { + final store = _Store()..value = draft; + final repo = _Repo(store)..queryError = code; + final flow = RechargeFlow(repo, store); + await expectLater( + flow.create(draft), + code == 1112 + ? throwsStateError + : throwsA(isA().having((e) => e.code, '错误码', 500)), + ); + expect(repo.calls, code == 1112 ? 1 : 0); + expect(store.value?.request, draft.request); + } + final store = _Store()..value = draft; + final repo = _Repo(store)..changeOwnerOnQuery = true; + await expectLater(RechargeFlow(repo, store).create(draft), throwsStateError); + expect(repo.calls, 0); + expect(store.value, isNotNull); + }); + test('存储失败不发单,响应丢失保留原请求,到账后才清除', () async { + final store = _Store()..fail = true; + final repo = _Repo(store); + final flow = RechargeFlow(repo, store); + const draft = PendingRecharge( + request: 'request-1', + amount: 100, + channel: 'alipay', + payType: 'wap', + ); + await expectLater(flow.create(draft), throwsStateError); + expect(repo.calls, 0); + store.fail = false; + await expectLater(flow.create(draft), throwsStateError); + expect(repo.calls, 1); + expect(store.value?.request, 'request-1'); + await expectLater( + flow.create( + const PendingRecharge(request: 'request-2', amount: 100, channel: 'alipay', payType: 'wap'), + ), + throwsStateError, + ); + expect(repo.calls, 1); + expect((await flow.recover())?.credited, isFalse); + expect(store.value, isNotNull); + repo.status = 23; + expect(await flow.create(draft), {'recharge_status': 23}); + expect(repo.calls, 1); + expect(store.value, isNotNull); + expect((await flow.recover())?.credited, isTrue); + expect(store.value, isNull); + }); +} diff --git a/apps/user_app/test/domain/recharge_test.dart b/apps/user_app/test/domain/recharge_test.dart new file mode 100644 index 0000000..75cf9f0 --- /dev/null +++ b/apps/user_app/test/domain/recharge_test.dart @@ -0,0 +1,49 @@ +// 功能描述:充值金额精度、协议完整性和到账事实校验;版本:1.0.0。 +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/domain/models/recharge.dart'; + +void main() { + test('金额按十进制精确转分,拒绝超精度和指数输入', () { + expect(parseRechargeAmount('0.29'), 29); + expect(parseRechargeAmount(' 100.1 '), 10010); + expect(parseRechargeAmount('999999999.99'), 99999999999); + for (final value in ['-1', '1e2', '1.001', '.5', '1.', 'NaN', '']) { + expect(parseRechargeAmount(value), isNull, reason: value); + } + }); + + test('只有钱包充值完成状态才表示到账', () { + RechargeRecord record(int status, int? paymentStatus) => RechargeRecord.fromJson({ + 'identity': 'recharge-1', + 'amount': 10000, + 'recharge_status': status, + 'payment_status': paymentStatus, + 'created_at': '2026-09-11T00:00:00Z', + }); + expect(record(10, 23).credited, isFalse); + expect(record(10, 30).statusText, '支付已关闭,未入账'); + expect(record(23, null).credited, isTrue); + expect(record(23, 30).statusText, '已到账'); + }); + + test('缺少协议允许读取配置,残缺协议必须拒绝', () { + final json = {'min_amount': 1, 'max_amount': 500000, 'channels': []}; + expect(RechargeOptions.fromJson(json).agreement, isNull); + for (final identity in ['', ' ']) { + expect( + () => RechargeOptions.fromJson({ + ...json, + 'agreement': { + 'identity': identity, + 'version': 1, + 'body': '协议正文', + }, + }), + throwsFormatException, + ); + } + expect(() => RechargeOptions.fromJson({...json, 'min_amount': 0}), throwsFormatException); + expect(() => RechargeOptions.fromJson({...json, 'max_amount': 0}), throwsFormatException); + expect(() => rechargeCents(1.5), throwsFormatException); + }); +} diff --git a/apps/user_app/test/support/a1_fixture.dart b/apps/user_app/test/support/a1_fixture.dart new file mode 100644 index 0000000..507e6a2 --- /dev/null +++ b/apps/user_app/test/support/a1_fixture.dart @@ -0,0 +1,851 @@ +// 功能描述:A1 自动测试与截图共用的明确 Fixture,禁止作为运行入口使用。 +// 版本:1.0.0 +import 'package:user_app/data/repositories/client_repository.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/client_models.dart'; +import 'package:user_app/domain/models/shipping_address.dart'; +import 'package:user_app/domain/models/product_detail.dart'; +import 'package:user_app/domain/models/cart_item.dart'; +import 'package:user_app/domain/models/product_favorite.dart'; +import 'package:user_app/domain/models/primary_models.dart'; +import 'package:user_app/domain/models/product_recommendations.dart'; +import 'package:user_app/domain/models/shop_order_detail.dart'; +import 'package:user_app/domain/models/gas_order_detail.dart'; +import 'package:user_app/domain/models/gas_order_checkout.dart'; +import 'package:user_app/domain/models/wallet_bill.dart'; +import 'package:user_app/domain/models/wallet_account.dart'; +import 'package:user_app/domain/models/deposit.dart'; +import 'package:user_app/domain/models/recharge.dart'; +import 'package:user_app/domain/models/delivery_detail.dart'; +import 'package:user_app/domain/models/delivery_track.dart'; +import 'package:user_app/domain/models/usage_statistics.dart'; +import 'package:user_app/domain/models/message_center.dart'; +import 'package:user_app/domain/models/family_sharing.dart'; + +/// 提供可控数据及失败状态,测试不依赖本地数据库。 +class A1FixtureRepository extends ClientRepository { + A1FixtureRepository() : super(ApiClient(() => '')); + bool fail = false; + int withdrawalCalls = 0; + int depositReturnCalls = 0; + int gasOrderCreateCalls = 0; + final markedMessageKeys = []; + + @override + Future familyDashboard() async => FamilyDashboardData( + householdName: '薛海路之家', + ownerName: '张先生', + memberCount: 3, + deviceCount: 3, + members: [ + const FamilyMember( + identity: 'owner', + name: '张先生', + phoneMasked: '138****5678', + relationship: '房主', + inviteStatus: 20, + isOwner: true, + permissions: [], + ), + const FamilyMember( + identity: 'member-1', + name: '张女士', + phoneMasked: '139****6078', + relationship: '家人', + inviteStatus: 20, + isOwner: false, + permissions: [ + FamilyDevicePermission( + deviceIdentity: 'device-1', + deviceName: '厨房角阀', + deviceKind: 'valve', + canView: true, + canAlert: true, + canControl: true, + ), + ], + ), + const FamilyMember( + identity: 'member-2', + name: '李先生', + phoneMasked: '138****0028', + relationship: '房东', + inviteStatus: 20, + isOwner: false, + permissions: [ + FamilyDevicePermission( + deviceIdentity: 'device-2', + deviceName: '厨房报警器', + deviceKind: 'alarm', + canView: true, + canAlert: true, + canControl: false, + ), + ], + ), + FamilyMember( + identity: 'member-3', + name: '王女士', + phoneMasked: '137****9056', + relationship: '家人', + inviteStatus: 10, + isOwner: false, + permissions: const [], + expiresAt: DateTime(2026, 9, 20), + ), + ], + devices: const [ + FamilyDevice( + identity: 'device-1', + name: '厨房角阀', + kind: 'valve', + shareCount: 3, + mappingConfigured: true, + ), + FamilyDevice( + identity: 'device-2', + name: '厨房报警器', + kind: 'alarm', + shareCount: 3, + mappingConfigured: true, + ), + FamilyDevice( + identity: 'device-3', + name: '客厅报警器', + kind: 'alarm', + shareCount: 2, + mappingConfigured: true, + ), + ], + ); + + @override + Future messages() async => MessageCenterData( + counts: const {'all': 5, 'unread': 2, 'safety': 1, 'order': 2, 'service': 1, 'notice': 1}, + items: [ + UserMessage( + key: 'safety-1', + category: 'safety', + title: '厨房燃气浓度异常', + summary: '厨房报警器1:检测到燃气浓度异常', + statusText: '已解除', + target: '', + targetIdentity: '', + occurredAt: DateTime(2026, 9, 12, 9, 18), + read: false, + ), + UserMessage( + key: 'order-2', + category: 'order', + title: '厨房角阀已远程关闭', + summary: '厨房角阀已为您远程关闭,保障用气安全', + statusText: '已完成', + target: '', + targetIdentity: '', + occurredAt: DateTime(2026, 9, 12, 8, 52), + read: true, + ), + UserMessage( + key: 'service-1', + category: 'service', + title: '报修工单已受理', + summary: '您的报修工单已受理,工程师将尽快与您联系', + statusText: '进行中', + target: 'ticket', + targetIdentity: 'ticket-1', + occurredAt: DateTime(2026, 9, 11, 16, 30), + read: true, + ), + UserMessage( + key: 'order-1', + category: 'order', + title: '配送员已出发', + summary: '您的燃气商品订单配送状态已更新', + statusText: '配送中', + target: 'gas_order', + targetIdentity: 'gas-1', + occurredAt: DateTime(2026, 9, 3, 9, 42), + read: false, + ), + UserMessage( + key: 'notice-1', + category: 'notice', + title: '秋季安全用气提醒', + summary: '天气转凉,请注意用气通风,定期检查燃气设备。', + statusText: '平台公告', + target: 'content', + targetIdentity: 'notice-1', + occurredAt: DateTime(2026, 9, 1, 10), + read: true, + ), + ], + ); + + @override + Future markMessagesRead(List keys) async => markedMessageKeys.addAll(keys); + + @override + Future> devices() async => const [ + ClientRecord( + identity: 'device-1', + title: '厨房15kg气瓶', + subtitle: 'GZ-15-0286', + raw: {'kind': 'valve'}, + ), + ]; + + @override + Future usageStatistics({ + required String deviceIdentity, + required String period, + DateTime? anchor, + }) async { + final start = DateTime(2026, 9, 1); + final values = [ + 0.12, + 0.20, + 0.13, + 0.15, + 0.08, + 0.16, + 0.30, + 0.40, + 0.28, + 0.31, + 0.42, + 0.30, + 0.29, + 0.20, + 0.28, + 0.35, + 0.20, + 0.27, + 0.39, + 0.33, + 0.19, + 0.32, + 0.49, + 0.62, + 0.35, + 0.29, + 0.37, + 0.31, + 0.17, + 0.23, + ]; + return UsageStatistics( + available: true, + deviceIdentity: deviceIdentity, + deviceName: '厨房15kg气瓶', + deviceCode: 'GZ-15-0286', + period: period, + periodStart: start, + periodEnd: DateTime(2026, 9, 30), + unit: 'kg', + points: [ + for (var index = 0; index < values.length; index++) + UsagePoint( + date: start.add(Duration(days: index)), + usage: values[index], + ), + ], + totalUsage: values.fold(0.0, (sum, value) => sum + value), + averageUsage: values.fold(0.0, (sum, value) => sum + value) / values.length, + composition: const UsageComposition(breakfast: 2.2, lunch: 2.5, dinner: 3.9), + source: 'device', + calcVersion: 'v1', + updatedAt: DateTime(2026, 9, 30, 23, 50), + unavailableReason: '', + ); + } + + @override + Future gasOrderDelivery(String identity) async => DeliveryDetail.fromJson({ + 'identity': identity, + 'order_no': '178509050920001', + 'status_code': 33, + 'status_name': '配送中', + 'status_message': '配送员正在前往您指定地点,请留意电话或消息通知', + 'appointment_at': '2026-09-05T10:30:00+08:00', + 'track_updated_at': '2026-09-05T09:56:00+08:00', + 'address': '北京市朝阳区望京中环南路2号星源国际B座1201室', + 'contact_name': '张女士', + 'contact_phone_masked': '138****5678', + 'station_name': '薛海气站', + 'delivery_name': '薛海气站配送点', + 'delivery_address': '北京市朝阳区薛海路8号', + 'staff_name': '张师傅', + 'staff_avatar': 'asset:assets/staff/delivery-worker.png', + 'staff_assigned': true, + 'credential_verified': true, + 'credential_type': 'hazmat', + 'track_available': true, + 'controlled_call_available': false, + 'message_available': false, + 'vehicle_configured': false, + 'products': [ + {'name': '15kg液化气', 'quantity': 1}, + ], + }); + + @override + Future gasOrderDeliveryTrack(String identity) async => DeliveryTrack.fromJson({ + 'identity': identity, + 'status_code': 33, + 'status_name': '配送中', + 'status_message': '配送员正在为您配送,请耐心等待', + 'appointment_at': '2026-09-05T10:30:00+08:00', + 'updated_at': '2026-09-05T09:56:00+08:00', + 'station_name': '薛海气站', + 'staff_name': '张师傅', + 'staff_avatar': 'asset:assets/staff/delivery-worker.png', + 'staff_phone_masked': '138****5678', + 'controlled_call_available': false, + 'route_available': true, + 'destination_available': true, + 'destination_longitude': 116.472, + 'destination_latitude': 39.997, + 'points': [ + {'longitude': 116.451, 'latitude': 40.002, 'occurred_at': '2026-09-05T09:42:00+08:00'}, + {'longitude': 116.456, 'latitude': 40.001, 'occurred_at': '2026-09-05T09:47:00+08:00'}, + {'longitude': 116.461, 'latitude': 39.999, 'occurred_at': '2026-09-05T09:51:00+08:00'}, + {'longitude': 116.466, 'latitude': 39.998, 'occurred_at': '2026-09-05T09:56:00+08:00'}, + ], + 'timeline': [ + { + 'status_code': 18, + 'title': '气站已接单', + 'detail': '薛海气站已接收您的订单', + 'occurred_at': '2026-09-05T09:21:00+08:00', + }, + { + 'status_code': 20, + 'title': '商品装车完成', + 'detail': '商品已装车,准备出发', + 'occurred_at': '2026-09-05T09:35:00+08:00', + }, + { + 'status_code': 33, + 'title': '配送员已出发', + 'detail': '配送员已从薛海气站出发', + 'occurred_at': '2026-09-05T09:42:00+08:00', + }, + ], + }); + + @override + Future rechargeOptions() async => const RechargeOptions( + min: 1, + max: 500000, + channels: [ + RechargeChannel('wechat', app: true, wap: true), + RechargeChannel('alipay', app: true, wap: true), + ], + ); + + @override + Future gasOrderOptions() async => GasOrderCheckoutData( + stationName: '薛海气站', + stationStatus: '营业中', + deliveryScope: '薛海镇及周边区域', + deliveryFee: 0, + products: const [ + GasOrderProductOption( + name: '5kg 便携瓶', + description: '小巧轻便,适合单人或短期使用', + unitPrice: 5800, + depositAmount: 10000, + itemIdentities: ['g5'], + ), + GasOrderProductOption( + name: '10kg 家用瓶', + description: '家庭日常使用,经济实惠', + unitPrice: 9600, + depositAmount: 10000, + itemIdentities: ['g10'], + ), + GasOrderProductOption( + name: '15kg 家用瓶', + description: '大容量更耐用,适合多人家庭', + unitPrice: 12800, + depositAmount: 10000, + itemIdentities: ['g15'], + ), + ], + addresses: const [ + ShippingAddress( + identity: 'a1', + address: '薛海镇泰山路188号 薛海花园3栋602室', + contactName: '张先生', + contactPhone: '13812345678', + isDefault: true, + ), + ], + slots: [ + GasOrderAppointmentSlot(startAt: DateTime(2026, 9, 12, 9), label: '明日 09:00-11:00'), + ], + ); + + @override + Future createGasOrder({ + required String requestNo, + required String addressIdentity, + required List itemIdentities, + required DateTime appointmentAt, + required int expectedAmount, + }) async { + gasOrderCreateCalls++; + return GasOrderCreateResult(identity: 'gas-created', payableAmount: expectedAmount); + } + + @override + Future deposits() async => DepositSummary( + refundableAmount: 20000, + usingCount: 2, + ruleText: '气瓶完好、回收检测合格后,押金原路退回至余额账户。', + items: [ + DepositRecord( + identity: 'd1', + depositNo: 'YJ20260905001', + status: 10, + statusName: '使用中', + amount: 10000, + productName: '15kg 液化气钢瓶', + productCode: 'GZ-15-0286', + paidAt: DateTime(2026, 8, 18), + refundedAt: null, + allowedActions: const ['return_bottle'], + ), + DepositRecord( + identity: 'd2', + depositNo: 'YJ20260904002', + status: 20, + statusName: '退款中', + amount: 10000, + productName: '15kg 液化气钢瓶', + productCode: 'GZ-15-0193', + paidAt: DateTime(2026, 8, 12), + refundedAt: null, + allowedActions: const [], + ), + DepositRecord( + identity: 'd3', + depositNo: 'YJ20260828003', + status: 23, + statusName: '已退回', + amount: 10000, + productName: '15kg 液化气钢瓶', + productCode: 'GZ-15-0127', + paidAt: DateTime(2026, 7, 28), + refundedAt: DateTime(2026, 7, 30), + allowedActions: const [], + ), + ], + ); + @override + Future createDepositReturn({ + required String depositIdentity, + required ShippingAddress address, + required DateTime appointmentStart, + required DateTime appointmentEnd, + required String requestNo, + }) async { + depositReturnCalls++; + return const DepositReturnRequest( + identity: 'return-1', + status: 10, + statusName: '待上门', + estimatedAmount: 10000, + refundAmount: 0, + ); + } + + @override + Future walletBills({String direction = '', String cursor = ''}) async { + if (fail) throw const ApiException(500, '测试账单错误'); + final items = [ + for (final (id, type, amount, flow) in [ + ('1', 'gas_order', 22800, 'expense'), + ('2', 'refund', 5900, 'income'), + ('3', 'recharge', 30000, 'income'), + ]) + WalletBill( + identity: id, + number: 'TEST-$id', + direction: flow, + tradeType: type, + amount: amount, + fee: 0, + balanceAfter: 56800, + createdAt: DateTime(2026, 9, 6 - int.parse(id)), + channel: 'wallet', + ), + ]; + return WalletBillPage( + items.where((i) => direction.isEmpty || i.direction == direction).toList(), + '', + ); + } + + @override + Future gasOrderDetail(String identity) async { + final payment = identity == 'payment-1'; + return GasOrderDetail.fromJson({ + 'identity': identity, + 'order_no': payment ? '202609051628001' : 'G202609050920001', + 'status_name': payment ? '待付款' : '待签收', + 'allowed_actions': payment ? ['pay', 'cancel'] : ['confirm_receipt'], + 'station_name': '薛海气站', + 'delivery_staff_name': '李师傅', + 'contract_identity': 'contract-1', + 'contract_no': 'HT20260905001', + 'product_amount': 12800, + 'deposit_amount': payment ? 10000 : 0, + 'delivery_fee': 0, + 'discount_amount': 0, + 'payable_amount': payment ? 22800 : 12800, + 'contact_name': '张女士', + 'contact_phone': '13800005678', + 'address': '北京市朝阳区望京街道望京中环南路2号星源国际B座1201室', + 'created_at': '2026-09-05T09:20:00+08:00', + 'items': [ + {'identity': 'gas-item', 'name': '15kg家用液化气', 'quantity': 1, 'sale_amount': 12800}, + ], + 'timeline': payment + ? [] + : [ + {'status_name': '已分配', 'occurred_at': '2026-09-05T09:21:00+08:00'}, + {'status_name': '配送中', 'occurred_at': '2026-09-05T09:30:00+08:00'}, + {'status_name': '待签收', 'occurred_at': '2026-09-05T10:30:00+08:00'}, + ], + 'payments': payment + ? [] + : [ + {'amount': 12800, 'channel': 'wechat', 'paid_at': '2026-09-05T09:22:00+08:00'}, + ], + }, (s) => s); + } + + @override + Future> gasContracts() async => [ + GasContractSummary.fromJson({ + 'identity': 'contract-1', + 'contract_no': 'HT20260905001', + 'title': '居民瓶装燃气供用气合同', + 'terms': '测试合同正文', + 'contract_status': 11, + 'station_name': '薛海气站', + 'signed_at': '2026-08-18T00:00:00+08:00', + 'effective_at': '2026-08-18T00:00:00+08:00', + 'expired_at': '2027-08-17T00:00:00+08:00', + 'has_attachment': false, + }), + ]; + + @override + Future gasContractDetail(String identity) async => GasContractDetail.fromJson({ + 'identity': identity, + 'contract_no': 'HT20260905001', + 'title': '居民瓶装燃气供用气合同', + 'terms': '测试合同正文,仅用于自动验证,不作为真实合同。', + 'contract_status': 11, + 'signed_at': '2026-08-18T00:00:00+08:00', + 'effective_at': '2026-08-18T00:00:00+08:00', + 'expired_at': '2027-08-17T00:00:00+08:00', + 'has_attachment': false, + }); + @override + Future shopOrderDetail(String identity) async { + final payment = identity == 'payment-1'; + return ShopOrderDetail.fromJson({ + 'identity': identity, + 'order_no': payment ? 'EC202609051628001' : 'EC202609050920001', + 'status_name': payment ? '待付款' : '待收货', + 'allowed_actions': payment ? ['pay', 'cancel'] : ['refund', 'confirm_receipt'], + 'contact_name': '张女士', + 'contact_phone': '13800005678', + 'address': '北京市朝阳区望京街道望京中环南路2号星源国际B座1201室', + 'product_amount': payment ? 23800 : 12800, + 'discount_amount': payment ? 1000 : 0, + 'payable_amount': payment ? 22800 : 12800, + 'created_at': '2026-09-05T09:20:00+08:00', + 'paid_at': payment ? null : '2026-09-05T09:21:00+08:00', + 'shipped_at': payment ? null : '2026-09-05T09:30:00+08:00', + 'received_at': null, + 'remark': '送达前电话联系', + 'items': [ + { + 'identity': 'item-1', + 'product_identity': 'p1', + 'name': '15kg家用液化气', + 'quantity': 1, + 'sale_amount': payment ? 23800 : 12800, + 'image_url': '', + }, + ], + }, (s) => s); + } + + @override + Future recommendations({required String source, int page = 1}) async => + ProductRecommendations( + items: const [ + ProductSummary(identity: 'r1', name: '家用燃气减压阀', price: 3900, stock: 10, category: '燃气配件'), + ProductSummary(identity: 'r2', name: '不锈钢燃气管', price: 5900, stock: 10, category: '燃气配件'), + ], + page: page, + hasMore: false, + ); + @override + Future favoriteState(String identity) async => + ProductFavoriteState(productIdentity: identity, active: false, revision: ''); + @override + Future> favorites() async => [ + for (final p in await products()) + ProductFavorite( + state: ProductFavoriteState(productIdentity: p.identity, active: true, revision: 'r1'), + product: ProductSummary( + identity: p.identity, + name: p.title, + price: p.raw['price_amount'] as int, + stock: 20, + category: p.raw['category_name'] as String, + ), + available: true, + ), + const ProductFavorite( + state: ProductFavoriteState(productIdentity: 'p5', active: true, revision: 'r1'), + product: ProductSummary( + identity: 'p5', + name: '家用燃气灶 双灶', + price: 69900, + stock: 0, + category: '灶具', + ), + available: false, + specifications: ['天然气'], + ), + ]; + @override + Future> cart() async => const [ + CartItem( + product: ProductSummary(identity: 'p1', name: '15kg家用液化气', price: 12800, stock: 30), + quantity: 1, + selected: true, + available: true, + revision: 'r1', + ), + CartItem( + product: ProductSummary(identity: 'p2', name: '燃气报警器', price: 16900, stock: 20), + quantity: 1, + selected: true, + available: true, + revision: 'r2', + ), + ]; + @override + Future productDetail(String identity) async => ProductDetail( + identity: identity, + name: '15kg家用液化气', + price: 12800, + stock: 30, + category: '家用液化气', + attributes: const [(name: '净重', value: '15kg'), (name: '适用家庭', value: '3—5人家庭日常烹饪使用')], + ); + int relationCalls = 0; + @override + Future> shippingAddresses() async => const [ + ShippingAddress( + identity: 'a1', + address: '广东省广州市白云区薛海路88号\n6栋502', + contactName: '张先生', + contactPhone: '13800005678', + isDefault: true, + ), + ShippingAddress( + identity: 'a2', + address: '广东省广州市白云区泰山路188号\n薛海花园3栋602', + contactName: '李女士', + contactPhone: '13900006078', + ), + ShippingAddress( + identity: 'a3', + address: '广东省广州市白云区同和街道同泰路28号\n同泰大厦1栋901', + contactName: '王先生', + contactPhone: '13700008910', + ), + ]; + @override + Future> contents() async { + if (fail) throw const ApiException(500, '测试网络失败'); + return const [ + ClientRecord( + identity: 'notice-1', + title: '夏季用气安全提醒', + subtitle: '', + raw: { + 'body': '高温天气注意通风,定期检查燃气设备', + 'content_type': 'notice', + 'version_no': 1, + 'created_at': '2026-07-26T10:00:00+08:00', + }, + ), + ClientRecord( + identity: 'notice-2', + title: '燃气设施维护通知', + subtitle: '', + raw: { + 'body': '本周将对部分区域进行例行维护', + 'content_type': 'notice', + 'version_no': 1, + 'created_at': '2026-07-24T10:00:00+08:00', + }, + ), + ]; + } + + @override + Future?> serviceRelation() async { + relationCalls++; + return {'gas_name': '薛海气站', 'delivery_name': '城南配送点'}; + } + + @override + Future> products() async => [ + for (final (identity, name, price, category) in [ + ('p1', '15kg家用液化气', 12800, '液化气'), + ('p2', '燃气报警器', 16900, '安全设备'), + ('p3', '不锈钢减压阀', 5900, '燃气配件'), + ('p4', '燃气软管2米', 2990, '燃气配件'), + ]) + ClientRecord( + identity: identity, + title: name, + subtitle: '', + raw: {'price_amount': price, 'stock_quantity': 20, 'category_name': category}, + ), + ]; + @override + Future profile() async => const UserProfile( + identity: 'user-fixture', + name: '张先生', + phone: '13800005678', + avatar: '', + realName: '张先生', + ); + @override + Future wallet() async => + const WalletSummary(balance: 56800, withdrawalBalance: 56800, paymentPasswordSet: true); + @override + Future> walletBanks() async => const [ + WalletBank( + identity: 'bank-1', + maskedNumber: '**** **** **** 2868', + bankName: '中国建设银行', + owner: '张先生', + type: 'debit', + isDefault: true, + ), + WalletBank( + identity: 'bank-2', + maskedNumber: '**** **** **** 6078', + bankName: '中国工商银行', + owner: '张先生', + type: 'debit', + isDefault: false, + ), + ]; + @override + Future> walletWithdrawals() async => const []; + @override + Future createWalletWithdrawal({ + required String bankIdentity, + required int amount, + required String requestNo, + required String paymentPassword, + }) async { + withdrawalCalls++; + return WalletWithdrawal( + identity: 'withdraw-1', + number: 'WD20260911001', + amount: amount, + fee: 0, + status: 10, + createdAt: DateTime(2026, 9, 11, 12), + ); + } + + @override + Future> addresses() async => []; + @override + Future> emergencyContacts() async => const [ + ClientRecord(identity: 'contact-1', title: '家人一', subtitle: '', raw: {}), + ClientRecord(identity: 'contact-2', title: '家人二', subtitle: '', raw: {}), + ]; + @override + Future> shopOrders() async => const [ + ClientRecord( + identity: 'order-1', + title: '订单号:1785051399865', + subtitle: '', + status: 16, + raw: { + 'payable_amount': 22800, + 'status_name': '待付款', + 'allowed_actions': ['pay', 'cancel'], + 'created_at': '2026-07-26T15:36:00+08:00', + 'items': [ + {'identity': 'item-1', 'quantity': 1, 'product_snapshot': '{"name":"15kg家用液化气"}'}, + ], + }, + ), + ]; + @override + Future> gasOrders() async => (await shopOrders()) + .map( + (r) => ClientRecord( + identity: r.identity, + title: r.title, + subtitle: r.subtitle, + status: r.status, + raw: { + ...r.raw, + 'station_name': '薛海气站', + 'items': [ + { + 'identity': 'item-1', + 'quantity': 1, + 'product_type_name': '15kg家用液化气', + 'product_params': '{"weight":"15kg"}', + }, + ], + 'allowed_actions': ['pay', 'cancel'], + }, + ), + ) + .toList(); + @override + Future> refunds() async => []; + @override + Future> tickets() async => [ + const ClientRecord( + identity: 't1', + title: 'BX202609040123', + subtitle: '厨房角阀关闭后仍有轻微异响', + status: 34, + raw: { + 'ticket_no': 'BX202609040123', + 'ticket_status': 34, + 'status_name': '待确认', + 'allowed_actions': ['cancel', 'confirm'], + 'category': 'repair', + 'description': '厨房角阀关闭后仍有轻微异响', + 'address': '四川省成都市武侯区万科·公园传奇南区3栋2单元1804', + 'created_at': '2026-09-04T01:15:00Z', + 'started_at': '2026-09-04T08:00:00Z', + 'appointment_at': '2026-09-04T08:00:00Z', + 'result': '已检查阀门并更换密封件,请确认处理结果。', + }, + ), + ]; +} diff --git a/apps/user_app/test/ui/addresses_page_test.dart b/apps/user_app/test/ui/addresses_page_test.dart new file mode 100644 index 0000000..b75115e --- /dev/null +++ b/apps/user_app/test/ui/addresses_page_test.dart @@ -0,0 +1,126 @@ +// 功能描述:验证地址新增、失败重试、删除确认和默认地址刷新。 +// 版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/shipping_address.dart'; +import 'package:user_app/ui/core/app_theme.dart'; +import 'package:user_app/ui/features/address/addresses_page.dart'; +import '../support/a1_fixture.dart'; + +class AddressFixture extends A1FixtureRepository { + List values = []; + final requests = []; + int deletes = 0; + @override + Future> shippingAddresses() async => values; + @override + Future saveShippingAddress(ShippingAddress address, {required String requestNo}) async { + requests.add(requestNo); + if (this.fail) throw const ApiException(500, '保存失败,请重试'); + values = [ + ShippingAddress( + identity: 'saved', + address: address.address, + contactName: address.contactName, + contactPhone: address.contactPhone, + isDefault: address.isDefault, + ), + ]; + } + + @override + Future setDefaultAddress(String identity) async { + if (this.fail) throw const ApiException(500, '设置失败'); + final a = values.single; + values = [ + ShippingAddress( + identity: a.identity, + address: a.address, + contactName: a.contactName, + contactPhone: a.contactPhone, + isDefault: true, + ), + ]; + } + + @override + Future deleteAddress(String identity) async { + deletes++; + values = []; + } +} + +void main() { + testWidgets('新增校验,失败保留输入与请求号,成功刷新列表', (tester) async { + final repo = AddressFixture()..fail = true; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: AddressesPage(repository: repo), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('新增收货地址')); + await tester.pumpAndSettle(); + await tester.tap(find.text('保存地址')); + await tester.pumpAndSettle(); + expect(repo.requests, isEmpty); + final fields = find.byType(TextFormField); + await tester.enterText(fields.at(0), '收货人'); + await tester.enterText(fields.at(1), '13800000001'); + await tester.enterText(fields.at(2), '广东省广州市测试路 1 号'); + await tester.tap(find.text('保存地址')); + await tester.pumpAndSettle(); + expect(find.text('保存失败,请重试'), findsOneWidget); + repo.fail = false; + await tester.tap(find.text('保存地址')); + await tester.pumpAndSettle(); + expect(repo.requests.length, 2); + expect(repo.requests.first, repo.requests.last); + expect(find.text('收货人'), findsOneWidget); + expect(find.text('138****0001'), findsOneWidget); + expect(find.text('地址管理'), findsOneWidget); + }); + + testWidgets('默认切换失败不假更新,成功刷新,删除需要确认', (tester) async { + final repo = AddressFixture() + ..values = [ + const ShippingAddress( + identity: 'owned', + address: '测试地址', + contactName: '收货人', + contactPhone: '13800000001', + ), + ]; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: AddressesPage(repository: repo), + ), + ); + await tester.pumpAndSettle(); + repo.fail = true; + await tester.tap(find.text('设为默认')); + await tester.pumpAndSettle(); + expect(repo.values.single.isDefault, false); + repo.fail = false; + await tester.tap(find.text('设为默认')); + await tester.pumpAndSettle(); + expect(repo.values.single.isDefault, true); + await tester.tap(find.text('编辑')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('删除地址')); + await tester.tap(find.text('删除地址')); + await tester.pumpAndSettle(); + await tester.tap(find.text('取消')); + await tester.pumpAndSettle(); + expect(repo.deletes, 0); + await tester.tap(find.text('删除地址')); + await tester.pumpAndSettle(); + await tester.tap(find.text('删除')); + await tester.pumpAndSettle(); + expect(repo.deletes, 1); + expect(find.text('暂无收货地址'), findsOneWidget); + }); +} diff --git a/apps/user_app/test/ui/cart_page_test.dart b/apps/user_app/test/ui/cart_page_test.dart new file mode 100644 index 0000000..e1164bf --- /dev/null +++ b/apps/user_app/test/ui/cart_page_test.dart @@ -0,0 +1,188 @@ +// 功能描述:购物车服务端确认、失效处理、管理删除与多商品重试回归;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:user_app/domain/models/cart_item.dart'; +import 'package:user_app/domain/models/primary_models.dart'; +import 'package:user_app/domain/models/shipping_address.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/ui/features/shop/cart_page.dart'; +import 'package:user_app/ui/core/app_theme.dart'; +import 'package:user_app/ui/features/shop/checkout_page.dart'; +import 'package:user_app/ui/features/shop/product_detail_page.dart'; +import '../support/a1_fixture.dart'; + +class _Repo extends A1FixtureRepository { + List items = [ + const CartItem( + product: ProductSummary(identity: 'p1', name: '测试商品一', price: 1999, stock: 4), + quantity: 1, + selected: true, + available: true, + revision: 'one', + ), + const CartItem( + product: ProductSummary(identity: 'p2', name: '测试商品二', price: 3000, stock: 5), + quantity: 2, + selected: true, + available: true, + revision: 'two', + ), + const CartItem( + product: ProductSummary(identity: 'p3', name: '失效商品', price: 1000, stock: 0), + quantity: 1, + selected: true, + available: false, + revision: 'three', + ), + ]; + final submitted = <({String request, int amount, List items})>[]; + int writes = 0; + bool failWrite = false; + final targets = []; + @override + Future> cart() async => List.of(items); + @override + Future cartItem(String identity) async => + items.firstWhere((i) => i.product.identity == identity); + @override + Future setCartItem( + CartItem item, { + required int quantity, + required bool selected, + }) async { + writes++; + targets.add(quantity); + final updated = CartItem( + product: item.product, + quantity: quantity, + selected: selected, + available: item.available, + revision: 'v$writes', + ); + items = [ + for (final old in items) + if (old.product.identity != item.product.identity) old else if (quantity > 0) updated, + ]; + if (failWrite) { + failWrite = false; + throw const ApiException(500, '结果待确认'); + } + return updated; + } + + @override + Future> submitCartOrder({ + required String requestNo, + required List items, + required ShippingAddress address, + required int expectedAmount, + String remark = '', + }) async { + submitted.add((request: requestNo, amount: expectedAmount, items: List.of(items))); + if (submitted.length == 1) throw const ApiException(500, '提交结果未知'); + return {'identity': 'order'}; + } +} + +Future _show(WidgetTester tester, _Repo repo, {String path = '/cart'}) async { + final router = GoRouter( + initialLocation: path, + routes: [ + GoRoute( + path: '/cart', + builder: (_, s) => CartPage(repository: repo), + ), + GoRoute( + path: '/cart/checkout', + builder: (_, s) => CheckoutPage(repository: repo, productIdentity: '', fromCart: true), + ), + GoRoute( + path: '/products/p1', + builder: (_, s) => ProductDetailPage(repository: repo, identity: 'p1'), + ), + GoRoute( + path: '/orders', + builder: (_, s) => const Scaffold(body: Text('订单列表')), + ), + GoRoute( + path: '/payment/shop/:identity', + builder: (_, s) => Scaffold(body: Text('支付订单 ${s.pathParameters['identity']}')), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router, theme: AppTheme.light())); + await tester.pumpAndSettle(); + return router; +} + +void main() { + testWidgets('服务端数量与失败重读,全选、失效取消、管理删除', (tester) async { + final repo = _Repo(); + await _show(tester, repo); + expect(find.text('合计:¥79.99'), findsOneWidget); + expect(find.text('商品已失效'), findsOneWidget); + repo.failWrite = true; + await tester.tap(find.byTooltip('增加 测试商品一')); + await tester.pumpAndSettle(); + expect(repo.items.first.quantity, 2); + expect(find.text('合计:¥99.98'), findsOneWidget); + expect(find.text('结果待确认'), findsOneWidget); + await tester.tap(find.text('重新加载')); + await tester.pumpAndSettle(); + final invalid = find.descendant( + of: find.bySemanticsLabel('选择 失效商品'), + matching: find.byType(Checkbox), + ); + await tester.scrollUntilVisible(invalid, 180); + await tester.drag(find.byType(Scrollable).first, const Offset(0, -120)); + await tester.pumpAndSettle(); + await tester.tap(invalid); + await tester.pumpAndSettle(); + expect(repo.items.last.selected, isFalse); + await tester.tap(find.text('管理')); + await tester.pumpAndSettle(); + final all = find.descendant(of: find.bySemanticsLabel('全选商品'), matching: find.byType(Checkbox)); + await tester.tap(all); + await tester.pumpAndSettle(); + await tester.tap(find.text('删除(3)')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(TextButton, '删除')); + await tester.pumpAndSettle(); + expect(repo.items, isEmpty); + expect(find.text('购物车还是空的'), findsOneWidget); + }); + + testWidgets('多商品确认与未知结果重试保留同一请求和整数金额', (tester) async { + final repo = _Repo()..items.removeLast(); + await _show(tester, repo); + await tester.tap(find.text('去结算(2)')); + await tester.pumpAndSettle(); + expect(find.text('测试商品一'), findsOneWidget); + expect(find.text('测试商品二'), findsOneWidget); + expect(repo.submitted, isEmpty); + await tester.tap(find.widgetWithText(FilledButton, '提交订单')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '重试提交')); + await tester.pumpAndSettle(); + expect(repo.submitted.length, 2); + expect(repo.submitted[0].request, repo.submitted[1].request); + expect(repo.submitted[0].amount, 7999); + expect(repo.submitted[1].items.last.quantity, 2); + expect(find.text('支付订单 order'), findsOneWidget); + }); + + testWidgets('加入购物车未知结果重试不在新数量上重复累加', (tester) async { + final repo = _Repo()..failWrite = true; + await _show(tester, repo, path: '/products/p1'); + await tester.ensureVisible(find.text('加入购物车')); + await tester.tap(find.text('加入购物车')); + await tester.pumpAndSettle(); + expect(repo.targets, [2]); + await tester.tap(find.text('重试加入')); + await tester.pumpAndSettle(); + expect(repo.targets, [2, 2]); + expect(repo.items.first.quantity, 2); + }); +} diff --git a/apps/user_app/test/ui/contract_change_requests_test.dart b/apps/user_app/test/ui/contract_change_requests_test.dart new file mode 100644 index 0000000..10903ee --- /dev/null +++ b/apps/user_app/test/ui/contract_change_requests_test.dart @@ -0,0 +1,77 @@ +// 功能描述:合同申请未知失败重试保持请求号,取消必须确认并刷新;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/domain/models/gas_order_detail.dart'; +import 'package:user_app/ui/features/orders/contract_change_requests.dart'; +import '../support/a1_fixture.dart'; + +class _Repo extends A1FixtureRepository { + final ids = []; + bool created = false, cancelled = false; + @override + Future requestContractChange(String identity, String description, String requestNo) async { + expect(identity, 'c1'); + expect(description, '申请变更配送约定'); + ids.add(requestNo); + if (ids.length == 1) throw Exception('网络异常'); + created = true; + return false; + } + + @override + Future> contractChangeRequests(String identity) async => !created + ? [] + : [ + ContractChangeRequest.fromJson({ + 'identity': 'request', + 'ticket_no': 'TK1', + 'status_name': cancelled ? '已取消' : '待受理', + 'description': '申请变更配送约定', + 'result': '', + 'allowed_actions': cancelled ? [] : ['cancel'], + }), + ]; + @override + Future cancelTicket(String identity) async { + expect(identity, 'request'); + cancelled = true; + } +} + +void main() { + testWidgets('申请失败重试和取消确认', (tester) async { + final repo = _Repo(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ContractChangeRequests(repository: repo, identity: 'c1'), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '申请变更配送约定'); + await tester.tap(find.text('提交申请')); + await tester.pumpAndSettle(); + await tester.tap(find.text('提交申请')); + await tester.pumpAndSettle(); + expect(repo.ids.length, 2); + expect(repo.ids.first, repo.ids.last); + await tester.scrollUntilVisible( + find.text('取消申请'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('取消申请')); + await tester.pumpAndSettle(); + await tester.tap(find.text('返回')); + await tester.pumpAndSettle(); + expect(repo.cancelled, isFalse); + await tester.tap(find.text('取消申请')); + await tester.pumpAndSettle(); + await tester.tap(find.text('确认')); + await tester.pumpAndSettle(); + expect(repo.cancelled, isTrue); + expect(find.textContaining('已取消'), findsOneWidget); + }); +} diff --git a/apps/user_app/test/ui/contract_download_button_test.dart b/apps/user_app/test/ui/contract_download_button_test.dart new file mode 100644 index 0000000..4a3a800 --- /dev/null +++ b/apps/user_app/test/ui/contract_download_button_test.dart @@ -0,0 +1,42 @@ +// 功能描述:PDF保存失败重试、取消及防重复提交;版本:1.0.0。 +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/ui/features/orders/contract_download_button.dart'; +import '../support/a1_fixture.dart'; + +class _Repo extends A1FixtureRepository { + @override + Future gasContractPdf(String identity) async => + Uint8List.fromList('%PDF-1.7'.codeUnits); +} + +void main() { + testWidgets('失败可重试,系统取消不报告成功', (tester) async { + var calls = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ContractDownloadButton( + repository: _Repo(), + identity: 'c1', + number: '../HT1', + saver: (name, bytes) async { + expect(name, '___HT1_合同'); + expect(String.fromCharCodes(bytes), '%PDF-1.7'); + if (++calls == 1) throw Exception('保存失败'); + return false; + }, + ), + ), + ), + ); + await tester.tap(find.text('下载PDF')); + await tester.pumpAndSettle(); + expect(find.text('保存失败,请重试'), findsOneWidget); + await tester.tap(find.text('下载PDF')); + await tester.pumpAndSettle(); + expect(calls, 2); + expect(find.text('合同已保存'), findsNothing); + }); +} diff --git a/apps/user_app/test/ui/delivery_detail_page_test.dart b/apps/user_app/test/ui/delivery_detail_page_test.dart new file mode 100644 index 0000000..76f3bcc --- /dev/null +++ b/apps/user_app/test/ui/delivery_detail_page_test.dart @@ -0,0 +1,29 @@ +// 功能描述:验证配送详情使用本人接口事实并明确展示首期缺项;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/ui/features/orders/delivery_detail_page.dart'; + +import '../support/a1_fixture.dart'; + +void main() { + testWidgets('配送详情展示真实分组并提示受控联系暂未开放', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: DeliveryDetailPage(repository: A1FixtureRepository(), identity: 'gas-1'), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('配送中'), findsOneWidget); + expect(find.text('张师傅'), findsOneWidget); + expect(find.text('危险品从业资质 · 已核验'), findsOneWidget); + expect(find.textContaining('178509050920001'), findsOneWidget); + expect(find.text('电话与订单消息暂未开放'), findsOneWidget); + await tester.ensureVisible(find.text('电话联系')); + await tester.tap(find.text('电话联系')); + await tester.pumpAndSettle(); + expect(find.text('电话联系暂未开放'), findsOneWidget); + expect(find.textContaining('受控呼叫服务'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/apps/user_app/test/ui/delivery_track_page_test.dart b/apps/user_app/test/ui/delivery_track_page_test.dart new file mode 100644 index 0000000..1af4e5c --- /dev/null +++ b/apps/user_app/test/ui/delivery_track_page_test.dart @@ -0,0 +1,28 @@ +// 功能描述:验证配送轨迹使用隐私化接口事实并保留首期未开放提示;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/ui/features/orders/delivery_track_page.dart'; + +import '../support/a1_fixture.dart'; + +void main() { + testWidgets('配送轨迹展示路线、进度、配送人员并刷新', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: DeliveryTrackPage(repository: A1FixtureRepository(), identity: 'gas-1'), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('配送中'), findsOneWidget); + expect(find.text('薛海气站'), findsOneWidget); + expect(find.text('气站已接单'), findsOneWidget); + expect(find.text('商品装车完成'), findsOneWidget); + expect(find.text('正在前往'), findsOneWidget); + expect(find.text('张师傅'), findsOneWidget); + await tester.tap(find.byTooltip('刷新轨迹')); + await tester.pumpAndSettle(); + expect(find.text('实时更新 09:56'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/apps/user_app/test/ui/deposit_page_test.dart b/apps/user_app/test/ui/deposit_page_test.dart new file mode 100644 index 0000000..1e737ee --- /dev/null +++ b/apps/user_app/test/ui/deposit_page_test.dart @@ -0,0 +1,28 @@ +// 功能描述:押金汇总、筛选、规则与退瓶状态入口回归;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/ui/core/app_theme.dart'; +import 'package:user_app/ui/features/wallet/deposit_page.dart'; + +import '../support/a1_fixture.dart'; + +void main() { + testWidgets('展示服务端押金并按状态筛选,退瓶入口不伪造提交', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: DepositPage(repository: A1FixtureRepository()), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('¥200.00'), findsOneWidget); + expect(find.text('GZ-15-0286'), findsOneWidget); + await tester.tap(find.text('退款中').first); + await tester.pumpAndSettle(); + expect(find.text('GZ-15-0193'), findsOneWidget); + expect(find.text('GZ-15-0286'), findsNothing); + await tester.tap(find.text('使用中').first); + await tester.pumpAndSettle(); + expect(find.text('申请退瓶'), findsOneWidget); + }); +} diff --git a/apps/user_app/test/ui/deposit_return_page_test.dart b/apps/user_app/test/ui/deposit_return_page_test.dart new file mode 100644 index 0000000..295d3d9 --- /dev/null +++ b/apps/user_app/test/ui/deposit_return_page_test.dart @@ -0,0 +1,43 @@ +// 功能描述:退瓶退押金页状态确认与最终确认回归;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/ui/core/app_theme.dart'; +import 'package:user_app/ui/features/wallet/deposit_return_page.dart'; + +import '../support/a1_fixture.dart'; + +void main() { + testWidgets('完整展示退瓶要素且最终确认前不提交', (tester) async { + final repository = A1FixtureRepository(); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: DepositReturnPage(repository: repository, depositIdentity: 'd1'), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('退瓶退押金'), findsOneWidget); + expect(find.text('15kg 液化气钢瓶'), findsOneWidget); + expect(find.text('上门回收'), findsOneWidget); + await tester.scrollUntilVisible(find.text('预计退款'), 220); + expect(find.text('¥100.00'), findsOneWidget); + for (final label in [ + '瓶体完整,无变形、锈蚀', + '阀门无缺失,无泄漏', + '已停止使用并关闭阀门', + '我已阅读《退瓶规则》', + ]) { + await tester.scrollUntilVisible(find.text(label), 160); + await tester.tap(find.text(label)); + await tester.pump(); + } + await tester.ensureVisible(find.text('提交退瓶申请')); + await tester.tap(find.text('提交退瓶申请')); + await tester.pumpAndSettle(); + expect(find.text('确认提交退瓶申请'), findsOneWidget); + expect(repository.depositReturnCalls, 0); + await tester.tap(find.text('再检查一下')); + await tester.pumpAndSettle(); + expect(repository.depositReturnCalls, 0); + }); +} diff --git a/apps/user_app/test/ui/device_groups_page_test.dart b/apps/user_app/test/ui/device_groups_page_test.dart new file mode 100644 index 0000000..14603fe --- /dev/null +++ b/apps/user_app/test/ui/device_groups_page_test.dart @@ -0,0 +1,95 @@ +// 功能:设备分组首期资料管理与设备归组交互回归;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/domain/models/client_models.dart'; +import 'package:user_app/domain/models/device_group.dart'; +import 'package:user_app/ui/features/profile/device_groups_page.dart'; + +import '../support/a1_fixture.dart'; + +class _GroupFixture extends A1FixtureRepository { + final groups = [ + const DeviceGroup(identity: 'kitchen', name: '厨房', sortNo: 0, deviceIdentities: {'valve'}), + ]; + String assignedGroup = 'kitchen'; + + @override + Future> devices() async => const [ + ClientRecord(identity: 'valve', title: '厨房角阀', subtitle: 'V001', raw: {'kind': 'valve'}), + ]; + + @override + Future> deviceGroups() async => List.of(groups); + + @override + Future saveDeviceGroup({ + String? identity, + required String name, + required String requestNo, + }) async { + if (identity == null) { + groups.add( + DeviceGroup( + identity: 'new-group', + name: name, + sortNo: groups.length, + deviceIdentities: const {}, + ), + ); + return; + } + final index = groups.indexWhere((group) => group.identity == identity); + final old = groups[index]; + groups[index] = DeviceGroup( + identity: old.identity, + name: name, + sortNo: old.sortNo, + deviceIdentities: old.deviceIdentities, + ); + } + + @override + Future deleteDeviceGroup(String identity) async { + groups.removeWhere((group) => group.identity == identity); + } + + @override + Future assignDeviceGroup(String deviceIdentity, String groupIdentity) async { + assignedGroup = groupIdentity; + } +} + +void main() { + testWidgets('可新建设备分组并保留未开放控制提示', (tester) async { + final repository = _GroupFixture(); + await tester.pumpWidget(MaterialApp(home: DeviceGroupsPage(repository: repository))); + await tester.pumpAndSettle(); + + expect(find.text('厨房'), findsOneWidget); + expect(find.textContaining('分组开关及安全检查暂未开放'), findsOneWidget); + await tester.tap(find.text('新建分组')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '客厅'); + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + expect(repository.groups.any((group) => group.name == '客厅'), isTrue); + expect(find.text('客厅'), findsOneWidget); + }); + + testWidgets('设备可真实移回未分组', (tester) async { + final repository = _GroupFixture(); + await tester.pumpWidget(MaterialApp(home: DeviceGroupsPage(repository: repository))); + await tester.pumpAndSettle(); + + await tester.tap(find.text('管理分组')); + await tester.pumpAndSettle(); + await tester.tap(find.text('更改')); + await tester.pumpAndSettle(); + await tester.tap(find.text('未分组')); + await tester.pumpAndSettle(); + + expect(repository.assignedGroup, isEmpty); + expect(find.text('设备分组已更新'), findsOneWidget); + }); +} diff --git a/apps/user_app/test/ui/device_status_placeholder_test.dart b/apps/user_app/test/ui/device_status_placeholder_test.dart new file mode 100644 index 0000000..524dca3 --- /dev/null +++ b/apps/user_app/test/ui/device_status_placeholder_test.dart @@ -0,0 +1,23 @@ +// 功能:未开放设备卡在窄屏和放大文字下不溢出、不展示模拟遥测;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/ui/features/home/device_status_placeholder.dart'; + +void main() { + for (final width in [320.0, 360.0, 390.0, 430.0]) { + testWidgets('设备未开放卡片$width宽度放大文字', (tester) async { + tester.view.physicalSize = Size(width, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp(home: MediaQuery( + data: MediaQueryData(size: Size(width, 844), textScaler: const TextScaler.linear(1.3)), + child: const Scaffold(body: Padding(padding: EdgeInsets.all(32), child: DeviceStatusPlaceholder())), + ))); + expect(tester.takeException(), isNull); + expect(find.text('暂不可查询'), findsNWidgets(4)); + expect(find.text('在线'), findsNothing); + expect(find.text('正常'), findsNothing); + }); + } +} diff --git a/apps/user_app/test/ui/devices_page_test.dart b/apps/user_app/test/ui/devices_page_test.dart new file mode 100644 index 0000000..e62bc17 --- /dev/null +++ b/apps/user_app/test/ui/devices_page_test.dart @@ -0,0 +1,61 @@ +// 功能:本人设备搜索、类型筛选和未知状态展示;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/domain/models/client_models.dart'; +import 'package:user_app/domain/models/device_group.dart'; +import 'package:user_app/ui/features/profile/devices_page.dart'; +import '../support/a1_fixture.dart'; + +class _DeviceFixture extends A1FixtureRepository { + @override + Future> devices() async => [ + ClientRecord(identity: 'valve', title: '厨房阀门', subtitle: 'V001', raw: {'kind': 'valve'}), + ClientRecord(identity: 'alarm', title: '厨房报警器', subtitle: 'A002', raw: {'kind': 'alarm'}), + ]; + @override + Future> deviceGroups() async => const [ + DeviceGroup(identity: 'kitchen', name: '厨房组', sortNo: 0, deviceIdentities: {'valve', 'alarm'}), + ]; +} + +void main() { + testWidgets('按类型及编号筛选,未知状态不冒充零告警或离线', (tester) async { + await tester.pumpWidget(MaterialApp(home: DevicesPage(repository: _DeviceFixture()))); + await tester.pumpAndSettle(); + expect(find.text('—'), findsNWidgets(3)); + expect(find.text('厨房组'), findsOneWidget); + expect(find.text('厨房阀门'), findsOneWidget); + await tester.tap(find.text('报警器')); + await tester.pumpAndSettle(); + expect(find.text('厨房阀门'), findsNothing); + expect(find.text('厨房报警器'), findsOneWidget); + await tester.enterText(find.byType(TextField), 'V001'); + await tester.pumpAndSettle(); + expect(find.text('没有符合条件的设备'), findsOneWidget); + await tester.tap(find.text('全部')); + await tester.pumpAndSettle(); + expect(find.text('厨房阀门'), findsOneWidget); + await tester.tap(find.text('控制')); + await tester.pumpAndSettle(); + expect(find.text('该功能暂未开放,目前无法使用。'), findsOneWidget); + }); + for (final width in [320.0, 390.0, 430.0]) { + testWidgets('设备页$width宽度放大字体无溢出', (tester) async { + tester.view.physicalSize = Size(width, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(1.3)), + child: child!, + ), + home: DevicesPage(repository: _DeviceFixture()), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/apps/user_app/test/ui/emergency_contacts_page_test.dart b/apps/user_app/test/ui/emergency_contacts_page_test.dart new file mode 100644 index 0000000..8dc30a9 --- /dev/null +++ b/apps/user_app/test/ui/emergency_contacts_page_test.dart @@ -0,0 +1,81 @@ +// 功能:联系人真实成功刷新与失败幂等重试回归;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/domain/models/client_models.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/ui/features/profile/emergency_contacts_page.dart'; +import '../support/a1_fixture.dart'; + +class _ContactsFixture extends A1FixtureRepository { + final calls = []; + bool failContactSave = true; + int? failureCode; + final contacts = []; + @override + Future> emergencyContacts() async => List.of(contacts); + @override + Future saveEmergencyContact({String? identity,required String name,required String phone, + required String relationship,required String requestNo}) async { + calls.add(requestNo); + if (failureCode != null) throw ApiException(failureCode!, 'server message'); + if (failContactSave) throw StateError('timeout'); + contacts.add(ClientRecord(identity:'test-contact',title:name,subtitle:phone,raw:{'relationship':relationship})); + } +} + +void main() { + for (final entry in {2501:'最多可添加5位联系人',2502:'该手机号已在联系人列表中',2503:'该新增请求已处理'}.entries) { + testWidgets('业务拒绝${entry.key}显示具体原因且不添加记录', (tester) async { + final repo = _ContactsFixture()..failureCode=entry.key; + await tester.pumpWidget(MaterialApp(home:EmergencyContactsPage(repository:repo))); + await tester.pumpAndSettle(); + await tester.tap(find.text('添加')); await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField).at(0),'联系人'); + await tester.enterText(find.byType(TextFormField).at(1),'13800000002'); + await tester.tap(find.text('保存')); await tester.pumpAndSettle(); + expect(find.textContaining(entry.value),findsOneWidget); + expect(repo.contacts,isEmpty); + }); + } + for (final width in [320.0,390.0,430.0]) { + testWidgets('联系人长姓名在$width宽度和放大文字下保持可操作', (tester) async { + tester.view.physicalSize = Size(width,844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final repo = _ContactsFixture(); + repo.contacts.add(ClientRecord(identity:'long',title:'用于核对长姓名换行的联系人',subtitle:'13800000002',raw:{'relationship':'需要保持完整显示的关系描述'})); + await tester.pumpWidget(MaterialApp(builder: (context,child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(1.3)),child:child!), + home:EmergencyContactsPage(repository:repo))); + await tester.pumpAndSettle(); + expect(find.byTooltip('编辑联系人'),findsOneWidget); + expect(tester.takeException(),isNull); + }); + } + testWidgets('失败保留输入及新增请求号,成功后刷新且权限仍未开放', (tester) async { + final repo = _ContactsFixture(); + await tester.pumpWidget(MaterialApp(home:EmergencyContactsPage(repository:repo))); + await tester.pumpAndSettle(); + await tester.tap(find.text('添加')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField).at(0),'测试联系人'); + await tester.enterText(find.byType(TextFormField).at(1),'13800000002'); + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + expect(find.textContaining('未能确认保存结果'),findsOneWidget); + expect(repo.contacts,isEmpty); + repo.failContactSave = false; + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + expect(repo.calls.length,2); + expect(repo.calls[0],repo.calls[1]); + expect(find.text('测试联系人'),findsOneWidget); + expect(find.text('138****0002'),findsOneWidget); + expect(find.text('权限 · 暂未开放'),findsOneWidget); + await tester.tap(find.text('远程控制')); + await tester.pumpAndSettle(); + expect(find.text('该功能暂未开放,目前无法使用。'),findsOneWidget); + expect(tester.takeException(),isNull); + }); +} diff --git a/apps/user_app/test/ui/family_sharing_page_test.dart b/apps/user_app/test/ui/family_sharing_page_test.dart new file mode 100644 index 0000000..84de051 --- /dev/null +++ b/apps/user_app/test/ui/family_sharing_page_test.dart @@ -0,0 +1,101 @@ +// 功能:家庭共享页面布局、邀请幂等和撤销交互回归;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/domain/models/family_sharing.dart'; +import 'package:user_app/ui/features/profile/family_sharing_page.dart'; + +import '../support/a1_fixture.dart'; + +class _FamilyFixture extends A1FixtureRepository { + String? invitedPhone; + String? requestNo; + String? revokedIdentity; + + @override + Future familyDashboard() async => FamilyDashboardData( + householdName: '薛海路之家', + ownerName: '张先生', + memberCount: 2, + deviceCount: 1, + members: [ + const FamilyMember( + identity: 'owner', + name: '张先生', + phoneMasked: '138****5678', + relationship: '房主', + inviteStatus: 20, + isOwner: true, + permissions: [], + ), + FamilyMember( + identity: 'pending', + name: '王女士', + phoneMasked: '137****9056', + relationship: '家人', + inviteStatus: 10, + isOwner: false, + permissions: const [], + expiresAt: DateTime(2026, 9, 20), + ), + ], + devices: const [ + FamilyDevice( + identity: 'device-1', + name: '厨房报警器', + kind: 'alarm', + shareCount: 0, + mappingConfigured: true, + ), + ], + ); + + @override + Future inviteFamilyMember({ + required String name, + required String phone, + required String relationship, + required String requestNo, + required List permissions, + }) async { + invitedPhone = phone; + this.requestNo = requestNo; + } + + @override + Future revokeFamilyMember(String identity) async { + revokedIdentity = identity; + } +} + +void main() { + testWidgets('家庭共享展示房主、待确认成员、设备和安全规则', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp(home: FamilySharingPage(repository: _FamilyFixture()))); + await tester.pumpAndSettle(); + expect(find.text('薛海路之家'), findsOneWidget); + expect(find.text('张先生'), findsWidgets); + expect(find.text('王女士'), findsOneWidget); + expect(find.text('待确认'), findsOneWidget); + expect(find.text('厨房报警器'), findsOneWidget); + expect(find.text('远程控制需二次确认,保障用气安全'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('邀请校验通过后提交手机号和稳定请求号', (tester) async { + final repository = _FamilyFixture(); + await tester.pumpWidget(MaterialApp(home: FamilySharingPage(repository: repository))); + await tester.pumpAndSettle(); + await tester.tap(find.text('添加')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField).at(0), '李女士'); + await tester.enterText(find.byType(TextFormField).at(1), '13900000003'); + await tester.tap(find.text('发送邀请')); + await tester.pumpAndSettle(); + expect(repository.invitedPhone, '13900000003'); + expect(repository.requestNo, isNotEmpty); + expect(tester.takeException(), isNull); + }); +} diff --git a/apps/user_app/test/ui/favorites_page_test.dart b/apps/user_app/test/ui/favorites_page_test.dart new file mode 100644 index 0000000..c96b43b --- /dev/null +++ b/apps/user_app/test/ui/favorites_page_test.dart @@ -0,0 +1,136 @@ +// 功能描述:收藏分类、下架保留、管理取消和加购重试回归;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/domain/models/product_favorite.dart'; +import 'package:user_app/domain/models/primary_models.dart'; +import 'package:user_app/domain/models/cart_item.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/ui/core/app_theme.dart'; +import 'package:user_app/ui/features/shop/favorites_page.dart'; +import '../support/a1_fixture.dart'; + +class _Repo extends A1FixtureRepository { + List items = const [ + ProductFavorite( + state: ProductFavoriteState(productIdentity: 'p1', active: true, revision: 'r1'), + product: ProductSummary( + identity: 'p1', + name: '燃气报警器', + price: 16900, + stock: 5, + category: '配件', + ), + available: true, + specifications: ['家用款'], + ), + ProductFavorite( + state: ProductFavoriteState(productIdentity: 'p2', active: true, revision: 'r2'), + product: ProductSummary( + identity: 'p2', + name: '家用燃气灶', + price: 69900, + stock: 0, + category: '灶具', + ), + available: false, + ), + ]; + final removed = []; + final quantities = []; + int quantity = 2; + bool failAdd = true; + @override + Future> favorites() async => List.of(items); + @override + Future setFavorite(ProductFavoriteState state, bool active) async { + removed.add(state.productIdentity); + items = items.where((i) => i.product.identity != state.productIdentity).toList(); + return ProductFavoriteState( + productIdentity: state.productIdentity, + active: active, + revision: 'new', + ); + } + + @override + Future cartItem(String identity) async => CartItem( + product: items.first.product, + quantity: quantity, + selected: true, + available: true, + revision: 'cart', + ); + @override + Future setCartItem( + CartItem item, { + required int quantity, + required bool selected, + }) async { + quantities.add(quantity); + this.quantity = quantity; + if (failAdd) { + failAdd = false; + throw const ApiException(500, '加购结果未知'); + } + return CartItem( + product: item.product, + quantity: quantity, + selected: selected, + available: true, + revision: 'next', + ); + } +} + +void main() { + testWidgets('按分类计数、下架保留且无加购,管理取消恢复空态', (tester) async { + final repo = _Repo(); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: FavoritesPage(repository: repo), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('全部 2'), findsOneWidget); + expect(find.text('暂不可售'), findsOneWidget); + expect(find.text('加入购物车'), findsOneWidget); + await tester.tap(find.text('灶具 1')); + await tester.pumpAndSettle(); + expect(find.text('家用燃气灶'), findsOneWidget); + expect(find.text('燃气报警器'), findsNothing); + await tester.tap(find.text('删除')); + await tester.pumpAndSettle(); + expect(repo.removed, ['p2']); + expect(find.text('全部 1'), findsOneWidget); + await tester.tap(find.text('管理')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(Checkbox).last); + await tester.pumpAndSettle(); + await tester.tap(find.text('取消收藏(1)')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(TextButton, '取消收藏')); + await tester.pumpAndSettle(); + expect(repo.removed, ['p2', 'p1']); + expect(find.text('暂无收藏商品'), findsOneWidget); + }); + testWidgets('收藏加购未知结果复用绝对目标数量', (tester) async { + final repo = _Repo(); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: FavoritesPage(repository: repo), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('加入购物车')); + await tester.pumpAndSettle(); + expect(repo.quantities, [3]); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + await tester.tap(find.text('重试加入')); + await tester.pumpAndSettle(); + expect(repo.quantities, [3, 3]); + expect(repo.quantity, 3); + }); +} diff --git a/apps/user_app/test/ui/feature_entry_test.dart b/apps/user_app/test/ui/feature_entry_test.dart new file mode 100644 index 0000000..8462f83 --- /dev/null +++ b/apps/user_app/test/ui/feature_entry_test.dart @@ -0,0 +1,34 @@ +// 功能:验证首期缺项与明确二期入口的不同提示,以及已开放入口行为;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/ui/core/feature_entry.dart'; + +void main() { + testWidgets('未指定阶段的缺项保留入口并提示暂未开放', (tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: + FeatureEntry(icon: Icons.devices, label: '设备测试')))); + expect(find.text('暂未开放'), findsOneWidget); + expect(find.text('即将开放'), findsNothing); + await tester.tap(find.text('设备测试')); + await tester.pumpAndSettle(); + expect(find.text(UnavailableStage.firstPhase.message), findsOneWidget); + }); + + testWidgets('明确二期显示即将开放而已开放功能继续执行', (tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: + FeatureEntry(icon: Icons.devices, label: '二期测试', stage: UnavailableStage.secondPhase)))); + expect(find.text('即将开放'), findsOneWidget); + await tester.tap(find.text('二期测试')); + await tester.pumpAndSettle(); + expect(find.text(UnavailableStage.secondPhase.message), findsOneWidget); + await tester.tap(find.text('知道了')); + await tester.pumpAndSettle(); + var invoked = false; + await tester.pumpWidget(MaterialApp(home: Scaffold(body: + FeatureEntry(icon: Icons.devices, label: '可用测试', onTap: () => invoked = true)))); + await tester.pumpAndSettle(); + expect(find.text('暂未开放'), findsNothing); + await tester.tap(find.text('可用测试')); + expect(invoked, isTrue); + }); +} diff --git a/apps/user_app/test/ui/gas_contract_history_test.dart b/apps/user_app/test/ui/gas_contract_history_test.dart new file mode 100644 index 0000000..549c5f9 --- /dev/null +++ b/apps/user_app/test/ui/gas_contract_history_test.dart @@ -0,0 +1,54 @@ +// 功能描述:合同历史读取失败重试、事实显示及关闭;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/domain/models/gas_order_detail.dart'; +import 'package:user_app/ui/features/orders/gas_contract_history.dart'; +import '../support/a1_fixture.dart'; + +class _Repo extends A1FixtureRepository { + int calls = 0; + @override + Future> gasContractHistory(String identity) async { + expect(identity, 'c1'); + if (++calls == 1) throw Exception('网络故障'); + return [ + GasContractEvent.fromJson({ + 'identity': 'r1', + 'action': 'renew', + 'contract_status': 11, + 'occurred_at': '2026-09-08T10:00:00Z', + 'effective_at': '2026-09-08T00:00:00Z', + 'expired_at': '2027-09-08T00:00:00Z', + }), + ]; + } +} + +void main() { + testWidgets('历史错误可重试且不冒充签署记录', (tester) async { + final repo = _Repo(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showGasContractHistory(context, repo, 'c1'), + child: const Text('打开'), + ), + ), + ), + ), + ); + await tester.tap(find.text('打开')); + await tester.pumpAndSettle(); + expect(find.text('加载失败'), findsOneWidget); + await tester.tap(find.text('重新加载')); + await tester.pumpAndSettle(); + expect(find.text('合同续期'), findsOneWidget); + expect(find.text('变更后状态:生效中'), findsOneWidget); + expect(find.text('到期日期:2027-09-08'), findsOneWidget); + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + expect(find.text('合同续期'), findsNothing); + }); +} diff --git a/apps/user_app/test/ui/gas_contracts_page_test.dart b/apps/user_app/test/ui/gas_contracts_page_test.dart new file mode 100644 index 0000000..df334c9 --- /dev/null +++ b/apps/user_app/test/ui/gas_contracts_page_test.dart @@ -0,0 +1,47 @@ +// 功能描述:合同页面筛选、搜索、正文读取及失败重试;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/domain/models/gas_order_detail.dart'; +import 'package:user_app/ui/core/app_theme.dart'; +import 'package:user_app/ui/features/orders/gas_contracts_page.dart'; +import '../support/a1_fixture.dart'; + +class _Repo extends A1FixtureRepository { + int calls = 0; + @override + Future> gasContracts() async { + if (++calls == 1) throw Exception('网络错误'); + return super.gasContracts(); + } +} + +void main() { + testWidgets('加载失败可重试,按真实状态和合同编号筛选,查看正文', (tester) async { + final repo = _Repo(); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: GasContractsPage(repository: repo), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('加载失败'), findsOneWidget); + await tester.tap(find.text('重新加载')); + await tester.pumpAndSettle(); + expect(find.text('居民瓶装燃气供用气合同'), findsOneWidget); + await tester.tap(find.text('已到期')); + await tester.pumpAndSettle(); + expect(find.text('暂无符合条件的合同'), findsOneWidget); + await tester.tap(find.text('全部')); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '不存在'); + await tester.pumpAndSettle(); + expect(find.text('暂无符合条件的合同'), findsOneWidget); + await tester.enterText(find.byType(TextField), 'HT20260905001'); + await tester.pumpAndSettle(); + await tester.tap(find.text('查看合同')); + await tester.pumpAndSettle(); + expect(find.textContaining('测试合同正文'), findsOneWidget); + }); +} diff --git a/apps/user_app/test/ui/gas_order_create_page_test.dart b/apps/user_app/test/ui/gas_order_create_page_test.dart new file mode 100644 index 0000000..4c89e15 --- /dev/null +++ b/apps/user_app/test/ui/gas_order_create_page_test.dart @@ -0,0 +1,25 @@ +// 功能描述:气瓶下单页金额和二次确认提交回归;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/ui/features/orders/gas_order_create_page.dart'; + +import '../support/a1_fixture.dart'; + +void main() { + testWidgets('展示服务端报价且最终确认前不创建订单', (tester) async { + final repository = A1FixtureRepository(); + await tester.pumpWidget( + MaterialApp(home: GasOrderCreatePage(repository: repository)), + ); + await tester.pumpAndSettle(); + expect(find.text('薛海气站'), findsOneWidget); + expect(find.text('15kg 家用瓶'), findsOneWidget); + expect(find.text('确认下单'), findsOneWidget); + await tester.tap(find.byTooltip('增加').last); + await tester.pump(); + await tester.tap(find.text('确认下单')); + await tester.pumpAndSettle(); + expect(find.text('确认创建气瓶订单?'), findsOneWidget); + expect(repository.gasOrderCreateCalls, 0); + }); +} diff --git a/apps/user_app/test/ui/gas_order_detail_test.dart b/apps/user_app/test/ui/gas_order_detail_test.dart new file mode 100644 index 0000000..578be4c --- /dev/null +++ b/apps/user_app/test/ui/gas_order_detail_test.dart @@ -0,0 +1,107 @@ +// 功能描述:供气订单事实、合同阅读及本人签收确认和重试;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/gas_order_detail.dart'; +import 'package:user_app/ui/core/app_theme.dart'; +import 'package:user_app/ui/features/orders/shop_order_detail_page.dart'; +import '../support/a1_fixture.dart'; + +class _Repo extends A1FixtureRepository { + final requests = []; + int contractReads = 0; + bool done = false, failContract = true; + @override + Future gasOrderDetail(String identity) async { + if (!done) return super.gasOrderDetail(identity); + return GasOrderDetail.fromJson({ + 'identity': identity, + 'order_no': 'G1', + 'status_name': '已完成', + 'allowed_actions': [], + 'product_amount': 12800, + 'delivery_fee': 0, + 'discount_amount': 0, + 'payable_amount': 12800, + 'items': [], + 'timeline': [], + 'payments': [], + }, (s) => s); + } + + @override + Future gasContractDetail(String identity) async { + contractReads++; + if (failContract) { + failContract = false; + throw const ApiException(500, '合同暂时读取失败'); + } + return super.gasContractDetail(identity); + } + + @override + Future confirmGasReceipt(String identity, {required String requestNo}) async { + requests.add(requestNo); + if (requests.length == 1) throw const ApiException(500, '签收结果待确认'); + done = true; + } +} + +void main() { + testWidgets('读取气站、历史支付与合同正文,签收须确认且重试保持请求号', (tester) async { + final repo = _Repo(); + final router = GoRouter( + initialLocation: '/gas/orders/g1', + routes: [ + GoRoute( + path: '/gas/orders/:id', + builder: (_, s) => ShopOrderDetailPage( + repository: repo, + identity: s.pathParameters['id']!, + business: 'gas', + ), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(theme: AppTheme.light(), routerConfig: router)); + await tester.pumpAndSettle(); + expect(find.text('薛海气站'), findsOneWidget); + expect(find.text('配送员:李师傅'), findsOneWidget); + expect(repo.requests, isEmpty); + await tester.scrollUntilVisible( + find.text('查看合同'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('查看合同')); + await tester.pumpAndSettle(); + expect(find.text('加载失败'), findsOneWidget); + await tester.tap(find.text('重新加载')); + await tester.pumpAndSettle(); + expect(find.text('测试合同正文,仅用于自动验证,不作为真实合同。'), findsOneWidget); + expect(repo.contractReads, 2); + await tester.tap(find.byWidgetPredicate((w) => w is IconButton && w.tooltip == '关闭合同')); + await tester.pumpAndSettle(); + await tester.tap(find.text('确认收货')); + await tester.pumpAndSettle(); + await tester.tap(find.text('返回')); + await tester.pumpAndSettle(); + expect(repo.requests, isEmpty); + await tester.tap(find.text('确认收货')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '确认收货').last); + await tester.pumpAndSettle(); + expect(find.text('签收结果待确认'), findsOneWidget); + await tester.tap(find.text('确认收货')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '确认收货').last); + await tester.pumpAndSettle(); + expect(repo.requests.length, 2); + expect(repo.requests[0], repo.requests[1]); + expect(find.text('确认收货'), findsNothing); + expect(tester.takeException(), isNull); + }); +} diff --git a/apps/user_app/test/ui/invoice_preview_page_test.dart b/apps/user_app/test/ui/invoice_preview_page_test.dart new file mode 100644 index 0000000..c862bc6 --- /dev/null +++ b/apps/user_app/test/ui/invoice_preview_page_test.dart @@ -0,0 +1,35 @@ +// 功能描述:验证电子发票二期页面只展示真实订单并明确提示即将开放;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/ui/features/orders/invoice_preview_page.dart'; + +import '../support/a1_fixture.dart'; + +void main() { + testWidgets('电子发票页面显示订单事实且不提交申请', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: InvoicePreviewPage( + repository: A1FixtureRepository(), + business: 'gas', + identity: 'gas-1', + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('二期功能 · 即将开放'), findsOneWidget); + expect(find.textContaining('G202609050920001'), findsOneWidget); + expect(find.text('押金不计入发票金额'), findsOneWidget); + await tester.scrollUntilVisible( + find.text('提交开票申请 · 即将开放'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(find.text('提交开票申请 · 即将开放')); + await tester.pumpAndSettle(); + expect(find.text('电子发票即将开放'), findsOneWidget); + expect(find.textContaining('不会提交开票申请'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/apps/user_app/test/ui/login_page_test.dart b/apps/user_app/test/ui/login_page_test.dart index 3baf262..a011e49 100644 --- a/apps/user_app/test/ui/login_page_test.dart +++ b/apps/user_app/test/ui/login_page_test.dart @@ -14,6 +14,14 @@ class _RecordingUserSession extends UserSession { int loginCallCount = 0; String? lastPhone; String? lastPassword; + String? lastCode; + int codeCalls = 0; + + @override + Future sendCode(String phone, String purpose) async { + codeCalls++; + return 'request-1'; + } @override Future login({ @@ -25,30 +33,84 @@ class _RecordingUserSession extends UserSession { loginCallCount += 1; lastPhone = phone; lastPassword = password; + lastCode = verificationCode; + } +} + +/// 模拟服务端确认短信未实际发送,验证页面不会显示虚假倒计时。 +class _UnavailableCodeSession extends _RecordingUserSession { + @override + Future sendCode(String phone, String purpose) async { + throw const ApiException(2410, '短信验证码暂未开放'); } } /// 验证用户端登录界面的关键交互。 void main() { + testWidgets('显隐保留密码和焦点,重复选择当前登录方式不清空输入', (tester) async { + await tester.pumpWidget(MaterialApp(home: LoginPage(session: _RecordingUserSession()))); + await tester.tap(find.text('密码登录')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).at(1), 'Input-test-123'); + await tester.tap(find.byTooltip('显示密码')); + await tester.pumpAndSettle(); + var field = tester.widget(find.byType(TextField).at(1)); + expect(field.controller!.text, 'Input-test-123'); + expect(field.obscureText, isFalse); + expect(field.focusNode!.hasFocus, isTrue); + await tester.tap(find.text('密码登录')); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(TextField).at(1)).controller!.text, + 'Input-test-123', + ); + await tester.tap(find.byTooltip('隐藏密码')); + await tester.pumpAndSettle(); + field = tester.widget(find.byType(TextField).at(1)); + expect(field.obscureText, isTrue); + expect(field.controller!.text, 'Input-test-123'); + expect(tester.takeException(), isNull); + }); + + testWidgets('有焦点时切换验证码建立新连接并清除上一种凭据', (tester) async { + await tester.pumpWidget(MaterialApp(home: LoginPage(session: _RecordingUserSession()))); + await tester.tap(find.text('密码登录')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).at(1), 'Input-test-123'); + await tester.tap(find.text('验证码登录')); + await tester.pumpAndSettle(); + final field = tester.widget(find.byType(TextField).at(1)); + expect(field.controller!.text, isEmpty); + expect(field.keyboardType, TextInputType.number); + expect(field.obscureText, isFalse); + expect(field.focusNode!.hasFocus, isTrue); + await tester.enterText(find.byType(TextField).at(1), '314159'); + expect(field.controller!.text, '314159'); + expect(tester.takeException(), isNull); + }); + testWidgets('login page exposes phone and password fields', (tester) async { final session = UserSession(SecureSessionStore()); await tester.pumpWidget(MaterialApp(home: LoginPage(session: session))); - expect(find.text('瓶安芯'), findsOneWidget); + expect(find.bySemanticsLabel('瓶安芯'), findsOneWidget); expect(find.byType(TextField), findsNWidgets(2)); - expect(find.text('安全登录'), findsOneWidget); + expect(find.text('登录'), findsOneWidget); }); testWidgets('手机号错误且密码为空时同时提示并阻止登录调用', (tester) async { final session = _RecordingUserSession(); await tester.pumpWidget(MaterialApp(home: LoginPage(session: session))); + await tester.tap(find.text('密码登录')); + await tester.pump(); await tester.enterText(find.byType(TextField).first, '+8613800138000'); - await tester.tap(find.text('安全登录')); + await tester.ensureVisible(find.text('登录')); + await tester.tap(find.text('登录')); await tester.pump(); expect(find.text('请输入正确的11位手机号'), findsOneWidget); - expect(find.text('请输入密码'), findsOneWidget); + expect(tester.widget(find.byType(TextField).at(1)).decoration?.errorText, '请输入密码'); expect(find.text('Invalid Argument'), findsNothing); expect(session.loginCallCount, 0); expect(tester.widget(find.byType(TextField).first).focusNode?.hasFocus, isTrue); @@ -57,13 +119,16 @@ void main() { testWidgets('合法手机号配空密码时提示密码必填并阻止登录调用', (tester) async { final session = _RecordingUserSession(); await tester.pumpWidget(MaterialApp(home: LoginPage(session: session))); + await tester.tap(find.text('密码登录')); + await tester.pump(); await tester.enterText(find.byType(TextField).first, '13800138000'); - await tester.tap(find.text('安全登录')); + await tester.ensureVisible(find.text('登录')); + await tester.tap(find.text('登录')); await tester.pump(); expect(find.text('请输入正确的11位手机号'), findsNothing); - expect(find.text('请输入密码'), findsOneWidget); + expect(tester.widget(find.byType(TextField).at(1)).decoration?.errorText, '请输入密码'); expect(session.loginCallCount, 0); expect(tester.widget(find.byType(TextField).at(1)).focusNode?.hasFocus, isTrue); }); @@ -71,18 +136,21 @@ void main() { testWidgets('修改字段清除对应错误并由回车原样提交密码', (tester) async { final session = _RecordingUserSession(); await tester.pumpWidget(MaterialApp(home: LoginPage(session: session))); + await tester.tap(find.text('密码登录')); + await tester.pump(); - await tester.tap(find.text('安全登录')); + await tester.ensureVisible(find.text('登录')); + await tester.tap(find.text('登录')); await tester.pump(); await tester.enterText(find.byType(TextField).first, '13800138000'); await tester.pump(); expect(find.text('请输入正确的11位手机号'), findsNothing); - expect(find.text('请输入密码'), findsOneWidget); + expect(tester.widget(find.byType(TextField).at(1)).decoration?.errorText, '请输入密码'); await tester.enterText(find.byType(TextField).at(1), ' 密码123 '); await tester.pump(); - expect(find.text('请输入密码'), findsNothing); + expect(tester.widget(find.byType(TextField).at(1)).decoration?.errorText, isNull); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pump(); @@ -95,7 +163,33 @@ void main() { test('接口错误优先按错误码中文化并屏蔽未知英文', () { expect(localizeApiErrorMessage(1704, 'Invalid Argument'), '请求参数错误'); expect(localizeApiErrorMessage(1108, 'Password Incorrect'), '密码错误'); + expect(localizeApiErrorMessage(2410, 'service unavailable'), '短信验证码暂未开放'); expect(localizeApiErrorMessage(9999, '业务处理失败'), '业务处理失败'); expect(localizeApiErrorMessage(9999, 'Unexpected Error'), '操作失败,请稍后重试'); }); + + testWidgets('验证码发送后修改手机号不得复用旧请求', (tester) async { + final session = _RecordingUserSession(); + await tester.pumpWidget(MaterialApp(home: LoginPage(session: session))); + await tester.enterText(find.byType(TextField).first, '13800138000'); + await tester.tap(find.text('获取验证码')); + await tester.pump(); + await tester.enterText(find.byType(TextField).first, '13800138001'); + await tester.enterText(find.byType(TextField).at(1), '123456'); + await tester.ensureVisible(find.text('登录')); + await tester.tap(find.text('登录')); + await tester.pump(); + expect(find.text('请先获取当前手机号的验证码'), findsOneWidget); + expect(session.loginCallCount, 0); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('短信未实际发送时明确提示暂未开放且不启动倒计时', (tester) async { + await tester.pumpWidget(MaterialApp(home: LoginPage(session: _UnavailableCodeSession()))); + await tester.enterText(find.byType(TextField).first, '13800138000'); + await tester.tap(find.text('获取验证码')); + await tester.pump(); + expect(find.text('短信验证码暂未开放'), findsOneWidget); + expect(find.text('60s'), findsNothing); + }); } diff --git a/apps/user_app/test/ui/message_center_page_test.dart b/apps/user_app/test/ui/message_center_page_test.dart new file mode 100644 index 0000000..1265ecb --- /dev/null +++ b/apps/user_app/test/ui/message_center_page_test.dart @@ -0,0 +1,31 @@ +// 功能描述:验证消息分类、筛选和全部已读使用真实仓储动作;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/ui/features/profile/message_center_page.dart'; + +import '../support/a1_fixture.dart'; + +void main() { + testWidgets('消息中心展示分类并执行全部已读', (tester) async { + final repository = A1FixtureRepository(); + await tester.pumpWidget(MaterialApp(home: MessageCenterPage(repository: repository))); + await tester.pumpAndSettle(); + expect(find.text('安全告警'), findsOneWidget); + expect(find.text('厨房燃气浓度异常'), findsOneWidget); + await tester.tap(find.widgetWithText(TextButton, '全部已读')); + await tester.pumpAndSettle(); + expect(repository.markedMessageKeys, containsAll(['safety-1', 'order-1'])); + }); + + testWidgets('未读筛选只展示未读消息', (tester) async { + await tester.pumpWidget( + MaterialApp(home: MessageCenterPage(repository: A1FixtureRepository())), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('未读')); + await tester.pumpAndSettle(); + expect(find.text('厨房燃气浓度异常'), findsOneWidget); + expect(find.text('配送员已出发'), findsOneWidget); + expect(find.text('秋季安全用气提醒'), findsNothing); + }); +} diff --git a/apps/user_app/test/ui/orders_page_test.dart b/apps/user_app/test/ui/orders_page_test.dart index a638fe1..5d9963b 100644 --- a/apps/user_app/test/ui/orders_page_test.dart +++ b/apps/user_app/test/ui/orders_page_test.dart @@ -16,29 +16,73 @@ class _FakeRepository extends ClientRepository { int refundCalls = 0; int ticketCalls = 0; int createRefundCalls = 0; + int cancellations = 0, gasCancellations = 0, receipts = 0; + bool serveGas = false; + bool completed = false; + List actions = ['refund']; @override Future> shopOrders() async { shopCalls += 1; - return const [ + return [ ClientRecord( identity: 'shop-1', title: '商城订单一', subtitle: '', - status: 18, + status: completed ? 21 : 18, raw: { + 'order_no': 'EC-SEARCH-001', + 'status_name': completed ? '已完成' : '待履约', + 'allowed_actions': actions, 'items': [ - {'identity': 'item-1', 'quantity': 1}, + { + 'identity': 'item-1', + 'quantity': 1, + 'product_snapshot': '{"name":"燃气报警器"}', + }, ], }, ), ]; } + @override + Future cancelShopOrder(String identity) async { + cancellations++; + actions = []; + } + + @override + Future confirmShopReceipt(String identity) async { + receipts++; + actions = []; + } + @override Future> gasOrders() async { gasCalls += 1; - return const []; + if (!serveGas) return const []; + return [ + ClientRecord( + identity: 'gas-1', + title: '气瓶订单一', + subtitle: '', + status: 16, + raw: { + 'status_name': '待付款', + 'allowed_actions': actions, + 'items': [ + {'identity': 'gas-item-1', 'quantity': 1, 'product_type_name': '15kg液化气'}, + ], + }, + ), + ]; + } + + @override + Future cancelGasOrder(String identity) async { + gasCancellations++; + actions = []; } @override @@ -66,6 +110,46 @@ class _FakeRepository extends ClientRepository { } void main() { + testWidgets('气瓶订单取消调用气瓶接口并刷新气瓶列表', (tester) async { + final repository = _FakeRepository() + ..serveGas = true + ..actions = ['cancel']; + await tester.pumpWidget( + MaterialApp(home: OrdersPage(repository: repository, initialTab: 1)), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('取消订单')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '确认取消')); + await tester.pumpAndSettle(); + expect(repository.gasCancellations, 1); + expect(repository.cancellations, 0); + expect(repository.gasCalls, 2); + }); + + for (final action in ['cancel', 'confirm_receipt']) { + testWidgets('$action 必须确认,完成后刷新动作', (tester) async { + final repository = _FakeRepository()..actions = [action]; + await tester.pumpWidget(MaterialApp(home: OrdersPage(repository: repository))); + await tester.pumpAndSettle(); + final label = action == 'cancel' ? '取消订单' : '确认收货'; + await tester.tap(find.text(label)); + await tester.pumpAndSettle(); + await tester.tap(find.text('返回')); + await tester.pumpAndSettle(); + expect(repository.cancellations + repository.receipts, 0); + await tester.tap(find.text(label)); + await tester.pumpAndSettle(); + await tester.tap( + find.widgetWithText(FilledButton, action == 'cancel' ? '确认取消' : '确认收货').last, + ); + await tester.pumpAndSettle(); + expect(repository.cancellations + repository.receipts, 1); + expect(repository.shopCalls, 2); + expect(find.text(label), findsNothing); + expect(tester.takeException(), isNull); + }); + } testWidgets('退款成功后刷新商城订单和退款记录', (tester) async { final repository = _FakeRepository(); await tester.pumpWidget( @@ -73,25 +157,62 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.text('退款')); + await tester.tap(find.text('退款/售后')); await tester.pumpAndSettle(); expect(repository.refundCalls, 1); - await tester.tap(find.text('商城')); + await tester.tap(find.byTooltip('关闭退款记录')); await tester.pumpAndSettle(); expect(repository.shopCalls, 1); - await tester.tap(find.text('商城订单一')); + await tester.tap(find.text('查看操作')); await tester.pumpAndSettle(); await tester.tap(find.text('申请退款')); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), '商品问题'); + await tester.enterText( + find.descendant(of: find.byType(AlertDialog), matching: find.byType(TextField)), + '商品问题', + ); await tester.tap(find.text('提交')); await tester.pumpAndSettle(); expect(repository.createRefundCalls, 1); expect(repository.shopCalls, 2); + await tester.tap(find.text('退款/售后')); + await tester.pumpAndSettle(); expect(repository.refundCalls, 2); expect(tester.takeException(), isNull); }); + + testWidgets('订单搜索按订单号和商品名称过滤,并可关闭清空', (tester) async { + final repository = _FakeRepository()..actions = []; + await tester.pumpWidget(MaterialApp(home: OrdersPage(repository: repository))); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('搜索订单')); + await tester.pumpAndSettle(); + expect(find.text('搜索订单号或商品名称'), findsOneWidget); + await tester.enterText(find.byType(TextField), '不存在'); + await tester.pumpAndSettle(); + expect(find.text('暂无订单'), findsOneWidget); + await tester.enterText(find.byType(TextField), '燃气报警器'); + await tester.pumpAndSettle(); + expect(find.text('燃气报警器 × 1'), findsOneWidget); + await tester.tap(find.byTooltip('关闭搜索')); + await tester.pumpAndSettle(); + expect(find.textContaining('EC-SEARCH-001'), findsOneWidget); + }); + + testWidgets('已完成订单的二期功能显示即将开放说明', (tester) async { + final repository = _FakeRepository() + ..actions = [] + ..completed = true; + await tester.pumpWidget(MaterialApp(home: OrdersPage(repository: repository))); + await tester.pumpAndSettle(); + + await tester.tap(find.text('开发票')); + await tester.pumpAndSettle(); + expect(find.text('电子发票'), findsOneWidget); + expect(find.text('该功能将在后续版本开放,敬请期待。'), findsOneWidget); + }); } diff --git a/apps/user_app/test/ui/payment_confirmation_page_test.dart b/apps/user_app/test/ui/payment_confirmation_page_test.dart new file mode 100644 index 0000000..163fbae --- /dev/null +++ b/apps/user_app/test/ui/payment_confirmation_page_test.dart @@ -0,0 +1,144 @@ +// 功能描述:验证图19真实订单金额、余额密码支付和未开放优惠提示;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:user_app/domain/models/shop_order_detail.dart'; +import 'package:user_app/domain/models/recharge.dart'; +import 'package:user_app/ui/features/orders/payment_confirmation_page.dart'; + +import '../support/a1_fixture.dart'; + +class _PaymentFixture extends A1FixtureRepository { + String? channel, password; + + @override + Future shopOrderDetail(String identity) async => ShopOrderDetail.fromJson({ + 'identity': identity, + 'order_no': 'EC20260911001', + 'status_name': '待付款', + 'allowed_actions': ['pay', 'cancel'], + 'address': '测试地址', + 'contact_name': '测试用户', + 'contact_phone': '13800000001', + 'remark': '', + 'logistics_no': '', + 'logistics_company': '', + 'product_amount': 23800, + 'discount_amount': 1000, + 'payable_amount': 22800, + 'created_at': '2026-09-11T10:00:00+08:00', + 'paid_at': null, + 'shipped_at': null, + 'received_at': null, + 'items': [ + { + 'identity': 'item-1', + 'product_identity': 'product-1', + 'name': '15kg家用液化气', + 'quantity': 1, + 'sale_amount': 23800, + 'image_url': '', + }, + ], + }, resolveImageUrl); + + @override + Future> payOrder({ + required String business, + required String identity, + required String requestNo, + required String channel, + required String payType, + String openid = '', + String paymentPassword = '', + }) async { + this.channel = channel; + password = paymentPassword; + return {'paid': true}; + } +} + +/// 模拟生产环境未配置外部商户,验证页面不会把不可用渠道伪装成可支付。 +class _UnavailablePaymentFixture extends _PaymentFixture { + @override + Future rechargeOptions() async => const RechargeOptions( + min: 1, + max: 500000, + channels: [ + RechargeChannel('wechat', app: false, wap: false), + RechargeChannel('alipay', app: false, wap: false), + ], + ); +} + +void main() { + testWidgets('支付确认页使用服务端金额并通过六位支付密码提交余额支付', (tester) async { + final repository = _PaymentFixture(); + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => PaymentConfirmationPage( + repository: repository, + business: 'shop', + identity: 'order-1', + ), + ), + GoRoute( + path: '/shop/orders/order-1', + builder: (_, _) => const Scaffold(body: Text('订单详情')), + ), + ], + ); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + expect(find.text('¥228.00'), findsWidgets); + expect(find.text('余额 ¥568.00'), findsOneWidget); + expect(find.text('暂未开放'), findsOneWidget); + + await tester.ensureVisible(find.text('优惠券')); + await tester.drag(find.byType(ListView), const Offset(0, -140)); + await tester.pumpAndSettle(); + await tester.tap(find.text('优惠券')); + await tester.pumpAndSettle(); + expect(find.text('优惠券暂未开放'), findsOneWidget); + await tester.tap(find.text('知道了')); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.byKey(const Key('payment-password'))); + await tester.enterText(find.byKey(const Key('payment-password')), '123456'); + await tester.tap(find.byKey(const Key('confirm-payment'))); + await tester.pumpAndSettle(); + expect(find.text('订单号:EC20260911001\n支付方式:余额支付\n支付金额:¥228.00'), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, '确认支付')); + await tester.pumpAndSettle(); + + expect(repository.channel, 'wallet'); + expect(repository.password, '123456'); + expect(find.text('订单详情'), findsOneWidget); + router.dispose(); + }); + + testWidgets('外部商户未配置时显示暂未开放且不会提交支付', (tester) async { + final repository = _UnavailablePaymentFixture(); + await tester.pumpWidget( + MaterialApp( + home: PaymentConfirmationPage( + repository: repository, + business: 'shop', + identity: 'order-1', + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('暂未开放'), findsNWidgets(3)); + final wechat = tester.widget>( + find.widgetWithText(RadioListTile, '微信支付'), + ); + expect(wechat.enabled, isFalse); + expect(repository.channel, isNull); + }); +} diff --git a/apps/user_app/test/ui/payment_password_page_test.dart b/apps/user_app/test/ui/payment_password_page_test.dart new file mode 100644 index 0000000..d96967f --- /dev/null +++ b/apps/user_app/test/ui/payment_password_page_test.dart @@ -0,0 +1,122 @@ +// 功能描述:支付密码状态分支、六位校验、验证码用途和失败重试测试;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/client_models.dart'; +import 'package:user_app/ui/core/app_theme.dart'; +import 'package:user_app/ui/features/settings/payment_password_page.dart'; +import '../support/a1_fixture.dart'; + +class PaymentPasswordFixture extends A1FixtureRepository { + bool? hasPassword = false; + bool failSave = false; + int codes = 0, saves = 0; + bool? resetting; + String? oldPassword, requestId, codeValue; + @override + Future wallet() async => + WalletSummary(balance: 0, withdrawalBalance: 0, paymentPasswordSet: hasPassword); + @override + Future requestPaymentPasswordCode({required bool resetting}) async { + codes++; + this.resetting = resetting; + return const PaymentCodeRequest( + identity: 'test-request', + delivered: false, + maskedPhone: '138****0001', + retryAfter: 60, + ); + } + + @override + Future setPaymentPassword({ + required String newPassword, + String? currentPassword, + String? requestIdentity, + String? code, + }) async { + saves++; + oldPassword = currentPassword; + requestId = requestIdentity; + codeValue = code; + if (failSave) throw const ApiException(1306, '密码错误'); + hasPassword = true; + return true; + } +} + +Future openPayment(WidgetTester tester, PaymentPasswordFixture repo) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: PaymentPasswordPage(repository: repo), + ), + ); + await tester.pumpAndSettle(); +} + +Future fillNext(WidgetTester tester) async { + await tester.enterText(find.byKey(const ValueKey('payment-next')), '782913'); + await tester.enterText(find.byKey(const ValueKey('payment-repeat')), '782913'); +} + +void main() { + testWidgets('未知支付密码状态不能误当首次设置', (tester) async { + final repo = PaymentPasswordFixture()..hasPassword = null; + await openPayment(tester, repo); + expect(find.text('支付密码状态读取失败'), findsOneWidget); + expect(find.text('获取验证码'), findsNothing); + }); + testWidgets('首次设置明确Mock未发送,验证码请求与确认成功后切换状态', (tester) async { + final repo = PaymentPasswordFixture(); + await openPayment(tester, repo); + expect(find.text('设置支付密码'), findsOneWidget); + await tester.tap(find.text('获取验证码')); + await tester.pumpAndSettle(); + expect(repo.resetting, false); + expect(find.text('验证码请求已建立,但当前环境未发送短信'), findsOneWidget); + expect(find.textContaining('已发送至'), findsNothing); + await tester.enterText(find.byKey(const ValueKey('payment-code')), '314159'); + await fillNext(tester); + await tester.tap(find.text('确认')); + await tester.pumpAndSettle(); + expect(repo.saves, 1); + expect(repo.oldPassword, isNull); + expect(repo.requestId, 'test-request'); + expect(repo.codeValue, '314159'); + expect(find.text('支付密码已更新'), findsOneWidget); + expect(find.text('修改支付密码'), findsOneWidget); + }); + testWidgets('旧密码修改失败保留表单,找回使用独立用途且不传旧密码', (tester) async { + final repo = PaymentPasswordFixture() + ..hasPassword = true + ..failSave = true; + await openPayment(tester, repo); + await tester.enterText(find.byKey(const ValueKey('payment-old')), '123'); + await fillNext(tester); + await tester.tap(find.text('确认')); + await tester.pumpAndSettle(); + expect(repo.saves, 0); + await tester.enterText(find.byKey(const ValueKey('payment-old')), '123456'); + await tester.tap(find.text('确认')); + await tester.pumpAndSettle(); + expect(repo.saves, 1); + expect(find.text('密码错误'), findsOneWidget); + expect( + tester.widget(find.byKey(const ValueKey('payment-next'))).controller!.text, + '782913', + ); + await tester.tap(find.text('忘记支付密码?')); + await tester.pumpAndSettle(); + await tester.tap(find.text('获取验证码')); + await tester.pumpAndSettle(); + expect(repo.resetting, true); + await tester.enterText(find.byKey(const ValueKey('payment-code')), '314159'); + repo.failSave = false; + await tester.tap(find.text('确认')); + await tester.pumpAndSettle(); + expect(repo.saves, 2); + expect(repo.oldPassword, isNull); + expect(repo.requestId, 'test-request'); + }); +} diff --git a/apps/user_app/test/ui/primary_pages_test.dart b/apps/user_app/test/ui/primary_pages_test.dart new file mode 100644 index 0000000..2dd77a8 --- /dev/null +++ b/apps/user_app/test/ui/primary_pages_test.dart @@ -0,0 +1,147 @@ +// 功能描述:验证一级页面保留入口、游客数据边界、筛选及刷新错误恢复。 +// 版本:1.0.0 +import 'package:flutter/material.dart'; +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:user_app/domain/models/client_models.dart'; +import 'package:user_app/domain/models/order_summary.dart'; +import 'package:user_app/ui/features/home/home_page.dart'; +import 'package:user_app/ui/features/shop/shop_page.dart'; +import 'package:user_app/ui/core/async_content.dart'; +import 'package:user_app/ui/core/text_entry_dialog.dart'; +import '../support/a1_fixture.dart'; + +void main() { + testWidgets('刷新失败保留上次数据,较迟的旧请求不覆盖新数据', (tester) async { + final key = GlobalKey>(); + final pending = >[]; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AsyncContent( + key: key, + load: () { + final c = Completer(); + pending.add(c); + return c.future; + }, + builder: (_, value) => ListView(children: [Text(value)]), + ), + ), + ), + ); + pending[0].complete('初始数据'); + await tester.pumpAndSettle(); + final first = key.currentState!.refresh(); + final second = key.currentState!.refresh(); + pending[2].complete('最新数据'); + await second; + await tester.pump(); + pending[1].complete('旧请求结果'); + await first; + await tester.pump(); + expect(find.text('最新数据'), findsOneWidget); + expect(find.text('旧请求结果'), findsNothing); + final failure = key.currentState!.refresh(); + pending[3].completeError(Exception('offline')); + await failure; + await tester.pumpAndSettle(); + expect(find.text('最新数据'), findsOneWidget); + expect(find.textContaining('刷新失败,当前显示上次数据'), findsOneWidget); + }); + testWidgets('输入弹窗关闭动画期间控制器有效,空值阻止提交', (tester) async { + String? result; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () async { + result = await showDialog( + context: context, + builder: (_) => const TextEntryDialog(title: '新增地址', label: '详细地址', action: '保存'), + ); + }, + child: const Text('打开'), + ), + ), + ), + ), + ); + await tester.tap(find.text('打开')); + await tester.pumpAndSettle(); + await tester.tap(find.text('保存')); + await tester.pump(); + expect(find.text('请填写详细地址'), findsOneWidget); + await tester.enterText(find.byType(TextField), '测试地址'); + await tester.tap(find.text('保存')); + await tester.pump(); + await tester.pumpAndSettle(); + expect(result, '测试地址'); + expect(tester.takeException(), isNull); + }); + testWidgets('游客首页不读取个人归属并保留设备入口', (tester) async { + final source = A1FixtureRepository(); + await tester.pumpWidget(MaterialApp(home: HomePage(repository: source, authenticated: false))); + await tester.pumpAndSettle(); + expect(source.relationCalls, 0); + await tester.ensureVisible(find.text('扫码添加')); + await tester.tap(find.text('扫码添加')); + await tester.pumpAndSettle(); + expect(find.textContaining('该功能暂未开放'), findsOneWidget); + expect(find.text('85%'), findsNothing); + }); + testWidgets('首页真实入口可进入设备页且未接入入口明确提示', (tester) async { + final source = A1FixtureRepository(); + final router = GoRouter( + initialLocation: '/home', + routes: [ + GoRoute( + path: '/home', + builder: (_, _) => HomePage(repository: source), + ), + GoRoute( + path: '/devices', + builder: (_, _) => const Scaffold(body: Text('设备页已打开')), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + expect(find.text('查看设备状态与告警记录'), findsOneWidget); + expect(find.text('查看气瓶使用与检测信息'), findsOneWidget); + await tester.tap(find.text('查看详情')); + await tester.pumpAndSettle(); + expect(find.text('设备页已打开'), findsOneWidget); + }); + testWidgets('首次加载失败可重试恢复真实内容', (tester) async { + final source = A1FixtureRepository()..fail = true; + await tester.pumpWidget(MaterialApp(home: HomePage(repository: source))); + await tester.pumpAndSettle(); + expect(find.text('加载失败'), findsOneWidget); + source.fail = false; + await tester.tap(find.text('重新加载')); + await tester.pumpAndSettle(); + expect(find.text('当前服务归属'), findsOneWidget); + }); + testWidgets('商城搜索与分类联动并显示空结果', (tester) async { + await tester.pumpWidget(MaterialApp(home: ShopPage(repository: A1FixtureRepository()))); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '报警'); + await tester.pumpAndSettle(); + expect(find.text('燃气报警器'), findsOneWidget); + expect(find.text('15kg家用液化气'), findsNothing); + await tester.enterText(find.byType(TextField), '不存在'); + await tester.pumpAndSettle(); + expect(find.textContaining('暂无符合条件'), findsOneWidget); + }); + test('旧响应缺少 allowed_actions 时不从状态码推测权限', () { + final order = OrderSummary.fromRecord( + const ClientRecord(identity: 'o', title: '', subtitle: '', status: 16, raw: {}), + ); + expect(order.actions, isEmpty); + expect(order.amount, isNull); + }); +} diff --git a/apps/user_app/test/ui/product_detail_page_test.dart b/apps/user_app/test/ui/product_detail_page_test.dart new file mode 100644 index 0000000..51a3c23 --- /dev/null +++ b/apps/user_app/test/ui/product_detail_page_test.dart @@ -0,0 +1,109 @@ +// 功能描述:商品详情的公开浏览、售罄与错误边界、数量向结算传递回归。 +// 版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/product_detail.dart'; +import 'package:user_app/ui/features/shop/product_detail_page.dart'; +import '../support/a1_fixture.dart'; + +class _Repository extends A1FixtureRepository { + int stock = 2; + bool unavailable = false; + @override + Future productDetail(String identity) async { + if (unavailable) throw const ApiException(1112, '商品已下架'); + return ProductDetail( + identity: identity, + name: '测试商品', + price: 1999, + stock: stock, + attributes: const [(name: '材质', value: '不锈钢')], + ); + } +} + +void main() { + testWidgets('查看商品与参数,数量限制库存,购买只进入结算', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final repo = _Repository(); + final router = GoRouter( + initialLocation: '/products/p1', + routes: [ + GoRoute( + path: '/products/:identity', + builder: (_, state) => + ProductDetailPage(repository: repo, identity: state.pathParameters['identity']!), + ), + GoRoute( + path: '/checkout/:identity', + builder: (_, state) => Scaffold( + body: Text( + '结算 ${state.pathParameters['identity']} 数量${state.uri.queryParameters['quantity']}', + ), + ), + ), + ], + ); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + expect(find.text('测试商品'), findsOneWidget); + expect(find.text('¥19.99'), findsOneWidget); + await tester.tap(find.byTooltip('增加数量')); + await tester.pump(); + expect( + tester + .widget( + find.byWidgetPredicate((widget) => widget is IconButton && widget.tooltip == '增加数量'), + ) + .onPressed, + isNull, + ); + await tester.scrollUntilVisible( + find.textContaining('不锈钢'), + 180, + scrollable: find.byType(Scrollable).first, + ); + expect(find.textContaining('不锈钢'), findsOneWidget); + await tester.tap(find.text('立即购买')); + await tester.pumpAndSettle(); + expect(find.text('结算 p1 数量2'), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + router.dispose(); + }); + testWidgets('售罄可查看,禁止购买;下架响应可重试恢复', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final repo = _Repository()..stock = 0; + await tester.pumpWidget( + MaterialApp( + home: ProductDetailPage(repository: repo, identity: 'p1'), + ), + ); + await tester.pumpAndSettle(); + expect( + tester.widget(find.widgetWithText(FilledButton, '暂时售罄')).onPressed, + isNull, + ); + await tester.pumpWidget(const SizedBox()); + repo.unavailable = true; + await tester.pumpWidget( + MaterialApp( + home: ProductDetailPage(repository: repo, identity: 'missing'), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('商品已下架'), findsOneWidget); + expect(find.text('立即购买'), findsNothing); + repo.unavailable = false; + await tester.tap(find.text('重新加载')); + await tester.pumpAndSettle(); + expect(find.text('测试商品'), findsOneWidget); + }); +} diff --git a/apps/user_app/test/ui/product_recommendations_test.dart b/apps/user_app/test/ui/product_recommendations_test.dart new file mode 100644 index 0000000..e0fbc9b --- /dev/null +++ b/apps/user_app/test/ui/product_recommendations_test.dart @@ -0,0 +1,195 @@ +// 功能描述:推荐分页、加购不重复、购物车刷新及收藏入口回归;版本:1.0.0。 +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/cart_item.dart'; +import 'package:user_app/domain/models/product_favorite.dart'; +import 'package:user_app/domain/models/primary_models.dart'; +import 'package:user_app/domain/models/product_recommendations.dart'; +import 'package:user_app/ui/core/app_theme.dart'; +import 'package:user_app/ui/features/shop/cart_page.dart'; +import 'package:user_app/ui/features/shop/favorites_page.dart'; +import 'package:user_app/ui/features/shop/product_recommendations.dart'; +import '../support/a1_fixture.dart'; + +/// 模拟请求已写入却丢失响应,确认重试仍发送同一目标。 +class _Repo extends A1FixtureRepository { + final requests = <(String, int)>[]; + final targets = []; + List items = []; + bool failLoad = false, failWrite = false; + int stock = 5; + Completer? gate; + ProductSummary get product => + ProductSummary(identity: 'r', name: '真实推荐', price: 3900, stock: stock); + @override + Future> favorites() async => []; + @override + Future recommendations({required String source, int page = 1}) async { + requests.add((source, page)); + if (failLoad) { + failLoad = false; + throw const ApiException(500, '暂时无法加载'); + } + return ProductRecommendations( + items: [ + page == 1 + ? product + : const ProductSummary(identity: 'other', name: '下一批商品', price: 500, stock: 3), + ], + page: page, + hasMore: page == 1, + ); + } + + @override + Future> cart() async => List.of(items); + @override + Future cartItem(String identity) async => items.isNotEmpty + ? items.first + : CartItem( + product: product, + quantity: 0, + selected: false, + available: true, + revision: 'original', + ); + @override + Future setCartItem( + CartItem item, { + required int quantity, + required bool selected, + }) async { + targets.add(quantity); + await gate?.future; + final updated = CartItem( + product: product, + quantity: quantity, + selected: selected, + available: true, + revision: 'new', + ); + items = [updated]; + if (failWrite) { + failWrite = false; + throw const ApiException(500, '加入结果待确认'); + } + return updated; + } +} + +Finder _button(String label) => + find.byWidgetPredicate((w) => w is IconButton && w.tooltip == label); +Future _show(WidgetTester tester, Widget child) async { + await tester.pumpWidget(MaterialApp(theme: AppTheme.light(), home: child)); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('从推荐进入详情修改购物车,返回后重读合计', (tester) async { + final repo = _Repo(); + final router = GoRouter( + initialLocation: '/cart', + routes: [ + GoRoute( + path: '/cart', + builder: (_, _) => CartPage(repository: repo), + ), + GoRoute( + path: '/products/:id', + builder: (context, _) => Scaffold( + body: TextButton( + onPressed: () async { + await repo.setCartItem(await repo.cartItem('r'), quantity: 1, selected: true); + if (context.mounted) context.pop(); + }, + child: const Text('详情加购后返回'), + ), + ), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(theme: AppTheme.light(), routerConfig: router)); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('真实推荐')); + await tester.tap(find.text('真实推荐')); + await tester.pumpAndSettle(); + await tester.tap(find.text('详情加购后返回')); + await tester.pumpAndSettle(); + expect(find.text('合计:¥39.00'), findsOneWidget); + expect(find.text('去结算(1)'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('推荐分页可轮换,失败可重读,加购未知结果复用绝对数量', (tester) async { + final repo = _Repo() + ..failLoad = true + ..failWrite = true; + var changes = 0; + await _show( + tester, + Scaffold( + body: ProductRecommendationSection( + repository: repo, + source: 'cart', + onCartChanged: () async { + changes++; + }, + ), + ), + ); + expect(find.text('暂时无法加载'), findsOneWidget); + await tester.tap(find.text('重新加载')); + await tester.pumpAndSettle(); + await tester.tap(find.text('换一换')); + await tester.pumpAndSettle(); + expect(find.text('下一批商品'), findsOneWidget); + await tester.tap(find.text('换一换')); + await tester.pumpAndSettle(); + expect(repo.requests.map((r) => r.$2), [1, 1, 2, 1]); + await tester.tap(_button('加入购物车 真实推荐')); + await tester.pumpAndSettle(); + expect(changes, 0); + expect(find.text('加入结果待确认'), findsOneWidget); + await tester.tap(_button('重试加入 真实推荐')); + await tester.pumpAndSettle(); + expect(repo.targets, [1, 1]); + expect(changes, 1); + expect(find.text('已加入购物车'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('加购期间阻止结算,确认后购物车数量与合计刷新', (tester) async { + final repo = _Repo()..gate = Completer(); + await _show(tester, CartPage(repository: repo)); + await tester.ensureVisible(_button('加入购物车 真实推荐')); + await tester.tap(_button('加入购物车 真实推荐')); + await tester.pump(); + expect(tester.widget(find.widgetWithText(FilledButton, '处理中')).onPressed, isNull); + repo.gate!.complete(); + await tester.pumpAndSettle(); + expect(find.text('合计:¥39.00'), findsOneWidget); + expect(find.text('去结算(1)'), findsOneWidget); + expect(repo.items.single.quantity, 1); + expect(tester.takeException(), isNull); + }); + testWidgets('库存变为零不写购物车,收藏入口只在打开后读取推荐', (tester) async { + final repo = _Repo(); + await _show(tester, FavoritesPage(repository: repo)); + expect(repo.requests, isEmpty); + await tester.tap(find.text('猜你喜欢')); + await tester.pumpAndSettle(); + expect(repo.requests, [('favorites', 1)]); + expect(repo.targets, isEmpty); + repo.stock = 0; + await tester.tap(_button('加入购物车 真实推荐')); + await tester.pumpAndSettle(); + expect(find.text('商品库存不足'), findsOneWidget); + expect(repo.targets, isEmpty); + await tester.tap(_button('关闭推荐')); + await tester.pumpAndSettle(); + expect(find.byType(ProductRecommendationSection), findsNothing); + expect(tester.takeException(), isNull); + }); +} diff --git a/apps/user_app/test/ui/profile_edit_page_test.dart b/apps/user_app/test/ui/profile_edit_page_test.dart new file mode 100644 index 0000000..b883766 --- /dev/null +++ b/apps/user_app/test/ui/profile_edit_page_test.dart @@ -0,0 +1,152 @@ +// 功能描述:验证个人资料的真实保存、头像草稿、取消及失败重试边界。 +// 版本:1.0.0。 +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/client_models.dart'; +import 'package:user_app/ui/core/app_theme.dart'; +import 'package:user_app/ui/features/profile/profile_edit_page.dart'; +import '../support/a1_fixture.dart'; + +/// 记录写入调用,用于确认失败不会误报成功或重复上传。 +class ProfileFixture extends A1FixtureRepository { + int uploads = 0, saves = 0; + String? savedName, savedAvatar; + bool failSave = false; + bool hasAvatar = false; + @override + Future profile() async => UserProfile( + identity: 'user-fixture', + name: '张先生', + phone: '13800005678', + avatar: hasAvatar ? '/uploads/avatars/owned.png' : '', + realName: '张实名', + ); + @override + Future profileAvatar() async => _png; + @override + Future> addresses() async => []; + @override + Future uploadAvatar(Uint8List bytes, String filename) async { + uploads++; + return '/uploads/avatars/owned.png'; + } + + @override + Future updateProfile(String name, {String? avatar}) async { + saves++; + if (failSave) throw const ApiException(500, '保存失败,请重试'); + savedName = name; + savedAvatar = avatar; + } +} + +/// 测试使用真实有效的 1 像素 PNG,仅用于验证文件选择与请求,不作为产品素材。 +final _png = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', +); + +Future openEditor(WidgetTester tester, ProfileFixture repo, AvatarPicker picker) async { + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold( + body: TextButton( + onPressed: () async { + final result = await context.push('/edit'); + if (context.mounted && result == true) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存已确认'))); + } + }, + child: const Text('编辑'), + ), + ), + ), + GoRoute( + path: '/edit', + builder: (context, state) => ProfileEditPage(repository: repo, pickImage: picker), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router, theme: AppTheme.light())); + await tester.tap(find.text('编辑')); + await tester.pumpAndSettle(); +} + +Future selectPhoto(WidgetTester tester) async { + await tester.tap(find.text('更换头像')); + await tester.pumpAndSettle(); + await tester.tap(find.text('从相册选择')); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('服务端清空头像后下拉刷新不残留旧图片', (tester) async { + final repo = ProfileFixture()..hasAvatar = true; + await openEditor(tester, repo, (_) async => null); + expect(find.text('已认证 张实名'), findsOneWidget); + expect(find.byKey(const Key('profile-avatar-image')), findsOneWidget); + repo.hasAvatar = false; + await tester.drag(find.byType(ListView).first, const Offset(0, 350)); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('profile-avatar-image')), findsNothing); + expect(find.byKey(const Key('profile-avatar-fallback')), findsOneWidget); + }); + testWidgets('选图保存失败保留草稿,重试不重复上传,成功才返回', (tester) async { + final repo = ProfileFixture()..failSave = true; + await openEditor(tester, repo, (_) async => XFile.fromData(_png, name: 'test.png')); + await selectPhoto(tester); + await tester.enterText(find.byKey(const Key('profile-name-input')), '新昵称'); + await tester.drag(find.byType(ListView).first, const Offset(0, 350)); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byKey(const Key('profile-name-input'))).controller!.text, + '新昵称', + ); + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + expect(repo.uploads, 1); + expect(repo.saves, 1); + expect(find.text('个人资料'), findsOneWidget); + expect(find.byKey(const Key('profile-save-error')), findsOneWidget); + repo.failSave = false; + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + expect(repo.uploads, 1); + expect(repo.saves, 2); + expect(repo.savedName, '新昵称'); + expect(repo.savedAvatar, '/uploads/avatars/owned.png'); + expect(find.text('保存已确认'), findsOneWidget); + }); + testWidgets('取消选图后昵称保存不覆盖原头像', (tester) async { + final repo = ProfileFixture(); + await openEditor(tester, repo, (_) async => null); + await selectPhoto(tester); + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + expect(repo.uploads, 0); + expect(repo.savedAvatar, isNull); + expect(repo.saves, 1); + }); + testWidgets('空昵称不能提交;离开有草稿需要确认', (tester) async { + final repo = ProfileFixture(); + await openEditor(tester, repo, (_) async => null); + await tester.enterText(find.byKey(const Key('profile-name-input')), ' '); + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + expect(repo.saves, 0); + expect(find.text('昵称不能为空'), findsOneWidget); + await tester.tap(find.byTooltip('返回')); + await tester.pumpAndSettle(); + expect(find.text('放弃未保存的修改?'), findsOneWidget); + await tester.tap(find.text('继续编辑')); + await tester.pumpAndSettle(); + expect(find.text('个人资料'), findsOneWidget); + }); +} diff --git a/apps/user_app/test/ui/recharge_page_test.dart b/apps/user_app/test/ui/recharge_page_test.dart new file mode 100644 index 0000000..4d42941 --- /dev/null +++ b/apps/user_app/test/ui/recharge_page_test.dart @@ -0,0 +1,133 @@ +// 功能描述:充值页面缺少协议保护和待确认请求恢复;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/data/services/recharge_draft_store.dart'; +import 'package:user_app/domain/models/recharge.dart'; +import 'package:user_app/domain/models/client_models.dart'; +import 'package:user_app/ui/features/wallet/recharge_page.dart'; +import '../support/a1_fixture.dart'; + +class _Store implements RechargeDraftStore { + PendingRecharge? draft; + @override + Future read(String owner) async => draft; + @override + Future write(String owner, PendingRecharge value) async { + draft = value; + } + + @override + Future delete(String owner) async { + draft = null; + } +} + +class _Repo extends A1FixtureRepository { + bool failBalanceRefresh = false; + int balanceReads = 0; + @override + Future wallet() async { + if (++balanceReads > 1 && failBalanceRefresh) throw StateError('余额断网'); + return super.wallet(); + } + + @override + Future rechargeDraftOwner() async => 'api#alice'; + @override + Future rechargeOptions() async => const RechargeOptions( + min: 1, + max: 500000, + channels: [RechargeChannel('alipay', app: true, wap: true)], + ); + @override + Future rechargeResult(String request) async => RechargeRecord( + identity: 'one', + number: request, + amount: 10000, + status: 23, + channel: 'alipay', + createdAt: DateTime(2026), + ); +} + +void main() { + for (final width in [320.0, 390.0]) { + testWidgets('充值页窄屏与放大字体无溢出 $width', (tester) async { + tester.view.physicalSize = Size(width, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(1.3)), + child: child!, + ), + home: RechargePage(repository: _Repo(), store: _Store()), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.scrollUntilVisible( + find.byType(FilledButton), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + } + testWidgets('缺少协议不能创建充值,但可恢复并查询已有订单', (tester) async { + final store = _Store() + ..draft = const PendingRecharge( + request: 'request-1', + amount: 10000, + channel: 'alipay', + payType: 'app', + ); + await tester.pumpWidget( + MaterialApp( + home: RechargePage(repository: _Repo()..failBalanceRefresh = true, store: store), + ), + ); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(TextField)).enabled, isFalse); + await tester.scrollUntilVisible( + find.text('查询到账结果'), + 250, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(FilledButton)).onPressed, isNull); + await tester.tap(find.text('查询到账结果')); + await tester.pumpAndSettle(); + expect(store.draft, isNull); + expect(find.text('查询到账结果'), findsNothing); + await tester.scrollUntilVisible( + find.byType(TextField), + -250, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(TextField)).enabled, isTrue); + await tester.tap(find.byTooltip('清空金额')); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(TextField)).controller!.text, isEmpty); + await tester.enterText(find.byType(TextField), '200.29'); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text('确认充值 ¥200.29'), + 250, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + expect(find.text('确认充值 ¥200.29'), findsOneWidget); + await tester.scrollUntilVisible( + find.text('已到账,余额刷新失败,请重新进入页面刷新'), + -250, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + expect(find.text('暂未确认充值结果,请稍后查询'), findsNothing); + }); +} diff --git a/apps/user_app/test/ui/recharge_records_page_test.dart b/apps/user_app/test/ui/recharge_records_page_test.dart new file mode 100644 index 0000000..3e92047 --- /dev/null +++ b/apps/user_app/test/ui/recharge_records_page_test.dart @@ -0,0 +1,51 @@ +// 功能描述:充值记录分页去重和刷新失败恢复;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/domain/models/recharge.dart'; +import 'package:user_app/ui/features/wallet/recharge_records_page.dart'; +import '../support/a1_fixture.dart'; + +class _Repository extends A1FixtureRepository { + bool broken = false; + final cursors = []; + @override + Future rechargeRecords({String cursor = ''}) async { + cursors.add(cursor); + if (broken) throw Exception('断网'); + RechargeRecord item(String id) => RechargeRecord( + identity: id, + number: id, + amount: 10000, + status: 10, + channel: 'wechat', + createdAt: DateTime(2026, 9, 11), + ); + return RechargeRecordPage( + cursor.isEmpty ? [item('one')] : [item('one'), item('two')], + cursor.isEmpty ? '7' : '', + ); + } +} + +void main() { + testWidgets('续页去重,刷新失败保留记录并可重试', (tester) async { + final repo = _Repository(); + await tester.pumpWidget(MaterialApp(home: RechargeRecordsPage(repository: repo))); + await tester.pumpAndSettle(); + expect(find.text('待确认到账'), findsOneWidget); + await tester.tap(find.text('加载更多')); + await tester.pumpAndSettle(); + expect(repo.cursors, ['', '7']); + expect(find.text('充值单号:one'), findsOneWidget); + expect(find.text('充值单号:two'), findsOneWidget); + repo.broken = true; + await tester.tap(find.byTooltip('刷新充值状态')); + await tester.pumpAndSettle(); + expect(find.text('充值单号:two'), findsOneWidget); + repo.broken = false; + await tester.tap(find.text('充值记录加载失败,请重试')); + await tester.pumpAndSettle(); + expect(find.text('充值单号:two'), findsNothing); + expect(repo.cursors.last, ''); + }); +} diff --git a/apps/user_app/test/ui/repair_draft_test.dart b/apps/user_app/test/ui/repair_draft_test.dart new file mode 100644 index 0000000..631f776 --- /dev/null +++ b/apps/user_app/test/ui/repair_draft_test.dart @@ -0,0 +1,214 @@ +// 功能描述:草稿重开恢复、跨账户隔离、提交前持久化与存储失败不发单。 +// 版本:1.0.0。 +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:user_app/data/repositories/client_repository.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/data/services/repair_draft_store.dart'; +import 'package:user_app/ui/features/tickets/repair_page.dart'; + +class _Drafts implements RepairDraftStore { + final records = >{}; + bool fail = false; + @override + Future?> read(String owner) async => records[owner]; + @override + Future write(String owner, Map draft) async { + if (fail) throw Exception('storage'); + records[owner] = draft; + } + + @override + Future delete(String owner) async { + records.remove(owner); + } +} + +class _Repository extends ClientRepository { + _Repository(this.store) : super(ApiClient(() => '', baseUrl: 'https://api.example.com')); + final _Drafts store; + String owner = 'api#alice'; + int submissions = 0; + @override + Future repairDraftOwner() async => owner; + @override + Future uploadedTicketPhoto(String uri) async => base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1cAAAAASUVORK5CYII=', + ); + @override + Future submitRepair({ + required String requestNo, + required String description, + required String faultType, + required String addressIdentity, + DateTime? appointment, + List> photos = const [], + }) async { + expect(requestNo, 'persisted-request'); + expect(store.records[owner]!['attempted'], true); + expect(store.records[owner]!['request_no'], requestNo); + expect(photos.length, 1); + submissions++; + if (submissions == 1) throw const ApiException(500, '结果待确认'); + return 'ticket-1'; + } +} + +Map draft({bool pending = true}) => { + 'schema': 1, + 'request_no': 'persisted-request', + 'description': '已保存故障', + 'fault_type': 'valve', + 'step': pending ? 2 : 0, + 'attempted': pending, + 'address': pending + ? { + 'identity': 'address-1', + 'address': '草稿地址', + 'contact_name': '联系人', + 'contact_phone': '13800000001', + } + : null, + 'photos': pending + ? [ + { + 'uri': '/uploads/ticket-photos/owned/photo.png', + 'source': 'gallery', + 'added_at': '2026-09-07T01:00:00Z', + }, + ] + : [], +}; +GoRouter routerFor(ClientRepository repository, RepairDraftStore store) => GoRouter( + initialLocation: '/repair', + routes: [ + GoRoute( + path: '/repair', + builder: (_, s) => RepairPage(repository: repository, draftStore: store), + ), + GoRoute( + path: '/me', + builder: (_, s) => const Scaffold(body: Text('已暂存退出')), + ), + GoRoute( + path: '/tickets/:identity', + builder: (_, s) => const Scaffold(body: Text('已提交')), + ), + GoRoute( + path: '/orders', + builder: (_, s) => const Scaffold(body: Text('工单列表')), + ), + ], +); + +void main() { + testWidgets('损坏草稿不会自动丢弃,明确清除后才可重新填写', (tester) async { + final store = _Drafts()..records['api#alice'] = {'schema': 1}; + final repository = _Repository(store); + final router = routerFor(repository, store); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + await tester.tap(find.text('恢复草稿')); + await tester.pumpAndSettle(); + expect(store.records['api#alice'], isNotNull); + await tester.tap(find.text('清除无法读取的草稿')); + await tester.pumpAndSettle(); + expect(store.records['api#alice'], isNotNull); + await tester.tap(find.text('清除草稿')); + await tester.pumpAndSettle(); + expect(store.records['api#alice'], isNull); + expect(find.byType(TextField), findsOneWidget); + expect(repository.submissions, 0); + await tester.pumpWidget(const SizedBox()); + router.dispose(); + }); + testWidgets('结果未知重开后恢复原请求,成功清理草稿', (tester) async { + final store = _Drafts()..records['api#alice'] = draft(); + final repository = _Repository(store); + var router = routerFor(repository, store); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + expect(repository.submissions, 0); + await tester.tap(find.text('恢复草稿')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('重试提交')); + await tester.tap(find.text('重试提交')); + await tester.pumpAndSettle(); + expect(repository.submissions, 1); + expect(store.records['api#alice']!['request_no'], 'persisted-request'); + await tester.pumpWidget(const SizedBox()); + router.dispose(); + router = routerFor(repository, store); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + await tester.tap(find.text('恢复草稿')); + await tester.pumpAndSettle(); + expect(find.text('已保存故障'), findsOneWidget); + expect(repository.submissions, 1); + await tester.ensureVisible(find.text('重试提交')); + await tester.tap(find.text('重试提交')); + await tester.pumpAndSettle(); + expect(repository.submissions, 2); + expect(store.records['api#alice'], isNull); + expect(find.text('已提交'), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + router.dispose(); + }); + testWidgets('保存失败不发送工单且保留输入', (tester) async { + final store = _Drafts() + ..records['api#alice'] = draft() + ..fail = true; + final repository = _Repository(store); + final router = routerFor(repository, store); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + await tester.tap(find.text('恢复草稿')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('重试提交')); + await tester.tap(find.text('重试提交')); + await tester.pumpAndSettle(); + expect(repository.submissions, 0); + expect(find.textContaining('提交前保存失败'), findsOneWidget); + expect(find.text('已保存故障'), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + router.dispose(); + }); + testWidgets('其他账户不会出现已有草稿', (tester) async { + final store = _Drafts()..records['api#alice'] = draft(); + final repository = _Repository(store)..owner = 'api#bob'; + final router = routerFor(repository, store); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + expect(find.text('发现报修草稿'), findsNothing); + expect(find.text('已保存故障'), findsNothing); + expect(store.records['api#alice'], isNotNull); + await tester.pumpWidget(const SizedBox()); + router.dispose(); + }); + testWidgets('暂存退出保存编辑和同一个请求号', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final store = _Drafts()..records['api#alice'] = draft(pending: false); + final repository = _Repository(store); + final router = routerFor(repository, store); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + await tester.tap(find.text('恢复草稿')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '修改后暂存'); + await tester.ensureVisible(find.text('暂存并退出')); + await tester.tap(find.text('暂存并退出')); + await tester.pumpAndSettle(); + expect(find.text('已暂存退出'), findsOneWidget); + expect(store.records['api#alice']!['description'], '修改后暂存'); + expect(store.records['api#alice']!['request_no'], 'persisted-request'); + expect(repository.submissions, 0); + await tester.pumpWidget(const SizedBox()); + router.dispose(); + }); +} diff --git a/apps/user_app/test/ui/repair_page_test.dart b/apps/user_app/test/ui/repair_page_test.dart new file mode 100644 index 0000000..68da935 --- /dev/null +++ b/apps/user_app/test/ui/repair_page_test.dart @@ -0,0 +1,155 @@ +// 功能描述:验证报修分步校验、地址返回、显式提交与未知结果重试。 +// 版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:image_picker/image_picker.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:user_app/data/repositories/client_repository.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/shipping_address.dart'; +import 'package:user_app/ui/features/tickets/repair_page.dart'; + +class _RepairRepository extends ClientRepository { + _RepairRepository() : super(ApiClient(() => '', baseUrl: 'https://api.example.com')); + final requests = []; + int uploads = 0; + @override + Future uploadTicketPhoto(Uint8List bytes, String filename) async { + uploads++; + return '/uploads/ticket-photos/owned/test.png'; + } + + @override + Future submitRepair({ + required String requestNo, + required String description, + required String faultType, + required String addressIdentity, + DateTime? appointment, + List> photos = const [], + }) async { + expect(description, '阀门异响'); + expect(addressIdentity, 'address-1'); + expect(faultType, 'valve'); + requests.add(requestNo); + expect(photos.length, 1); + expect(photos.first['uri'], '/uploads/ticket-photos/owned/test.png'); + if (requests.length == 1) throw const ApiException(500, '结果待确认'); + return 'ticket-1'; + } +} + +void main() { + testWidgets('紧急电话只在用户点击后调用系统拨号入口', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + Uri? opened; + final repository = _RepairRepository(); + await tester.pumpWidget( + MaterialApp( + home: RepairPage( + repository: repository, + launchEmergency: (uri) async { + opened = uri; + return true; + }, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -500)); + await tester.pumpAndSettle(); + expect(opened, isNull); + await tester.tap(find.widgetWithText(TextButton, '紧急电话')); + await tester.pumpAndSettle(); + expect(opened, Uri(scheme: 'tel', path: '119')); + }); + + testWidgets('分步校验,失败冻结原请求,重试不重新生成请求号', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final repository = _RepairRepository(); + final router = GoRouter( + initialLocation: '/repair', + routes: [ + GoRoute( + path: '/repair', + builder: (_, state) => RepairPage( + repository: repository, + pickPhoto: (_) async => XFile.fromData( + base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1cAAAAASUVORK5CYII=', + ), + name: 'test.png', + ), + ), + ), + GoRoute( + path: '/addresses', + builder: (context, state) => Scaffold( + body: TextButton( + onPressed: () => context.pop( + const ShippingAddress( + identity: 'address-1', + address: '测试路88号', + contactName: '联系人', + contactPhone: '13800000001', + ), + ), + child: const Text('选这个地址'), + ), + ), + ), + GoRoute( + path: '/tickets/:identity', + builder: (_, state) => const Scaffold(body: Text('已打开工单详情')), + ), + ], + ); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('下一步')); + await tester.tap(find.text('下一步')); + await tester.pumpAndSettle(); + expect(find.text('请描述故障现象'), findsWidgets); + await tester.enterText(find.byType(TextField), '阀门异响'); + expect(find.text('添加照片'), findsNWidgets(3)); + await tester.ensureVisible(find.text('添加照片').first); + await tester.tap(find.text('添加照片').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('从相册选择')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('下一步')); + await tester.tap(find.text('下一步')); + await tester.pumpAndSettle(); + await tester.tap(find.text('下一步')); + await tester.pumpAndSettle(); + expect(find.text('请选择并补齐联系人、电话和地址'), findsOneWidget); + await tester.tap(find.text('选择报修地址')); + await tester.pumpAndSettle(); + await tester.tap(find.text('选这个地址')); + await tester.pumpAndSettle(); + await tester.tap(find.text('下一步')); + await tester.pumpAndSettle(); + expect(repository.requests, isEmpty); + await tester.ensureVisible(find.widgetWithText(FilledButton, '确认提交')); + await tester.tap(find.widgetWithText(FilledButton, '确认提交')); + await tester.pumpAndSettle(); + expect(find.text('结果待确认'), findsOneWidget); + await tester.ensureVisible(find.text('重试提交')); + await tester.tap(find.text('重试提交')); + await tester.pumpAndSettle(); + expect(repository.requests.length, 2); + expect(repository.uploads, 1); + expect(repository.requests[0], repository.requests[1]); + expect(find.text('已打开工单详情'), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + router.dispose(); + }); +} diff --git a/apps/user_app/test/ui/repair_speech_test.dart b/apps/user_app/test/ui/repair_speech_test.dart new file mode 100644 index 0000000..2e638b6 --- /dev/null +++ b/apps/user_app/test/ui/repair_speech_test.dart @@ -0,0 +1,165 @@ +// 功能描述:语音结果选区替换、重复中间结果、拒绝权限、离页取消与长度边界回归。 +// 版本:1.0.0。 +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/data/services/repair_speech.dart'; +import 'package:user_app/ui/features/tickets/repair_speech_button.dart'; + +class _Speech implements RepairSpeech { + bool available = true; + int cancels = 0, starts = 0; + Completer? pending; + late void Function(String) words, error; + late void Function() done; + @override + Future start({ + required void Function(String) onWords, + required void Function(String) onError, + required void Function() onDone, + }) async { + starts++; + words = onWords; + error = onError; + done = onDone; + return pending?.future ?? available; + } + + @override + Future stop() async { + done(); + } + + @override + Future cancel() async { + cancels++; + } +} + +void main() { + testWidgets('权限弹窗不取消初始化,切后台立即取消', (tester) async { + final speech = _Speech()..pending = Completer(); + final controller = TextEditingController(text: '保留'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RepairSpeechButton(controller: controller, onBusy: (_) {}, speech: speech), + ), + ), + ); + await tester.tap(find.text('语音输入')); + await tester.pump(); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + await tester.pump(); + expect(speech.cancels, 0); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(); + expect(speech.cancels, 1); + speech.pending!.complete(true); + speech.words('迟到'); + await tester.pump(); + expect(controller.text, '保留'); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpWidget(const SizedBox()); + controller.dispose(); + }); + testWidgets('选区替换和中间结果不重复追加,结束后可再次录入', (tester) async { + final speech = _Speech(), controller = TextEditingController(text: '前旧后'); + controller.selection = const TextSelection(baseOffset: 1, extentOffset: 2); + final busy = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RepairSpeechButton(controller: controller, onBusy: busy.add, speech: speech), + ), + ), + ); + await tester.tap(find.text('语音输入')); + await tester.pump(); + speech.words('阀门'); + speech.words('阀门异响'); + await tester.pump(); + expect(controller.text, '前阀门异响后'); + await tester.tap(find.text('结束识别')); + await tester.pump(); + speech.words('迟到结果'); + expect(controller.text, '前阀门异响后'); + expect(busy.last, false); + await tester.tap(find.text('语音输入')); + await tester.pump(); + speech.words('补充'); + speech.done(); + await tester.pump(); + expect(controller.text, '前阀门异响补充后'); + expect(speech.starts, 2); + await tester.pumpWidget(const SizedBox()); + controller.dispose(); + }); + testWidgets('不可用和权限拒绝保留输入并解除忙碌', (tester) async { + final speech = _Speech()..available = false, controller = TextEditingController(text: '已有内容'); + final busy = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RepairSpeechButton(controller: controller, onBusy: busy.add, speech: speech), + ), + ), + ); + await tester.tap(find.text('语音输入')); + await tester.pump(); + expect(find.textContaining('无法使用语音识别'), findsOneWidget); + expect(controller.text, '已有内容'); + expect(busy.last, false); + speech.available = true; + await tester.tap(find.text('语音输入')); + await tester.pump(); + speech.error('error_permission'); + await tester.pump(); + expect(find.textContaining('未获得麦克风'), findsOneWidget); + expect(controller.text, '已有内容'); + expect(busy.last, false); + await tester.pumpWidget(const SizedBox()); + controller.dispose(); + }); + testWidgets('初始化中离页取消,迟到结果不改变文字', (tester) async { + final speech = _Speech()..pending = Completer(), + controller = TextEditingController(text: '保留'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RepairSpeechButton(controller: controller, onBusy: (_) {}, speech: speech), + ), + ), + ); + await tester.tap(find.text('语音输入')); + await tester.pump(); + await tester.pumpWidget(const SizedBox()); + speech.pending!.complete(true); + speech.words('迟到'); + await tester.pump(); + expect(speech.cancels, 1); + expect(controller.text, '保留'); + expect(tester.takeException(), isNull); + controller.dispose(); + }); + testWidgets('识别遵守2000字符上限,不截断表情字符', (tester) async { + final speech = _Speech(), + controller = TextEditingController(text: List.filled(1999, '字').join()); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RepairSpeechButton(controller: controller, onBusy: (_) {}, speech: speech), + ), + ), + ); + await tester.tap(find.text('语音输入')); + await tester.pump(); + speech.words('😀多余'); + speech.done(); + await tester.pump(); + expect(controller.text.characters.length, 2000); + expect(controller.text.endsWith('😀'), true); + await tester.pumpWidget(const SizedBox()); + controller.dispose(); + }); +} diff --git a/apps/user_app/test/ui/safety_contents_page_test.dart b/apps/user_app/test/ui/safety_contents_page_test.dart new file mode 100644 index 0000000..bb4a1c8 --- /dev/null +++ b/apps/user_app/test/ui/safety_contents_page_test.dart @@ -0,0 +1,43 @@ +// 功能:公告中心搜索、协议隔离和下架内容提示回归;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/domain/models/client_models.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/ui/features/home/safety_contents_page.dart'; +import '../support/a1_fixture.dart'; + +class _ContentsRepository extends A1FixtureRepository { + @override + Future safetyContent(String identity) async => throw const ApiException(1112, '未发布'); + @override + Future> contents() async => const [ + ClientRecord(identity: 'notice', title: '配送调整', subtitle: '', raw: {'content_type': 'notice', 'body': '测试正文'}), + ClientRecord(identity: 'agreement', title: '用户协议', subtitle: '', raw: {'content_type': 'agreement'}), + ClientRecord(identity: 'law', title: '测试法规', subtitle: '', raw: {'content_type': 'law', 'body': '测试法规正文'}), + ]; +} + +void main() { + testWidgets('公告中心不展示协议且支持标题搜索', (tester) async { + await tester.pumpWidget(MaterialApp(home: SafetyContentsPage(repository: _ContentsRepository()))); + await tester.pumpAndSettle(); + expect(find.text('配送调整'), findsOneWidget); + expect(find.text('用户协议'), findsNothing); + await tester.enterText(find.byType(TextField), '无匹配'); + await tester.pump(); + expect(find.text('暂无符合条件的内容'), findsOneWidget); + }); + testWidgets('法律法规分类只显示该类型的已发布内容', (tester) async { + await tester.pumpWidget(MaterialApp(home: SafetyContentsPage(repository: _ContentsRepository()))); + await tester.pumpAndSettle(); + await tester.tap(find.text('法律法规')); + await tester.pump(); + expect(find.text('测试法规'), findsOneWidget); + expect(find.text('配送调整'), findsNothing); + }); + testWidgets('未知或非公告标识不能显示协议正文', (tester) async { + await tester.pumpWidget(MaterialApp(home: SafetyContentsPage(repository: _ContentsRepository(), identity: 'agreement'))); + await tester.pumpAndSettle(); + expect(find.text('内容不存在或已下架'), findsOneWidget); + }); +} diff --git a/apps/user_app/test/ui/settings_page_test.dart b/apps/user_app/test/ui/settings_page_test.dart new file mode 100644 index 0000000..80c89df --- /dev/null +++ b/apps/user_app/test/ui/settings_page_test.dart @@ -0,0 +1,136 @@ +// 功能描述:设置确认操作、附属读取失败降级与登录密码表单回归;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/app/dependencies.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/data/services/app_settings_service.dart'; +import 'package:user_app/data/services/secure_session_store.dart'; +import 'package:user_app/domain/models/client_models.dart'; +import 'package:user_app/ui/core/app_theme.dart'; +import 'package:user_app/ui/features/settings/settings_page.dart'; +import 'package:user_app/ui/features/settings/login_password_page.dart'; +import '../support/a1_fixture.dart'; + +class SettingsSession extends UserSession { + SettingsSession() : super(SecureSessionStore()); + int logouts = 0; + @override + Future logout() async { + logouts++; + } +} + +class SettingsFixture extends A1FixtureRepository { + bool failProfile = false, failPassword = true; + int changes = 0; + String? receivedCurrent, receivedNext; + @override + Future profile() async { + if (failProfile) throw const ApiException(500, '资料暂时不可用'); + return super.profile(); + } + + @override + Future changeLoginPassword(String currentPassword, String newPassword) async { + changes++; + receivedCurrent = currentPassword; + receivedNext = newPassword; + if (failPassword) throw const ApiException(1305, '当前密码错误'); + } +} + +class SettingsServiceFixture extends AppSettingsService { + int clears = 0; + @override + Future version() async => '1.0.0 (1)'; + @override + int get imageCacheBytes => clears == 0 ? 1048576 : 0; + @override + void clearImageCache() { + clears++; + } +} + +void main() { + testWidgets('资料读取失败仍可清图片缓存,取消不清除,退出需确认', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final repo = SettingsFixture()..failProfile = true; + final session = SettingsSession(), service = SettingsServiceFixture(); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: SettingsPage(repository: repo, session: session, service: service), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('读取失败,点击重试'), findsOneWidget); + await tester.scrollUntilVisible(find.text('清除缓存'), 300); + await tester.pumpAndSettle(); + await tester.tap(find.text('清除缓存')); + await tester.pumpAndSettle(); + await tester.tap(find.text('取消')); + await tester.pumpAndSettle(); + expect(service.clears, 0); + await tester.tap(find.text('清除缓存')); + await tester.pumpAndSettle(); + await tester.tap(find.text('确认')); + await tester.pumpAndSettle(); + expect(service.clears, 1); + expect(find.text('0.0MB 图片缓存'), findsOneWidget); + await tester.scrollUntilVisible(find.text('退出登录'), 300); + await tester.pumpAndSettle(); + await tester.tap(find.text('退出登录')); + await tester.pumpAndSettle(); + await tester.tap(find.text('取消')); + await tester.pumpAndSettle(); + expect(session.logouts, 0); + await tester.tap(find.text('退出登录')); + await tester.pumpAndSettle(); + await tester.tap(find.text('确认')); + await tester.pumpAndSettle(); + expect(session.logouts, 1); + }); + + testWidgets('密码校验阻止错误提交,服务端失败保留输入,成功才退出', (tester) async { + final repo = SettingsFixture(), session = SettingsSession(); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: LoginPasswordPage(repository: repo, session: session), + ), + ); + await tester.tap(find.text('确认修改并重新登录')); + await tester.pumpAndSettle(); + expect(repo.changes, 0); + await tester.enterText(find.byKey(const ValueKey('password-0')), 'old-test-password'); + await tester.enterText(find.byKey(const ValueKey('password-1')), 'new-test-password'); + await tester.enterText(find.byKey(const ValueKey('password-2')), 'mismatch'); + await tester.tap(find.text('确认修改并重新登录')); + await tester.pumpAndSettle(); + expect(find.text('两次新密码不一致'), findsOneWidget); + expect(repo.changes, 0); + await tester.enterText(find.byKey(const ValueKey('password-2')), 'new-test-password'); + await tester.tap(find.text('确认修改并重新登录')); + await tester.pumpAndSettle(); + expect(find.text('当前密码错误'), findsOneWidget); + expect(session.logouts, 0); + expect( + tester.widget(find.byKey(const ValueKey('password-1'))).controller!.text, + 'new-test-password', + ); + repo.failPassword = false; + await tester.tap(find.text('确认修改并重新登录')); + await tester.pumpAndSettle(); + expect(repo.changes, 2); + expect(session.logouts, 1); + expect(repo.receivedCurrent, 'old-test-password'); + expect(repo.receivedNext, 'new-test-password'); + expect( + tester.widget(find.byKey(const ValueKey('password-1'))).controller!.text, + '', + ); + }); +} diff --git a/apps/user_app/test/ui/shop_address_selection_test.dart b/apps/user_app/test/ui/shop_address_selection_test.dart new file mode 100644 index 0000000..6a72494 --- /dev/null +++ b/apps/user_app/test/ui/shop_address_selection_test.dart @@ -0,0 +1,130 @@ +// 功能描述:验证商城显式选择收货地址并提交该地址的联系人,不使用账户联系人。 +// 版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:user_app/domain/models/shipping_address.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/ui/core/app_theme.dart'; +import 'package:user_app/ui/features/shop/shop_page.dart'; +import 'package:user_app/ui/features/shop/product_detail_page.dart'; +import 'package:user_app/ui/features/shop/checkout_page.dart'; +import '../support/a1_fixture.dart'; + +class CheckoutFixture extends A1FixtureRepository { + String? selected, name, phone; + final requests = []; + int? sentQuantity, sentAmount; + @override + Future> submitShopOrder({ + required String requestNo, + required String productIdentity, + required ShippingAddress address, + required int quantity, + required int expectedAmount, + String remark = '', + }) async { + requests.add(requestNo); + sentQuantity = quantity; + sentAmount = expectedAmount; + if (requests.length == 1) throw const ApiException(500, '提交结果未知'); + selected = address.identity; + name = address.contactName; + phone = address.contactPhone; + return {'identity': 'created'}; + } + + @override + Future> shippingAddresses() async => []; +} + +void main() { + testWidgets('确认前不下单,提交选中地址的联系人', (tester) async { + final repo = CheckoutFixture(); + final router = GoRouter( + initialLocation: '/shop', + routes: [ + GoRoute( + path: '/products/:identity', + builder: (_, state) => + ProductDetailPage(repository: repo, identity: state.pathParameters['identity']!), + ), + GoRoute( + path: '/checkout/:identity', + builder: (_, state) => + CheckoutPage(repository: repo, productIdentity: state.pathParameters['identity']!), + ), + GoRoute( + path: '/shop', + builder: (_, state) => ShopPage(repository: repo), + ), + GoRoute( + path: '/addresses', + builder: (context, state) => Scaffold( + body: TextButton( + onPressed: () => context.pop( + const ShippingAddress( + identity: 'selected-address', + address: '收货地址', + contactName: '收货联系人', + contactPhone: '13900000001', + ), + ), + child: const Text('选择此地址'), + ), + ), + ), + GoRoute( + path: '/orders', + builder: (_, state) => const Scaffold(body: Text('订单列表')), + ), + GoRoute( + path: '/payment/shop/:identity', + builder: (_, state) => Scaffold( + body: Text('支付订单 ${state.pathParameters['identity']}'), + ), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router, theme: AppTheme.light())); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('15kg家用液化气')); + await tester.pumpAndSettle(); + await tester.tap(find.text('15kg家用液化气')); + await tester.pumpAndSettle(); + expect(repo.requests, isEmpty); + await tester.tap(find.text('立即购买')); + await tester.pumpAndSettle(); + expect(repo.selected, isNull); + await tester.tap(find.text('选择收货地址')); + await tester.pumpAndSettle(); + await tester.tap(find.text('选择此地址')); + await tester.pumpAndSettle(); + expect(find.text('收货联系人 139****0001'), findsOneWidget); + expect(find.text('配送时间'), findsOneWidget); + expect(repo.selected, isNull); + await tester.tap(find.byTooltip('增加数量')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '提交订单')); + await tester.pumpAndSettle(); + expect(find.text('提交结果未知'), findsOneWidget); + expect( + tester + .widget( + find.byWidgetPredicate((widget) => widget is IconButton && widget.tooltip == '增加数量'), + ) + .onPressed, + isNull, + ); + await tester.tap(find.widgetWithText(FilledButton, '重试提交')); + await tester.pumpAndSettle(); + expect(repo.selected, 'selected-address'); + expect(repo.name, '收货联系人'); + expect(repo.phone, '13900000001'); + expect(repo.requests[0], repo.requests[1]); + expect(repo.sentQuantity, 2); + expect(repo.sentAmount, 25600); + expect(find.text('支付订单 created'), findsOneWidget); + }); +} diff --git a/apps/user_app/test/ui/shop_category_scroll_test.dart b/apps/user_app/test/ui/shop_category_scroll_test.dart new file mode 100644 index 0000000..d525ca9 --- /dev/null +++ b/apps/user_app/test/ui/shop_category_scroll_test.dart @@ -0,0 +1,36 @@ +// 功能描述:验证商城分类支持鼠标、触屏横向拖动及拖动后的筛选。 +// 版本:1.0.0 +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/ui/features/shop/shop_page.dart'; +import '../support/a1_fixture.dart'; + +void main() { + for (final kind in [PointerDeviceKind.mouse, PointerDeviceKind.touch]) { + testWidgets('分类拖动与筛选:${kind.name}', (tester) async { + tester.view.physicalSize = const Size(320, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp(home: ShopPage(repository: A1FixtureRepository()))); + await tester.pumpAndSettle(); + final strip = find.byKey(const ValueKey('shop-category-scroll')); + final scrollable = find.descendant(of: strip, matching: find.byType(Scrollable)); + final state = tester.state(scrollable); + expect(state.position.pixels, 0); + expect(state.position.maxScrollExtent, greaterThan(0)); + final gesture = await tester.startGesture(tester.getCenter(strip), kind: kind); + await gesture.moveBy(const Offset(-30, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(-100, 0)); + await gesture.up(); + await tester.pumpAndSettle(); + expect(state.position.pixels, greaterThan(0)); + await tester.tap(find.widgetWithText(ChoiceChip, '燃气配件')); + await tester.pumpAndSettle(); + expect(find.text('不锈钢减压阀'), findsOneWidget); + expect(find.text('燃气报警器'), findsNothing); + }); + } +} diff --git a/apps/user_app/test/ui/shop_order_detail_test.dart b/apps/user_app/test/ui/shop_order_detail_test.dart new file mode 100644 index 0000000..6a0fb6b --- /dev/null +++ b/apps/user_app/test/ui/shop_order_detail_test.dart @@ -0,0 +1,134 @@ +// 功能描述:订单深链详情、历史快照、确认操作及状态变化回归;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/shop_order_detail.dart'; +import 'package:user_app/ui/core/app_theme.dart'; +import 'package:user_app/ui/features/orders/shop_order_detail_page.dart'; +import '../support/a1_fixture.dart'; + +class _Repo extends A1FixtureRepository { + int reads = 0, cancels = 0; + bool failLoad = false, failCancel = false, stale = false; + String status = '待付款'; + @override + Future shopOrderDetail(String identity) async { + reads++; + if (failLoad) { + failLoad = false; + throw const ApiException(500, '暂时读取失败'); + } + return ShopOrderDetail.fromJson({ + 'identity': identity, + 'order_no': 'EC123', + 'status_name': status, + 'allowed_actions': status == '待付款' && !(stale && reads > 1) ? ['cancel'] : [], + 'contact_name': '历史联系人', + 'contact_phone': '13800000001', + 'address': '成交时地址', + 'product_amount': 3998, + 'discount_amount': 0, + 'payable_amount': 3998, + 'remark': '原订单备注', + 'created_at': '2026-09-05T09:20:00+08:00', + 'paid_at': null, + 'items': [ + {'identity': 'line', 'name': '成交时商品', 'quantity': 2, 'sale_amount': 1999}, + ], + }, (s) => s); + } + + @override + Future cancelShopOrder(String identity) async { + cancels++; + if (failCancel) { + failCancel = false; + throw const ApiException(500, '取消结果待确认'); + } + status = '已取消'; + } +} + +Future _show(WidgetTester tester, _Repo repo) async { + final router = GoRouter( + initialLocation: '/shop/orders/one', + routes: [ + GoRoute( + path: '/shop/orders/:id', + builder: (_, s) => ShopOrderDetailPage(repository: repo, identity: s.pathParameters['id']!), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(theme: AppTheme.light(), routerConfig: router)); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('深链读取快照,失败重试、金额、复制订单号与取消确认', (tester) async { + final repo = _Repo()..failLoad = true; + String? copied; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + call, + ) async { + if (call.method == 'Clipboard.setData') copied = (call.arguments as Map)['text'] as String; + return null; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + await _show(tester, repo); + await tester.tap(find.text('重新加载')); + await tester.pumpAndSettle(); + expect(find.text('成交时商品'), findsOneWidget); + expect(find.text('成交时地址'), findsOneWidget); + expect(find.text('¥19.99'), findsOneWidget); + expect(find.text('已付款'), findsNothing); + await tester.scrollUntilVisible(find.text('复制'), 250); + await tester.pumpAndSettle(); + await tester.tap(find.text('复制')); + await tester.pumpAndSettle(); + expect(copied, 'EC123'); + await tester.tap(find.text('取消订单')); + await tester.pumpAndSettle(); + await tester.tap(find.text('返回')); + await tester.pumpAndSettle(); + expect(repo.cancels, 0); + await tester.tap(find.text('取消订单')); + await tester.pumpAndSettle(); + await tester.tap(find.text('确认取消')); + await tester.pumpAndSettle(); + expect(repo.cancels, 1); + expect(find.text('取消订单'), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets('刷新后不再允许的动作不会提交,失败操作保留重试', (tester) async { + final repo = _Repo()..stale = true; + await _show(tester, repo); + await tester.tap(find.text('取消订单')); + await tester.pumpAndSettle(); + expect(repo.cancels, 0); + expect(find.text('订单状态已变化,请查看最新信息'), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + final retry = _Repo()..failCancel = true; + await _show(tester, retry); + await tester.tap(find.text('取消订单')); + await tester.pumpAndSettle(); + await tester.tap(find.text('确认取消')); + await tester.pumpAndSettle(); + expect(find.text('取消结果待确认'), findsOneWidget); + expect(find.text('取消订单'), findsOneWidget); + await tester.tap(find.text('取消订单')); + await tester.pumpAndSettle(); + await tester.tap(find.text('确认取消')); + await tester.pumpAndSettle(); + expect(retry.cancels, 2); + expect(tester.takeException(), isNull); + }); +} diff --git a/apps/user_app/test/ui/ticket_detail_page_test.dart b/apps/user_app/test/ui/ticket_detail_page_test.dart new file mode 100644 index 0000000..402f2b8 --- /dev/null +++ b/apps/user_app/test/ui/ticket_detail_page_test.dart @@ -0,0 +1,90 @@ +// 功能描述:工单详情动作确认、失败保留、重试刷新与终态隐藏回归。 +// 版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/data/repositories/client_repository.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/client_models.dart'; +import 'package:user_app/ui/features/tickets/ticket_detail_page.dart'; + +class _Tickets extends ClientRepository { + _Tickets() : super(ApiClient(() => '', baseUrl: 'https://api.example.com')); + int calls = 0, writes = 0; + bool fail = false; + List actions = ['cancel', 'confirm']; + @override + Future ticket(String identity) async { + calls++; + return ClientRecord( + identity: identity, + title: 'TK测试', + subtitle: '', + raw: { + 'status_name': actions.isEmpty ? '已完成' : '待确认', + 'allowed_actions': actions, + 'description': '阀门异响', + 'category': 'repair', + 'result': '已更换密封件', + 'created_at': '2026-09-07T01:00:00Z', + 'address': '测试路88号', + }, + ); + } + + @override + Future cancelTicket(String identity) async => _write(); + @override + Future confirmTicket(String identity) async => _write(); + void _write() { + writes++; + if (fail) throw const ApiException(500, '网络异常,请重试'); + actions = []; + } +} + +void main() { + for (final cancel in [true, false]) { + testWidgets('${cancel ? '取消' : '完成'}必须确认,失败后允许幂等重试', (tester) async { + final repository = _Tickets(); + await tester.pumpWidget( + MaterialApp( + home: TicketDetailPage(repository: repository, identity: 'owned'), + ), + ); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text('已更换密封件'), + 250, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text('已更换密封件'), findsOneWidget); + final action = find.text(cancel ? '取消工单' : '确认处理完成'); + await tester.scrollUntilVisible(action, 200, scrollable: find.byType(Scrollable).first); + await tester.tap(action); + await tester.pumpAndSettle(); + await tester.tap(find.text('返回')); + await tester.pumpAndSettle(); + expect(repository.writes, 0); + repository.fail = true; + await tester.tap(action); + await tester.pumpAndSettle(); + await tester.tap(find.text(cancel ? '确认取消' : '确认完成')); + await tester.pumpAndSettle(); + expect(repository.writes, 1); + expect(find.text('网络异常,请重试'), findsOneWidget); + expect(action, findsOneWidget); + repository.fail = false; + // 等待错误提示消失,避免遮挡底部操作按钮。 + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + await tester.tap(action); + await tester.pumpAndSettle(); + await tester.tap(find.text(cancel ? '确认取消' : '确认完成')); + await tester.pumpAndSettle(); + expect(repository.writes, 2); + expect(repository.calls, 2); + expect(find.text('取消工单'), findsNothing); + expect(find.text('确认处理完成'), findsNothing); + }); + } +} diff --git a/apps/user_app/test/ui/usage_statistics_page_test.dart b/apps/user_app/test/ui/usage_statistics_page_test.dart new file mode 100644 index 0000000..190cfe7 --- /dev/null +++ b/apps/user_app/test/ui/usage_statistics_page_test.dart @@ -0,0 +1,82 @@ +// 功能描述:验证用气统计仅展示权威数据并保留缺失能力提示;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/ui/features/profile/usage_statistics_page.dart'; +import 'package:user_app/domain/models/usage_statistics.dart'; + +import '../support/a1_fixture.dart'; + +void main() { + testWidgets('日期确认传入查询并在切换周期时保留,取消不查询', (tester) async { + final repository = _DateRepository(); + await tester.pumpWidget(MaterialApp( + home: UsageStatisticsPage(repository: repository), + )); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('选择日期')); + await tester.pumpAndSettle(); + await tester.tap(find.text('取消')); + await tester.pumpAndSettle(); + expect(repository.anchors.length, 1); + await tester.tap(find.byTooltip('选择日期')); + await tester.pumpAndSettle(); + await tester.tap(find.text('确定')); + await tester.pumpAndSettle(); + expect(repository.anchors.last, DateUtils.dateOnly(DateTime.now())); + await tester.tap(find.text('年')); + await tester.pumpAndSettle(); + expect(repository.anchors.length, 3); + expect(repository.anchors.last, repository.anchors[1]); + }); + testWidgets('用气统计展示口径、趋势和明细', (tester) async { + await tester.pumpWidget( + MaterialApp(home: UsageStatisticsPage(repository: A1FixtureRepository())), + ); + await tester.pumpAndSettle(); + + expect(find.text('用气统计'), findsOneWidget); + expect(find.textContaining('厨房15kg气瓶'), findsOneWidget); + expect(find.text('本期用气总览'), findsOneWidget); + expect(find.text('每日用气量(kg)'), findsOneWidget); + await tester.scrollUntilVisible( + find.text('用气明细'), + 300, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text('安全趋势暂未开放'), findsOneWidget); + await tester.ensureVisible(find.text('用气明细')); + await tester.pumpAndSettle(); + await tester.tap(find.text('用气明细')); + await tester.pumpAndSettle(); + expect(find.text('09月30日'), findsWidgets); + expect(find.text('0.230 kg'), findsOneWidget); + }); + + testWidgets('日周月年切换触发重新读取', (tester) async { + await tester.pumpWidget( + MaterialApp(home: UsageStatisticsPage(repository: A1FixtureRepository())), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('年')); + await tester.pumpAndSettle(); + expect(find.text('本期用气总览'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} + +// 记录真实页面传给数据层的日期,避免仅验证弹窗是否出现。 +class _DateRepository extends A1FixtureRepository { + final anchors = []; + + @override + Future usageStatistics({ + required String deviceIdentity, + String period = 'month', + DateTime? anchor, + }) { + anchors.add(anchor); + return super.usageStatistics( + deviceIdentity: deviceIdentity, period: period, anchor: anchor, + ); + } +} diff --git a/apps/user_app/test/ui/user_records_page_test.dart b/apps/user_app/test/ui/user_records_page_test.dart new file mode 100644 index 0000000..febdb53 --- /dev/null +++ b/apps/user_app/test/ui/user_records_page_test.dart @@ -0,0 +1,52 @@ +// 功能描述:验证我的记录聚合真实用气与报修数据,并正确区分首期缺项和二期能力;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/ui/features/profile/user_records_page.dart'; + +import '../support/a1_fixture.dart'; + +void main() { + testWidgets('我的记录展示真实记录并可筛选', (tester) async { + await tester.pumpWidget( + MaterialApp(home: UserRecordsPage(repository: A1FixtureRepository())), + ); + await tester.pumpAndSettle(); + + expect(find.text('我的记录'), findsOneWidget); + await tester.scrollUntilVisible( + find.textContaining('15kg家用液化气配送'), + 250, + scrollable: find.byType(Scrollable).first, + ); + expect(find.textContaining('15kg家用液化气配送'), findsOneWidget); + expect(find.text('报修工单'), findsOneWidget); + + await tester.tap(find.text('筛选')); + await tester.pumpAndSettle(); + await tester.tap(find.text('报修记录').last); + await tester.pumpAndSettle(); + + expect(find.text('报修工单'), findsOneWidget); + expect(find.textContaining('15kg家用液化气配送'), findsNothing); + }); + + testWidgets('首期缺项和二期入口显示不同提示', (tester) async { + await tester.pumpWidget( + MaterialApp(home: UserRecordsPage(repository: A1FixtureRepository())), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('角阀操作')); + await tester.pumpAndSettle(); + expect(find.text('角阀操作'), findsNWidgets(2)); + expect(find.text('该功能暂未开放,目前无法使用。'), findsOneWidget); + await tester.tap(find.text('知道了')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('发票记录')); + await tester.pumpAndSettle(); + expect(find.text('发票记录'), findsNWidgets(2)); + expect(find.text('该功能将在后续版本开放,敬请期待。'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/apps/user_app/test/ui/wallet_page_test.dart b/apps/user_app/test/ui/wallet_page_test.dart new file mode 100644 index 0000000..b26ba7d --- /dev/null +++ b/apps/user_app/test/ui/wallet_page_test.dart @@ -0,0 +1,51 @@ +// 功能描述:钱包隐私开关、真实空错状态、账单筛选和详情;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/ui/core/app_theme.dart'; +import 'package:user_app/ui/features/wallet/wallet_page.dart'; +import 'package:user_app/ui/features/wallet/wallet_bills_page.dart'; +import '../support/a1_fixture.dart'; + +void main() { + testWidgets('金额隐藏覆盖余额与最近账单,账单失败可独立重试', (tester) async { + final repo = A1FixtureRepository()..fail = true; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: WalletPage(repository: repo), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('¥568.00'), findsOneWidget); + await tester.tap(find.byTooltip('隐藏金额')); + await tester.pumpAndSettle(); + expect(find.text('¥568.00'), findsNothing); + await tester.scrollUntilVisible( + find.text('账单加载失败,点击重试'), + 250, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + repo.fail = false; + await tester.tap(find.text('账单加载失败,点击重试')); + await tester.pumpAndSettle(); + expect(find.text('-¥228.00'), findsNothing); + expect(find.text('气瓶订单'), findsOneWidget); + }); + testWidgets('账单筛选只显示对应方向并能查看记账详情', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: WalletBillsPage(repository: A1FixtureRepository()), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('收入')); + await tester.pumpAndSettle(); + expect(find.text('气瓶订单'), findsNothing); + expect(find.text('+¥59.00'), findsOneWidget); + await tester.tap(find.text('退款入账')); + await tester.pumpAndSettle(); + expect(find.text('记账后余额:¥568.00'), findsOneWidget); + }); +} diff --git a/apps/user_app/test/ui/withdrawal_page_test.dart b/apps/user_app/test/ui/withdrawal_page_test.dart new file mode 100644 index 0000000..3ee05e7 --- /dev/null +++ b/apps/user_app/test/ui/withdrawal_page_test.dart @@ -0,0 +1,40 @@ +// 功能描述:提现页面真实校验、二次确认和状态语义测试;版本:1.0.0。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/ui/core/app_theme.dart'; +import 'package:user_app/ui/features/wallet/withdrawal_page.dart'; + +import '../support/a1_fixture.dart'; + +void main() { + testWidgets('提现展示真实可提余额并在二次确认后提交', (tester) async { + final repo = A1FixtureRepository(); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: WithdrawalPage(repository: repo), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('¥568.00'), findsOneWidget); + await tester.enterText(find.byKey(const Key('withdrawal-amount')), '50'); + await tester.scrollUntilVisible( + find.text('支付密码验证'), + 260, + scrollable: find.byType(Scrollable).first, + ); + await tester.enterText(find.byKey(const Key('withdrawal-password')), '123456'); + await tester.scrollUntilVisible( + find.text('提交提现申请'), + 260, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(find.text('提交提现申请')); + await tester.pumpAndSettle(); + expect(find.text('确认提现申请'), findsOneWidget); + await tester.tap(find.text('确认提交')); + await tester.pumpAndSettle(); + expect(repo.withdrawalCalls, 1); + expect(find.textContaining('提现申请已提交'), findsOneWidget); + }); +} diff --git a/apps/user_app/tool/a1_visual_test.dart b/apps/user_app/tool/a1_visual_test.dart new file mode 100644 index 0000000..f7bb2ff --- /dev/null +++ b/apps/user_app/tool/a1_visual_test.dart @@ -0,0 +1,197 @@ +// 功能描述:以独立 Fixture 生成 A1 五页截图,验证多宽度与文本缩放无溢出。 +// 版本:1.0.0 +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:user_app/app/dependencies.dart'; +import 'package:user_app/app/router.dart'; +import 'package:user_app/data/services/secure_session_store.dart'; +import 'package:user_app/data/services/repair_draft_store.dart'; +import 'package:user_app/ui/core/app_theme.dart'; +import '../test/support/a1_fixture.dart'; + +/// 仅截图使用的会话,不提供真实令牌或网络凭据。 +class VisualSession extends UserSession { + VisualSession(this.signedIn) : super(SecureSessionStore()); + final bool signedIn; + @override + bool get isAuthenticated => signedIn; +} + +/// 视觉夹具不访问本机持久化,但保留生产页面的草稿入口。 +class VisualDraftStore implements RepairDraftStore { + @override + Future?> read(String owner) async => null; + @override + Future write(String owner, Map draft) async {} + @override + Future delete(String owner) async {} +} + +/// 图片输出为新截图基线,不能单凭生成成功声称匹配设计稿。 +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() async { + // 本文件是由 flutter test 执行的视觉测试,位置在tool目录。 + // ignore: invalid_use_of_visible_for_testing_member + PackageInfo.setMockInitialValues( + appName: '瓶安芯', + packageName: 'user_app', + version: '1.0.0', + buildNumber: '1', + buildSignature: '', + ); + final font = File('C:/Windows/Fonts/msyh.ttc'); + if (await font.exists()) { + final loader = FontLoader('VisualChinese') + ..addFont(Future.value(ByteData.sublistView(await font.readAsBytes()))); + await loader.load(); + // Widget 测试默认使用 Ahem;组件主题中的独立文字样式也需真实字体。 + for (final family in ['Roboto', 'Ahem']) { + final fallback = FontLoader(family) + ..addFont(Future.value(ByteData.sublistView(await font.readAsBytes()))); + await fallback.load(); + } + } + final icons = FontLoader('MaterialIcons') + ..addFont(rootBundle.load('fonts/MaterialIcons-Regular.otf')); + await icons.load(); + }); + for (final (number, path) in [ + ('01', '/login'), + ('03', '/home'), + ('05', '/repair'), + ('11', '/shop'), + ('12', '/products/p1'), + ('13', '/cart'), + ('14', '/cart/checkout'), + ('15', '/favorites'), + ('16', '/deposits'), + ('17', '/deposits/return?deposit=d1'), + ('18', '/gas/order'), + ('19', '/payment/gas/payment-1'), + ('20', '/orders'), + ('20-shop', '/orders?tab=shop'), + ('21', '/shop/orders/order-1'), + ('21-gas', '/gas/orders/gas-1'), + ('22', '/gas/orders/gas-1/delivery'), + ('23', '/gas/orders/gas-1/delivery/track'), + ('24', '/invoice/gas/gas-1'), + ('25', '/me'), + ('26', '/records'), + ('27', '/usage'), + ('28', '/wallet'), + ('29', '/addresses'), + ('30', '/messages'), + ('31', '/settings'), + ('31-payment', '/settings/payment-password'), + ('32', '/records/contracts'), + ('33', '/family'), + ('39', '/wallet/withdraw'), + ('40', '/wallet/banks'), + ('41', '/profile/edit'), + ('42', '/tickets/t1'), + ]) { + for (final width in [320, 360, 390, 430]) { + for (final scale in [1.0, 1.3]) { + testWidgets('$number ${width}px 文本$scale', (tester) async { + // 图30原稿是853×1873双倍像素,390宽归一化后高度约856像素。 + tester.view.physicalSize = Size(width.toDouble(), number == '30' ? 856 : 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final session = VisualSession(path != '/login'); + final router = createRouter( + AppDependencies( + session: session, + repository: A1FixtureRepository(), + repairDraftStore: VisualDraftStore(), + ), + initialLocation: path, + ); + final theme = AppTheme.light(); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + theme: theme.copyWith( + textTheme: theme.textTheme.apply(fontFamily: 'VisualChinese'), + appBarTheme: theme.appBarTheme.copyWith( + titleTextStyle: theme.appBarTheme.titleTextStyle?.copyWith( + fontFamily: 'VisualChinese', + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: theme.filledButtonTheme.style?.copyWith( + textStyle: WidgetStatePropertyAll( + theme.textTheme.labelLarge?.copyWith(fontFamily: 'VisualChinese'), + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: theme.textButtonTheme.style?.copyWith( + textStyle: WidgetStatePropertyAll( + theme.textTheme.labelLarge?.copyWith(fontFamily: 'VisualChinese'), + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: theme.outlinedButtonTheme.style?.copyWith( + textStyle: WidgetStatePropertyAll( + theme.textTheme.labelLarge?.copyWith(fontFamily: 'VisualChinese'), + ), + ), + ), + chipTheme: theme.chipTheme.copyWith( + labelStyle: theme.chipTheme.labelStyle?.copyWith(fontFamily: 'VisualChinese'), + ), + ), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(scale)), + child: RepaintBoundary(key: const ValueKey('capture'), child: child!), + ), + ), + ); + await tester.pumpAndSettle(); + if (number == '17') { + // 产品稿展示用户已逐项确认的状态,视觉样本先完成同样的真实交互。 + final checkboxCount = find.byType(Checkbox).evaluate().length; + for (var index = 0; index < checkboxCount; index++) { + await tester.tap(find.byType(Checkbox).at(index)); + await tester.pump(); + } + } + if (number == '18') { + // 产品稿默认选择15kg一只;通过真实“增加”按钮进入相同视觉状态。 + await tester.tap(find.byTooltip('增加').last); + await tester.pumpAndSettle(); + } + if (number == '39') { + await tester.enterText(find.byKey(const Key('withdrawal-amount')), '300'); + await tester.pumpAndSettle(); + } + expect(tester.takeException(), isNull); + await expectLater( + find.byKey(const ValueKey('capture')), + matchesGoldenFile( + Uri.file( + '${Directory.current.path}/../../docs/视觉验收/A1/${number}_${width}_$scale.png', + ), + ), + ); + // 验证滚动到底部后仍无文字、按钮或导航溢出。 + final list = find.byType(Scrollable).first; + if (list.evaluate().isNotEmpty) { + await tester.drag(list, const Offset(0, -1600)); + await tester.pumpAndSettle(); + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + router.dispose(); + session.dispose(); + }); + } + } + } +} diff --git a/apps/user_app/tool/failures/14_320_1.0_isolatedDiff.png b/apps/user_app/tool/failures/14_320_1.0_isolatedDiff.png new file mode 100644 index 0000000..02e9533 Binary files /dev/null and b/apps/user_app/tool/failures/14_320_1.0_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/14_320_1.0_maskedDiff.png b/apps/user_app/tool/failures/14_320_1.0_maskedDiff.png new file mode 100644 index 0000000..054fbe2 Binary files /dev/null and b/apps/user_app/tool/failures/14_320_1.0_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/14_320_1.0_masterImage.png b/apps/user_app/tool/failures/14_320_1.0_masterImage.png new file mode 100644 index 0000000..5dba0d5 Binary files /dev/null and b/apps/user_app/tool/failures/14_320_1.0_masterImage.png differ diff --git a/apps/user_app/tool/failures/14_320_1.0_testImage.png b/apps/user_app/tool/failures/14_320_1.0_testImage.png new file mode 100644 index 0000000..f8c49d3 Binary files /dev/null and b/apps/user_app/tool/failures/14_320_1.0_testImage.png differ diff --git a/apps/user_app/tool/failures/14_320_1.3_isolatedDiff.png b/apps/user_app/tool/failures/14_320_1.3_isolatedDiff.png new file mode 100644 index 0000000..02e9533 Binary files /dev/null and b/apps/user_app/tool/failures/14_320_1.3_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/14_320_1.3_maskedDiff.png b/apps/user_app/tool/failures/14_320_1.3_maskedDiff.png new file mode 100644 index 0000000..0cea954 Binary files /dev/null and b/apps/user_app/tool/failures/14_320_1.3_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/14_320_1.3_masterImage.png b/apps/user_app/tool/failures/14_320_1.3_masterImage.png new file mode 100644 index 0000000..1802d0f Binary files /dev/null and b/apps/user_app/tool/failures/14_320_1.3_masterImage.png differ diff --git a/apps/user_app/tool/failures/14_320_1.3_testImage.png b/apps/user_app/tool/failures/14_320_1.3_testImage.png new file mode 100644 index 0000000..e3e0278 Binary files /dev/null and b/apps/user_app/tool/failures/14_320_1.3_testImage.png differ diff --git a/apps/user_app/tool/failures/14_360_1.0_isolatedDiff.png b/apps/user_app/tool/failures/14_360_1.0_isolatedDiff.png new file mode 100644 index 0000000..f41ff26 Binary files /dev/null and b/apps/user_app/tool/failures/14_360_1.0_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/14_360_1.0_maskedDiff.png b/apps/user_app/tool/failures/14_360_1.0_maskedDiff.png new file mode 100644 index 0000000..108ea32 Binary files /dev/null and b/apps/user_app/tool/failures/14_360_1.0_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/14_360_1.0_masterImage.png b/apps/user_app/tool/failures/14_360_1.0_masterImage.png new file mode 100644 index 0000000..78fbecc Binary files /dev/null and b/apps/user_app/tool/failures/14_360_1.0_masterImage.png differ diff --git a/apps/user_app/tool/failures/14_360_1.0_testImage.png b/apps/user_app/tool/failures/14_360_1.0_testImage.png new file mode 100644 index 0000000..d94863c Binary files /dev/null and b/apps/user_app/tool/failures/14_360_1.0_testImage.png differ diff --git a/apps/user_app/tool/failures/14_360_1.3_isolatedDiff.png b/apps/user_app/tool/failures/14_360_1.3_isolatedDiff.png new file mode 100644 index 0000000..f41ff26 Binary files /dev/null and b/apps/user_app/tool/failures/14_360_1.3_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/14_360_1.3_maskedDiff.png b/apps/user_app/tool/failures/14_360_1.3_maskedDiff.png new file mode 100644 index 0000000..04095fe Binary files /dev/null and b/apps/user_app/tool/failures/14_360_1.3_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/14_360_1.3_masterImage.png b/apps/user_app/tool/failures/14_360_1.3_masterImage.png new file mode 100644 index 0000000..5dc2414 Binary files /dev/null and b/apps/user_app/tool/failures/14_360_1.3_masterImage.png differ diff --git a/apps/user_app/tool/failures/14_360_1.3_testImage.png b/apps/user_app/tool/failures/14_360_1.3_testImage.png new file mode 100644 index 0000000..9e8fe0e Binary files /dev/null and b/apps/user_app/tool/failures/14_360_1.3_testImage.png differ diff --git a/apps/user_app/tool/failures/14_390_1.0_isolatedDiff.png b/apps/user_app/tool/failures/14_390_1.0_isolatedDiff.png new file mode 100644 index 0000000..b0174ac Binary files /dev/null and b/apps/user_app/tool/failures/14_390_1.0_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/14_390_1.0_maskedDiff.png b/apps/user_app/tool/failures/14_390_1.0_maskedDiff.png new file mode 100644 index 0000000..903be1f Binary files /dev/null and b/apps/user_app/tool/failures/14_390_1.0_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/14_390_1.0_masterImage.png b/apps/user_app/tool/failures/14_390_1.0_masterImage.png new file mode 100644 index 0000000..5cf91e1 Binary files /dev/null and b/apps/user_app/tool/failures/14_390_1.0_masterImage.png differ diff --git a/apps/user_app/tool/failures/14_390_1.0_testImage.png b/apps/user_app/tool/failures/14_390_1.0_testImage.png new file mode 100644 index 0000000..82a181e Binary files /dev/null and b/apps/user_app/tool/failures/14_390_1.0_testImage.png differ diff --git a/apps/user_app/tool/failures/14_390_1.3_isolatedDiff.png b/apps/user_app/tool/failures/14_390_1.3_isolatedDiff.png new file mode 100644 index 0000000..b0174ac Binary files /dev/null and b/apps/user_app/tool/failures/14_390_1.3_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/14_390_1.3_maskedDiff.png b/apps/user_app/tool/failures/14_390_1.3_maskedDiff.png new file mode 100644 index 0000000..c588a75 Binary files /dev/null and b/apps/user_app/tool/failures/14_390_1.3_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/14_390_1.3_masterImage.png b/apps/user_app/tool/failures/14_390_1.3_masterImage.png new file mode 100644 index 0000000..7459787 Binary files /dev/null and b/apps/user_app/tool/failures/14_390_1.3_masterImage.png differ diff --git a/apps/user_app/tool/failures/14_390_1.3_testImage.png b/apps/user_app/tool/failures/14_390_1.3_testImage.png new file mode 100644 index 0000000..69b24e7 Binary files /dev/null and b/apps/user_app/tool/failures/14_390_1.3_testImage.png differ diff --git a/apps/user_app/tool/failures/14_430_1.0_isolatedDiff.png b/apps/user_app/tool/failures/14_430_1.0_isolatedDiff.png new file mode 100644 index 0000000..e99db04 Binary files /dev/null and b/apps/user_app/tool/failures/14_430_1.0_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/14_430_1.0_maskedDiff.png b/apps/user_app/tool/failures/14_430_1.0_maskedDiff.png new file mode 100644 index 0000000..6043c30 Binary files /dev/null and b/apps/user_app/tool/failures/14_430_1.0_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/14_430_1.0_masterImage.png b/apps/user_app/tool/failures/14_430_1.0_masterImage.png new file mode 100644 index 0000000..484bc9c Binary files /dev/null and b/apps/user_app/tool/failures/14_430_1.0_masterImage.png differ diff --git a/apps/user_app/tool/failures/14_430_1.0_testImage.png b/apps/user_app/tool/failures/14_430_1.0_testImage.png new file mode 100644 index 0000000..6752ea5 Binary files /dev/null and b/apps/user_app/tool/failures/14_430_1.0_testImage.png differ diff --git a/apps/user_app/tool/failures/14_430_1.3_isolatedDiff.png b/apps/user_app/tool/failures/14_430_1.3_isolatedDiff.png new file mode 100644 index 0000000..e99db04 Binary files /dev/null and b/apps/user_app/tool/failures/14_430_1.3_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/14_430_1.3_maskedDiff.png b/apps/user_app/tool/failures/14_430_1.3_maskedDiff.png new file mode 100644 index 0000000..b78137c Binary files /dev/null and b/apps/user_app/tool/failures/14_430_1.3_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/14_430_1.3_masterImage.png b/apps/user_app/tool/failures/14_430_1.3_masterImage.png new file mode 100644 index 0000000..7794d66 Binary files /dev/null and b/apps/user_app/tool/failures/14_430_1.3_masterImage.png differ diff --git a/apps/user_app/tool/failures/14_430_1.3_testImage.png b/apps/user_app/tool/failures/14_430_1.3_testImage.png new file mode 100644 index 0000000..37f20f7 Binary files /dev/null and b/apps/user_app/tool/failures/14_430_1.3_testImage.png differ diff --git a/apps/user_app/tool/failures/21-gas_320_1.0_isolatedDiff.png b/apps/user_app/tool/failures/21-gas_320_1.0_isolatedDiff.png new file mode 100644 index 0000000..a35c052 Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_320_1.0_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/21-gas_320_1.0_maskedDiff.png b/apps/user_app/tool/failures/21-gas_320_1.0_maskedDiff.png new file mode 100644 index 0000000..aabe181 Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_320_1.0_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/21-gas_320_1.0_masterImage.png b/apps/user_app/tool/failures/21-gas_320_1.0_masterImage.png new file mode 100644 index 0000000..1e7b1a3 Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_320_1.0_masterImage.png differ diff --git a/apps/user_app/tool/failures/21-gas_320_1.0_testImage.png b/apps/user_app/tool/failures/21-gas_320_1.0_testImage.png new file mode 100644 index 0000000..b4aab66 Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_320_1.0_testImage.png differ diff --git a/apps/user_app/tool/failures/21-gas_320_1.3_isolatedDiff.png b/apps/user_app/tool/failures/21-gas_320_1.3_isolatedDiff.png new file mode 100644 index 0000000..41fea7b Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_320_1.3_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/21-gas_320_1.3_maskedDiff.png b/apps/user_app/tool/failures/21-gas_320_1.3_maskedDiff.png new file mode 100644 index 0000000..600763e Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_320_1.3_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/21-gas_320_1.3_masterImage.png b/apps/user_app/tool/failures/21-gas_320_1.3_masterImage.png new file mode 100644 index 0000000..383e694 Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_320_1.3_masterImage.png differ diff --git a/apps/user_app/tool/failures/21-gas_320_1.3_testImage.png b/apps/user_app/tool/failures/21-gas_320_1.3_testImage.png new file mode 100644 index 0000000..ba3de62 Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_320_1.3_testImage.png differ diff --git a/apps/user_app/tool/failures/21-gas_360_1.0_isolatedDiff.png b/apps/user_app/tool/failures/21-gas_360_1.0_isolatedDiff.png new file mode 100644 index 0000000..043acf1 Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_360_1.0_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/21-gas_360_1.0_maskedDiff.png b/apps/user_app/tool/failures/21-gas_360_1.0_maskedDiff.png new file mode 100644 index 0000000..eb1961d Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_360_1.0_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/21-gas_360_1.0_masterImage.png b/apps/user_app/tool/failures/21-gas_360_1.0_masterImage.png new file mode 100644 index 0000000..f35167d Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_360_1.0_masterImage.png differ diff --git a/apps/user_app/tool/failures/21-gas_360_1.0_testImage.png b/apps/user_app/tool/failures/21-gas_360_1.0_testImage.png new file mode 100644 index 0000000..73395dc Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_360_1.0_testImage.png differ diff --git a/apps/user_app/tool/failures/21-gas_360_1.3_isolatedDiff.png b/apps/user_app/tool/failures/21-gas_360_1.3_isolatedDiff.png new file mode 100644 index 0000000..603868c Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_360_1.3_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/21-gas_360_1.3_maskedDiff.png b/apps/user_app/tool/failures/21-gas_360_1.3_maskedDiff.png new file mode 100644 index 0000000..49729d2 Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_360_1.3_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/21-gas_360_1.3_masterImage.png b/apps/user_app/tool/failures/21-gas_360_1.3_masterImage.png new file mode 100644 index 0000000..a85b7ab Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_360_1.3_masterImage.png differ diff --git a/apps/user_app/tool/failures/21-gas_360_1.3_testImage.png b/apps/user_app/tool/failures/21-gas_360_1.3_testImage.png new file mode 100644 index 0000000..1065655 Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_360_1.3_testImage.png differ diff --git a/apps/user_app/tool/failures/21-gas_390_1.0_isolatedDiff.png b/apps/user_app/tool/failures/21-gas_390_1.0_isolatedDiff.png new file mode 100644 index 0000000..59fcf4d Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_390_1.0_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/21-gas_390_1.0_maskedDiff.png b/apps/user_app/tool/failures/21-gas_390_1.0_maskedDiff.png new file mode 100644 index 0000000..fea313e Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_390_1.0_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/21-gas_390_1.0_masterImage.png b/apps/user_app/tool/failures/21-gas_390_1.0_masterImage.png new file mode 100644 index 0000000..82e213f Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_390_1.0_masterImage.png differ diff --git a/apps/user_app/tool/failures/21-gas_390_1.0_testImage.png b/apps/user_app/tool/failures/21-gas_390_1.0_testImage.png new file mode 100644 index 0000000..4030d3e Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_390_1.0_testImage.png differ diff --git a/apps/user_app/tool/failures/21-gas_390_1.3_isolatedDiff.png b/apps/user_app/tool/failures/21-gas_390_1.3_isolatedDiff.png new file mode 100644 index 0000000..50cba0c Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_390_1.3_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/21-gas_390_1.3_maskedDiff.png b/apps/user_app/tool/failures/21-gas_390_1.3_maskedDiff.png new file mode 100644 index 0000000..f6e4333 Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_390_1.3_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/21-gas_390_1.3_masterImage.png b/apps/user_app/tool/failures/21-gas_390_1.3_masterImage.png new file mode 100644 index 0000000..7030310 Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_390_1.3_masterImage.png differ diff --git a/apps/user_app/tool/failures/21-gas_390_1.3_testImage.png b/apps/user_app/tool/failures/21-gas_390_1.3_testImage.png new file mode 100644 index 0000000..943b7e5 Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_390_1.3_testImage.png differ diff --git a/apps/user_app/tool/failures/21-gas_430_1.0_isolatedDiff.png b/apps/user_app/tool/failures/21-gas_430_1.0_isolatedDiff.png new file mode 100644 index 0000000..29a8053 Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_430_1.0_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/21-gas_430_1.0_maskedDiff.png b/apps/user_app/tool/failures/21-gas_430_1.0_maskedDiff.png new file mode 100644 index 0000000..6ba1eb9 Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_430_1.0_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/21-gas_430_1.0_masterImage.png b/apps/user_app/tool/failures/21-gas_430_1.0_masterImage.png new file mode 100644 index 0000000..518e661 Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_430_1.0_masterImage.png differ diff --git a/apps/user_app/tool/failures/21-gas_430_1.0_testImage.png b/apps/user_app/tool/failures/21-gas_430_1.0_testImage.png new file mode 100644 index 0000000..224f2e1 Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_430_1.0_testImage.png differ diff --git a/apps/user_app/tool/failures/21-gas_430_1.3_isolatedDiff.png b/apps/user_app/tool/failures/21-gas_430_1.3_isolatedDiff.png new file mode 100644 index 0000000..7b9bcb2 Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_430_1.3_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/21-gas_430_1.3_maskedDiff.png b/apps/user_app/tool/failures/21-gas_430_1.3_maskedDiff.png new file mode 100644 index 0000000..25a81b6 Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_430_1.3_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/21-gas_430_1.3_masterImage.png b/apps/user_app/tool/failures/21-gas_430_1.3_masterImage.png new file mode 100644 index 0000000..6fe8f04 Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_430_1.3_masterImage.png differ diff --git a/apps/user_app/tool/failures/21-gas_430_1.3_testImage.png b/apps/user_app/tool/failures/21-gas_430_1.3_testImage.png new file mode 100644 index 0000000..6832988 Binary files /dev/null and b/apps/user_app/tool/failures/21-gas_430_1.3_testImage.png differ diff --git a/apps/user_app/tool/failures/41_320_1.0_isolatedDiff.png b/apps/user_app/tool/failures/41_320_1.0_isolatedDiff.png new file mode 100644 index 0000000..bec0bfd Binary files /dev/null and b/apps/user_app/tool/failures/41_320_1.0_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/41_320_1.0_maskedDiff.png b/apps/user_app/tool/failures/41_320_1.0_maskedDiff.png new file mode 100644 index 0000000..1e8edab Binary files /dev/null and b/apps/user_app/tool/failures/41_320_1.0_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/41_320_1.0_masterImage.png b/apps/user_app/tool/failures/41_320_1.0_masterImage.png new file mode 100644 index 0000000..f3dae0e Binary files /dev/null and b/apps/user_app/tool/failures/41_320_1.0_masterImage.png differ diff --git a/apps/user_app/tool/failures/41_320_1.0_testImage.png b/apps/user_app/tool/failures/41_320_1.0_testImage.png new file mode 100644 index 0000000..ad464d2 Binary files /dev/null and b/apps/user_app/tool/failures/41_320_1.0_testImage.png differ diff --git a/apps/user_app/tool/failures/41_360_1.0_isolatedDiff.png b/apps/user_app/tool/failures/41_360_1.0_isolatedDiff.png new file mode 100644 index 0000000..1aa7a58 Binary files /dev/null and b/apps/user_app/tool/failures/41_360_1.0_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/41_360_1.0_maskedDiff.png b/apps/user_app/tool/failures/41_360_1.0_maskedDiff.png new file mode 100644 index 0000000..af6cc79 Binary files /dev/null and b/apps/user_app/tool/failures/41_360_1.0_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/41_360_1.0_masterImage.png b/apps/user_app/tool/failures/41_360_1.0_masterImage.png new file mode 100644 index 0000000..4815f1c Binary files /dev/null and b/apps/user_app/tool/failures/41_360_1.0_masterImage.png differ diff --git a/apps/user_app/tool/failures/41_360_1.0_testImage.png b/apps/user_app/tool/failures/41_360_1.0_testImage.png new file mode 100644 index 0000000..75d5063 Binary files /dev/null and b/apps/user_app/tool/failures/41_360_1.0_testImage.png differ diff --git a/apps/user_app/tool/failures/41_390_1.0_isolatedDiff.png b/apps/user_app/tool/failures/41_390_1.0_isolatedDiff.png new file mode 100644 index 0000000..149531b Binary files /dev/null and b/apps/user_app/tool/failures/41_390_1.0_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/41_390_1.0_maskedDiff.png b/apps/user_app/tool/failures/41_390_1.0_maskedDiff.png new file mode 100644 index 0000000..770b948 Binary files /dev/null and b/apps/user_app/tool/failures/41_390_1.0_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/41_390_1.0_masterImage.png b/apps/user_app/tool/failures/41_390_1.0_masterImage.png new file mode 100644 index 0000000..aa03ffd Binary files /dev/null and b/apps/user_app/tool/failures/41_390_1.0_masterImage.png differ diff --git a/apps/user_app/tool/failures/41_390_1.0_testImage.png b/apps/user_app/tool/failures/41_390_1.0_testImage.png new file mode 100644 index 0000000..b147877 Binary files /dev/null and b/apps/user_app/tool/failures/41_390_1.0_testImage.png differ diff --git a/apps/user_app/tool/failures/41_390_1.3_isolatedDiff.png b/apps/user_app/tool/failures/41_390_1.3_isolatedDiff.png new file mode 100644 index 0000000..699765a Binary files /dev/null and b/apps/user_app/tool/failures/41_390_1.3_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/41_390_1.3_maskedDiff.png b/apps/user_app/tool/failures/41_390_1.3_maskedDiff.png new file mode 100644 index 0000000..1562c50 Binary files /dev/null and b/apps/user_app/tool/failures/41_390_1.3_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/41_390_1.3_masterImage.png b/apps/user_app/tool/failures/41_390_1.3_masterImage.png new file mode 100644 index 0000000..939577c Binary files /dev/null and b/apps/user_app/tool/failures/41_390_1.3_masterImage.png differ diff --git a/apps/user_app/tool/failures/41_390_1.3_testImage.png b/apps/user_app/tool/failures/41_390_1.3_testImage.png new file mode 100644 index 0000000..55ff11f Binary files /dev/null and b/apps/user_app/tool/failures/41_390_1.3_testImage.png differ diff --git a/apps/user_app/tool/failures/41_430_1.0_isolatedDiff.png b/apps/user_app/tool/failures/41_430_1.0_isolatedDiff.png new file mode 100644 index 0000000..9460127 Binary files /dev/null and b/apps/user_app/tool/failures/41_430_1.0_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/41_430_1.0_maskedDiff.png b/apps/user_app/tool/failures/41_430_1.0_maskedDiff.png new file mode 100644 index 0000000..7ecb921 Binary files /dev/null and b/apps/user_app/tool/failures/41_430_1.0_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/41_430_1.0_masterImage.png b/apps/user_app/tool/failures/41_430_1.0_masterImage.png new file mode 100644 index 0000000..fcd54f2 Binary files /dev/null and b/apps/user_app/tool/failures/41_430_1.0_masterImage.png differ diff --git a/apps/user_app/tool/failures/41_430_1.0_testImage.png b/apps/user_app/tool/failures/41_430_1.0_testImage.png new file mode 100644 index 0000000..3c9c066 Binary files /dev/null and b/apps/user_app/tool/failures/41_430_1.0_testImage.png differ diff --git a/apps/user_app/tool/failures/41_430_1.3_isolatedDiff.png b/apps/user_app/tool/failures/41_430_1.3_isolatedDiff.png new file mode 100644 index 0000000..3fb02b2 Binary files /dev/null and b/apps/user_app/tool/failures/41_430_1.3_isolatedDiff.png differ diff --git a/apps/user_app/tool/failures/41_430_1.3_maskedDiff.png b/apps/user_app/tool/failures/41_430_1.3_maskedDiff.png new file mode 100644 index 0000000..d0b776a Binary files /dev/null and b/apps/user_app/tool/failures/41_430_1.3_maskedDiff.png differ diff --git a/apps/user_app/tool/failures/41_430_1.3_masterImage.png b/apps/user_app/tool/failures/41_430_1.3_masterImage.png new file mode 100644 index 0000000..e617160 Binary files /dev/null and b/apps/user_app/tool/failures/41_430_1.3_masterImage.png differ diff --git a/apps/user_app/tool/failures/41_430_1.3_testImage.png b/apps/user_app/tool/failures/41_430_1.3_testImage.png new file mode 100644 index 0000000..ca1f5b7 Binary files /dev/null and b/apps/user_app/tool/failures/41_430_1.3_testImage.png differ diff --git a/backend/api/cmd/cli/address_migration.go b/backend/api/cmd/cli/address_migration.go new file mode 100644 index 0000000..eb5dd8c --- /dev/null +++ b/backend/api/cmd/cli/address_migration.go @@ -0,0 +1,65 @@ +// 功能描述:只扩展用户地址的联系人和幂等字段,不执行全库迁移或初始化。 +// 版本:1.0.0。 +package main + +import ( + "fmt" + "git.apinb.com/bsm-sdk/core/database" + dbsql "git.apinb.com/bsm-sdk/core/database/sql" + "git.apinb.com/heqiapp/platforms/backend/api/internal/config" + "gorm.io/gorm" +) + +// migrateUserAddress 使用当前环境的已有数据库配置,事务失败全部回滚。 +func migrateUserAddress() error { + return migrateClientColumns([]string{ + `ALTER TABLE user_address ADD COLUMN IF NOT EXISTS contact_name varchar(64) NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS contact_phone varchar(32) NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS request_no varchar(64) NOT NULL DEFAULT ''`, + `COMMENT ON COLUMN user_address.contact_name IS '收货联系人姓名;历史记录为空,编辑时补齐'`, + `COMMENT ON COLUMN user_address.contact_phone IS '收货联系人手机号;历史记录为空,编辑时补齐'`, + `COMMENT ON COLUMN user_address.request_no IS '客户端新增请求幂等标识,同一用户重试不重复新增,历史记录为空'`, + }) +} + +// migrateTicketContact 仅补齐报修故障分类和提交时的联系人快照。 +func migrateTicketContact() error { + return migrateClientColumns([]string{ + `ALTER TABLE cs_ticket ADD COLUMN IF NOT EXISTS fault_type varchar(32) NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS contact_name varchar(64) NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS contact_phone varchar(32) NOT NULL DEFAULT ''`, + `COMMENT ON COLUMN cs_ticket.fault_type IS '故障类型:leak=燃气泄漏,valve=阀门故障,alarm=报警器故障,other=其他;历史记录为空'`, + `COMMENT ON COLUMN cs_ticket.contact_name IS '用户提交工单时的联系人姓名快照,历史记录为空'`, + `COMMENT ON COLUMN cs_ticket.contact_phone IS '用户提交工单时的联系电话快照,历史记录为空'`, + }) +} + +// migrateTicketContract 只增加可选合同关联,旧工单保持0且不改变原处理规则。 +func migrateTicketContract() error { + return migrateClientColumns([]string{ + `ALTER TABLE cs_ticket ADD COLUMN IF NOT EXISTS gasorder_contract_id bigint NOT NULL DEFAULT 0`, + `COMMENT ON COLUMN cs_ticket.gasorder_contract_id IS '关联供气合同内部主键;0=非合同申请;非0由本人合同校验后写入,不代表合同已修改'`, + `CREATE INDEX IF NOT EXISTS idx_cs_ticket_gasorder_contract_id ON cs_ticket(gasorder_contract_id)`, + }) +} + +// migrateClientColumns 在已有远程配置中执行明确字段扩展,锁超时或失败全部回滚。 +func migrateClientColumns(statements []string) error { + config.New(serviceKey) + if config.Spec.Databases == nil { + return fmt.Errorf("database configuration required") + } + db, err := database.NewDatabase(config.Spec.Databases.Driver, config.Spec.Databases.Source, dbsql.SetOptions(nil)) + if err != nil { + return fmt.Errorf("connect configured database failed") + } + sqlDB, err := db.DB() + if err != nil { + return err + } + defer sqlDB.Close() + return db.Transaction(func(tx *gorm.DB) error { + for _, statement := range append([]string{`SET LOCAL lock_timeout = '5s'`}, statements...) { + if err := tx.Exec(statement).Error; err != nil { + return err + } + } + return nil + }) +} diff --git a/backend/api/cmd/cli/deposit_migration.go b/backend/api/cmd/cli/deposit_migration.go new file mode 100644 index 0000000..74cecce --- /dev/null +++ b/backend/api/cmd/cli/deposit_migration.go @@ -0,0 +1,93 @@ +// 功能描述:仅新增押金规则、押金记录及完整中文数据库注释;版本:1.0.0。 +package main + +// migrateDeposit 创建首期押金事实表,不写入规则、金额或用户业务数据。 +func migrateDeposit() error { + return migrateClientColumns([]string{ + `CREATE TABLE IF NOT EXISTS deposit_policy ( + id bigserial PRIMARY KEY, identity varchar(36) NOT NULL UNIQUE, + created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, deleted_at timestamptz, + status integer NOT NULL DEFAULT 1 CHECK (status IN (1,2,3)), + product_type_id bigint NOT NULL REFERENCES product_type(id), name varchar(128) NOT NULL, + amount bigint NOT NULL CHECK (amount >= 0), rule_text text NOT NULL DEFAULT '', + CONSTRAINT ux_deposit_policy_product_type UNIQUE (product_type_id) + )`, + `CREATE TABLE IF NOT EXISTS deposit_record ( + id bigserial PRIMARY KEY, identity varchar(36) NOT NULL UNIQUE, + created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, deleted_at timestamptz, + status integer NOT NULL DEFAULT 1 CHECK (status IN (1,3)), + deposit_status integer NOT NULL DEFAULT 10 CHECK (deposit_status IN (10,20,23)), + deposit_no varchar(64) NOT NULL UNIQUE, user_account_id bigint NOT NULL REFERENCES user_account(id), + product_info_id bigint NOT NULL REFERENCES product_info(id), policy_id bigint NOT NULL REFERENCES deposit_policy(id), + gasorder_id bigint NOT NULL DEFAULT 0, amount bigint NOT NULL CHECK (amount >= 0), + paid_at timestamptz NOT NULL, refunded_at timestamptz + )`, + `CREATE TABLE IF NOT EXISTS deposit_return_request ( + id bigserial PRIMARY KEY, identity varchar(36) NOT NULL UNIQUE, + created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, deleted_at timestamptz, + status integer NOT NULL DEFAULT 1 CHECK (status IN (1,3)), + return_status integer NOT NULL DEFAULT 10 CHECK (return_status IN (10,20,30,40,50)), + request_no varchar(128) NOT NULL UNIQUE, deposit_record_id bigint NOT NULL UNIQUE REFERENCES deposit_record(id), + user_account_id bigint NOT NULL REFERENCES user_account(id), user_address_id bigint NOT NULL REFERENCES user_address(id), + address_snapshot varchar(255) NOT NULL, contact_name varchar(64) NOT NULL, contact_phone varchar(32) NOT NULL, + appointment_start timestamptz NOT NULL, appointment_end timestamptz NOT NULL, + estimated_amount bigint NOT NULL CHECK (estimated_amount >= 0), + deduction_amount bigint NOT NULL DEFAULT 0 CHECK (deduction_amount >= 0), + refund_amount bigint NOT NULL DEFAULT 0 CHECK (refund_amount >= 0), + inspection_remark text NOT NULL DEFAULT '', operator_identity varchar(36) NOT NULL DEFAULT '', + picked_up_at timestamptz, inspected_at timestamptz, completed_at timestamptz + )`, + `COMMENT ON TABLE deposit_policy IS '气瓶产品类型押金规则;金额与退还说明由平台运营配置'`, + `COMMENT ON COLUMN deposit_policy.id IS '押金规则内部自增主键,不向客户端暴露'`, + `COMMENT ON COLUMN deposit_policy.identity IS '押金规则公开UUID V7标识'`, + `COMMENT ON COLUMN deposit_policy.created_at IS '规则创建时间'`, + `COMMENT ON COLUMN deposit_policy.updated_at IS '规则最后更新时间'`, + `COMMENT ON COLUMN deposit_policy.deleted_at IS '软删除时间'`, + `COMMENT ON COLUMN deposit_policy.status IS '通用状态:1=启用,2=停用,3=归档'`, + `COMMENT ON COLUMN deposit_policy.product_type_id IS '适用气瓶产品类型内部主键'`, + `COMMENT ON COLUMN deposit_policy.name IS '押金规则名称'`, + `COMMENT ON COLUMN deposit_policy.amount IS '单只气瓶押金金额,单位分'`, + `COMMENT ON COLUMN deposit_policy.rule_text IS '用户端展示的押金退还规则正文'`, + `COMMENT ON TABLE deposit_record IS '用户每只实体气瓶的押金收取及退回事实'`, + `COMMENT ON COLUMN deposit_record.id IS '押金记录内部自增主键,不向客户端暴露'`, + `COMMENT ON COLUMN deposit_record.identity IS '押金记录公开UUID V7标识'`, + `COMMENT ON COLUMN deposit_record.created_at IS '押金记录创建时间'`, + `COMMENT ON COLUMN deposit_record.updated_at IS '押金状态最后更新时间'`, + `COMMENT ON COLUMN deposit_record.deleted_at IS '软删除时间;资金事实通常不删除'`, + `COMMENT ON COLUMN deposit_record.status IS '通用状态:1=有效,3=归档'`, + `COMMENT ON COLUMN deposit_record.deposit_status IS '押金状态:10=使用中,20=退款中,23=已退回'`, + `COMMENT ON COLUMN deposit_record.deposit_no IS '押金业务编号'`, + `COMMENT ON COLUMN deposit_record.user_account_id IS '押金所属用户内部主键'`, + `COMMENT ON COLUMN deposit_record.product_info_id IS '绑定实体气瓶内部主键'`, + `COMMENT ON COLUMN deposit_record.policy_id IS '计费时采用的押金规则内部主键'`, + `COMMENT ON COLUMN deposit_record.gasorder_id IS '来源供气订单内部主键,0表示历史导入'`, + `COMMENT ON COLUMN deposit_record.amount IS '实收押金金额,单位分'`, + `COMMENT ON COLUMN deposit_record.paid_at IS '押金缴纳时间'`, + `COMMENT ON COLUMN deposit_record.refunded_at IS '押金实际退回时间'`, + `COMMENT ON TABLE deposit_return_request IS '退瓶上门回收、验收扣减及押金入账申请表'`, + `COMMENT ON COLUMN deposit_return_request.id IS '退瓶申请内部自增主键'`, + `COMMENT ON COLUMN deposit_return_request.identity IS '退瓶申请公开UUID V7标识'`, + `COMMENT ON COLUMN deposit_return_request.created_at IS '申请创建时间'`, + `COMMENT ON COLUMN deposit_return_request.updated_at IS '状态最后更新时间'`, + `COMMENT ON COLUMN deposit_return_request.deleted_at IS '软删除时间;完成资金事实不删除'`, + `COMMENT ON COLUMN deposit_return_request.status IS '通用状态:1=有效,3=归档'`, + `COMMENT ON COLUMN deposit_return_request.return_status IS '退瓶状态:10=待上门,20=已回收待验收,30=已验收待退款,40=已完成,50=已取消'`, + `COMMENT ON COLUMN deposit_return_request.request_no IS '用户端幂等请求号'`, + `COMMENT ON COLUMN deposit_return_request.deposit_record_id IS '对应押金记录内部主键'`, + `COMMENT ON COLUMN deposit_return_request.user_account_id IS '申请用户内部主键'`, + `COMMENT ON COLUMN deposit_return_request.user_address_id IS '上门回收地址内部主键'`, + `COMMENT ON COLUMN deposit_return_request.address_snapshot IS '申请时上门地址快照'`, + `COMMENT ON COLUMN deposit_return_request.contact_name IS '申请时联系人快照'`, + `COMMENT ON COLUMN deposit_return_request.contact_phone IS '申请时联系手机快照'`, + `COMMENT ON COLUMN deposit_return_request.appointment_start IS '预约上门时段开始时间'`, + `COMMENT ON COLUMN deposit_return_request.appointment_end IS '预约上门时段结束时间'`, + `COMMENT ON COLUMN deposit_return_request.estimated_amount IS '申请时预计可退押金,单位分'`, + `COMMENT ON COLUMN deposit_return_request.deduction_amount IS '验收后损坏或缺件扣减,单位分'`, + `COMMENT ON COLUMN deposit_return_request.refund_amount IS '最终退入余额账户金额,单位分'`, + `COMMENT ON COLUMN deposit_return_request.inspection_remark IS '验收结果及扣减理由'`, + `COMMENT ON COLUMN deposit_return_request.operator_identity IS '最后操作的平台账号标识'`, + `COMMENT ON COLUMN deposit_return_request.picked_up_at IS '实际回收时间'`, + `COMMENT ON COLUMN deposit_return_request.inspected_at IS '完成验收时间'`, + `COMMENT ON COLUMN deposit_return_request.completed_at IS '押金退回完成时间'`, + }) +} diff --git a/backend/api/cmd/cli/device_group_migration.go b/backend/api/cmd/cli/device_group_migration.go new file mode 100644 index 0000000..402aaee --- /dev/null +++ b/backend/api/cmd/cli/device_group_migration.go @@ -0,0 +1,37 @@ +// 功能:独立创建用户设备分组表及产品归组字段,不迁移其他业务表;版本:1.0.0。 +package main + +// migrateDeviceGroups 创建分组资料并补充产品归组字段,重复执行不清空现有数据。 +func migrateDeviceGroups() error { + return migrateClientColumns([]string{ + `CREATE TABLE IF NOT EXISTS user_device_group ( + id bigserial PRIMARY KEY, + identity varchar(36) NOT NULL UNIQUE, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + deleted_at timestamptz, + status integer NOT NULL DEFAULT 1 CHECK (status IN (1,3)), + user_account_id bigint NOT NULL REFERENCES user_account(id), + name varchar(32) NOT NULL, + request_no varchar(36) NOT NULL, + sort_no integer NOT NULL DEFAULT 0, + UNIQUE(user_account_id, request_no) + )`, + `COMMENT ON TABLE user_device_group IS '用户本人设备展示分组;不授予设备控制、共享或物联网权限'`, + `COMMENT ON COLUMN user_device_group.id IS '数据库自增内部主键'`, + `COMMENT ON COLUMN user_device_group.identity IS '对外业务UUID标识'`, + `COMMENT ON COLUMN user_device_group.created_at IS '分组创建时间'`, + `COMMENT ON COLUMN user_device_group.updated_at IS '分组最后修改时间'`, + `COMMENT ON COLUMN user_device_group.deleted_at IS '软删除时间;当前删除操作使用状态归档'`, + `COMMENT ON COLUMN user_device_group.status IS '状态:1=启用,3=已归档'`, + `COMMENT ON COLUMN user_device_group.user_account_id IS '分组所属用户内部主键,仅本人可读写'`, + `COMMENT ON COLUMN user_device_group.name IS '用户自定义分组名称,最多32字'`, + `COMMENT ON COLUMN user_device_group.request_no IS '客户端新增请求UUID,同一用户内唯一且归档后不复用'`, + `COMMENT ON COLUMN user_device_group.sort_no IS '同一用户内显示顺序,数值越小越靠前'`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_device_group_active_name ON user_device_group(user_account_id,name) WHERE status=1 AND deleted_at IS NULL`, + `CREATE INDEX IF NOT EXISTS idx_device_group_user_sort ON user_device_group(user_account_id,status,sort_no,id)`, + `ALTER TABLE product_info ADD COLUMN IF NOT EXISTS device_group_id bigint NOT NULL DEFAULT 0`, + `COMMENT ON COLUMN product_info.device_group_id IS '当前用户设备分组内部主键;0表示未分组,产品归属变化时必须清空'`, + `CREATE INDEX IF NOT EXISTS idx_product_device_group ON product_info(user_account_id,device_group_id) WHERE device_group_id <> 0`, + }) +} diff --git a/backend/api/cmd/cli/device_mapping_migration.go b/backend/api/cmd/cli/device_mapping_migration.go new file mode 100644 index 0000000..fc500a7 --- /dev/null +++ b/backend/api/cmd/cli/device_mapping_migration.go @@ -0,0 +1,12 @@ +// 功能:只扩展产品分类和厂商映射字段,不猜测或改写既有归属;版本:1.0.0。 +package main + +// migrateDeviceMapping 保留历史档案为unknown,非空厂商编号全局唯一。 +func migrateDeviceMapping() error { + return migrateClientColumns([]string{ + `ALTER TABLE product_info ADD COLUMN IF NOT EXISTS device_kind varchar(16) NOT NULL DEFAULT 'unknown', ADD COLUMN IF NOT EXISTS vendor_device_id varchar(16) NOT NULL DEFAULT ''`, + `COMMENT ON COLUMN product_info.device_kind IS '设备分类:unknown=未分类,valve=智能阀,alarm=报警器,cylinder=钢瓶;后台明确设置,不按产品名称推断'`, + `COMMENT ON COLUMN product_info.vendor_device_id IS '厂商16位十进制设备编号,空表示未建立映射;同一编号仅能关联一份平台档案,不代表在线或可控制'`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_product_vendor_device_id ON product_info(vendor_device_id) WHERE vendor_device_id <> ''`, + }) +} diff --git a/backend/api/cmd/cli/emergency_contact_migration.go b/backend/api/cmd/cli/emergency_contact_migration.go new file mode 100644 index 0000000..86a307d --- /dev/null +++ b/backend/api/cmd/cli/emergency_contact_migration.go @@ -0,0 +1,35 @@ +// 功能:事务创建独立联系人资料表,不迁移其他业务表;版本:1.0.0。 +package main + +// migrateEmergencyContacts 新增联系人表、字段注释和唯一约束,重复执行不清空数据。 +func migrateEmergencyContacts() error { + return migrateClientColumns([]string{ + `CREATE TABLE IF NOT EXISTS user_emergency_contact ( + id bigserial PRIMARY KEY, + identity varchar(36) NOT NULL UNIQUE, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + deleted_at timestamptz, + status integer NOT NULL DEFAULT 1 CHECK (status IN (1,3)), + user_account_id bigint NOT NULL REFERENCES user_account(id), + name varchar(64) NOT NULL, + phone varchar(32) NOT NULL, + relationship varchar(32) NOT NULL DEFAULT '', + request_no varchar(36) NOT NULL, + UNIQUE(user_account_id, request_no) + )`, + `COMMENT ON TABLE user_emergency_contact IS '用户本人紧急联系人资料;不代表设备授权或告警订阅已生效'`, + `COMMENT ON COLUMN user_emergency_contact.id IS '数据库自增内部主键'`, + `COMMENT ON COLUMN user_emergency_contact.identity IS '对外业务UUID标识'`, + `COMMENT ON COLUMN user_emergency_contact.created_at IS '创建时间,也用于联系人默认排列顺序'`, + `COMMENT ON COLUMN user_emergency_contact.updated_at IS '最后修改时间'`, + `COMMENT ON COLUMN user_emergency_contact.deleted_at IS '软删除时间;当前删除操作使用状态归档保留幂等记录'`, + `COMMENT ON COLUMN user_emergency_contact.status IS '状态:1=启用,3=已归档'`, + `COMMENT ON COLUMN user_emergency_contact.user_account_id IS '资料所属用户内部主键,仅本人可读写'`, + `COMMENT ON COLUMN user_emergency_contact.name IS '联系人姓名,最多64字'`, + `COMMENT ON COLUMN user_emergency_contact.phone IS '联系人大陆手机号,仅本人接口返回用于编辑及联系'`, + `COMMENT ON COLUMN user_emergency_contact.relationship IS '与用户关系描述,最多32字;空字符串表示未填写'`, + `COMMENT ON COLUMN user_emergency_contact.request_no IS '客户端新增请求UUID,同一用户重试唯一且归档后不复活'`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_emergency_contact_active_phone ON user_emergency_contact(user_account_id,phone) WHERE status=1 AND deleted_at IS NULL`, + }) +} diff --git a/backend/api/cmd/cli/family_migration.go b/backend/api/cmd/cli/family_migration.go new file mode 100644 index 0000000..19b6ee3 --- /dev/null +++ b/backend/api/cmd/cli/family_migration.go @@ -0,0 +1,66 @@ +// 功能:家庭成员与设备共享表的增量迁移;版本:1.0.0。 +package main + +// migrateFamilySharing 只新增家庭共享相关表、约束、索引和中文注释。 +func migrateFamilySharing() error { + return migrateClientColumns([]string{ + `CREATE TABLE IF NOT EXISTS user_family_member ( + id bigserial PRIMARY KEY, identity varchar(36) NOT NULL UNIQUE, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, deleted_at timestamptz, + status integer NOT NULL DEFAULT 1 CHECK (status IN (1,3)), owner_user_account_id bigint NOT NULL REFERENCES user_account(id), member_user_account_id bigint NOT NULL DEFAULT 0, + name varchar(64) NOT NULL, phone varchar(32) NOT NULL, relationship varchar(32) NOT NULL DEFAULT '', invite_status integer NOT NULL DEFAULT 10 CHECK (invite_status IN (10,20,30,40)), + request_no varchar(36) NOT NULL, expires_at timestamptz NOT NULL, accepted_at timestamptz, revoked_at timestamptz, + UNIQUE(owner_user_account_id, request_no))`, + `COMMENT ON TABLE user_family_member IS '家庭成员邀请与确认关系;紧急联系人不自动成为家庭成员'`, + `COMMENT ON COLUMN user_family_member.id IS '数据库自增内部主键'`, + `COMMENT ON COLUMN user_family_member.identity IS '对外业务UUID标识'`, + `COMMENT ON COLUMN user_family_member.created_at IS '邀请创建时间'`, + `COMMENT ON COLUMN user_family_member.updated_at IS '最后修改时间'`, + `COMMENT ON COLUMN user_family_member.deleted_at IS '软删除时间'`, + `COMMENT ON COLUMN user_family_member.status IS '通用状态:1=启用,3=归档'`, + `COMMENT ON COLUMN user_family_member.owner_user_account_id IS '房主用户内部主键'`, + `COMMENT ON COLUMN user_family_member.member_user_account_id IS '已接受邀请的成员用户内部主键;0=尚未接受'`, + `COMMENT ON COLUMN user_family_member.name IS '房主填写的成员称呼'`, + `COMMENT ON COLUMN user_family_member.phone IS '受邀手机号;接口仅脱敏显示'`, + `COMMENT ON COLUMN user_family_member.relationship IS '与房主关系描述'`, + `COMMENT ON COLUMN user_family_member.invite_status IS '邀请状态:10=待确认,20=已接受,30=已拒绝,40=已撤销'`, + `COMMENT ON COLUMN user_family_member.request_no IS '同一房主邀请请求幂等UUID'`, + `COMMENT ON COLUMN user_family_member.expires_at IS '邀请失效时间'`, + `COMMENT ON COLUMN user_family_member.accepted_at IS '成员接受邀请时间'`, + `COMMENT ON COLUMN user_family_member.revoked_at IS '房主撤销成员时间'`, + `CREATE UNIQUE INDEX IF NOT EXISTS ux_family_active_phone ON user_family_member(owner_user_account_id,phone) WHERE status=1 AND invite_status IN (10,20) AND deleted_at IS NULL`, + `CREATE INDEX IF NOT EXISTS idx_family_invitee ON user_family_member(phone,invite_status,status)`, + `CREATE TABLE IF NOT EXISTS user_device_share ( + id bigserial PRIMARY KEY, identity varchar(36) NOT NULL UNIQUE, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, deleted_at timestamptz, + status integer NOT NULL DEFAULT 1 CHECK (status IN (1,3)), family_member_id bigint NOT NULL REFERENCES user_family_member(id), product_info_id bigint NOT NULL REFERENCES product_info(id), + can_view boolean NOT NULL DEFAULT false, can_alert boolean NOT NULL DEFAULT false, can_control boolean NOT NULL DEFAULT false, UNIQUE(family_member_id,product_info_id))`, + `COMMENT ON TABLE user_device_share IS '家庭成员按设备分配的查看、告警和控制权限'`, + `COMMENT ON COLUMN user_device_share.id IS '数据库自增内部主键'`, + `COMMENT ON COLUMN user_device_share.identity IS '对外业务UUID标识'`, + `COMMENT ON COLUMN user_device_share.created_at IS '授权创建时间'`, + `COMMENT ON COLUMN user_device_share.updated_at IS '授权最后修改时间'`, + `COMMENT ON COLUMN user_device_share.deleted_at IS '软删除时间'`, + `COMMENT ON COLUMN user_device_share.status IS '授权状态:1=启用,3=归档'`, + `COMMENT ON COLUMN user_device_share.family_member_id IS '家庭成员关系内部主键'`, + `COMMENT ON COLUMN user_device_share.product_info_id IS '房主名下设备内部主键'`, + `COMMENT ON COLUMN user_device_share.can_view IS '是否允许查看设备资料和状态'`, + `COMMENT ON COLUMN user_device_share.can_alert IS '是否允许接收危险告警'`, + `COMMENT ON COLUMN user_device_share.can_control IS '是否允许发起远程控制;执行时仍需二次确认'`, + `CREATE TABLE IF NOT EXISTS user_family_audit ( + id bigserial PRIMARY KEY, identity varchar(36) NOT NULL UNIQUE, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, deleted_at timestamptz, + status integer NOT NULL DEFAULT 1, owner_user_account_id bigint NOT NULL REFERENCES user_account(id), family_member_id bigint NOT NULL DEFAULT 0, + actor_user_account_id bigint NOT NULL REFERENCES user_account(id), action varchar(32) NOT NULL, summary varchar(512) NOT NULL DEFAULT '')`, + `COMMENT ON TABLE user_family_audit IS '家庭邀请和设备授权操作审计,只记录必要摘要'`, + `COMMENT ON COLUMN user_family_audit.id IS '数据库自增内部主键'`, + `COMMENT ON COLUMN user_family_audit.identity IS '对外业务UUID标识'`, + `COMMENT ON COLUMN user_family_audit.created_at IS '操作发生时间'`, + `COMMENT ON COLUMN user_family_audit.updated_at IS '记录更新时间'`, + `COMMENT ON COLUMN user_family_audit.deleted_at IS '软删除时间,审计记录正常不删除'`, + `COMMENT ON COLUMN user_family_audit.status IS '记录状态:1=有效'`, + `COMMENT ON COLUMN user_family_audit.owner_user_account_id IS '房主用户内部主键'`, + `COMMENT ON COLUMN user_family_audit.family_member_id IS '关联成员内部主键;0=无成员'`, + `COMMENT ON COLUMN user_family_audit.actor_user_account_id IS '执行操作的用户内部主键'`, + `COMMENT ON COLUMN user_family_audit.action IS '操作类型:invite/accept/reject/update_permissions/revoke'`, + `COMMENT ON COLUMN user_family_audit.summary IS '不含手机号的操作摘要'`, + `CREATE INDEX IF NOT EXISTS idx_family_audit_owner_time ON user_family_audit(owner_user_account_id,created_at DESC)`, + }) +} diff --git a/backend/api/cmd/cli/favorite_migration.go b/backend/api/cmd/cli/favorite_migration.go new file mode 100644 index 0000000..5652be5 --- /dev/null +++ b/backend/api/cmd/cli/favorite_migration.go @@ -0,0 +1,30 @@ +// 功能描述:仅新增电商收藏关系表与中文注释;版本:1.0.0。 +package main + +// migrateEcFavorite 使用既有环境数据库配置,不执行其他表迁移或初始化数据。 +func migrateEcFavorite() error { + return migrateClientColumns([]string{ + `CREATE TABLE IF NOT EXISTS ec_favorite ( + id bigserial PRIMARY KEY, + identity varchar(36) NOT NULL UNIQUE, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + deleted_at timestamptz, + status integer NOT NULL DEFAULT 1 CHECK (status IN (1, 3)), + user_account_id bigint NOT NULL REFERENCES user_account(id), + ec_product_id bigint NOT NULL REFERENCES ec_product(id), + revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0), + CONSTRAINT ux_ec_favorite_owner_product UNIQUE (user_account_id, ec_product_id) + )`, + `COMMENT ON TABLE ec_favorite IS '电商商品收藏关系;每账户每商品唯一,取消后归档,下架商品保留'`, + `COMMENT ON COLUMN ec_favorite.id IS '收藏关系内部自增主键,不向客户端暴露'`, + `COMMENT ON COLUMN ec_favorite.identity IS '收藏关系公开UUID V7标识'`, + `COMMENT ON COLUMN ec_favorite.created_at IS '首次建立收藏关系的服务端时间'`, + `COMMENT ON COLUMN ec_favorite.updated_at IS '最近一次收藏状态变化的服务端时间'`, + `COMMENT ON COLUMN ec_favorite.deleted_at IS '软删除时间;用户取消收藏不写此字段而使用归档状态'`, + `COMMENT ON COLUMN ec_favorite.status IS '收藏状态:1=已收藏,3=已取消并归档'`, + `COMMENT ON COLUMN ec_favorite.user_account_id IS '收藏者账户内部关联键,对应user_account.id'`, + `COMMENT ON COLUMN ec_favorite.ec_product_id IS '收藏商品内部关联键,对应ec_product.id;商品下架不删除关系'`, + `COMMENT ON COLUMN ec_favorite.revision IS '正整数并发版本,首次为1,每次收藏状态变化递增,相同目标重试不递增'`, + }) +} diff --git a/backend/api/cmd/cli/main.go b/backend/api/cmd/cli/main.go index fd31d90..344cefc 100644 --- a/backend/api/cmd/cli/main.go +++ b/backend/api/cmd/cli/main.go @@ -37,6 +37,36 @@ func main() { os.Exit(1) } switch os.Args[1] { + case "migrate-family-sharing": + if err := migrateFamilySharing(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("family sharing tables ready") + case "migrate-usage-messages": + if err := migrateUsageMessages(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("usage statistics and message read tables ready") + case "migrate-device-groups": + if err := migrateDeviceGroups(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("device group table ready") + case "migrate-device-mapping": + if err := migrateDeviceMapping(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("device mapping columns ready") + case "migrate-emergency-contacts": + if err := migrateEmergencyContacts(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("emergency contact table ready") case "version": fmt.Println("platform-cli 0.1.0") case "resource-contract": @@ -88,11 +118,48 @@ func main() { os.Exit(1) } fmt.Printf("mock refund reviewers repaired: %d\n", count) + case "repair-mock-user-password": + count, err := repairMockUserPassword() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Printf("mock user passwords repaired: %d\n", count) case "repair-legacy-gas-account": if err := repairLegacyGasAccount(os.Args[2:], os.Stdout); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } + case "migrate-user-address": + if err := migrateUserAddress(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("user address additive migration completed") + case "migrate-ec-favorite": + if err := migrateEcFavorite(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("ec favorite additive migration completed") + case "migrate-deposit": + if err := migrateDeposit(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("deposit additive migration completed") + case "migrate-ticket-contact": + if err := migrateTicketContact(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("ticket contact additive migration completed") + case "migrate-ticket-contract": + if err := migrateTicketContract(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("ticket contract additive migration completed") case "migrate": if err := migrateDatabase(); err != nil { fmt.Fprintln(os.Stderr, err) @@ -107,7 +174,7 @@ func main() { } func printUsage() { - fmt.Fprintln(os.Stderr, "usage: platform-cli ") + fmt.Fprintln(os.Stderr, "usage: platform-cli ") } type route struct { @@ -302,6 +369,23 @@ func repairMockRefundReviewer() (int64, error) { return count, nil } +// repairMockUserPassword 使用当前远程配置,只恢复固定开发账号凭据。 +func repairMockUserPassword() (int64, error) { + config.New(serviceKey) + if config.Spec.Databases == nil { + return 0, fmt.Errorf("database configuration is required") + } + databaseService, err := database.NewDatabase( + config.Spec.Databases.Driver, + config.Spec.Databases.Source, + dbsql.SetOptions(nil), + ) + if err != nil { + return 0, fmt.Errorf("connect database: %w", err) + } + return seed.RepairMockUserPassword(databaseService) +} + // repairLegacyGasAccount 显式修复唯一历史 010 气站账号,并将前后值写入操作审计表。 func repairLegacyGasAccount(arguments []string, output io.Writer) error { flags := flag.NewFlagSet("repair-legacy-gas-account", flag.ContinueOnError) diff --git a/backend/api/cmd/cli/usage_message_migration.go b/backend/api/cmd/cli/usage_message_migration.go new file mode 100644 index 0000000..4a74ebc --- /dev/null +++ b/backend/api/cmd/cli/usage_message_migration.go @@ -0,0 +1,56 @@ +// 功能:创建用气统计和用户消息已读表并补齐全部数据库注释;版本:1.0.0。 +package main + +// migrateUsageMessages 只新增统计及已读表,不生成计量数据或业务消息。 +func migrateUsageMessages() error { + return migrateClientColumns([]string{ + `CREATE TABLE IF NOT EXISTS dev_usage_stat ( + id bigserial PRIMARY KEY, identity varchar(36) NOT NULL UNIQUE, + created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, deleted_at timestamptz, + status integer NOT NULL DEFAULT 1 CHECK (status IN (1,2,3)), + product_info_id bigint NOT NULL REFERENCES product_info(id), stat_date date NOT NULL, + usage_kg numeric(12,3) NOT NULL DEFAULT 0 CHECK (usage_kg >= 0), + breakfast_kg numeric(12,3) NOT NULL DEFAULT 0 CHECK (breakfast_kg >= 0), + lunch_kg numeric(12,3) NOT NULL DEFAULT 0 CHECK (lunch_kg >= 0), + dinner_kg numeric(12,3) NOT NULL DEFAULT 0 CHECK (dinner_kg >= 0), + source varchar(32) NOT NULL CHECK (source IN ('meter','device','manual')), + calc_version varchar(32) NOT NULL, calculated_at timestamptz NOT NULL, + CONSTRAINT ux_dev_usage_stat_day UNIQUE(product_info_id,stat_date) +)`, + `COMMENT ON TABLE dev_usage_stat IS '实体设备每日用气统计事实;数据来源和计算口径必须可追溯'`, + `COMMENT ON COLUMN dev_usage_stat.id IS '数据库自增内部主键'`, + `COMMENT ON COLUMN dev_usage_stat.identity IS '对外业务UUID标识'`, + `COMMENT ON COLUMN dev_usage_stat.created_at IS '统计记录创建时间'`, + `COMMENT ON COLUMN dev_usage_stat.updated_at IS '统计记录最后更新时间'`, + `COMMENT ON COLUMN dev_usage_stat.deleted_at IS '软删除时间'`, + `COMMENT ON COLUMN dev_usage_stat.status IS '通用状态:1=启用,2=停用,3=归档'`, + `COMMENT ON COLUMN dev_usage_stat.product_info_id IS '统计所属实体设备内部主键'`, + `COMMENT ON COLUMN dev_usage_stat.stat_date IS '统计日期,按用户服务时区划分'`, + `COMMENT ON COLUMN dev_usage_stat.usage_kg IS '当日总用气量,单位千克'`, + `COMMENT ON COLUMN dev_usage_stat.breakfast_kg IS '早餐时段用气量,单位千克'`, + `COMMENT ON COLUMN dev_usage_stat.lunch_kg IS '午餐时段用气量,单位千克'`, + `COMMENT ON COLUMN dev_usage_stat.dinner_kg IS '晚餐时段用气量,单位千克'`, + `COMMENT ON COLUMN dev_usage_stat.source IS '数据来源:meter=计量表,device=设备上报,manual=人工导入'`, + `COMMENT ON COLUMN dev_usage_stat.calc_version IS '生成统计的计算口径版本'`, + `COMMENT ON COLUMN dev_usage_stat.calculated_at IS '统计计算完成时间'`, + `CREATE INDEX IF NOT EXISTS idx_dev_usage_stat_query ON dev_usage_stat(product_info_id,stat_date,status)`, + `CREATE TABLE IF NOT EXISTS user_message_read ( + id bigserial PRIMARY KEY, identity varchar(36) NOT NULL UNIQUE, + created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, deleted_at timestamptz, + status integer NOT NULL DEFAULT 1 CHECK (status IN (1,3)), + user_account_id bigint NOT NULL REFERENCES user_account(id), message_key varchar(96) NOT NULL, + read_at timestamptz NOT NULL, CONSTRAINT ux_user_message_read UNIQUE(user_account_id,message_key) +)`, + `COMMENT ON TABLE user_message_read IS '用户对订单、工单及公告衍生消息的已读回执;消息正文仍取自业务事实表'`, + `COMMENT ON COLUMN user_message_read.id IS '数据库自增内部主键'`, + `COMMENT ON COLUMN user_message_read.identity IS '对外业务UUID标识'`, + `COMMENT ON COLUMN user_message_read.created_at IS '回执创建时间'`, + `COMMENT ON COLUMN user_message_read.updated_at IS '回执最后更新时间'`, + `COMMENT ON COLUMN user_message_read.deleted_at IS '软删除时间'`, + `COMMENT ON COLUMN user_message_read.status IS '通用状态:1=启用,3=归档'`, + `COMMENT ON COLUMN user_message_read.user_account_id IS '读取消息的用户账号内部主键'`, + `COMMENT ON COLUMN user_message_read.message_key IS '消息来源类型与业务唯一标识组成的稳定键'`, + `COMMENT ON COLUMN user_message_read.read_at IS '用户首次确认已读时间'`, + `CREATE INDEX IF NOT EXISTS idx_user_message_read_lookup ON user_message_read(user_account_id,status)`, + }) +} diff --git a/backend/api/internal/logic/client/user/address.go b/backend/api/internal/logic/client/user/address.go new file mode 100644 index 0000000..5f449f1 --- /dev/null +++ b/backend/api/internal/logic/client/user/address.go @@ -0,0 +1,173 @@ +// 功能描述:用户地址新增、编辑、归档及默认切换,账户行锁保护并发写入。 +// 版本:1.0.0。 +package user + +import ( + "errors" + "math" + "strconv" + "strings" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// addressRequest 保留旧新增字段,联系人省略时从本账户补齐。 +type addressRequest struct { + Address string `json:"address" binding:"required,max=255"` + ContactName *string `json:"contact_name" binding:"omitempty,max=64"` + ContactPhone *string `json:"contact_phone" binding:"omitempty,max=32"` + Longitude string `json:"longitude" binding:"max=32"` + Latitude string `json:"latitude" binding:"max=32"` + IsDefault bool `json:"is_default"` + RequestNo string `json:"request_no" binding:"max=64"` +} + +// validAddressCoordinates 接受手动地址的空坐标;有坐标时必须成对且为合法经纬度。 +func validAddressCoordinates(longitude, latitude string) bool { + if longitude == "" && latitude == "" { + return true + } + lon, e1 := strconv.ParseFloat(longitude, 64) + lat, e2 := strconv.ParseFloat(latitude, 64) + return e1 == nil && e2 == nil && !math.IsNaN(lon) && !math.IsNaN(lat) && lon >= -180 && lon <= 180 && lat >= -90 && lat <= 90 +} + +// lockAddressOwner 按账户串行处理默认地址和幂等请求,避免首次新增的并发竞态。 +func lockAddressOwner(tx *gorm.DB, id uint64) error { + return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&models.UserAccount{}, id).Error +} + +// clearAddressDefault 在同一事务取消旧默认,保留现有唯一索引约束。 +func clearAddressDefault(tx *gorm.DB, userID uint64) error { + return tx.Model(&models.UserAddress{}).Where("user_account_id = ? AND is_default = ?", userID, true).Update("is_default", false).Error +} + +// SaveAddress 新增地址,带 request_no 的重试返回原标识,不重复创建。 +func SaveAddress(ctx *gin.Context) { writeAddress(ctx, false) } + +// UpdateAddress 仅更新本人地址,旧订单使用已有快照,不随地址修改。 +func UpdateAddress(ctx *gin.Context) { writeAddress(ctx, true) } + +// writeAddress 校验请求后在事务中持久化,失败时保持原地址和默认设置。 +func writeAddress(ctx *gin.Context, updating bool) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var request addressRequest + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + request.Address = strings.TrimSpace(request.Address) + request.Longitude = strings.TrimSpace(request.Longitude) + request.Latitude = strings.TrimSpace(request.Latitude) + if request.Address == "" || !validAddressCoordinates(request.Longitude, request.Latitude) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var address models.UserAddress + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := lockAddressOwner(tx, account.ID); err != nil { + return err + } + if updating { + if err := tx.Where("identity = ? AND user_account_id = ? AND status <> ?", ctx.Param("identity"), account.ID, common.StatusArchived).First(&address).Error; err != nil { + return errcode.ErrRecordNotFound + } + } else { + address = models.UserAddress{Entity: common.NewEntity(common.StatusEnable), UserAccountID: account.ID, RequestNo: request.RequestNo} + } + name, phone := address.ContactName, address.ContactPhone + if name == "" { + name = account.Name + } + if phone == "" { + phone = account.Phone + } + if request.ContactName != nil { + name = strings.TrimSpace(*request.ContactName) + } + if request.ContactPhone != nil { + phone = strings.TrimSpace(*request.ContactPhone) + } + if (request.ContactName != nil && name == "") || !common.ValidPhone(phone) { + return errcode.ErrInvalidArgument + } + if !updating && request.RequestNo != "" { + var previous models.UserAddress + err := tx.Where("user_account_id = ? AND request_no = ?", account.ID, request.RequestNo).First(&previous).Error + if err == nil { + if previous.Status == common.StatusArchived || previous.Address != request.Address || previous.ContactName != name || previous.ContactPhone != phone || previous.Longitude != request.Longitude || previous.Latitude != request.Latitude { + return errcode.ErrInvalidArgument + } + address = previous + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + } + if request.IsDefault { + if err := clearAddressDefault(tx, account.ID); err != nil { + return err + } + } + address.Address, address.ContactName, address.ContactPhone = request.Address, name, phone + address.Longitude, address.Latitude, address.IsDefault = request.Longitude, request.Latitude, request.IsDefault + if updating { + return tx.Model(&address).Select("address", "contact_name", "contact_phone", "longitude", "latitude", "is_default").Updates(&address).Error + } + return tx.Create(&address).Error + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"identity": address.Identity}) +} + +// SetDefaultAddress 设置本人有效地址为默认,不接受请求体中的用户或地址归属。 +func SetDefaultAddress(ctx *gin.Context) { changeAddressState(ctx, false) } + +// DeleteAddress 归档本人地址并取消默认;重复删除成功,历史订单引用保留。 +func DeleteAddress(ctx *gin.Context) { changeAddressState(ctx, true) } + +// changeAddressState 所有权校验先于默认切换,跨账户请求不能影响本人的默认设置。 +func changeAddressState(ctx *gin.Context, deleting bool) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := lockAddressOwner(tx, account.ID); err != nil { + return err + } + var address models.UserAddress + if err := tx.Where("identity = ? AND user_account_id = ?", ctx.Param("identity"), account.ID).First(&address).Error; err != nil { + return errcode.ErrRecordNotFound + } + if deleting { + return tx.Model(&address).Updates(map[string]any{"status": common.StatusArchived, "is_default": false}).Error + } + if address.Status == common.StatusArchived { + return errcode.ErrRecordNotFound + } + if err := clearAddressDefault(tx, account.ID); err != nil { + return err + } + return tx.Model(&address).Update("is_default", true).Error + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"identity": ctx.Param("identity")}) +} diff --git a/backend/api/internal/logic/client/user/address_test.go b/backend/api/internal/logic/client/user/address_test.go new file mode 100644 index 0000000..61addfd --- /dev/null +++ b/backend/api/internal/logic/client/user/address_test.go @@ -0,0 +1,98 @@ +// 功能描述:验证地址坐标、所有权、事务回滚及创建幂等,不访问远程数据。 +// 版本:1.0.0。 +package user + +import ( + "git.apinb.com/bsm-sdk/core/types" + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" + "net/http/httptest" + "strings" + "testing" +) + +func TestAddressCoordinates(t *testing.T) { + for _, tc := range []struct { + lon, lat string + valid bool + }{ + {"", "", true}, {"113.2", "23.4", true}, {"180", "-90", true}, {"181", "0", false}, + {"0", "91", false}, {"0", "", false}, {"NaN", "0", false}, {"Inf", "0", false}, + } { + if validAddressCoordinates(tc.lon, tc.lat) != tc.valid { + t.Errorf("坐标边界不正确:%q %q", tc.lon, tc.lat) + } + } +} + +// addressContext 构造当前用户鉴权,不依赖登录接口。 +func addressContext(body, identity string) (*gin.Context, *httptest.ResponseRecorder) { + response := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(response) + ctx.Set("Auth", &types.JwtClaims{Client: "user_app", Identity: "alice"}) + ctx.Request = httptest.NewRequest("POST", "/addresses", strings.NewReader(body)) + ctx.Request.Header.Set("Content-Type", "application/json") + ctx.Params = gin.Params{{Key: "identity", Value: identity}} + return ctx, response +} + +func expectAddressAccount(mock sqlmock.Sqlmock) { + mock.ExpectQuery(`SELECT .* FROM "user_account"`).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "name", "phone"}).AddRow(1, "alice", "测试用户", "13800000001")) +} + +func expectAddressLock(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + mock.ExpectQuery(`SELECT "id" FROM "user_account".*FOR UPDATE`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) +} + +func TestAddressMutationsRejectOtherOwner(t *testing.T) { + for _, action := range []func(*gin.Context){SetDefaultAddress, DeleteAddress, UpdateAddress} { + mock := primaryTestDB(t) + expectAddressAccount(mock) + expectAddressLock(mock) + mock.ExpectQuery(`SELECT .* FROM "user_address".*identity = \$1 AND user_account_id = \$2`).WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectRollback() + ctx, response := addressContext(`{"address":"测试地址"}`, "other-address") + action(ctx) + if strings.Contains(response.Body.String(), `"code":0`) { + t.Fatal("越权修改成功") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + } +} + +func TestAddressCreateRetryDoesNotDuplicateOrResetDefault(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + expectAddressLock(mock) + mock.ExpectQuery(`SELECT .* FROM "user_address".*request_no`).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "status", "address", "contact_name", "contact_phone"}).AddRow(5, "existing", 1, "测试地址", "测试用户", "13800000001")) + mock.ExpectCommit() + ctx, response := addressContext(`{"address":"测试地址","request_no":"same-request","is_default":true}`, "") + SaveAddress(ctx) + if !strings.Contains(response.Body.String(), `"identity":"existing"`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestAddressDefaultUpdateFailureRollsBack(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + expectAddressLock(mock) + mock.ExpectQuery(`SELECT .* FROM "user_address"`).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "status"}).AddRow(5, "owned", 1)) + mock.ExpectExec(`UPDATE "user_address" SET "is_default"`).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`UPDATE "user_address" SET "is_default"`).WillReturnError(sqlmock.ErrCancelled) + mock.ExpectRollback() + ctx, response := addressContext("", "owned") + SetDefaultAddress(ctx) + if strings.Contains(response.Body.String(), `"code":0`) { + t.Fatal("失败写入被当作成功") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/client/user/address_ticket.go b/backend/api/internal/logic/client/user/address_ticket.go index 338a874..b55df13 100644 --- a/backend/api/internal/logic/client/user/address_ticket.go +++ b/backend/api/internal/logic/client/user/address_ticket.go @@ -1,6 +1,9 @@ +// 功能描述:用户地址列表及本人客服工单创建、查询、确认和取消。 +// 版本:1.1.0。 package user import ( + "errors" "strings" "time" @@ -11,6 +14,7 @@ import ( "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // ListAddresses 返回当前用户未归档地址。 @@ -27,41 +31,6 @@ func ListAddresses(ctx *gin.Context) { infra.Response.Success(ctx, common.ResourceResponse(list)) } -// SaveAddress 新增地址,设为默认时原默认地址会在同一事务取消默认。 -func SaveAddress(ctx *gin.Context) { - account, ok := common.UserAccount(ctx) - if !ok { - return - } - var request struct { - Address string `json:"address" binding:"required,max=255"` - Longitude string `json:"longitude"` - Latitude string `json:"latitude"` - IsDefault bool `json:"is_default"` - } - if ctx.ShouldBindJSON(&request) != nil { - infra.Response.Error(ctx, errcode.ErrInvalidArgument) - return - } - address := models.UserAddress{ - Entity: common.NewEntity(common.StatusEnable), UserAccountID: account.ID, Address: request.Address, - Longitude: request.Longitude, Latitude: request.Latitude, IsDefault: request.IsDefault, - } - err := impl.DBService.Transaction(func(tx *gorm.DB) error { - if request.IsDefault { - if err := tx.Model(&models.UserAddress{}).Where("user_account_id = ?", account.ID).Update("is_default", false).Error; err != nil { - return err - } - } - return tx.Create(&address).Error - }) - if err != nil { - infra.Response.Error(ctx, err) - return - } - infra.Response.Success(ctx, gin.H{"identity": address.Identity}) -} - var userTicketCategories = map[string]bool{ "installation": true, "repair": true, "inspection": true, "reinspection": true, "customer_service": true, } @@ -73,35 +42,64 @@ func CreateTicket(ctx *gin.Context) { return } var request struct { - RequestNo string `json:"request_no" binding:"required"` - Category string `json:"category" binding:"required"` - Description string `json:"description" binding:"required,max=2000"` - AddressIdentity string `json:"address_identity"` - AppointmentAt *time.Time `json:"appointment_at"` + RequestNo string `json:"request_no" binding:"required,max=128"` + Category string `json:"category" binding:"required"` + Description string `json:"description" binding:"required,max=2000"` + AddressIdentity string `json:"address_identity"` + AppointmentAt *time.Time `json:"appointment_at"` + FaultType string `json:"fault_type" binding:"omitempty,oneof=leak valve alarm other"` + Photos []ticketPhotoRequest `json:"photos" binding:"max=3,dive"` } - if ctx.ShouldBindJSON(&request) != nil || !userTicketCategories[request.Category] { + if ctx.ShouldBindJSON(&request) != nil || !userTicketCategories[request.Category] || strings.TrimSpace(request.Description) == "" || strings.TrimSpace(request.RequestNo) == "" { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - var relation models.UserServiceRelation - _ = impl.DBService.Where("user_account_id = ? AND status = ?", account.ID, common.StatusEnable).First(&relation).Error - addressText := "" - if request.AddressIdentity != "" { - var address models.UserAddress - if impl.DBService.Where("identity = ? AND user_account_id = ? AND status <> ?", request.AddressIdentity, account.ID, common.StatusArchived).First(&address).Error != nil { - infra.Response.Error(ctx, errcode.ErrRecordNotFound) - return + var ticket models.CsTicket + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + // 同一账户串行创建;重试先返回首次事实,不受地址后续修改或删除影响。 + if err := lockAddressOwner(tx, account.ID); err != nil { + return err } - addressText = address.Address - } - ticket := models.CsTicket{ - Entity: common.NewEntity(common.StatusEnable), TicketStatus: 32, TicketNo: common.RecordNo("TK"), - RequestNo: request.RequestNo, - UserAccountID: account.ID, GasBasicID: relation.GasBasicID, DeliveryBasicID: relation.DeliveryBasicID, - Category: request.Category, Priority: "normal", Description: strings.TrimSpace(request.Description), - Address: addressText, AppointmentAt: request.AppointmentAt, OperatorIdentity: account.Identity, - } - if err := impl.DBService.Create(&ticket).Error; err != nil { + err := tx.Where("request_no = ? AND user_account_id = ?", request.RequestNo, account.ID).First(&ticket).Error + if err == nil { + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if request.AppointmentAt != nil && request.AppointmentAt.Before(time.Now()) { + return errcode.ErrInvalidArgument + } + var relation models.UserServiceRelation + if err := tx.Where("user_account_id = ? AND status = ?", account.ID, common.StatusEnable).First(&relation).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + addressText, contactName, contactPhone := "", account.Name, account.Phone + if request.AddressIdentity != "" { + var address models.UserAddress + if err := tx.Where("identity = ? AND user_account_id = ? AND status <> ?", request.AddressIdentity, account.ID, common.StatusArchived).First(&address).Error; err != nil { + return errcode.ErrRecordNotFound + } + addressText = address.Address + if address.ContactName != "" { + contactName = address.ContactName + } + if address.ContactPhone != "" { + contactPhone = address.ContactPhone + } + } + ticket = models.CsTicket{ + Entity: common.NewEntity(common.StatusEnable), TicketStatus: 32, TicketNo: common.RecordNo("TK"), RequestNo: request.RequestNo, + UserAccountID: account.ID, GasBasicID: relation.GasBasicID, DeliveryBasicID: relation.DeliveryBasicID, + Category: request.Category, Priority: "normal", Description: strings.TrimSpace(request.Description), FaultType: request.FaultType, + Address: addressText, ContactName: contactName, ContactPhone: contactPhone, AppointmentAt: request.AppointmentAt, OperatorIdentity: account.Identity, + } + if err := tx.Create(&ticket).Error; err != nil { + return err + } + return saveTicketPhotos(tx, ticket, account.Identity, request.Photos) + }) + if err != nil { infra.Response.Error(ctx, err) return } @@ -119,46 +117,84 @@ func ListTickets(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, common.ResourceResponse(list)) + items := make([]any, 0, len(list)) + photos, err := ticketPhotosForList(list) + if err != nil { + infra.Response.Error(ctx, err) + return + } + for _, ticket := range list { + item := common.ResourceResponse(ticket).(map[string]any) + item["status_name"], item["allowed_actions"] = ticketState(ticket.TicketStatus) + item["photos"] = photos[ticket.ID] + items = append(items, item) + } + infra.Response.Success(ctx, items) } -// ConfirmTicket 用户确认工作人员提交的处理结果。 +// ticketState 发布已有状态机允许的动作;未知状态不授予任何写权限。 +func ticketState(status int) (string, []string) { + names := map[int]string{32: "待受理", 18: "已派单", 11: "处理中", 21: "处理异常", 34: "待确认", 23: "已完成", 22: "已取消"} + name, exists := names[status] + if !exists { + name = "状态待核实" + } + actions := []string{} + switch status { + case 32, 18, 11, 21, 34: + actions = append(actions, "cancel") + } + if status == 34 { + actions = append(actions, "confirm") + } + return name, actions +} + +// ConfirmTicket 用户确认工作人员提交的处理结果,重复确认不覆盖完成时间。 func ConfirmTicket(ctx *gin.Context) { - account, ok := common.UserAccount(ctx) - if !ok { - return - } - now := time.Now() - result := impl.DBService.Model(&models.CsTicket{}). - Where("identity = ? AND user_account_id = ? AND ticket_status = ?", ctx.Param("identity"), account.ID, 34). - Updates(map[string]any{"ticket_status": 23, "completed_at": &now, "operator_identity": account.Identity}) - if result.Error != nil { - infra.Response.Error(ctx, result.Error) - return - } - if result.RowsAffected != 1 { - infra.Response.Error(ctx, errcode.ErrInvalidArgument) - return - } - infra.Response.Success(ctx, gin.H{"confirmed": true}) + changeTicketState(ctx, "confirm", 23) } // CancelTicket 取消尚未完成的本人工单。 func CancelTicket(ctx *gin.Context) { + changeTicketState(ctx, "cancel", 22) +} + +// changeTicketState 锁定本人工单并校验状态,归档和其他用户的记录不可操作。 +func changeTicketState(ctx *gin.Context, action string, target int) { account, ok := common.UserAccount(ctx) if !ok { return } - result := impl.DBService.Model(&models.CsTicket{}). - Where("identity = ? AND user_account_id = ? AND ticket_status IN ?", ctx.Param("identity"), account.ID, []int{32, 18, 11, 21, 34}). - Updates(map[string]any{"ticket_status": 22, "operator_identity": account.Identity}) - if result.Error != nil { - infra.Response.Error(ctx, result.Error) + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var ticket models.CsTicket + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND user_account_id = ? AND status <> ?", ctx.Param("identity"), account.ID, common.StatusArchived).First(&ticket).Error; err != nil { + return errcode.ErrRecordNotFound + } + if ticket.TicketStatus == target { + return nil + } + _, actions := ticketState(ticket.TicketStatus) + allowed := false + for _, candidate := range actions { + allowed = allowed || candidate == action + } + if !allowed { + return errcode.ErrInvalidArgument + } + values := map[string]any{"ticket_status": target, "operator_identity": account.Identity} + if action == "confirm" { + values["completed_at"] = time.Now() + } + return tx.Model(&ticket).Updates(values).Error + }) + if err != nil { + infra.Response.Error(ctx, err) return } - if result.RowsAffected != 1 { - infra.Response.Error(ctx, errcode.ErrInvalidArgument) - return + if action == "confirm" { + infra.Response.Success(ctx, gin.H{"confirmed": true}) + } else { + infra.Response.Success(ctx, gin.H{"cancelled": true}) } - infra.Response.Success(ctx, gin.H{"cancelled": true}) } diff --git a/backend/api/internal/logic/client/user/auth.go b/backend/api/internal/logic/client/user/auth.go index 0e83682..06882f4 100644 --- a/backend/api/internal/logic/client/user/auth.go +++ b/backend/api/internal/logic/client/user/auth.go @@ -13,14 +13,16 @@ import ( "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" + "gorm.io/gorm/logger" ) type loginRequest struct { - Phone string `json:"phone" binding:"required"` - Mode string `json:"mode" binding:"required,oneof=password verification_code"` - Password string `json:"password"` - Code string `json:"code"` - RequestIdentity string `json:"request_identity"` + Phone string `json:"phone" binding:"required"` + Mode string `json:"mode" binding:"required,oneof=password verification_code"` + Password string `json:"password"` + Code string `json:"code"` + RequestIdentity string `json:"request_identity"` + Consents []loginConsent `json:"consents" binding:"max=100"` // 新客户端提交本次明确同意的内容版本;旧客户端兼容省略。 } // Login 支持密码和一次性验证码两种登录模式。 @@ -43,6 +45,10 @@ func Login(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrPassword) return } + if err := recordLoginConsents(account.ID, account.Identity, request.Consents); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } accessToken, err := common.IssueToken(account.Identity, "user_app", "user", map[string]string{"phone": account.Phone}) if err != nil { infra.Response.Error(ctx, err) @@ -146,14 +152,23 @@ func UpdateProfile(ctx *gin.Context) { return } var request struct { - Name string `json:"name" binding:"required,max=64"` - Avatar string `json:"avatar" binding:"max=512"` + Name string `json:"name" binding:"required,max=64"` + Avatar *string `json:"avatar" binding:"omitempty,max=512"` } - if ctx.ShouldBindJSON(&request) != nil { + if ctx.ShouldBindJSON(&request) != nil || strings.TrimSpace(request.Name) == "" { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - if err := impl.DBService.Model(&account).Updates(map[string]any{"name": strings.TrimSpace(request.Name), "avatar": request.Avatar}).Error; err != nil { + updates := map[string]any{"name": strings.TrimSpace(request.Name)} + if request.Avatar != nil { + // 兼容保留旧头像或显式清空;替换必须引用当前用户刚上传的受控文件。 + if *request.Avatar != "" && *request.Avatar != account.Avatar && !upload.OwnsAvatar("user_app", account.Identity, *request.Avatar) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updates["avatar"] = *request.Avatar + } + if err := impl.DBService.Model(&account).Updates(updates).Error; err != nil { infra.Response.Error(ctx, err) return } @@ -180,8 +195,15 @@ func ChangePassword(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - if err := impl.DBService.Model(&account).Update("password_hash", hash).Error; err != nil { - infra.Response.Error(ctx, err) + // 仅替换刚验证过的密码版本,避免并发改密覆盖已经生效的新密码。 + // 密码散列不进入开发环境SQL日志。 + result := impl.DBService.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Model(&account).Where("password_hash = ?", account.PasswordHash).Update("password_hash", hash) + if result.Error != nil { + infra.Response.Error(ctx, result.Error) + return + } + if result.RowsAffected != 1 { + infra.Response.Error(ctx, errcode.ErrPassword) return } infra.Response.Success(ctx, gin.H{"changed": true}) diff --git a/backend/api/internal/logic/client/user/basic.go b/backend/api/internal/logic/client/user/basic.go index 90aecfe..664a9c2 100644 --- a/backend/api/internal/logic/client/user/basic.go +++ b/backend/api/internal/logic/client/user/basic.go @@ -59,6 +59,7 @@ func ConfirmContentRead(ctx *gin.Context) { } var request struct { ContentIdentity string `json:"content_identity" binding:"required"` + ContentVersion *int `json:"content_version"` // 新客户端必须发送实际展示版本,旧客户端保持兼容。 ClientVersion string `json:"client_version"` DeviceIdentity string `json:"device_identity"` RequestNo string `json:"request_no" binding:"required"` @@ -68,7 +69,15 @@ func ConfirmContentRead(ctx *gin.Context) { return } var content models.CmsContent - if impl.DBService.Where("identity = ? AND publish_status = ?", request.ContentIdentity, "published").First(&content).Error != nil { + query := impl.DBService.Where("identity = ? AND publish_status = ? AND status = ?", request.ContentIdentity, "published", common.StatusEnable) + if request.ContentVersion != nil { + if *request.ContentVersion < 1 { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + query = query.Where("version_no = ?", *request.ContentVersion) + } + if query.First(&content).Error != nil { infra.Response.Error(ctx, errcode.ErrRecordNotFound) return } @@ -83,6 +92,11 @@ func ConfirmContentRead(ctx *gin.Context) { infra.Response.Error(ctx, err) return } + // 同一幂等号不能把其他内容或其他版本的确认冒充本次确认。 + if existing.CmsContentID != content.ID || existing.VersionNo != content.VersionNo { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } record = existing } infra.Response.Success(ctx, gin.H{"identity": record.Identity, "content_version": record.VersionNo}) diff --git a/backend/api/internal/logic/client/user/cart.go b/backend/api/internal/logic/client/user/cart.go new file mode 100644 index 0000000..89dbdf4 --- /dev/null +++ b/backend/api/internal/logic/client/user/cart.go @@ -0,0 +1,193 @@ +// 功能描述:用户购物车读取及带版本条件的幂等数量设置;版本:1.0.0。 +package user + +import ( + "errors" + "time" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var errCartChanged = errcode.NewError(2403, "购物车已变化,请刷新后重新确认") + +// productCoverURL 返回商品当前封面;订单创建时会把它固化到成交快照。 +func productCoverURL(db *gorm.DB, productID uint64) (string, error) { + var picture models.EcProductImage + if err := db.Where("ec_product_id = ? AND status = ?", productID, common.StatusEnable). + Order("is_cover desc, sort_no, identity").Find(&picture).Error; err != nil { + return "", err + } + return picture.ImageURI, nil +} + +// cartRevision 返回公开并发版本,不向客户端暴露数据库主键。 +func cartRevision(item models.EcCart) string { + if item.ID == 0 { + return "" + } + // PostgreSQL 时间戳精度为微秒,响应必须与重读后的版本一致。 + return item.Identity + ":" + item.UpdatedAt.UTC().Truncate(time.Microsecond).Format(time.RFC3339Nano) +} + +// cartValue 仅展示关联商品和本人的购物状态;下架商品仍保留可删除条目。 +func cartValue(db *gorm.DB, item models.EcCart, product models.EcProduct) (gin.H, error) { + quantity := item.Quantity + if item.ID == 0 || item.Status == common.StatusArchived { + quantity = 0 + } + imageURL, err := productCoverURL(db, product.ID) + if err != nil { + return nil, err + } + return gin.H{ + "product_identity": product.Identity, "name": product.Name, "image_url": imageURL, + "price_amount": product.PriceAmount, "stock_quantity": product.StockQuantity, + "available": product.Status == common.StatusEnable && !product.DeletedAt.Valid && product.StockQuantity > 0, + "quantity": quantity, "selected": quantity > 0 && item.Selected, "revision": cartRevision(item), + }, nil +} + +// ListCart 返回本人未归档条目,失效商品不会被静默移除。 +func ListCart(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var items []models.EcCart + if err := impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, common.StatusArchived). + Order("created_at, identity").Find(&items).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + result := make([]gin.H, 0, len(items)) + for _, item := range items { + var product models.EcProduct + if err := impl.DBService.Unscoped().Where("id = ?", item.EcProductID).Take(&product).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + value, err := cartValue(impl.DBService, item, product) + if err != nil { + infra.Response.Error(ctx, err) + return + } + result = append(result, value) + } + infra.Response.Success(ctx, result) +} + +// GetCartItem 返回本商品当前数量和归档版本,重新加入时不会覆盖另一端的操作。 +func GetCartItem(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var product models.EcProduct + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).Take(&product).Error; err != nil { + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + return + } + var item models.EcCart + if err := impl.DBService.Where("user_account_id = ? AND ec_product_id = ?", account.ID, product.ID).Find(&item).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + value, err := cartValue(impl.DBService, item, product) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, value) +} + +// SetCartItem 以绝对数量写入;同目标重试无副作用,过期版本拒绝覆盖,数量零表示归档。 +func SetCartItem(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var request struct { + Quantity *int `json:"quantity" binding:"required,gte=0,lte=999"` + Selected *bool `json:"selected" binding:"required"` + Revision string `json:"revision" binding:"max=128"` + } + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var result gin.H + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + // 所有用户端购物车写入共用账户行锁,无需改动现有远程表结构。 + var owner models.UserAccount + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", account.ID).Take(&owner).Error; err != nil { + return err + } + var product models.EcProduct + if err := tx.Unscoped().Where("identity = ?", ctx.Param("identity")).Take(&product).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errcode.ErrRecordNotFound + } + return err + } + var item models.EcCart + if err := tx.Where("user_account_id = ? AND ec_product_id = ?", account.ID, product.ID).Find(&item).Error; err != nil { + return err + } + current := item.Quantity + if item.ID == 0 || item.Status == common.StatusArchived { + current = 0 + } + same := current == *request.Quantity && (current == 0 || item.Selected == *request.Selected) + if !same { + if cartRevision(item) != request.Revision { + return errCartChanged + } + if *request.Quantity > 0 && (*request.Quantity > current || current == 0) && + (product.Status != common.StatusEnable || product.DeletedAt.Valid || product.StockQuantity < *request.Quantity) { + return errShopStockChanged + } + status := common.StatusEnable + if *request.Quantity == 0 { + status = common.StatusArchived + } + if current == 0 { + var count int64 + if err := tx.Model(&models.EcCart{}).Where("user_account_id = ? AND status <> ?", account.ID, common.StatusArchived).Count(&count).Error; err != nil { + return err + } + if count >= 100 { + return errcode.ErrOutOfRange + } + } + if item.ID == 0 { + item = models.EcCart{Entity: common.NewEntity(status), UserAccountID: account.ID, EcProductID: product.ID, Quantity: *request.Quantity, Selected: true} + if err := tx.Create(&item).Error; err != nil { + return err + } + } + // 保留正数量以兼容现有 CHECK;归档状态对外显示零。 + quantity := *request.Quantity + if quantity == 0 { + quantity = item.Quantity + } + if err := tx.Model(&item).Updates(map[string]any{"quantity": quantity, "selected": *request.Selected, "status": status}).Error; err != nil { + return err + } + } + var err error + result, err = cartValue(tx, item, product) + return err + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, result) +} diff --git a/backend/api/internal/logic/client/user/cart_test.go b/backend/api/internal/logic/client/user/cart_test.go new file mode 100644 index 0000000..8cfe3d1 --- /dev/null +++ b/backend/api/internal/logic/client/user/cart_test.go @@ -0,0 +1,180 @@ +// 功能描述:购物车并发覆盖、数量边界、归档重试及账户隔离回归;版本:1.0.0。 +package user + +import ( + "fmt" + "strings" + "testing" + "time" + + common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/DATA-DOG/go-sqlmock" +) + +func TestCartConditionalMutation(t *testing.T) { + stamp := time.Date(2026, 9, 8, 1, 2, 3, 123456000, time.UTC) + for _, tc := range []struct { + name string + quantity, current, status, stock, code int + selected bool + revision string + write bool + }{ + {"相同目标重试不重复加量", 3, 3, 1, 10, 0, true, "old", false}, + {"过期版本不可覆盖", 4, 3, 1, 10, 2403, true, "old", false}, + {"增加超过库存被拒绝", 4, 3, 1, 3, 2402, true, "cart:2026-09-08T01:02:03.123456Z", false}, + {"减少数量保留真实结果", 2, 3, 1, 10, 0, true, "cart:2026-09-08T01:02:03.123456Z", true}, + {"取消勾选即使库存不足", 3, 3, 1, 0, 0, false, "cart:2026-09-08T01:02:03.123456Z", true}, + {"删除归档不物理删除", 0, 3, 1, 0, 0, false, "cart:2026-09-08T01:02:03.123456Z", true}, + {"重复删除无副作用", 0, 3, common.StatusArchived, 0, 0, false, "old", false}, + } { + t.Run(tc.name, func(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectBegin() + mock.ExpectQuery(`SELECT .* FROM "user_account".*FOR UPDATE`).WithArgs(1, 1).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) + mock.ExpectQuery(`SELECT .* FROM "ec_product"`).WithArgs("product", 1).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "name", "status", "price_amount", "stock_quantity"}).AddRow(3, "product", "报警器", 1, 16900, tc.stock)) + mock.ExpectQuery(`SELECT .* FROM "ec_cart".*user_account_id = \$1 AND ec_product_id = \$2`).WithArgs(1, 3). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "status", "quantity", "selected", "updated_at"}).AddRow(4, "cart", tc.status, tc.current, true, stamp)) + if tc.write { + mock.ExpectExec(`UPDATE "ec_cart" SET .*`).WillReturnResult(sqlmock.NewResult(0, 1)) + } + if tc.code == 0 { + mock.ExpectQuery(`SELECT .* FROM "ec_product_image"`).WithArgs(3, 1).WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectCommit() + } else { + mock.ExpectRollback() + } + ctx, response := addressContext(fmt.Sprintf(`{"quantity":%d,"selected":%t,"revision":%q}`, tc.quantity, tc.selected, tc.revision), "product") + SetCartItem(ctx) + if !strings.Contains(response.Body.String(), fmt.Sprintf(`"code":%d`, tc.code)) { + t.Fatal(response.Body.String()) + } + if tc.code == 0 && !strings.Contains(response.Body.String(), fmt.Sprintf(`"quantity":%d`, tc.quantity)) { + t.Fatal("数量返回错误", response.Body.String()) + } + if strings.Contains(response.Body.String(), `"user_account_id"`) || strings.Contains(response.Body.String(), `"ec_product_id"`) { + t.Fatal("泄漏内部标识") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestCartQuantityValidation(t *testing.T) { + for _, body := range []string{`{"quantity":-1,"selected":true}`, `{"quantity":1000,"selected":true}`, `{"quantity":1}`, `{"selected":true}`, `{"quantity":1.5,"selected":true}`} { + mock := primaryTestDB(t) + expectAddressAccount(mock) + ctx, response := addressContext(body, "product") + SetCartItem(ctx) + if !strings.Contains(response.Body.String(), `"code":1704`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + } +} + +// 首次建行需要兼容 GORM 的 true 默认值,同时保留客户端明确取消勾选。 +func TestCartFirstAddUnselected(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectBegin() + mock.ExpectQuery(`SELECT .* FROM "user_account".*FOR UPDATE`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) + mock.ExpectQuery(`SELECT .* FROM "ec_product"`).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "status", "stock_quantity"}).AddRow(3, "product", 1, 10)) + mock.ExpectQuery(`SELECT .* FROM "ec_cart"`).WithArgs(1, 3).WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectQuery(`SELECT count\(\*\) FROM "ec_cart"`).WithArgs(1, common.StatusArchived).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery(`INSERT INTO "ec_cart"`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(4)) + mock.ExpectExec(`UPDATE "ec_cart" SET .*`).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery(`SELECT .* FROM "ec_product_image"`).WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectCommit() + ctx, response := addressContext(`{"quantity":2,"selected":false,"revision":""}`, "product") + SetCartItem(ctx) + if !strings.Contains(response.Body.String(), `"selected":false`) || !strings.Contains(response.Body.String(), `"quantity":2`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestCheckoutRejectsInvalidItemQuantityAndDuplicates(t *testing.T) { + for _, items := range []string{`[{"product_identity":"p1","quantity":-1}]`, `[{"product_identity":"p1","quantity":0}]`, `[{"product_identity":"p1","quantity":1000}]`, `[{"product_identity":"p1","quantity":1},{"product_identity":"p1","quantity":2}]`} { + mock := primaryTestDB(t) + expectAddressAccount(mock) + ctx, response := addressContext(fmt.Sprintf(`{"request_no":"request","address_identity":"a1","contact_name":"用户","contact_phone":"13800000001","items":%s}`, items), "") + CreateShopOrder(ctx) + if !strings.Contains(response.Body.String(), `"code":1704`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + } +} + +func TestCartRevisionDatabasePrecision(t *testing.T) { + item := models.EcCart{Entity: models.Entity{ID: 1, Identity: "cart", UpdatedAt: time.Unix(10, 123456789)}} + before := cartRevision(item) + item.UpdatedAt = item.UpdatedAt.Truncate(time.Microsecond) + if before != cartRevision(item) { + t.Fatal("写入响应和数据库微秒时间戳必须产生相同版本") + } +} + +func TestCartCheckoutRollbackOnStaleVersion(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectQuery(`SELECT .* FROM "ec_order"`).WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectQuery(`SELECT .* FROM "user_address"`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(2)) + mock.ExpectBegin() + mock.ExpectQuery(`SELECT .* FROM "user_account".*FOR UPDATE`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) + mock.ExpectQuery(`SELECT .* FROM "ec_product".*FOR UPDATE`).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "price_amount", "stock_quantity"}).AddRow(3, "p1", 12800, 30)) + mock.ExpectQuery(`SELECT .* FROM "ec_cart".*user_account_id = \$1 AND ec_product_id = \$2`).WithArgs(1, 3). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "status", "quantity", "selected"}).AddRow(4, "cart", 1, 2, true)) + mock.ExpectRollback() + mock.ExpectQuery(`SELECT .* FROM "ec_order"`).WillReturnRows(sqlmock.NewRows([]string{"id"})) + ctx, response := addressContext(`{"request_no":"cart-order","address_identity":"owned","contact_name":"收货人","contact_phone":"13800000001","items":[{"product_identity":"p1","quantity":2,"cart_revision":"old"}]}`, "") + CreateShopOrder(ctx) + if !strings.Contains(response.Body.String(), `"code":2403`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestCartCheckoutTwoProductsAtomic(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectQuery(`SELECT .* FROM "ec_order"`).WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectQuery(`SELECT .* FROM "user_address"`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(2)) + mock.ExpectBegin() + mock.ExpectQuery(`SELECT .* FROM "user_account".*FOR UPDATE`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) + for i := 1; i <= 2; i++ { + mock.ExpectQuery(`SELECT .* FROM "ec_product".*FOR UPDATE`).WithArgs(fmt.Sprintf("p%d", i), 1, i, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "price_amount", "stock_quantity"}).AddRow(i, fmt.Sprintf("p%d", i), 10000, 30)) + mock.ExpectQuery(`SELECT .* FROM "ec_cart"`).WithArgs(1, i).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "status", "quantity", "selected"}).AddRow(i, fmt.Sprintf("c%d", i), 1, i, true)) + mock.ExpectExec(`UPDATE "ec_cart" SET "status"`).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`UPDATE "ec_product" SET "stock_quantity"`).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery(`SELECT .* FROM "ec_product_image"`). + WillReturnRows(sqlmock.NewRows([]string{"id", "image_uri"}).AddRow(i, fmt.Sprintf("/p%d.png", i))) + } + mock.ExpectQuery(`INSERT INTO "ec_order"`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(5)) + for i := 1; i <= 2; i++ { + mock.ExpectQuery(`INSERT INTO "ec_order_item"`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(i)) + } + mock.ExpectCommit() + ctx, response := addressContext(`{"request_no":"cart-order","address_identity":"owned","contact_name":"收货人","contact_phone":"13800000001","expected_payable_amount":30000,"items":[{"product_identity":"p2","quantity":2,"cart_revision":"c2:0001-01-01T00:00:00Z"},{"product_identity":"p1","quantity":1,"cart_revision":"c1:0001-01-01T00:00:00Z"}]}`, "") + CreateShopOrder(ctx) + if !strings.Contains(response.Body.String(), `"payable_amount":30000`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/client/user/change_password_remote_test.go b/backend/api/internal/logic/client/user/change_password_remote_test.go new file mode 100644 index 0000000..e32d4d4 --- /dev/null +++ b/backend/api/internal/logic/client/user/change_password_remote_test.go @@ -0,0 +1,89 @@ +// 功能描述:远程事务内验证改密成功/失败及回滚恢复,不依赖真实用户密码;版本:1.0.0。 +package user + +import ( + "encoding/json" + "git.apinb.com/bsm-sdk/core/database" + dbsql "git.apinb.com/bsm-sdk/core/database/sql" + "git.apinb.com/bsm-sdk/core/types" + "git.apinb.com/heqiapp/platforms/backend/api/internal/config" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/logger" + "os" + "testing" +) + +func TestChangePasswordRemoteRollback(t *testing.T) { + if os.Getenv("HEQI_REMOTE_PASSWORD_TEST") != "1" { + t.Skip("显式开启的远程回滚验证") + } + config.New("heqi") + if config.Spec.Databases == nil { + t.Fatal("缺少环境配置") + } + db, err := database.NewDatabase(config.Spec.Databases.Driver, config.Spec.Databases.Source, dbsql.SetOptions(nil)) + if err != nil { + t.Fatal("无法连接配置环境") + } + db = db.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}) + connection, err := db.DB() + if err != nil { + t.Fatal(err) + } + defer connection.Close() + tx := db.Begin() + if tx.Error != nil { + t.Fatal(tx.Error) + } + defer tx.Rollback() + if err := tx.Exec(`SET LOCAL lock_timeout = '3s'`).Error; err != nil { + t.Fatal(err) + } + var account models.UserAccount + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("phone = ? AND status = ?", "13800000001", 1).First(&account).Error; err != nil { + t.Fatal("授权测试用户不可用") + } + originalHash := account.PasswordHash + temporaryHash, err := bcrypt.GenerateFromPassword([]byte("transaction-only-current"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + if err := tx.Model(&account).Update("password_hash", string(temporaryHash)).Error; err != nil { + t.Fatal("事务内夹具失败") + } + previous := impl.DBService + impl.DBService = tx + defer func() { impl.DBService = previous }() + for _, tc := range []struct { + current string + success bool + }{ + {"wrong-test-current", false}, {"transaction-only-current", true}, {"transaction-only-current", false}, + } { + ctx, response := addressContext(`{"current_password":"`+tc.current+`","new_password":"transaction-only-next"}`, "") + ctx.Set("Auth", &types.JwtClaims{Client: "user_app", Identity: account.Identity}) + ChangePassword(ctx) + var result struct { + Code int `json:"code"` + } + if json.Unmarshal(response.Body.Bytes(), &result) != nil || (result.Code == 0) != tc.success { + t.Fatal("改密返回状态不符") + } + } + var changed models.UserAccount + if tx.First(&changed, account.ID).Error != nil || bcrypt.CompareHashAndPassword([]byte(changed.PasswordHash), []byte("transaction-only-next")) != nil { + t.Fatal("新密码没有生效") + } + if err := tx.Rollback().Error; err != nil { + t.Fatal(err) + } + var restored models.UserAccount + if db.First(&restored, account.ID).Error != nil || restored.PasswordHash != originalHash { + t.Fatal("回滚后原密码未恢复") + } + t.Log("错误旧密码拒绝、正确改密、旧密码失效及远程回滚恢复通过") +} diff --git a/backend/api/internal/logic/client/user/change_password_test.go b/backend/api/internal/logic/client/user/change_password_test.go new file mode 100644 index 0000000..e363abf --- /dev/null +++ b/backend/api/internal/logic/client/user/change_password_test.go @@ -0,0 +1,45 @@ +// 功能描述:验证本人密码校验与并发更新保护;版本:1.0.0。 +package user + +import ( + "github.com/DATA-DOG/go-sqlmock" + "golang.org/x/crypto/bcrypt" + "strings" + "testing" +) + +// 只使用测试散列,断言响应不泄漏密码且过期密码版本不会覆盖新值。 +func TestChangePasswordVersionGuard(t *testing.T) { + hash, err := bcrypt.GenerateFromPassword([]byte("test-current-password"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, current string + updated int64 + success bool + }{ + {"错误旧密码", "wrong", -1, false}, + {"正确旧密码", "test-current-password", 1, true}, + {"并发已修改", "test-current-password", 0, false}, + } { + t.Run(tc.name, func(t *testing.T) { + mock := primaryTestDB(t) + mock.ExpectQuery(`SELECT .* FROM "user_account"`).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "status", "password_hash"}).AddRow(1, "alice", 1, string(hash))) + if tc.updated >= 0 { + mock.ExpectExec(`UPDATE "user_account" SET .* WHERE password_hash = \$[0-9]+.*"id" = \$[0-9]+`).WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), string(hash), 1).WillReturnResult(sqlmock.NewResult(0, tc.updated)) + } + ctx, response := addressContext(`{"current_password":"`+tc.current+`","new_password":"test-next-password"}`, "") + ChangePassword(ctx) + if strings.Contains(response.Body.String(), `"code":0`) != tc.success { + t.Fatal(response.Body.String()) + } + if strings.Contains(response.Body.String(), "test-next-password") || strings.Contains(response.Body.String(), string(hash)) { + t.Fatal("响应泄漏密码资料") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/backend/api/internal/logic/client/user/checkout_test.go b/backend/api/internal/logic/client/user/checkout_test.go new file mode 100644 index 0000000..f6af3fa --- /dev/null +++ b/backend/api/internal/logic/client/user/checkout_test.go @@ -0,0 +1,81 @@ +// 功能描述:验证结算金额确认、溢出回滚及已有订单的幂等恢复。 +// 版本:1.0.0。 +package user + +import ( + "fmt" + "math" + "strings" + "testing" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/DATA-DOG/go-sqlmock" +) + +func TestCheckoutMoneyAndRollback(t *testing.T) { + for _, tc := range []struct { + name string + price, expected int64 + code int + }{ + {"数量按服务端价格结算", 12800, 25600, 0}, + {"价格变化回滚库存", 12800, 12800, 2401}, + {"金额溢出不扣库存", math.MaxInt64, 0, 1711}, + } { + t.Run(tc.name, func(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectQuery(`SELECT .* FROM "ec_order"`).WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectQuery(`SELECT .* FROM "user_address"`).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "address"}).AddRow(2, "owned", "测试地址")) + mock.ExpectBegin() + mock.ExpectQuery(`SELECT .* FROM "ec_product".*FOR UPDATE`).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "price_amount", "stock_quantity"}).AddRow(3, "product", tc.price, 20)) + if tc.code != 1711 { + mock.ExpectExec(`UPDATE "ec_product" SET "stock_quantity"`).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery(`SELECT .* FROM "ec_product_image"`). + WillReturnRows(sqlmock.NewRows([]string{"id", "image_uri"}).AddRow(8, "/uploads/products/cover.png")) + } + if tc.code == 0 { + mock.ExpectQuery(`INSERT INTO "ec_order"`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(5)) + mock.ExpectQuery(`INSERT INTO "ec_order_item"`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(6)) + mock.ExpectCommit() + } else { + mock.ExpectRollback() + mock.ExpectQuery(`SELECT .* FROM "ec_order"`).WillReturnRows(sqlmock.NewRows([]string{"id"})) + } + ctx, response := addressContext(fmt.Sprintf(`{"request_no":"order-request","address_identity":"owned","contact_name":"收货人","contact_phone":"13800000001","expected_payable_amount":%d,"items":[{"product_identity":"product","quantity":2}]}`, tc.expected), "") + CreateShopOrder(ctx) + if !strings.Contains(response.Body.String(), fmt.Sprintf(`"code":%d`, tc.code)) { + t.Fatal(response.Body.String()) + } + if tc.code == 0 && !strings.Contains(response.Body.String(), `"payable_amount":25600`) { + t.Fatal("未按数量返回总额") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestShopOrderProductSnapshotIncludesCover(t *testing.T) { + value := shopOrderProductSnapshot(models.EcProduct{Entity: models.Entity{Identity: "product"}, Name: "报警器", ProductCode: "BJQ-01"}, "/uploads/products/cover.png") + for _, expected := range []string{`"identity":"product"`, `"name":"报警器"`, `"product_code":"BJQ-01"`, `"image_url":"/uploads/products/cover.png"`} { + if !strings.Contains(value, expected) { + t.Fatalf("订单商品快照缺少 %s: %s", expected, value) + } + } +} + +func TestCheckoutRetrySurvivesAddressRemoval(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectQuery(`SELECT .* FROM "ec_order".*request_no = \$1 AND user_account_id = \$2`).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "payable_amount"}).AddRow(5, "existing-order", 25600)) + ctx, response := addressContext(`{"request_no":"order-request","address_identity":"removed","contact_name":"收货人","contact_phone":"13800000001","items":[{"product_identity":"product","quantity":2}]}`, "") + CreateShopOrder(ctx) + if !strings.Contains(response.Body.String(), `"identity":"existing-order"`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/client/user/content_detail.go b/backend/api/internal/logic/client/user/content_detail.go new file mode 100644 index 0000000..fc86195 --- /dev/null +++ b/backend/api/internal/logic/client/user/content_detail.go @@ -0,0 +1,31 @@ +// 功能:公开安全内容详情,仅允许读取已启用发布的图文内容;版本:1.0.0。 +package user + +import ( + "errors" + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// PublicSafetyContent 按公开标识重新校验发布状态,旧链接不能访问草稿和下架内容。 +func PublicSafetyContent(ctx *gin.Context) { + var content models.CmsContent + err := impl.DBService.Where("identity = ? AND status = ? AND publish_status = ? AND content_type IN ?", + ctx.Param("identity"), common.StatusEnable, "published", []string{"notice", "safety_article", "law"}).Take(&content).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + } else { + infra.Response.Error(ctx, err) + } + return + } + // 白名单输出正文所需字段,避免内部主键及未来管理字段泄露。 + infra.Response.Success(ctx, gin.H{"identity": content.Identity, "title": content.Title, + "body": content.Body, "content_type": content.ContentType, "version_no": content.VersionNo}) +} diff --git a/backend/api/internal/logic/client/user/content_detail_test.go b/backend/api/internal/logic/client/user/content_detail_test.go new file mode 100644 index 0000000..c8233ce --- /dev/null +++ b/backend/api/internal/logic/client/user/content_detail_test.go @@ -0,0 +1,51 @@ +// 功能:安全内容详情发布边界和最小字段回归;版本:1.0.0。 +package user + +import ( + "encoding/json" + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" + "net/http/httptest" + "testing" +) + +// TestSafetyContentDetail 校验SQL包含状态及类型约束,不存在时统一返回1112。 +func TestSafetyContentDetail(t *testing.T) { + for _, exists := range []bool{true, false} { + t.Run(map[bool]string{true: "published", false: "unavailable"}[exists], func(t *testing.T) { + mock := primaryTestDB(t) + rows := sqlmock.NewRows([]string{"id", "identity", "title", "body", "content_type", "version_no"}) + if exists { + rows.AddRow(7, "article", "测试公告", "正文", "notice", 2) + } + mock.ExpectQuery(`SELECT .* FROM "cms_content" WHERE \(identity = \$1 AND status = \$2 AND publish_status = \$3 AND content_type IN`). + WithArgs("article", 1, "published", "notice", "safety_article", "law", 1).WillReturnRows(rows) + response := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(response) + ctx.Params = gin.Params{{Key: "identity", Value: "article"}} + ctx.Request = httptest.NewRequest("GET", "/public/contents/article", nil) + PublicSafetyContent(ctx) + var result struct { + Code int `json:"code"` + Details json.RawMessage `json:"details"` + } + if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if exists { + var fields map[string]any + if err := json.Unmarshal(result.Details, &fields); err != nil { + t.Fatal(err) + } + if result.Code != 0 || len(fields) != 5 || fields["body"] != "正文" { + t.Fatal(response.Body.String()) + } + } else if result.Code != 1112 { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/backend/api/internal/logic/client/user/content_read_test.go b/backend/api/internal/logic/client/user/content_read_test.go new file mode 100644 index 0000000..a46d0aa --- /dev/null +++ b/backend/api/internal/logic/client/user/content_read_test.go @@ -0,0 +1,38 @@ +// 功能描述:协议阅读确认必须匹配客户端实际展示版本;版本:1.0.0。 +package user + +import ( + "github.com/DATA-DOG/go-sqlmock" + "strings" + "testing" +) + +// TestContentReadRejectsStaleVersion 版本过期不能被服务端当前版本替代后确认。 +func TestContentReadRejectsStaleVersion(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectQuery(`SELECT .* FROM "cms_content".*identity = \$1 AND publish_status = \$2 AND status = \$3.*version_no = \$4`). + WithArgs("agreement-1", "published", 1, 2, 1).WillReturnRows(sqlmock.NewRows([]string{"id"})) + ctx, response := addressContext(`{"content_identity":"agreement-1","content_version":2,"request_no":"read-1"}`, "") + ConfirmContentRead(ctx) + if strings.Contains(response.Body.String(), `"code":0`) { + t.Fatal("错误确认过期协议") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +// TestContentReadRejectsInvalidVersion 无效版本不得触发内容或阅读记录写入。 +func TestContentReadRejectsInvalidVersion(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + ctx, response := addressContext(`{"content_identity":"agreement-1","content_version":0,"request_no":"read-1"}`, "") + ConfirmContentRead(ctx) + if strings.Contains(response.Body.String(), `"code":0`) { + t.Fatal("错误确认无效版本") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/client/user/delivery_detail.go b/backend/api/internal/logic/client/user/delivery_detail.go new file mode 100644 index 0000000..65d8ce4 --- /dev/null +++ b/backend/api/internal/logic/client/user/delivery_detail.go @@ -0,0 +1,106 @@ +// 功能描述:向用户本人提供供气订单的配送人员、配送点、资质与交付状态;版本:1.0.0。 +package user + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" +) + +// GetGasOrderDelivery 先按当前用户限定订单,再读取关联履约事实;不下发员工手机号或内部账号。 +func GetGasOrderDelivery(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var order models.GasorderBasic + if err := impl.DBService.Where("identity = ? AND user_account_id = ? AND status <> ?", ctx.Param("identity"), account.ID, common.StatusArchived).First(&order).Error; err != nil { + gasDetailError(ctx, err) + return + } + var station models.GasBasic + if order.GasBasicID != 0 { + if err := impl.DBService.Where("id = ? AND status = ?", order.GasBasicID, common.StatusEnable).Find(&station).Error; err != nil { + gasDetailError(ctx, err) + return + } + } + var delivery models.DeliveryBasic + if order.DeliveryBasicID != 0 { + if err := impl.DBService.Where("id = ? AND status = ?", order.DeliveryBasicID, common.StatusEnable).Find(&delivery).Error; err != nil { + gasDetailError(ctx, err) + return + } + } + var staff models.StaffAccount + if order.StaffAccountID != 0 { + if err := impl.DBService.Where("id = ? AND role_code = ? AND status = ?", order.StaffAccountID, "delivery", common.StatusEnable).Find(&staff).Error; err != nil { + gasDetailError(ctx, err) + return + } + } + var credential models.StaffCredential + if staff.ID != 0 { + if err := impl.DBService.Where("staff_account_id = ? AND status = ? AND (expired_at IS NULL OR expired_at > ?)", staff.ID, common.StatusEnable, time.Now()).Order("expired_at desc nulls first, id desc").Limit(1).Find(&credential).Error; err != nil { + gasDetailError(ctx, err) + return + } + } + var track models.GasorderTrack + if err := impl.DBService.Where("gasorder_basic_id = ? AND status <> ?", order.ID, common.StatusArchived).Order("attempt_no desc, id desc").Limit(1).Find(&track).Error; err != nil { + gasDetailError(ctx, err) + return + } + var items []models.GasorderItem + if err := impl.DBService.Where("gasorder_basic_id = ?", order.ID).Order("created_at, id").Find(&items).Error; err != nil { + gasDetailError(ctx, err) + return + } + products := make([]gin.H, 0, len(items)) + for _, item := range items { + products = append(products, gin.H{"name": item.ProductTypeName, "quantity": 1}) + } + response := gin.H{ + "identity": order.Identity, "order_no": order.OrderNo, "status_code": order.OrderStatus, + "status_name": gasOrderStatusName(order.OrderStatus), "status_message": deliveryStatusMessage(order.OrderStatus), + "appointment_at": order.AppointmentAt, "address": order.Address, "contact_name": order.ContactName, + "contact_phone_masked": common.MaskPhone(order.ContactPhone), "station_name": station.Name, + "delivery_name": delivery.Name, "delivery_address": delivery.Address, "staff_name": staff.Name, + "staff_avatar": "", + "staff_assigned": staff.ID != 0, "staff_avatar_available": staff.Avatar != "", + "credential_verified": credential.ID != 0, "credential_type": credential.CredentialType, + "track_available": track.ID != 0, "track_started_at": nullableTrackTime(track.ID != 0, track.StartedAt), + "track_updated_at": nullableTrackTime(track.ID != 0, track.UpdatedAt), "products": products, + "controlled_call_available": false, "message_available": false, "vehicle_configured": false, + } + infra.Response.Success(ctx, response) +} + +// deliveryStatusMessage 只解释已有订单状态,不推断实时位置或预计到达时间。 +func deliveryStatusMessage(status int) string { + switch status { + case 18, 19, 20, 35: + return "订单正在准备配送" + case 33: + return "配送员正在履约,请留意订单状态更新" + case 34: + return "商品已到达,请核对后确认签收" + case 23: + return "本次配送已完成" + case 21: + return "配送出现异常,请联系平台处理" + default: + return "当前订单尚未进入配送阶段" + } +} + +func nullableTrackTime(available bool, value time.Time) any { + if !available || value.IsZero() { + return nil + } + return value +} diff --git a/backend/api/internal/logic/client/user/delivery_detail_test.go b/backend/api/internal/logic/client/user/delivery_detail_test.go new file mode 100644 index 0000000..8938478 --- /dev/null +++ b/backend/api/internal/logic/client/user/delivery_detail_test.go @@ -0,0 +1,51 @@ +// 功能描述:验证用户配送详情的状态文案与隐私边界;版本:1.0.0。 +package user + +import ( + "strings" + "testing" + "time" +) + +func TestDeliveryStatusMessageUsesOrderFacts(t *testing.T) { + cases := map[int]string{ + 20: "准备配送", + 33: "正在履约", + 34: "确认签收", + 23: "配送已完成", + 21: "配送出现异常", + } + for status, expected := range cases { + if message := deliveryStatusMessage(status); !strings.Contains(message, expected) { + t.Fatalf("status %d message = %q", status, message) + } + } +} + +func TestNullableTrackTimeRejectsUnavailableValue(t *testing.T) { + if nullableTrackTime(false, timeForDeliveryTest()) != nil { + t.Fatal("不可用轨迹不得返回更新时间") + } +} + +func timeForDeliveryTest() time.Time { return time.Date(2026, 9, 11, 9, 56, 0, 0, time.UTC) } + +func TestApproximateCoordinateProtectsPreciseLocation(t *testing.T) { + value, ok := approximateCoordinate("121.473701", -180, 180) + if !ok || value != 121.474 { + t.Fatalf("approximateCoordinate = %v, %v", value, ok) + } + if _, ok := approximateCoordinate("181", -180, 180); ok { + t.Fatal("越界坐标不得下发") + } +} + +func TestDeliveryTimelineTextOnlyExposesFulfilmentStates(t *testing.T) { + title, detail, visible := deliveryTimelineText(33, "薛海气站") + if !visible || title != "配送员已出发" || !strings.Contains(detail, "薛海气站") { + t.Fatalf("配送节点文案异常:%q %q %v", title, detail, visible) + } + if _, _, visible := deliveryTimelineText(16, "薛海气站"); visible { + t.Fatal("付款状态不得混入配送轨迹") + } +} diff --git a/backend/api/internal/logic/client/user/delivery_track.go b/backend/api/internal/logic/client/user/delivery_track.go new file mode 100644 index 0000000..41c1db3 --- /dev/null +++ b/backend/api/internal/logic/client/user/delivery_track.go @@ -0,0 +1,121 @@ +// 功能描述:向用户本人提供隐私化配送轨迹与履约时间线;版本:1.0.0。 +package user + +import ( + "math" + "strconv" + + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" +) + +// GetGasOrderDeliveryTrack 先限定本人订单,再将定位点约化到约百米精度后下发。 +func GetGasOrderDeliveryTrack(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var order models.GasorderBasic + if err := impl.DBService.Where("identity = ? AND user_account_id = ? AND status <> ?", ctx.Param("identity"), account.ID, common.StatusArchived).First(&order).Error; err != nil { + gasDetailError(ctx, err) + return + } + var station models.GasBasic + if order.GasBasicID != 0 { + if err := impl.DBService.Where("id = ? AND status = ?", order.GasBasicID, common.StatusEnable).Find(&station).Error; err != nil { + gasDetailError(ctx, err) + return + } + } + var staff models.StaffAccount + if order.StaffAccountID != 0 { + if err := impl.DBService.Where("id = ? AND role_code = ? AND status = ?", order.StaffAccountID, "delivery", common.StatusEnable).Find(&staff).Error; err != nil { + gasDetailError(ctx, err) + return + } + } + var track models.GasorderTrack + if err := impl.DBService.Where("gasorder_basic_id = ? AND status <> ?", order.ID, common.StatusArchived).Order("attempt_no desc, id desc").Limit(1).Find(&track).Error; err != nil { + gasDetailError(ctx, err) + return + } + var points []models.GasorderTrackPoint + if track.ID != 0 { + if err := impl.DBService.Where("gasorder_track_id = ? AND status = ?", track.ID, common.StatusEnable).Order("occurred_at asc, id asc").Limit(200).Find(&points).Error; err != nil { + gasDetailError(ctx, err) + return + } + } + var histories []models.GasorderStatus + if err := impl.DBService.Where("gasorder_basic_id = ? AND status <> ?", order.ID, common.StatusArchived).Order("occurred_at asc, id asc").Find(&histories).Error; err != nil { + gasDetailError(ctx, err) + return + } + publicPoints := make([]gin.H, 0, len(points)) + for _, point := range points { + longitude, longitudeOK := approximateCoordinate(point.Longitude, -180, 180) + latitude, latitudeOK := approximateCoordinate(point.Latitude, -90, 90) + if longitudeOK && latitudeOK { + publicPoints = append(publicPoints, gin.H{"longitude": longitude, "latitude": latitude, "occurred_at": point.OccurredAt}) + } + } + publicTimeline := make([]gin.H, 0, len(histories)) + for _, history := range histories { + if title, detail, visible := deliveryTimelineText(history.ToStatus, station.Name); visible { + publicTimeline = append(publicTimeline, gin.H{ + "status_code": history.ToStatus, "title": title, "detail": detail, "occurred_at": history.OccurredAt, + }) + } + } + destinationLongitude, destinationLongitudeOK := approximateCoordinate(order.Longitude, -180, 180) + destinationLatitude, destinationLatitudeOK := approximateCoordinate(order.Latitude, -90, 90) + infra.Response.Success(ctx, gin.H{ + "identity": order.Identity, "status_code": order.OrderStatus, "status_name": gasOrderStatusName(order.OrderStatus), + "status_message": deliveryStatusMessage(order.OrderStatus), "appointment_at": order.AppointmentAt, + "updated_at": nullableTrackTime(track.ID != 0, track.UpdatedAt), "station_name": station.Name, + "staff_name": staff.Name, "staff_avatar": "", "staff_phone_masked": common.MaskPhone(staff.Phone), + "controlled_call_available": false, "route_available": len(publicPoints) > 0, + "destination_available": destinationLongitudeOK && destinationLatitudeOK, + "destination_longitude": destinationLongitude, "destination_latitude": destinationLatitude, + "points": publicPoints, "timeline": publicTimeline, + }) +} + +// approximateCoordinate 仅返回三位小数,避免用户接口暴露服务人员的精确实时位置。 +func approximateCoordinate(value string, min, max float64) (float64, bool) { + parsed, err := strconv.ParseFloat(value, 64) + if err != nil || parsed < min || parsed > max { + return 0, false + } + return math.Round(parsed*1000) / 1000, true +} + +// deliveryTimelineText 将不可变订单状态转换为用户可理解的履约节点。 +func deliveryTimelineText(status int, stationName string) (string, string, bool) { + switch status { + case 18: + return "气站已接单", namedStation(stationName) + "已接收您的订单", true + case 20: + return "商品装车完成", "商品已装车,准备出发", true + case 33: + return "配送员已出发", "配送员已从" + namedStation(stationName) + "出发", true + case 34: + return "商品已送达", "请核对商品并确认签收", true + case 23: + return "配送已完成", "本次配送已完成", true + case 21: + return "配送出现异常", "平台正在处理本次配送异常", true + default: + return "", "", false + } +} + +func namedStation(name string) string { + if name == "" { + return "供气站" + } + return name +} diff --git a/backend/api/internal/logic/client/user/deposit.go b/backend/api/internal/logic/client/user/deposit.go new file mode 100644 index 0000000..5042687 --- /dev/null +++ b/backend/api/internal/logic/client/user/deposit.go @@ -0,0 +1,100 @@ +// 功能描述:用户本人押金汇总、状态筛选和规则读取;版本:1.0.0。 +package user + +import ( + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" +) + +// ListDeposits 只按JWT账户返回押金事实,不接受调用方指定用户。 +func ListDeposits(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + status := ctx.Query("status") + var summary struct { + RefundableAmount int64 `gorm:"column:refundable_amount"` + UsingCount int `gorm:"column:using_count"` + } + if err := impl.DBService.Model(&models.DepositRecord{}). + Where("user_account_id = ? AND status <> ? AND deposit_status = ?", account.ID, common.StatusArchived, 10). + Select("COALESCE(SUM(amount), 0) AS refundable_amount, COUNT(*) AS using_count").Scan(&summary).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + query := impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, common.StatusArchived) + switch status { + case "", "all": + case "using": + query = query.Where("deposit_status = ?", 10) + case "refunding": + query = query.Where("deposit_status = ?", 20) + case "returned": + query = query.Where("deposit_status = ?", 23) + default: + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var records []models.DepositRecord + if err := query.Order("paid_at desc, identity desc").Find(&records).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + items := make([]gin.H, 0, len(records)) + returnByDeposit := make(map[uint64]models.DepositReturnRequest, len(records)) + if len(records) > 0 { + ids := make([]uint64, 0, len(records)) + for _, record := range records { + ids = append(ids, record.ID) + } + var returns []models.DepositReturnRequest + if err := impl.DBService.Where("deposit_record_id IN ? AND return_status <> ?", ids, 50).Find(&returns).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + for _, request := range returns { + returnByDeposit[request.DepositRecordID] = request + } + } + for _, record := range records { + var product models.ProductInfo + if err := impl.DBService.Where("id = ?", record.ProductInfoID).Take(&product).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + var productType models.ProductType + if err := impl.DBService.Where("id = ?", product.ProductTypeID).Take(&productType).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + statusName := map[int]string{10: "使用中", 20: "退款中", 23: "已退回"}[record.DepositStatus] + if statusName == "" { + statusName = "处理中" + } + item := gin.H{ + "identity": record.Identity, "deposit_no": record.DepositNo, "deposit_status": record.DepositStatus, + "status_name": statusName, "amount": record.Amount, "product_name": productType.Name, + "product_code": product.Code, "paid_at": record.PaidAt, "refunded_at": record.RefundedAt, + "allowed_actions": func() []string { + if record.DepositStatus == 10 { + return []string{"return_bottle"} + } + return []string{} + }(), + } + if request, exists := returnByDeposit[record.ID]; exists { + item["return_request_identity"] = request.Identity + item["return_status"] = request.ReturnStatus + item["return_status_name"] = map[int]string{10: "待上门", 20: "已回收待验收", 30: "已验收待退款", 40: "已完成"}[request.ReturnStatus] + } + items = append(items, item) + } + var policy models.DepositPolicy + _ = impl.DBService.Where("status = ?", common.StatusEnable).Order("updated_at desc").Find(&policy).Error + infra.Response.Success(ctx, gin.H{"refundable_amount": summary.RefundableAmount, "using_count": summary.UsingCount, "items": items, "rule_text": policy.RuleText}) +} diff --git a/backend/api/internal/logic/client/user/deposit_return.go b/backend/api/internal/logic/client/user/deposit_return.go new file mode 100644 index 0000000..22a42e2 --- /dev/null +++ b/backend/api/internal/logic/client/user/deposit_return.go @@ -0,0 +1,189 @@ +// 功能描述:用户提交、查询和取消本人退瓶退押金申请;版本:1.0.0。 +package user + +import ( + "errors" + "strings" + "time" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// CreateDepositReturn 锁定本人使用中的押金记录,幂等创建上门回收申请。 +func CreateDepositReturn(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var request struct { + DepositIdentity string `json:"deposit_identity" binding:"required"` + AddressIdentity string `json:"address_identity" binding:"required"` + RequestNo string `json:"request_no" binding:"required,max=128"` + AppointmentStart time.Time `json:"appointment_start" binding:"required"` + AppointmentEnd time.Time `json:"appointment_end" binding:"required"` + BottleIntact bool `json:"bottle_intact"` + ValveSafe bool `json:"valve_safe"` + StoppedUsing bool `json:"stopped_using"` + RuleAccepted bool `json:"rule_accepted"` + } + if ctx.ShouldBindJSON(&request) != nil || strings.TrimSpace(request.RequestNo) == "" || + !request.BottleIntact || !request.ValveSafe || !request.StoppedUsing || !request.RuleAccepted || + !validReturnAppointment(request.AppointmentStart, request.AppointmentEnd, time.Now()) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var response models.DepositReturnRequest + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var existing models.DepositReturnRequest + if err := tx.Where("request_no = ? AND user_account_id = ?", request.RequestNo, account.ID).First(&existing).Error; err == nil { + var original models.DepositRecord + if existing.DepositRecordID == 0 || tx.Select("id").Where("identity = ? AND user_account_id = ?", request.DepositIdentity, account.ID).First(&original).Error != nil || original.ID != existing.DepositRecordID { + return gorm.ErrInvalidData + } + response = existing + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + var deposit models.DepositRecord + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where( + "identity = ? AND user_account_id = ? AND deposit_status = ? AND status = ?", + request.DepositIdentity, account.ID, 10, common.StatusEnable, + ).First(&deposit).Error; err != nil { + return err + } + var address models.UserAddress + if err := tx.Where("identity = ? AND user_account_id = ? AND status = ?", request.AddressIdentity, account.ID, common.StatusEnable).First(&address).Error; err != nil { + return err + } + if strings.TrimSpace(address.Address) == "" || strings.TrimSpace(address.ContactName) == "" || !common.ValidPhone(address.ContactPhone) { + return gorm.ErrInvalidData + } + var cancelled models.DepositReturnRequest + cancelledErr := tx.Where("deposit_record_id = ?", deposit.ID).First(&cancelled).Error + if cancelledErr == nil { + if cancelled.ReturnStatus != 50 { + return gorm.ErrInvalidData + } + updates := map[string]any{ + "return_status": 10, "request_no": request.RequestNo, + "user_address_id": address.ID, "address_snapshot": address.Address, + "contact_name": address.ContactName, "contact_phone": address.ContactPhone, + "appointment_start": request.AppointmentStart, "appointment_end": request.AppointmentEnd, + "estimated_amount": deposit.Amount, "deduction_amount": 0, "refund_amount": 0, + "inspection_remark": "", "operator_identity": "", "picked_up_at": nil, + "inspected_at": nil, "completed_at": nil, + } + if err := tx.Model(&cancelled).Updates(updates).Error; err != nil { + return err + } + response = cancelled + response.ReturnStatus = 10 + response.RequestNo = request.RequestNo + response.AddressSnapshot = address.Address + response.ContactName = address.ContactName + response.ContactPhone = address.ContactPhone + response.AppointmentStart = request.AppointmentStart + response.AppointmentEnd = request.AppointmentEnd + response.EstimatedAmount = deposit.Amount + response.DeductionAmount = 0 + response.RefundAmount = 0 + response.InspectionRemark = "" + response.OperatorIdentity = "" + response.PickedUpAt = nil + response.InspectedAt = nil + response.CompletedAt = nil + } else if !errors.Is(cancelledErr, gorm.ErrRecordNotFound) { + return cancelledErr + } + if cancelledErr == nil { + return tx.Model(&deposit).Where("deposit_status = ?", 10).Update("deposit_status", 20).Error + } + response = models.DepositReturnRequest{ + Entity: common.NewEntity(common.StatusEnable), ReturnStatus: 10, RequestNo: request.RequestNo, + DepositRecordID: deposit.ID, UserAccountID: account.ID, UserAddressID: address.ID, + AddressSnapshot: address.Address, ContactName: address.ContactName, ContactPhone: address.ContactPhone, + AppointmentStart: request.AppointmentStart, AppointmentEnd: request.AppointmentEnd, + EstimatedAmount: deposit.Amount, + } + if err := tx.Create(&response).Error; err != nil { + return err + } + return tx.Model(&deposit).Where("deposit_status = ?", 10).Update("deposit_status", 20).Error + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, gorm.ErrInvalidData) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, depositReturnResponse(response)) +} + +// GetDepositReturn 只返回当前用户的退瓶申请。 +func GetDepositReturn(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var request models.DepositReturnRequest + if err := impl.DBService.Where("identity = ? AND user_account_id = ?", ctx.Param("identity"), account.ID).First(&request).Error; err != nil { + common.RespondRecordError(ctx, err) + return + } + infra.Response.Success(ctx, depositReturnResponse(request)) +} + +// CancelDepositReturn 只允许用户取消尚未上门的申请。 +func CancelDepositReturn(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var request models.DepositReturnRequest + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND user_account_id = ? AND return_status = ?", ctx.Param("identity"), account.ID, 10).First(&request).Error; err != nil { + return err + } + if err := tx.Model(&request).Update("return_status", 50).Error; err != nil { + return err + } + return tx.Model(&models.DepositRecord{}).Where("id = ? AND deposit_status = ?", request.DepositRecordID, 20).Update("deposit_status", 10).Error + }) + if err != nil { + common.RespondRecordError(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"cancelled": true}) +} + +func validReturnAppointment(start, end, now time.Time) bool { + return start.After(now.Add(30*time.Minute)) && end.After(start) && end.Sub(start) <= 4*time.Hour && start.Before(now.Add(31*24*time.Hour)) +} + +func depositReturnResponse(request models.DepositReturnRequest) gin.H { + statusName := map[int]string{10: "待上门", 20: "已回收待验收", 30: "已验收待退款", 40: "已完成", 50: "已取消"}[request.ReturnStatus] + return gin.H{ + "identity": request.Identity, "return_status": request.ReturnStatus, "status_name": statusName, + "address": request.AddressSnapshot, "contact_name": request.ContactName, "contact_phone": request.ContactPhone, + "appointment_start": request.AppointmentStart, "appointment_end": request.AppointmentEnd, + "estimated_amount": request.EstimatedAmount, "deduction_amount": request.DeductionAmount, + "refund_amount": request.RefundAmount, "inspection_remark": request.InspectionRemark, + "picked_up_at": request.PickedUpAt, "inspected_at": request.InspectedAt, "completed_at": request.CompletedAt, + "allowed_actions": func() []string { + if request.ReturnStatus == 10 { + return []string{"cancel"} + } + return []string{} + }(), + } +} diff --git a/backend/api/internal/logic/client/user/deposit_return_test.go b/backend/api/internal/logic/client/user/deposit_return_test.go new file mode 100644 index 0000000..f8020a2 --- /dev/null +++ b/backend/api/internal/logic/client/user/deposit_return_test.go @@ -0,0 +1,29 @@ +// 功能描述:验证退瓶预约时段边界;版本:1.0.0。 +package user + +import ( + "testing" + "time" +) + +func TestValidReturnAppointment(t *testing.T) { + now := time.Date(2026, 9, 11, 12, 0, 0, 0, time.Local) + cases := []struct { + name string + start, end time.Time + want bool + }{ + {"valid", now.Add(time.Hour), now.Add(3 * time.Hour), true}, + {"too-soon", now.Add(20 * time.Minute), now.Add(2 * time.Hour), false}, + {"reverse", now.Add(2 * time.Hour), now.Add(time.Hour), false}, + {"too-long", now.Add(time.Hour), now.Add(6 * time.Hour), false}, + {"too-far", now.Add(32 * 24 * time.Hour), now.Add(32*24*time.Hour + 2*time.Hour), false}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + if got := validReturnAppointment(test.start, test.end, now); got != test.want { + t.Fatalf("validReturnAppointment()=%v want %v", got, test.want) + } + }) + } +} diff --git a/backend/api/internal/logic/client/user/device_groups.go b/backend/api/internal/logic/client/user/device_groups.go new file mode 100644 index 0000000..429bd56 --- /dev/null +++ b/backend/api/internal/logic/client/user/device_groups.go @@ -0,0 +1,218 @@ +// 功能:用户设备分组资料与本人设备归组,严格隔离账户并保持新增幂等;版本:1.0.0。 +package user + +import ( + "errors" + "strings" + "unicode/utf8" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const maxDeviceGroups = 20 + +var ( + errDeviceGroupLimit = errcode.NewError(2511, "最多可创建20个设备分组") + errDeviceGroupDuplicate = errcode.NewError(2512, "该分组名称已存在") + errDeviceGroupRequestChanged = errcode.NewError(2513, "该新增请求已处理,请关闭表单并刷新分组列表") +) + +type deviceGroupInput struct { + Name string `json:"name"` + RequestNo string `json:"request_no"` +} + +// validDeviceGroupInput 清理名称并校验新增幂等标识。 +func validDeviceGroupInput(input *deviceGroupInput, creating bool) bool { + input.Name = strings.TrimSpace(input.Name) + if input.Name == "" || utf8.RuneCountInString(input.Name) > 32 { + return false + } + if !creating { + return true + } + parsed, err := uuid.Parse(input.RequestNo) + return err == nil && parsed != uuid.Nil && parsed.String() == input.RequestNo +} + +// ListDeviceGroups 返回本人分组及每组当前仍归属本人的设备公开标识。 +func ListDeviceGroups(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var groups []models.UserDeviceGroup + if err := impl.DBService.Where("user_account_id = ? AND status = ?", account.ID, common.StatusEnable). + Order("sort_no, created_at, identity").Limit(maxDeviceGroups).Find(&groups).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + groupIDs := make([]uint64, 0, len(groups)) + for _, group := range groups { + groupIDs = append(groupIDs, group.ID) + } + devicesByGroup := map[uint64][]string{} + if len(groupIDs) > 0 { + var devices []models.ProductInfo + if err := ownedProductQuery(account.ID).Where("device_group_id IN ? AND device_kind IN ? AND product_status <> ?", groupIDs, []string{"valve", "alarm"}, common.StatusScrapped). + Order("created_at, identity").Find(&devices).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + for _, device := range devices { + devicesByGroup[device.DeviceGroupID] = append(devicesByGroup[device.DeviceGroupID], device.Identity) + } + } + items := make([]gin.H, 0, len(groups)) + for _, group := range groups { + identities := devicesByGroup[group.ID] + if identities == nil { + identities = []string{} + } + items = append(items, gin.H{"identity": group.Identity, "name": group.Name, "sort_no": group.SortNo, "device_identities": identities}) + } + infra.Response.Success(ctx, items) +} + +// SaveDeviceGroup 新建或改名;账户行锁保证上限、幂等和同名约束串行执行。 +func SaveDeviceGroup(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + creating := ctx.Param("identity") == "" + var input deviceGroupInput + if ctx.ShouldBindJSON(&input) != nil || !validDeviceGroupInput(&input, creating) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var group models.UserDeviceGroup + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := lockAddressOwner(tx, account.ID); err != nil { + return err + } + if creating { + err := tx.Where("user_account_id = ? AND request_no = ?", account.ID, input.RequestNo).First(&group).Error + if err == nil { + if group.Status != common.StatusEnable || group.Name != input.Name { + return errDeviceGroupRequestChanged + } + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + var count int64 + if err := tx.Model(&models.UserDeviceGroup{}).Where("user_account_id = ? AND status = ?", account.ID, common.StatusEnable).Count(&count).Error; err != nil { + return err + } + if count >= maxDeviceGroups { + return errDeviceGroupLimit + } + group = models.UserDeviceGroup{Entity: common.NewEntity(common.StatusEnable), UserAccountID: account.ID, Name: input.Name, RequestNo: input.RequestNo, SortNo: int(count)} + } else if err := tx.Where("identity = ? AND user_account_id = ? AND status = ?", ctx.Param("identity"), account.ID, common.StatusEnable).First(&group).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errcode.ErrRecordNotFound + } + return err + } + var duplicates int64 + if err := tx.Model(&models.UserDeviceGroup{}).Where("user_account_id = ? AND status = ? AND name = ? AND identity <> ?", account.ID, common.StatusEnable, input.Name, group.Identity).Count(&duplicates).Error; err != nil { + return err + } + if duplicates > 0 { + return errDeviceGroupDuplicate + } + group.Name = input.Name + return tx.Save(&group).Error + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"identity": group.Identity, "name": group.Name, "sort_no": group.SortNo}) +} + +// DeleteDeviceGroup 先解除本人设备归组再归档分组;重复删除保持成功。 +func DeleteDeviceGroup(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := lockAddressOwner(tx, account.ID); err != nil { + return err + } + var group models.UserDeviceGroup + if err := tx.Where("identity = ? AND user_account_id = ?", ctx.Param("identity"), account.ID).First(&group).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errcode.ErrRecordNotFound + } + return err + } + if group.Status == common.StatusArchived { + return nil + } + if err := tx.Model(&models.ProductInfo{}).Where("user_account_id = ? AND device_group_id = ?", account.ID, group.ID).Update("device_group_id", 0).Error; err != nil { + return err + } + return tx.Model(&group).Update("status", common.StatusArchived).Error + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"deleted": true}) +} + +// AssignDeviceGroup 将本人启用智能设备移入本人分组;空标识表示移出分组。 +func AssignDeviceGroup(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var request struct { + GroupIdentity string `json:"group_identity"` + } + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var device models.ProductInfo + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where( + "identity = ? AND user_account_id = ? AND status = ? AND device_kind IN ? AND product_status <> ?", + ctx.Param("identity"), account.ID, common.StatusEnable, []string{"valve", "alarm"}, common.StatusScrapped, + ).First(&device).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errcode.ErrRecordNotFound + } + return err + } + groupID := uint64(0) + if strings.TrimSpace(request.GroupIdentity) != "" { + var group models.UserDeviceGroup + if err := tx.Where("identity = ? AND user_account_id = ? AND status = ?", strings.TrimSpace(request.GroupIdentity), account.ID, common.StatusEnable).First(&group).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errcode.ErrRecordNotFound + } + return err + } + groupID = group.ID + } + return tx.Model(&device).Update("device_group_id", groupID).Error + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"updated": true}) +} diff --git a/backend/api/internal/logic/client/user/device_groups_test.go b/backend/api/internal/logic/client/user/device_groups_test.go new file mode 100644 index 0000000..0531d87 --- /dev/null +++ b/backend/api/internal/logic/client/user/device_groups_test.go @@ -0,0 +1,63 @@ +// 功能:验证设备分组输入边界、本人隔离与最小公开响应;版本:1.0.0。 +package user + +import ( + "net/http/httptest" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +// TestDeviceGroupInputBoundary 分组名称和幂等号不能被空白或畸形输入绕过。 +func TestDeviceGroupInputBoundary(t *testing.T) { + for _, input := range []deviceGroupInput{ + {Name: "", RequestNo: "00000000-0000-7000-8000-000000000001"}, + {Name: strings.Repeat("分", 33), RequestNo: "00000000-0000-7000-8000-000000000001"}, + {Name: "厨房", RequestNo: "not-a-uuid"}, + {Name: "厨房", RequestNo: "00000000-0000-0000-0000-000000000000"}, + } { + if validDeviceGroupInput(&input, true) { + t.Fatalf("接受了无效分组:%+v", input) + } + } + good := deviceGroupInput{Name: " 厨房 ", RequestNo: "00000000-0000-7000-8000-000000000001"} + if !validDeviceGroupInput(&good, true) || good.Name != "厨房" { + t.Fatal("合法分组被拒绝") + } +} + +// TestDeviceGroupsListDoesNotExposeOwner 空列表也必须限定本人且不泄露内部归属字段。 +func TestDeviceGroupsListDoesNotExposeOwner(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectQuery(`SELECT .* FROM "user_device_group".*user_account_id = \$1 AND status = \$2.*LIMIT \$3`). + WithArgs(uint64(1), 1, maxDeviceGroups).WillReturnRows(sqlmock.NewRows([]string{"id"})) + ctx, response := addressContext("", "") + ctx.Request = httptest.NewRequest("GET", "/device-groups?user_account_id=99", nil) + ListDeviceGroups(ctx) + if !strings.Contains(response.Body.String(), `"details":[]`) || strings.Contains(response.Body.String(), "user_account_id") { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +// TestAssignDeviceGroupRejectsOtherOwner 产品查询绑定登录账户,其他账户设备统一按不存在处理。 +func TestAssignDeviceGroupRejectsOtherOwner(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectBegin() + mock.ExpectQuery(`SELECT .* FROM "product_info".*identity = \$1 AND user_account_id = \$2 AND status = \$3 AND device_kind IN \(\$4,\$5\) AND product_status <> \$6.*FOR UPDATE`). + WithArgs("other-device", uint64(1), 1, "valve", "alarm", 27, 1).WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectRollback() + ctx, response := addressContext(`{"group_identity":"other-group"}`, "other-device") + AssignDeviceGroup(ctx) + if !strings.Contains(response.Body.String(), `"code":1112`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/client/user/emergency_contacts.go b/backend/api/internal/logic/client/user/emergency_contacts.go new file mode 100644 index 0000000..a2cfb72 --- /dev/null +++ b/backend/api/internal/logic/client/user/emergency_contacts.go @@ -0,0 +1,160 @@ +// 功能:紧急联系人本人隔离、五人上限和幂等新增;版本:1.0.0。 +package user + +import ( + "errors" + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gorm.io/gorm" + "regexp" + "strings" + "unicode/utf8" +) + +// contactInput 仅接收联系人资料,不接收客户端伪造的设备或通知授权。 +type contactInput struct { + Name string `json:"name"` + Phone string `json:"phone"` + Relationship string `json:"relationship"` + RequestNo string `json:"request_no"` +} + +// 业务拒绝使用稳定错误码,区别于连接失败和校验失败。 +var ( + errContactLimit = errcode.NewError(2501, "最多可添加5位联系人,请先移除不再使用的联系人") + errContactDuplicate = errcode.NewError(2502, "该手机号已在联系人列表中,请勿重复添加") + errContactRequestChanged = errcode.NewError(2503, "该新增请求已处理,请关闭表单并刷新联系人列表") +) + +// validContact 约束输入和新增幂等标识;返回是否可保存。 +func validContact(input *contactInput, creating bool) bool { + input.Name = strings.TrimSpace(input.Name) + input.Phone = strings.TrimSpace(input.Phone) + input.Relationship = strings.TrimSpace(input.Relationship) + if input.Name == "" || utf8.RuneCountInString(input.Name) > 64 || !regexp.MustCompile(`^1[3-9][0-9]{9}$`).MatchString(input.Phone) || utf8.RuneCountInString(input.Relationship) > 32 { + return false + } + if creating { + parsed, err := uuid.Parse(input.RequestNo) + return err == nil && parsed != uuid.Nil && parsed.String() == input.RequestNo + } + return true +} + +// contactView 白名单响应,不暴露内部归属或声明尚不存在的权限。 +func contactView(value models.UserEmergencyContact) gin.H { + return gin.H{"identity": value.Identity, "name": value.Name, "phone": value.Phone, "relationship": value.Relationship, + "notification_available": false, "device_access_available": false, "control_available": false} +} + +// ListEmergencyContacts 返回当前用户启用联系人,列表按创建顺序稳定排列。 +func ListEmergencyContacts(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var records []models.UserEmergencyContact + if err := impl.DBService.Where("user_account_id = ? AND status = ?", account.ID, common.StatusEnable).Order("created_at, identity").Limit(5).Find(&records).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + items := make([]gin.H, 0, len(records)) + for _, record := range records { + items = append(items, contactView(record)) + } + infra.Response.Success(ctx, items) +} + +// SaveEmergencyContact 新增或修改本人联系人;事务内锁账户保证并发上限。 +func SaveEmergencyContact(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var input contactInput + creating := ctx.Param("identity") == "" + if ctx.ShouldBindJSON(&input) != nil || !validContact(&input, creating) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var record models.UserEmergencyContact + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := lockAddressOwner(tx, account.ID); err != nil { + return err + } + if creating { + err := tx.Where("user_account_id = ? AND request_no = ?", account.ID, input.RequestNo).First(&record).Error + if err == nil { + // 已删除的新增请求不能再次激活,同一请求号也不能改变原资料。 + if record.Status != common.StatusEnable || record.Name != input.Name || record.Phone != input.Phone || record.Relationship != input.Relationship { + return errContactRequestChanged + } + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + var count int64 + if err := tx.Model(&models.UserEmergencyContact{}).Where("user_account_id = ? AND status = ?", account.ID, common.StatusEnable).Count(&count).Error; err != nil { + return err + } + if count >= 5 { + return errContactLimit + } + record = models.UserEmergencyContact{Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable}, UserAccountID: account.ID, RequestNo: input.RequestNo} + } else if err := tx.Where("identity = ? AND user_account_id = ? AND status = ?", ctx.Param("identity"), account.ID, common.StatusEnable).First(&record).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errcode.ErrRecordNotFound + } + return err + } + var duplicates int64 + if err := tx.Model(&models.UserEmergencyContact{}).Where("user_account_id = ? AND status = ? AND phone = ? AND identity <> ?", account.ID, common.StatusEnable, input.Phone, record.Identity).Count(&duplicates).Error; err != nil { + return err + } + if duplicates > 0 { + return errContactDuplicate + } + record.Name, record.Phone, record.Relationship = input.Name, input.Phone, input.Relationship + return tx.Save(&record).Error + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, contactView(record)) +} + +// DeleteEmergencyContact 归档本人记录,重复删除成功;不存在或跨账户统一拒绝。 +func DeleteEmergencyContact(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := lockAddressOwner(tx, account.ID); err != nil { + return err + } + var record models.UserEmergencyContact + if err := tx.Where("identity = ? AND user_account_id = ?", ctx.Param("identity"), account.ID).First(&record).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errcode.ErrRecordNotFound + } + return err + } + if record.Status == common.StatusArchived { + return nil + } + return tx.Model(&record).Update("status", common.StatusArchived).Error + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"deleted": true}) +} diff --git a/backend/api/internal/logic/client/user/emergency_contacts_test.go b/backend/api/internal/logic/client/user/emergency_contacts_test.go new file mode 100644 index 0000000..59bc904 --- /dev/null +++ b/backend/api/internal/logic/client/user/emergency_contacts_test.go @@ -0,0 +1,135 @@ +// 功能:紧急联系人校验、并发上限查询及越权保护回归;版本:1.0.0。 +package user + +import ( + "github.com/DATA-DOG/go-sqlmock" + "net/http/httptest" + "strings" + "testing" +) + +// TestContactInputBoundary 手机和幂等标识不能被畸形输入绕过。 +func TestContactInputBoundary(t *testing.T) { + for _, input := range []contactInput{ + {Name: "", Phone: "13800000001"}, + {Name: "联系人", Phone: "123"}, + {Name: "联系人", Phone: "13800000001", RequestNo: "00000000-0000-0000-0000-000000000000"}, + } { + if validContact(&input, true) { + t.Fatal("接受了无效联系人") + } + } + good := contactInput{Name: " 联系人 ", Phone: "13800000001", RequestNo: "00000000-0000-7000-8000-000000000001"} + if !validContact(&good, true) || good.Name != "联系人" { + t.Fatal("合法输入被拒绝") + } +} + +// TestContactReplayDoesNotDuplicateOrRevive 同一新增请求可重试,但归档后的记录不能被复活。 +func TestContactReplayDoesNotDuplicateOrRevive(t *testing.T) { + for _, status := range []int{1, 3} { + mock := primaryTestDB(t) + expectAddressAccount(mock) + expectAddressLock(mock) + mock.ExpectQuery(`SELECT .* FROM "user_emergency_contact".*user_account_id = \$1 AND request_no = \$2`). + WithArgs(1, "00000000-0000-7000-8000-000000000001", 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "status", "name", "phone", "relationship"}).AddRow(3, "contact", status, "联系人", "13800000001", "家人")) + if status == 1 { + mock.ExpectCommit() + } else { + mock.ExpectRollback() + } + ctx, response := addressContext(`{"name":"联系人","phone":"13800000001","relationship":"家人","request_no":"00000000-0000-7000-8000-000000000001"}`, "") + SaveEmergencyContact(ctx) + success := strings.Contains(response.Body.String(), `"code":0`) + if success != (status == 1) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + } +} + +// TestContactLimitLocked 五人上限在账户锁内检查,不允许第六人插入。 +func TestContactLimitLocked(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + expectAddressLock(mock) + mock.ExpectQuery(`SELECT .* FROM "user_emergency_contact".*user_account_id = \$1 AND request_no = \$2`).WithArgs(1, "00000000-0000-7000-8000-000000000001", 1).WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectQuery(`SELECT count\(\*\) FROM "user_emergency_contact".*user_account_id = \$1 AND status = \$2`).WithArgs(1, 1).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(5)) + mock.ExpectRollback() + ctx, response := addressContext(`{"name":"联系人","phone":"13800000001","request_no":"00000000-0000-7000-8000-000000000001"}`, "") + SaveEmergencyContact(ctx) + if !strings.Contains(response.Body.String(), `"code":2501`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +// TestContactOwnership 删除和编辑均使用当前账户限定记录,不泄露他人数据。 +func TestContactOwnership(t *testing.T) { + for _, removing := range []bool{false, true} { + mock := primaryTestDB(t) + expectAddressAccount(mock) + expectAddressLock(mock) + query := mock.ExpectQuery(`SELECT .* FROM "user_emergency_contact".*identity = \$1 AND user_account_id = \$2`) + if removing { + query.WithArgs("other", 1, 1) + } else { + query.WithArgs("other", 1, 1, 1) + } + query.WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectRollback() + ctx, response := addressContext(`{"name":"联系人","phone":"13800000001"}`, "other") + if removing { + DeleteEmergencyContact(ctx) + } else { + SaveEmergencyContact(ctx) + } + if !strings.Contains(response.Body.String(), `"code":1112`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + } +} + +// TestContactDuplicateRejectsUpdate 重复号码错误不能覆盖已有联系人资料。 +func TestContactDuplicateRejectsUpdate(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + expectAddressLock(mock) + mock.ExpectQuery(`SELECT .* FROM "user_emergency_contact".*identity = \$1 AND user_account_id = \$2`). + WithArgs("contact", 1, 1, 1).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "name", "phone"}).AddRow(3, "contact", "原姓名", "13800000003")) + mock.ExpectQuery(`SELECT count\(\*\) FROM "user_emergency_contact".*user_account_id = \$1 AND status = \$2 AND phone = \$3 AND identity <> \$4`). + WithArgs(1, 1, "13800000001", "contact").WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectRollback() + ctx, response := addressContext(`{"name":"修改姓名","phone":"13800000001"}`, "contact") + SaveEmergencyContact(ctx) + if !strings.Contains(response.Body.String(), `"code":2502`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +// TestContactsListNeverClaimsPermission 列表仅取本人启用记录,能力不能伪造为已授权。 +func TestContactsListNeverClaimsPermission(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectQuery(`SELECT .* FROM "user_emergency_contact".*user_account_id = \$1 AND status = \$2`).WithArgs(1, 1, 5).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "name", "phone"}).AddRow(3, "contact", "联系人", "13800000001")) + ctx, response := addressContext("", "") + ctx.Request = httptest.NewRequest("GET", "/emergency-contacts?user_account_id=99", nil) + ListEmergencyContacts(ctx) + if !strings.Contains(response.Body.String(), `"control_available":false`) || strings.Contains(response.Body.String(), `"user_account_id"`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/client/user/family.go b/backend/api/internal/logic/client/user/family.go new file mode 100644 index 0000000..2fd06f3 --- /dev/null +++ b/backend/api/internal/logic/client/user/family.go @@ -0,0 +1,365 @@ +// 功能:家庭成员邀请确认、按设备授权、撤销和审计;版本:1.0.0。 +package user + +import ( + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + familyInvitePending = 10 + familyInviteAccepted = 20 + familyInviteRejected = 30 + familyInviteRevoked = 40 +) + +var ( + errFamilySelf = errcode.NewError(2510, "不能邀请当前账号加入自己的家庭") + errFamilyDuplicate = errcode.NewError(2511, "该手机号已有待确认或已加入的家庭成员") + errFamilyExpired = errcode.NewError(2512, "邀请已失效,请联系房主重新邀请") +) + +type familyDevicePermissionInput struct { + DeviceIdentity string `json:"device_identity"` + CanView bool `json:"can_view"` + CanAlert bool `json:"can_alert"` + CanControl bool `json:"can_control"` +} + +type familyInviteInput struct { + Name string `json:"name"` + Phone string `json:"phone"` + Relationship string `json:"relationship"` + RequestNo string `json:"request_no"` + Permissions []familyDevicePermissionInput `json:"device_permissions"` +} + +// validFamilyInvite 清理并校验邀请资料;设备授权必须至少包含查看权限。 +func validFamilyInvite(input *familyInviteInput) bool { + input.Name, input.Phone, input.Relationship = strings.TrimSpace(input.Name), strings.TrimSpace(input.Phone), strings.TrimSpace(input.Relationship) + parsed, err := uuid.Parse(input.RequestNo) + if err != nil || parsed == uuid.Nil || parsed.String() != input.RequestNo || input.Name == "" || utf8.RuneCountInString(input.Name) > 64 || !common.ValidPhone(input.Phone) || utf8.RuneCountInString(input.Relationship) > 32 { + return false + } + for _, permission := range input.Permissions { + if strings.TrimSpace(permission.DeviceIdentity) == "" || (permission.CanAlert || permission.CanControl) && !permission.CanView { + return false + } + } + return true +} + +func maskFamilyPhone(phone string) string { + if len(phone) == 11 { + return phone[:3] + "****" + phone[7:] + } + return "***" +} + +// familyAudit 在业务事务内追加不可变审计记录。 +func familyAudit(tx *gorm.DB, ownerID, memberID, actorID uint64, action, summary string) error { + return tx.Create(&models.UserFamilyAudit{Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable}, OwnerUserAccountID: ownerID, FamilyMemberID: memberID, ActorUserAccountID: actorID, Action: action, Summary: summary}).Error +} + +// familyMemberView 返回脱敏成员和当前设备授权,不暴露内部关联键。 +func familyMemberView(record models.UserFamilyMember, shares []models.UserDeviceShare, products map[uint64]models.ProductInfo) gin.H { + permissions := make([]gin.H, 0, len(shares)) + for _, share := range shares { + product, ok := products[share.ProductInfoID] + if !ok || share.Status != common.StatusEnable { + continue + } + permissions = append(permissions, gin.H{"device_identity": product.Identity, "device_name": product.Name, "device_kind": product.DeviceKind, "can_view": share.CanView, "can_alert": share.CanAlert, "can_control": share.CanControl}) + } + return gin.H{"identity": record.Identity, "name": record.Name, "phone_masked": maskFamilyPhone(record.Phone), "relationship": record.Relationship, "invite_status": record.InviteStatus, "expires_at": record.ExpiresAt, "device_permissions": permissions} +} + +// FamilyDashboard 返回本人作为房主的成员、设备和权限汇总。 +func FamilyDashboard(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var members []models.UserFamilyMember + if err := impl.DBService.Where("owner_user_account_id = ? AND status = ? AND invite_status IN ?", account.ID, common.StatusEnable, []int{familyInvitePending, familyInviteAccepted}).Order("created_at, identity").Find(&members).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + var devices []models.ProductInfo + if err := ownedProductQuery(account.ID).Where("device_kind IN ? AND product_status <> ?", []string{"valve", "alarm"}, common.StatusScrapped).Order("created_at, identity").Find(&devices).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + memberIDs := make([]uint64, 0, len(members)) + for _, member := range members { + memberIDs = append(memberIDs, member.ID) + } + var shares []models.UserDeviceShare + if len(memberIDs) > 0 { + if err := impl.DBService.Where("family_member_id IN ? AND status = ?", memberIDs, common.StatusEnable).Find(&shares).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + } + productMap := make(map[uint64]models.ProductInfo, len(devices)) + for _, device := range devices { + productMap[device.ID] = device + } + sharesByMember := map[uint64][]models.UserDeviceShare{} + shareCounts := map[uint64]int{} + for _, share := range shares { + sharesByMember[share.FamilyMemberID] = append(sharesByMember[share.FamilyMemberID], share) + if share.CanView { + shareCounts[share.ProductInfoID]++ + } + } + memberViews := make([]gin.H, 0, len(members)+1) + memberViews = append(memberViews, gin.H{"identity": account.Identity, "name": account.Name, "phone_masked": maskFamilyPhone(account.Phone), "relationship": "房主", "invite_status": familyInviteAccepted, "is_owner": true, "device_permissions": []gin.H{}}) + for _, member := range members { + memberViews = append(memberViews, familyMemberView(member, sharesByMember[member.ID], productMap)) + } + deviceViews := make([]gin.H, 0, len(devices)) + for _, device := range devices { + deviceViews = append(deviceViews, gin.H{"identity": device.Identity, "name": device.Name, "kind": device.DeviceKind, "share_count": shareCounts[device.ID], "mapping_configured": device.VendorDeviceID != ""}) + } + incoming, err := incomingFamilyInvitations(account) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"household_name": account.Name + "的家", "owner_name": account.Name, "member_count": len(members), "device_count": len(deviceViews), "members": memberViews, "devices": deviceViews, "incoming_invitations": incoming}) +} + +// incomingFamilyInvitations 读取当前手机号收到的有效邀请及房主名称。 +func incomingFamilyInvitations(account models.UserAccount) ([]gin.H, error) { + var rows []models.UserFamilyMember + if err := impl.DBService.Where("phone = ? AND status = ? AND invite_status = ? AND expires_at > ?", account.Phone, common.StatusEnable, familyInvitePending, time.Now()).Order("created_at desc").Find(&rows).Error; err != nil { + return nil, err + } + ownerIDs := make([]uint64, 0, len(rows)) + for _, row := range rows { + ownerIDs = append(ownerIDs, row.OwnerUserAccountID) + } + owners := map[uint64]string{} + if len(ownerIDs) > 0 { + var users []models.UserAccount + if err := impl.DBService.Where("id IN ?", ownerIDs).Find(&users).Error; err != nil { + return nil, err + } + for _, user := range users { + owners[user.ID] = user.Name + } + } + items := make([]gin.H, 0, len(rows)) + for _, row := range rows { + items = append(items, gin.H{"identity": row.Identity, "owner_name": owners[row.OwnerUserAccountID], "name": row.Name, "relationship": row.Relationship, "expires_at": row.ExpiresAt}) + } + return items, nil +} + +// InviteFamilyMember 创建七天有效邀请及预设权限;成员接受前不会获得访问权。 +func InviteFamilyMember(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var input familyInviteInput + if ctx.ShouldBindJSON(&input) != nil || !validFamilyInvite(&input) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + if input.Phone == account.Phone { + infra.Response.Error(ctx, errFamilySelf) + return + } + var record models.UserFamilyMember + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := lockAddressOwner(tx, account.ID); err != nil { + return err + } + if err := tx.Where("owner_user_account_id = ? AND request_no = ?", account.ID, input.RequestNo).First(&record).Error; err == nil { + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + var duplicate int64 + if err := tx.Model(&models.UserFamilyMember{}).Where("owner_user_account_id = ? AND phone = ? AND status = ? AND invite_status IN ?", account.ID, input.Phone, common.StatusEnable, []int{familyInvitePending, familyInviteAccepted}).Count(&duplicate).Error; err != nil { + return err + } + if duplicate > 0 { + return errFamilyDuplicate + } + record = models.UserFamilyMember{Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable}, OwnerUserAccountID: account.ID, Name: input.Name, Phone: input.Phone, Relationship: input.Relationship, InviteStatus: familyInvitePending, RequestNo: input.RequestNo, ExpiresAt: time.Now().Add(7 * 24 * time.Hour)} + if err := tx.Create(&record).Error; err != nil { + return err + } + if err := replaceFamilyPermissions(tx, account.ID, record.ID, input.Permissions); err != nil { + return err + } + return familyAudit(tx, account.ID, record.ID, account.ID, "invite", fmt.Sprintf("邀请%s,预设%d台设备权限", record.Name, len(input.Permissions))) + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"identity": record.Identity, "invite_status": record.InviteStatus, "expires_at": record.ExpiresAt}) +} + +// replaceFamilyPermissions 仅允许房主名下设备,空列表表示撤销全部设备授权。 +func replaceFamilyPermissions(tx *gorm.DB, ownerID, memberID uint64, inputs []familyDevicePermissionInput) error { + if err := tx.Model(&models.UserDeviceShare{}).Where("family_member_id = ? AND status = ?", memberID, common.StatusEnable).Update("status", common.StatusArchived).Error; err != nil { + return err + } + seen := map[string]bool{} + for _, input := range inputs { + identity := strings.TrimSpace(input.DeviceIdentity) + if seen[identity] { + return errcode.ErrInvalidArgument + } + seen[identity] = true + var product models.ProductInfo + if err := tx.Where("identity = ? AND user_account_id = ? AND status = ? AND device_kind IN ? AND product_status <> ?", identity, ownerID, common.StatusEnable, []string{"valve", "alarm"}, common.StatusScrapped).First(&product).Error; err != nil { + return errcode.ErrPermissionDenied + } + share := models.UserDeviceShare{Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable}, FamilyMemberID: memberID, ProductInfoID: product.ID, CanView: input.CanView, CanAlert: input.CanAlert, CanControl: input.CanControl} + if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "family_member_id"}, {Name: "product_info_id"}}, DoUpdates: clause.Assignments(map[string]interface{}{"status": common.StatusEnable, "can_view": input.CanView, "can_alert": input.CanAlert, "can_control": input.CanControl, "updated_at": time.Now()})}).Create(&share).Error; err != nil { + return err + } + } + return nil +} + +// UpdateFamilyPermissions 调整成员设备范围,权限立即生效并写入审计。 +func UpdateFamilyPermissions(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var input struct { + Permissions []familyDevicePermissionInput `json:"device_permissions"` + } + if ctx.ShouldBindJSON(&input) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + for _, permission := range input.Permissions { + if strings.TrimSpace(permission.DeviceIdentity) == "" || (permission.CanAlert || permission.CanControl) && !permission.CanView { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + } + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var member models.UserFamilyMember + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND owner_user_account_id = ? AND status = ? AND invite_status IN ?", ctx.Param("identity"), account.ID, common.StatusEnable, []int{familyInvitePending, familyInviteAccepted}).First(&member).Error; err != nil { + return errcode.ErrRecordNotFound + } + if err := replaceFamilyPermissions(tx, account.ID, member.ID, input.Permissions); err != nil { + return err + } + return familyAudit(tx, account.ID, member.ID, account.ID, "update_permissions", fmt.Sprintf("调整%s的设备权限,共%d台", member.Name, len(input.Permissions))) + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"updated": true}) +} + +// RevokeFamilyMember 撤销邀请或移除成员,同时停用其全部设备授权。 +func RevokeFamilyMember(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var member models.UserFamilyMember + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND owner_user_account_id = ? AND status = ? AND invite_status IN ?", ctx.Param("identity"), account.ID, common.StatusEnable, []int{familyInvitePending, familyInviteAccepted}).First(&member).Error; err != nil { + return errcode.ErrRecordNotFound + } + now := time.Now() + if err := tx.Model(&member).Updates(map[string]interface{}{"invite_status": familyInviteRevoked, "revoked_at": now, "status": common.StatusArchived}).Error; err != nil { + return err + } + if err := tx.Model(&models.UserDeviceShare{}).Where("family_member_id = ? AND status = ?", member.ID, common.StatusEnable).Update("status", common.StatusArchived).Error; err != nil { + return err + } + return familyAudit(tx, account.ID, member.ID, account.ID, "revoke", "撤销成员"+member.Name) + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"revoked": true}) +} + +// ListFamilyInvitations 返回当前手机号尚未过期的邀请。 +func ListFamilyInvitations(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + items, err := incomingFamilyInvitations(account) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, items) +} + +// RespondFamilyInvitation 仅受邀手机号本人可接受或拒绝邀请。 +func RespondFamilyInvitation(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var input struct { + Accept bool `json:"accept"` + } + if ctx.ShouldBindJSON(&input) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var member models.UserFamilyMember + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND phone = ? AND status = ? AND invite_status = ?", ctx.Param("identity"), account.Phone, common.StatusEnable, familyInvitePending).First(&member).Error; err != nil { + return errcode.ErrRecordNotFound + } + if time.Now().After(member.ExpiresAt) { + return errFamilyExpired + } + status, action := familyInviteRejected, "reject" + updates := map[string]interface{}{"invite_status": status, "status": common.StatusArchived} + if input.Accept { + status, action = familyInviteAccepted, "accept" + now := time.Now() + updates = map[string]interface{}{"invite_status": status, "member_user_account_id": account.ID, "accepted_at": now} + } + if err := tx.Model(&member).Updates(updates).Error; err != nil { + return err + } + if !input.Accept { + if err := tx.Model(&models.UserDeviceShare{}).Where("family_member_id = ?", member.ID).Update("status", common.StatusArchived).Error; err != nil { + return err + } + } + return familyAudit(tx, member.OwnerUserAccountID, member.ID, account.ID, action, "成员响应家庭邀请") + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"accepted": input.Accept}) +} diff --git a/backend/api/internal/logic/client/user/favorite.go b/backend/api/internal/logic/client/user/favorite.go new file mode 100644 index 0000000..a17cdba --- /dev/null +++ b/backend/api/internal/logic/client/user/favorite.go @@ -0,0 +1,140 @@ +// 功能描述:用户商品收藏、分类列表与条件状态更新;版本:1.0.0。 +package user + +import ( + "errors" + "fmt" + "math" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +var errFavoriteChanged = errcode.NewError(2404, "收藏状态已变化,请刷新后重试") + +// favoriteValue 只返回商品公开标识和收藏状态,版本绑定收藏关系以防旧请求复活。 +func favoriteValue(item models.EcFavorite, productIdentity string) gin.H { + revision := "" + if item.ID != 0 { + revision = fmt.Sprintf("%s:%d", item.Identity, item.Revision) + } + return gin.H{"product_identity": productIdentity, "favorite": item.ID != 0 && item.Status == common.StatusEnable, "revision": revision} +} + +// FavoriteState 读取当前账户对指定商品的状态;商品下架后仍能取消收藏。 +func FavoriteState(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var product models.EcProduct + if err := impl.DBService.Unscoped().Where("identity = ?", ctx.Param("identity")).Take(&product).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + err = errcode.ErrRecordNotFound + } + infra.Response.Error(ctx, err) + return + } + var item models.EcFavorite + if err := impl.DBService.Where("user_account_id = ? AND ec_product_id = ?", account.ID, product.ID).Find(&item).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, favoriteValue(item, product.Identity)) +} + +// SetFavorite 使用绝对收藏状态和原版本;相同目标重试无写入,不支持物理删除。 +func SetFavorite(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var request struct { + Favorite *bool `json:"favorite" binding:"required"` + Revision string `json:"revision" binding:"max=128"` + } + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var item models.EcFavorite + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := lockAddressOwner(tx, account.ID); err != nil { + return err + } + var product models.EcProduct + if err := tx.Unscoped().Where("identity = ?", ctx.Param("identity")).Take(&product).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errcode.ErrRecordNotFound + } + return err + } + if err := tx.Where("user_account_id = ? AND ec_product_id = ?", account.ID, product.ID).Find(&item).Error; err != nil { + return err + } + state := favoriteValue(item, product.Identity) + if state["favorite"] == *request.Favorite { + return nil + } + if state["revision"] != request.Revision { + return errFavoriteChanged + } + if *request.Favorite && (product.Status != common.StatusEnable || product.DeletedAt.Valid) { + return errcode.ErrRecordNotFound + } + if *request.Favorite { + var count int64 + if err := tx.Model(&models.EcFavorite{}).Where("user_account_id = ? AND status = ?", account.ID, common.StatusEnable).Count(&count).Error; err != nil { + return err + } + if count >= 1000 { + return errcode.ErrOutOfRange + } + } + if item.ID == 0 { + item = models.EcFavorite{Entity: common.NewEntity(common.StatusEnable), UserAccountID: account.ID, EcProductID: product.ID, Revision: 1} + return tx.Create(&item).Error + } + if item.Revision >= math.MaxInt64 { + return errcode.ErrOutOfRange + } + status := common.StatusArchived + if *request.Favorite { + status = common.StatusEnable + } + return tx.Model(&item).Updates(map[string]any{"status": status, "revision": item.Revision + 1}).Error + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, favoriteValue(item, ctx.Param("identity"))) +} + +// ListFavorites 按最后状态更新时间分页;下架/软删除商品仍展示历史条目并禁止加购。 +func ListFavorites(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + query, page, ok := paginateClientList(ctx, impl.DBService.Where("user_account_id = ? AND status = ?", account.ID, common.StatusEnable)) + if !ok { + return + } + var items []models.EcFavorite + if err := query.Order("updated_at desc, identity").Find(&items).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + result, err := favoriteRows(impl.DBService, items) + if err != nil { + infra.Response.Error(ctx, err) + return + } + respondClientPage(ctx, page, result) +} diff --git a/backend/api/internal/logic/client/user/favorite_list.go b/backend/api/internal/logic/client/user/favorite_list.go new file mode 100644 index 0000000..8c81261 --- /dev/null +++ b/backend/api/internal/logic/client/user/favorite_list.go @@ -0,0 +1,81 @@ +// 功能描述:收藏页关联资料批量加载,避免远程数据库逐商品往返;版本:1.0.0。 +package user + +import ( + "git.apinb.com/bsm-sdk/core/errcode" + common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// favoriteRows 接收已按账户过滤的收藏页,只读取该页关联资料;响应不含内部主键。 +func favoriteRows(db *gorm.DB, items []models.EcFavorite) ([]gin.H, error) { + result := make([]gin.H, 0, len(items)) + if len(items) == 0 { + return result, nil + } + ids := make([]uint64, 0, len(items)) + for _, item := range items { + ids = append(ids, item.EcProductID) + } + var products []models.EcProduct + if err := db.Unscoped().Where("id IN ?", ids).Find(&products).Error; err != nil { + return nil, err + } + productMap := make(map[uint64]models.EcProduct, len(products)) + categoryIDs := make([]uint64, 0, len(products)) + seen := map[uint64]bool{} + for _, product := range products { + productMap[product.ID] = product + if !seen[product.EcCategoryID] { + categoryIDs = append(categoryIDs, product.EcCategoryID) + seen[product.EcCategoryID] = true + } + } + var pictures []models.EcProductImage + if err := db.Where("ec_product_id IN ? AND status = ?", ids, common.StatusEnable).Order("is_cover desc, sort_no, identity").Find(&pictures).Error; err != nil { + return nil, err + } + imageMap := map[uint64]string{} + for _, picture := range pictures { + if _, ok := imageMap[picture.EcProductID]; !ok { + imageMap[picture.EcProductID] = picture.ImageURI + } + } + var categories []models.EcCategory + if err := db.Where("id IN ? AND status = ?", categoryIDs, common.StatusEnable).Find(&categories).Error; err != nil { + return nil, err + } + categoryMap := map[uint64]string{} + for _, category := range categories { + categoryMap[category.ID] = category.Name + } + var attributes []models.EcProductAttribute + if err := db.Where("ec_product_id IN ? AND status = ?", ids, common.StatusEnable).Order("sort_no, identity").Find(&attributes).Error; err != nil { + return nil, err + } + labels := map[uint64][]string{} + for _, attribute := range attributes { + if len(labels[attribute.EcProductID]) < 2 { + labels[attribute.EcProductID] = append(labels[attribute.EcProductID], attribute.Name+":"+attribute.Value) + } + } + for _, item := range items { + product, ok := productMap[item.EcProductID] + if !ok { + return nil, errcode.ErrRecordNotFound + } + value := favoriteValue(item, product.Identity) + value["name"], value["price_amount"], value["stock_quantity"] = product.Name, product.PriceAmount, product.StockQuantity + value["available"] = product.Status == common.StatusEnable && !product.DeletedAt.Valid && product.StockQuantity > 0 + value["image_url"], value["category_name"] = imageMap[product.ID], categoryMap[product.EcCategoryID] + specifications := labels[product.ID] + if specifications == nil { + specifications = []string{} + } + value["specifications"] = specifications + result = append(result, value) + } + return result, nil +} diff --git a/backend/api/internal/logic/client/user/favorite_test.go b/backend/api/internal/logic/client/user/favorite_test.go new file mode 100644 index 0000000..9744d4c --- /dev/null +++ b/backend/api/internal/logic/client/user/favorite_test.go @@ -0,0 +1,138 @@ +// 功能描述:收藏幂等、归档版本、下架保留及所有权边界回归;版本:1.0.0。 +package user + +import ( + "encoding/json" + "fmt" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/DATA-DOG/go-sqlmock" + "strings" + "testing" +) + +func TestFavoriteConditionalWrite(t *testing.T) { + for _, tc := range []struct { + name string + exists, active bool + status, productStatus, code int + revision string + write bool + }{ + {"首次收藏", false, true, 0, 1, 0, "", true}, + {"首次未收藏无需建行", false, false, 0, 1, 0, "", false}, + {"重复收藏无副作用", true, true, 1, 1, 0, "old", false}, + {"下架仍可取消", true, false, 1, 0, 0, "favorite:2", true}, + {"重复取消不更新", true, false, 3, 0, 0, "old", false}, + {"旧版本不能重新收藏", true, true, 3, 1, 2404, "favorite:1", false}, + {"下架不能重新收藏", true, true, 3, 0, 1112, "favorite:2", false}, + {"归档后正确版本重新收藏", true, true, 3, 1, 0, "favorite:2", true}, + } { + t.Run(tc.name, func(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + expectAddressLock(mock) + mock.ExpectQuery(`SELECT .* FROM "ec_product"`).WithArgs("product", 1).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "status"}).AddRow(3, "product", tc.productStatus)) + rows := sqlmock.NewRows([]string{"id", "identity", "status", "revision"}) + if tc.exists { + rows.AddRow(4, "favorite", tc.status, 2) + } + mock.ExpectQuery(`SELECT .* FROM "ec_favorite".*user_account_id = \$1 AND ec_product_id = \$2`).WithArgs(1, 3).WillReturnRows(rows) + if tc.write { + if tc.active { + mock.ExpectQuery(`SELECT count\(\*\) FROM "ec_favorite"`).WithArgs(1, 1).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + } + if tc.exists { + mock.ExpectExec(`UPDATE "ec_favorite" SET .*`).WillReturnResult(sqlmock.NewResult(0, 1)) + } else { + mock.ExpectQuery(`INSERT INTO "ec_favorite"`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(4)) + } + } + if tc.code == 0 { + mock.ExpectCommit() + } else { + mock.ExpectRollback() + } + ctx, response := addressContext(fmt.Sprintf(`{"favorite":%t,"revision":%q}`, tc.active, tc.revision), "product") + SetFavorite(ctx) + if !strings.Contains(response.Body.String(), fmt.Sprintf(`"code":%d`, tc.code)) { + t.Fatal(response.Body.String()) + } + if tc.code == 0 && !strings.Contains(response.Body.String(), fmt.Sprintf(`"favorite":%t`, tc.active)) { + t.Fatal(response.Body.String()) + } + if tc.exists && tc.write && !strings.Contains(response.Body.String(), `"revision":"favorite:3"`) { + t.Fatal("修改后版本必须递增") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestFavoriteMissingStateRejected(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + ctx, response := addressContext(`{"revision":""}`, "product") + SetFavorite(ctx) + if !strings.Contains(response.Body.String(), `"code":1704`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestFavoriteListKeepsUnavailableProduct(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectQuery(`SELECT .* FROM "ec_favorite".*user_account_id = \$1 AND status = \$2`).WithArgs(1, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "ec_product_id", "status", "revision"}).AddRow(4, "favorite", 3, 1, 2)) + mock.ExpectQuery(`SELECT .* FROM "ec_product".*id IN`).WithArgs(3).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "name", "status", "ec_category_id", "price_amount", "stock_quantity"}).AddRow(3, "product", "下架燃气灶", 0, 7, 69900, 5)) + mock.ExpectQuery(`SELECT .* FROM "ec_product_image".*ec_product_id IN`).WithArgs(3, 1).WillReturnRows(sqlmock.NewRows([]string{"ec_product_id", "image_uri"}).AddRow(3, "/stove.png")) + mock.ExpectQuery(`SELECT .* FROM "ec_category".*id IN`).WithArgs(7, 1).WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(7, "灶具")) + mock.ExpectQuery(`SELECT .* FROM "ec_product_attribute".*ec_product_id IN`).WithArgs(3, 1).WillReturnRows(sqlmock.NewRows([]string{"ec_product_id", "name", "value"}).AddRow(3, "气源", "天然气")) + ctx, response := addressContext("", "") + ListFavorites(ctx) + var body struct { + Code int `json:"code"` + Details []map[string]any `json:"details"` + } + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Code != 0 || len(body.Details) != 1 || body.Details[0]["available"] != false || body.Details[0]["name"] != "下架燃气灶" { + t.Fatal(response.Body.String()) + } + if body.Details[0]["image_url"] != "/stove.png" || body.Details[0]["category_name"] != "灶具" || body.Details[0]["specifications"].([]any)[0] != "气源:天然气" { + t.Fatal("批量关联数据不匹配") + } + for _, key := range []string{"id", "ec_product_id", "user_account_id"} { + if _, ok := body.Details[0][key]; ok { + t.Fatal("公开响应泄露内部标识") + } + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +// 多商品只执行四次资料查询,按商品隔离主图和至多两条规格。 +func TestFavoriteRowsBatchAssociations(t *testing.T) { + mock := primaryTestDB(t) + mock.ExpectQuery(`SELECT .* FROM "ec_product".*id IN`).WithArgs(3, 5).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "ec_category_id", "status", "stock_quantity"}).AddRow(5, "p5", 7, 1, 2).AddRow(3, "p3", 7, 1, 2)) + mock.ExpectQuery(`SELECT .* FROM "ec_product_image".*ec_product_id IN`).WithArgs(3, 5, 1).WillReturnRows(sqlmock.NewRows([]string{"ec_product_id", "image_uri"}).AddRow(3, "/cover.png").AddRow(5, "/five.png").AddRow(3, "/other.png")) + mock.ExpectQuery(`SELECT .* FROM "ec_category".*id IN`).WithArgs(7, 1).WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(7, "配件")) + mock.ExpectQuery(`SELECT .* FROM "ec_product_attribute".*ec_product_id IN`).WithArgs(3, 5, 1).WillReturnRows(sqlmock.NewRows([]string{"ec_product_id", "name", "value"}).AddRow(3, "材质", "钢").AddRow(5, "长度", "2米").AddRow(3, "型号", "A").AddRow(3, "颜色", "蓝")) + rows, err := favoriteRows(impl.DBService, []models.EcFavorite{{Entity: models.Entity{ID: 1, Status: 1}, EcProductID: 3}, {Entity: models.Entity{ID: 2, Status: 1}, EcProductID: 5}}) + if err != nil { + t.Fatal(err) + } + if len(rows) != 2 || rows[0]["product_identity"] != "p3" || rows[0]["image_url"] != "/cover.png" || rows[1]["image_url"] != "/five.png" || len(rows[0]["specifications"].([]string)) != 2 || rows[1]["specifications"].([]string)[0] != "长度:2米" { + t.Fatal("批量关联串商品或改变顺序", rows) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/client/user/gas_confirm.go b/backend/api/internal/logic/client/user/gas_confirm.go new file mode 100644 index 0000000..440357d --- /dev/null +++ b/backend/api/internal/logic/client/user/gas_confirm.go @@ -0,0 +1,59 @@ +// 功能描述:用户本人确认供气签收,原子记录证据与释放气瓶占用;版本:1.0.0。 +package user + +import ( + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "time" +) + +// ConfirmGasReceipt JWT用户确认只处理待签收订单;已完成重试无二次写入。 +func ConfirmGasReceipt(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var request struct { + RequestNo string `json:"request_no" binding:"required,max=128"` + } + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var order models.GasorderBasic + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND user_account_id = ? AND status <> ?", ctx.Param("identity"), account.ID, common.StatusArchived).First(&order).Error; err != nil { + return err + } + if order.OrderStatus == common.StatusCompleted { + return nil + } + if order.OrderStatus != common.StatusAwaitingConfirmation { + return errcode.ErrInvalidArgument + } + now := time.Now() + // user_app表示本人通过已认证会话明确确认,不伪造手写签名、签收码或图片。 + confirm := models.GasorderConfirm{Entity: common.NewEntity(common.StatusEnable), GasorderBasicID: order.ID, RequestNo: request.RequestNo, ConfirmType: "user_app", RecipientName: account.Name, RecipientPhone: account.Phone, ConfirmedAt: now, Remark: "本人通过用户端确认收货"} + if err := tx.Create(&confirm).Error; err != nil { + return err + } + if err := tx.Model(&order).Update("order_status", common.StatusCompleted).Error; err != nil { + return err + } + if err := tx.Model(&models.GasorderItem{}).Where("gasorder_basic_id = ?", order.ID).Update("active", false).Error; err != nil { + return err + } + return tx.Create(&models.GasorderStatus{Entity: common.NewEntity(common.StatusEnable), GasorderBasicID: order.ID, FromStatus: common.StatusAwaitingConfirmation, ToStatus: common.StatusCompleted, OperatorIdentity: account.Identity, OperatorName: account.Name, OccurredAt: now, Reason: "本人通过用户端确认收货"}).Error + }) + if err != nil { + gasDetailError(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"confirmed": true, "order_status": common.StatusCompleted}) +} diff --git a/backend/api/internal/logic/client/user/gas_confirm_test.go b/backend/api/internal/logic/client/user/gas_confirm_test.go new file mode 100644 index 0000000..c877882 --- /dev/null +++ b/backend/api/internal/logic/client/user/gas_confirm_test.go @@ -0,0 +1,86 @@ +// 功能描述:本人签收事务、状态边界、失败回滚与重复确认回归;版本:1.0.0。 +package user + +import ( + "fmt" + "github.com/DATA-DOG/go-sqlmock" + "strings" + "testing" +) + +func TestGasReceiptAtomicAndIdempotent(t *testing.T) { + for _, tc := range []struct { + name string + status int + fail bool + code int + }{ + {"待签收本人确认", 34, false, 0}, {"重复完成不再次写入", 23, false, 0}, {"配送中禁止签收", 33, false, 1704}, {"已取消禁止签收", 22, false, 1704}, {"释放失败整体回滚", 34, true, 1105}, + } { + t.Run(tc.name, func(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectBegin() + mock.ExpectQuery(`SELECT .* FROM "gasorder_basic".*identity = \$1 AND user_account_id = \$2 AND status <> \$3.*FOR UPDATE`).WithArgs("order", 1, 3, 1).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "order_status"}).AddRow(7, "order", tc.status)) + if tc.status == 34 { + mock.ExpectQuery(`INSERT INTO "gasorder_confirm"`).WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), nil, 1, 7, "receipt-request", "user_app", "测试用户", "13800000001", "", sqlmock.AnyArg(), "本人通过用户端确认收货").WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(8)) + mock.ExpectExec(`UPDATE "gasorder_basic" SET`).WillReturnResult(sqlmock.NewResult(0, 1)) + update := mock.ExpectExec(`UPDATE "gasorder_item" SET .*active.*gasorder_basic_id =`) + if tc.fail { + update.WillReturnError(fmt.Errorf("database failure")) + } else { + update.WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectQuery(`INSERT INTO "gasorder_status"`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(9)) + } + } + if tc.code == 0 { + mock.ExpectCommit() + } else { + mock.ExpectRollback() + } + ctx, response := addressContext(`{"request_no":"receipt-request"}`, "order") + ConfirmGasReceipt(ctx) + if tc.code == 0 { + if !strings.Contains(response.Body.String(), `"confirmed":true`) { + t.Fatal(response.Body.String()) + } + } else if !tc.fail && !strings.Contains(response.Body.String(), fmt.Sprintf(`"code":%d`, tc.code)) { + t.Fatal(response.Body.String()) + } else if strings.Contains(response.Body.String(), `"code":0`) { + t.Fatal("失败被误报成功", response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestGasReceiptCannotConfirmAnotherOwner(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectBegin() + mock.ExpectQuery(`SELECT .* FROM "gasorder_basic".*identity = \$1 AND user_account_id = \$2 AND status <> \$3.*FOR UPDATE`).WithArgs("other", 1, 3, 1).WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectRollback() + ctx, response := addressContext(`{"request_no":"request"}`, "other") + ConfirmGasReceipt(ctx) + if !strings.Contains(response.Body.String(), `"code":1112`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestGasReceiptRequiresRequest(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + ctx, response := addressContext(`{}`, "order") + ConfirmGasReceipt(ctx) + if !strings.Contains(response.Body.String(), `"code":1704`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/client/user/gas_contract_attachment.go b/backend/api/internal/logic/client/user/gas_contract_attachment.go new file mode 100644 index 0000000..b51ddfa --- /dev/null +++ b/backend/api/internal/logic/client/user/gas_contract_attachment.go @@ -0,0 +1,24 @@ +// 功能描述:本人合同附件鉴权下载,复用平台受控文件读取;版本:1.0.0。 +package user + +import ( + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + gascontracts "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform/gasorder" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" +) + +// DownloadGasContractAttachment 仅接受合同identity,不接受存储路径、外部URL或其他账户ID。 +func DownloadGasContractAttachment(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var contract models.GasorderContract + if err := impl.DBService.Where("identity = ? AND user_account_id = ? AND status <> ?", ctx.Param("identity"), account.ID, common.StatusArchived).First(&contract).Error; err != nil { + gasDetailError(ctx, err) + return + } + gascontracts.ServeAuthorizedContractPDF(ctx, contract, "user:"+account.Identity) +} diff --git a/backend/api/internal/logic/client/user/gas_contract_attachment_test.go b/backend/api/internal/logic/client/user/gas_contract_attachment_test.go new file mode 100644 index 0000000..c9d85c2 --- /dev/null +++ b/backend/api/internal/logic/client/user/gas_contract_attachment_test.go @@ -0,0 +1,63 @@ +// 功能描述:本人PDF下载归属、受控路径及响应头回归;版本:1.0.0。 +package user + +import ( + "github.com/DATA-DOG/go-sqlmock" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGasContractAttachmentOwnedAndControlled(t *testing.T) { + root := t.TempDir() + t.Setenv("HEQI_UPLOAD_DIR", root) + if err := os.MkdirAll(filepath.Join(root, "contracts"), 0700); err != nil { + t.Fatal(err) + } + pdf := "%PDF-1.7\n" + strings.Repeat(" ", 512) + "\n%%EOF" + if err := os.WriteFile(filepath.Join(root, "contracts", "owned.pdf"), []byte(pdf), 0600); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, uri string + found bool + status int + }{ + {"本人附件", "/uploads/contracts/owned.pdf", true, 200}, + {"外部地址", "https://example.com/private.pdf", true, 404}, + {"目录越界", "/uploads/contracts/../owned.pdf", true, 404}, + {"不存在", "/uploads/contracts/missing.pdf", true, 404}, + {"非本人合同", "", false, 200}, + } { + t.Run(tc.name, func(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + rows := sqlmock.NewRows([]string{"id", "identity", "contract_no", "file_uri"}) + if tc.found { + rows.AddRow(7, "contract", "HT1", tc.uri) + } + mock.ExpectQuery(`SELECT .* FROM "gasorder_contract".*identity = \$1 AND user_account_id = \$2 AND status <> \$3`).WithArgs("contract", 1, 3, 1).WillReturnRows(rows) + ctx, response := addressContext("", "contract") + ctx.Request = httptest.NewRequest("GET", "/gas/contracts/contract/attachment", nil) + DownloadGasContractAttachment(ctx) + ctx.Writer.WriteHeaderNow() + if response.Code != tc.status { + t.Fatalf("status=%d", response.Code) + } + if !tc.found { + if !strings.Contains(response.Body.String(), `"code":1112`) { + t.Fatal(response.Body.String()) + } + } else if tc.status == 200 { + if response.Body.String() != pdf || response.Header().Get("Cache-Control") != "private, no-store" || response.Header().Get("Content-Type") != "application/pdf" { + t.Fatal("PDF内容或缓存保护错误") + } + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/backend/api/internal/logic/client/user/gas_contract_download_remote_test.go b/backend/api/internal/logic/client/user/gas_contract_download_remote_test.go new file mode 100644 index 0000000..b75b2f4 --- /dev/null +++ b/backend/api/internal/logic/client/user/gas_contract_download_remote_test.go @@ -0,0 +1,127 @@ +// 功能描述:显式启用的浏览器PDF下载夹具,独立测试合同与文件自动清理;版本:1.0.0。 +package user + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.apinb.com/bsm-sdk/core/database" + dbsql "git.apinb.com/bsm-sdk/core/database/sql" + "git.apinb.com/heqiapp/platforms/backend/api/internal/config" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// 仅用于授权账号的独立终止合同,绝不修改或绑定既有业务合同;等待人工浏览器下载后自动删除。 +func TestGasContractBrowserDownloadFixture(t *testing.T) { + if os.Getenv("HEQI_CONTRACT_DOWNLOAD_FIXTURE") != "1" { + t.Skip("仅显式启用的浏览器下载验证") + } + root := os.Getenv("HEQI_CONTRACT_FIXTURE_UPLOAD_ROOT") + if root == "" || !filepath.IsAbs(root) { + t.Fatal("必须明确指定当前API合同文件根目录") + } + config.New("heqi") + if config.Spec.Databases == nil { + t.Fatal("缺少远程数据库配置") + } + db, err := database.NewDatabase(config.Spec.Databases.Driver, config.Spec.Databases.Source, dbsql.SetOptions(nil)) + if err != nil { + t.Fatal("无法连接指定测试环境") + } + db = db.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}) + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + defer sqlDB.Close() + var account models.UserAccount + if err := db.Where("phone = ? AND status = ?", "13800000001", common.StatusEnable).First(&account).Error; err != nil { + t.Fatal("缺少授权测试账号") + } + var source models.GasorderContract + if err := db.Where("user_account_id = ? AND status <> ?", account.ID, common.StatusArchived).First(&source).Error; err != nil { + t.Fatal("缺少本人测试合同") + } + fixture := source + fixture.Entity = common.NewEntity(common.StatusEnable) + fixture.ContractStatus = 13 + fixture.ContractNo = "VERIFY" + strings.ReplaceAll(fixture.Identity, "-", "") + fixture.Title = "PDF下载验证文件(非业务合同)" + fixture.Terms = "仅用于验证文件下载,测试结束后自动删除,不构成供气或签署业务记录。" + filename := fixture.Identity + ".pdf" + fixture.FileURI = "/uploads/contracts/" + filename + directory := filepath.Join(root, "contracts") + if err := os.MkdirAll(directory, 0750); err != nil { + t.Fatal(err) + } + path := filepath.Join(directory, filename) + // 标准单页PDF,显式声明测试用途;不包含真实签名或业务条款。 + pdf := downloadFixturePDF() + if err := os.WriteFile(path, []byte(pdf), 0600); err != nil { + t.Fatal(err) + } + defer os.Remove(path) + if err := db.Create(&fixture).Error; err != nil { + t.Fatal("创建独立测试合同失败", err) + } + defer func() { + if err := db.Unscoped().Where("id = ? AND identity = ? AND user_account_id = ? AND contract_no = ?", fixture.ID, fixture.Identity, account.ID, fixture.ContractNo).Delete(&models.GasorderContract{}).Error; err != nil { + t.Error("测试合同清理失败", err) + return + } + var count int64 + if err := db.Unscoped().Model(&models.GasorderContract{}).Where("identity = ?", fixture.Identity).Count(&count).Error; err != nil || count != 0 { + t.Error("测试合同仍有残留", err) + } + }() + marker := filepath.Join(t.TempDir(), "download-finished") + fmt.Printf("DOWNLOAD_FIXTURE_READY identity=%s number=%s marker=%s file=%s\n", fixture.Identity, fixture.ContractNo, marker, path) + timeout := time.NewTimer(5 * time.Minute) + defer timeout.Stop() + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-timeout.C: + t.Fatal("下载验证超时,清理夹具后退出") + case <-ticker.C: + if _, err := os.Stat(marker); err == nil { + t.Log("下载验证结束;自动清理独立合同及PDF") + return + } + } + } +} + +// downloadFixturePDF 生成固定ASCII验证页及正确交叉引用偏移,避免下载HTML或伪PDF。 +func downloadFixturePDF() string { + stream := "BT /F1 18 Tf 50 780 Td (DOWNLOAD TEST ONLY - NOT A BUSINESS CONTRACT) Tj ET" + objects := []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(stream), stream), + } + var out strings.Builder + out.WriteString("%PDF-1.4\n") + offsets := []int{0} + for i, obj := range objects { + offsets = append(offsets, out.Len()) + fmt.Fprintf(&out, "%d 0 obj\n%s\nendobj\n", i+1, obj) + } + xref := out.Len() + fmt.Fprintf(&out, "xref\n0 %d\n0000000000 65535 f \n", len(offsets)) + for _, offset := range offsets[1:] { + fmt.Fprintf(&out, "%010d 00000 n \n", offset) + } + fmt.Fprintf(&out, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), xref) + return out.String() +} diff --git a/backend/api/internal/logic/client/user/gas_contract_history.go b/backend/api/internal/logic/client/user/gas_contract_history.go new file mode 100644 index 0000000..ff0ada7 --- /dev/null +++ b/backend/api/internal/logic/client/user/gas_contract_history.go @@ -0,0 +1,34 @@ +// 功能描述:本人合同不可变变更记录,只公开业务状态与发生时间;版本:1.0.0。 +package user + +import ( + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" +) + +// GetGasContractHistory 先验证合同归属,避免用公开标识读取其他用户历史。 +func GetGasContractHistory(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var contract models.GasorderContract + if err := impl.DBService.Where("identity = ? AND user_account_id = ? AND status <> ?", ctx.Param("identity"), account.ID, common.StatusArchived).First(&contract).Error; err != nil { + gasDetailError(ctx, err) + return + } + var records []models.GasorderContractRevision + if err := impl.DBService.Where("gasorder_contract_id = ? AND status <> ?", contract.ID, common.StatusArchived).Order("occurred_at desc, identity desc").Find(&records).Error; err != nil { + gasDetailError(ctx, err) + return + } + result := make([]gin.H, 0, len(records)) + for _, record := range records { + result = append(result, gin.H{"identity": record.Identity, "action": record.Action, "contract_status": record.ContractStatus, "occurred_at": record.OccurredAt, "effective_at": record.EffectiveAt, "expired_at": record.ExpiredAt}) + } + // 操作人及内部变更原因不属于用户端公开字段,亦不把变更历史称为电子签署证据。 + infra.Response.Success(ctx, result) +} diff --git a/backend/api/internal/logic/client/user/gas_contract_history_test.go b/backend/api/internal/logic/client/user/gas_contract_history_test.go new file mode 100644 index 0000000..b576b82 --- /dev/null +++ b/backend/api/internal/logic/client/user/gas_contract_history_test.go @@ -0,0 +1,39 @@ +// 功能描述:合同历史归属、顺序与内部信息隔离;版本:1.0.0。 +package user + +import ( + "github.com/DATA-DOG/go-sqlmock" + "strings" + "testing" + "time" +) + +func TestGasContractHistoryOwnedProjection(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectQuery(`SELECT .* FROM "gasorder_contract".*identity = \$1 AND user_account_id = \$2 AND status <> \$3`).WithArgs("contract", 1, 3, 1).WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(7, "contract")) + mock.ExpectQuery(`SELECT .* FROM "gasorder_contract_revision".*gasorder_contract_id = \$1 AND status <> \$2.*ORDER BY occurred_at desc, identity desc`).WithArgs(7, 3).WillReturnRows(sqlmock.NewRows([]string{"identity", "action", "contract_status", "occurred_at", "reason", "operator_name"}).AddRow("revision", "renew", 11, time.Now(), "内部审批备注", "内部员工")) + ctx, response := addressContext("", "contract") + GetGasContractHistory(ctx) + body := response.Body.String() + if !strings.Contains(body, `"action":"renew"`) || strings.Contains(body, "内部") || strings.Contains(body, "operator_name") || strings.Contains(body, "gasorder_contract_id") { + t.Fatal(body) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestGasContractHistoryRejectsOtherOwner(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectQuery(`SELECT .* FROM "gasorder_contract".*identity = \$1 AND user_account_id = \$2 AND status <> \$3`).WithArgs("other", 1, 3, 1).WillReturnRows(sqlmock.NewRows([]string{"id"})) + ctx, response := addressContext("", "other") + GetGasContractHistory(ctx) + if !strings.Contains(response.Body.String(), `"code":1112`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/client/user/gas_contract_list_test.go b/backend/api/internal/logic/client/user/gas_contract_list_test.go new file mode 100644 index 0000000..b7de9ff --- /dev/null +++ b/backend/api/internal/logic/client/user/gas_contract_list_test.go @@ -0,0 +1,39 @@ +// 功能描述:本人合同列表正确绑定、批量气站及私有附件隔离;版本:1.0.0。 +package user + +import ( + "github.com/DATA-DOG/go-sqlmock" + "strings" + "testing" +) + +func TestGasContractListUsesContractBindings(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectQuery(`SELECT .* FROM "gasorder_contract".*user_account_id = \$1 AND status <> \$2`).WithArgs(1, 3).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "contract_no", "gas_basic_id", "file_uri"}).AddRow(7, "contract", "HT1", 9, "/uploads/contracts/private.pdf")) + mock.ExpectQuery(`SELECT .* FROM "gasorder_contract_product".*gasorder_contract_id IN .*status <>`).WithArgs(7, 3).WillReturnRows(sqlmock.NewRows([]string{"gasorder_contract_id", "identity", "product_type_name", "unit_price"}).AddRow(7, "binding", "合同气瓶", 9800)) + mock.ExpectQuery(`SELECT .* FROM "gas_basic".*id IN .*status =`).WithArgs(9, 1).WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(9, "本人气站")) + ctx, response := addressContext("", "") + ListGasContracts(ctx) + body := response.Body.String() + if !strings.Contains(body, `"station_name":"本人气站"`) || !strings.Contains(body, `"product_type_name":"合同气瓶"`) || strings.Contains(body, "private.pdf") { + t.Fatal(body) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestGasContractEmptyListSkipsRelatedQueries(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectQuery(`SELECT .* FROM "gasorder_contract"`).WithArgs(1, 3).WillReturnRows(sqlmock.NewRows([]string{"id"})) + ctx, response := addressContext("", "") + ListGasContracts(ctx) + if !strings.Contains(response.Body.String(), `"details":[]`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/client/user/gas_contract_request.go b/backend/api/internal/logic/client/user/gas_contract_request.go new file mode 100644 index 0000000..23d2501 --- /dev/null +++ b/backend/api/internal/logic/client/user/gas_contract_request.go @@ -0,0 +1,96 @@ +// 功能描述:本人合同变更申请进入气站客服队列,不直接修改合同;版本:1.0.0。 +package user + +import ( + "errors" + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "strings" +) + +// CreateGasContractRequest 以账户锁保护重试和同合同进行中申请,防止重复受理。 +func CreateGasContractRequest(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var request struct { + RequestNo string `json:"request_no" binding:"required,max=128"` + Description string `json:"description" binding:"required,max=2000"` + } + if ctx.ShouldBindJSON(&request) != nil || strings.TrimSpace(request.RequestNo) == "" || strings.TrimSpace(request.Description) == "" { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var ticket models.CsTicket + existing := false + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := lockAddressOwner(tx, account.ID); err != nil { + return err + } + var contract models.GasorderContract + if err := tx.Where("identity = ? AND user_account_id = ? AND status <> ?", ctx.Param("identity"), account.ID, common.StatusArchived).First(&contract).Error; err != nil { + return err + } + err := tx.Where("request_no = ? AND user_account_id = ?", request.RequestNo, account.ID).First(&ticket).Error + if err == nil { + if ticket.GasorderContractID != contract.ID || ticket.Category != "contract_change" { + return errcode.ErrInvalidArgument + } + existing = true + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + // 转出后只能查看/终止原合同,不能继续向原供气单位发起条款变更。 + var relation models.UserServiceRelation + if err := tx.Where("user_account_id = ? AND gas_basic_id = ? AND status = ?", account.ID, contract.GasBasicID, common.StatusEnable).First(&relation).Error; err != nil { + return err + } + err = tx.Where("gasorder_contract_id = ? AND user_account_id = ? AND category = ? AND status <> ? AND ticket_status NOT IN ?", contract.ID, account.ID, "contract_change", common.StatusArchived, []int{22, 23}).First(&ticket).Error + if err == nil { + existing = true + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + ticket = models.CsTicket{Entity: common.NewEntity(common.StatusEnable), TicketStatus: 32, TicketNo: common.RecordNo("TK"), RequestNo: request.RequestNo, UserAccountID: account.ID, GasorderContractID: contract.ID, GasBasicID: contract.GasBasicID, DeliveryBasicID: relation.DeliveryBasicID, Category: "contract_change", Priority: "normal", Description: "合同编号:" + contract.ContractNo + "\n" + strings.TrimSpace(request.Description), ContactName: account.Name, ContactPhone: account.Phone, OperatorIdentity: account.Identity} + return tx.Create(&ticket).Error + }) + if err != nil { + gasDetailError(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"identity": ticket.Identity, "ticket_no": ticket.TicketNo, "existing": existing}) +} + +// ListGasContractRequests 只展示该本人合同的申请及处理结果,不返回内部关联ID。 +func ListGasContractRequests(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var contract models.GasorderContract + if err := impl.DBService.Where("identity = ? AND user_account_id = ? AND status <> ?", ctx.Param("identity"), account.ID, common.StatusArchived).First(&contract).Error; err != nil { + gasDetailError(ctx, err) + return + } + var tickets []models.CsTicket + if err := impl.DBService.Where("gasorder_contract_id = ? AND user_account_id = ? AND category = ? AND status <> ?", contract.ID, account.ID, "contract_change", common.StatusArchived).Order("created_at desc, identity desc").Find(&tickets).Error; err != nil { + gasDetailError(ctx, err) + return + } + result := make([]gin.H, 0, len(tickets)) + for _, ticket := range tickets { + name, actions := ticketState(ticket.TicketStatus) + result = append(result, gin.H{"identity": ticket.Identity, "ticket_no": ticket.TicketNo, "status_name": name, "allowed_actions": actions, "description": ticket.Description, "result": ticket.Result, "created_at": ticket.CreatedAt}) + } + infra.Response.Success(ctx, result) +} diff --git a/backend/api/internal/logic/client/user/gas_contract_request_remote_test.go b/backend/api/internal/logic/client/user/gas_contract_request_remote_test.go new file mode 100644 index 0000000..33106b6 --- /dev/null +++ b/backend/api/internal/logic/client/user/gas_contract_request_remote_test.go @@ -0,0 +1,123 @@ +// 功能描述:远程回滚事务验证用户申请、气站答复、用户确认及合同不被更改;版本:1.0.0。 +package user + +import ( + "encoding/json" + "fmt" + "git.apinb.com/bsm-sdk/core/database" + dbsql "git.apinb.com/bsm-sdk/core/database/sql" + "git.apinb.com/bsm-sdk/core/types" + "git.apinb.com/heqiapp/platforms/backend/api/internal/config" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + gaslogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/gas" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "os" + "testing" +) + +func TestContractRequestRemoteRollback(t *testing.T) { + if os.Getenv("HEQI_REMOTE_CONTRACT_REQUEST_TEST") != "1" { + t.Skip("显式启用的远程回滚验证") + } + config.New("heqi") + if config.Spec.Databases == nil { + t.Fatal("缺少远程配置") + } + db, err := database.NewDatabase(config.Spec.Databases.Driver, config.Spec.Databases.Source, dbsql.SetOptions(nil)) + if err != nil { + t.Fatal("无法连接配置环境") + } + db = db.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}) + connection, err := db.DB() + if err != nil { + t.Fatal(err) + } + defer connection.Close() + var account models.UserAccount + if err := db.Where("phone = ? AND status = ?", "13800000001", 1).First(&account).Error; err != nil { + t.Fatal("缺少授权测试用户") + } + var relation models.UserServiceRelation + if err := db.Where("user_account_id = ? AND status = ?", account.ID, 1).First(&relation).Error; err != nil { + t.Fatal("缺少测试用户服务关系") + } + var original models.GasorderContract + if err := db.Where("user_account_id = ? AND gas_basic_id = ? AND status <> ?", account.ID, relation.GasBasicID, 3).First(&original).Error; err != nil { + t.Fatal("缺少本人当前气站合同") + } + var gasAccount models.GasAccount + if err := db.Where("gas_basic_id = ? AND status = ?", relation.GasBasicID, 1).First(&gasAccount).Error; err != nil { + t.Fatal("缺少测试气站账户") + } + tx := db.Begin() + if tx.Error != nil { + t.Fatal(tx.Error) + } + defer tx.Rollback() + if err := tx.Exec(`SET LOCAL lock_timeout = '3s'`).Error; err != nil { + t.Fatal(err) + } + previous := impl.DBService + impl.DBService = tx + defer func() { impl.DBService = previous }() + // 独立终止合同只用于事务内请求关联,既有业务合同不被写入。 + contract := original + contract.Entity = common.NewEntity(1) + contract.ContractStatus = 13 + contract.ContractNo = "VERIFY" + contract.Identity + if err := tx.Create(&contract).Error; err != nil { + t.Fatal(err) + } + requestNo := "verify:" + models.NewIdentity() + ctx, response := addressContext(fmt.Sprintf(`{"request_no":%q,"description":"事务回滚验证,不修改合同"}`, requestNo), contract.Identity) + ctx.Set("Auth", &types.JwtClaims{Client: "user_app", Identity: account.Identity}) + CreateGasContractRequest(ctx) + var created struct { + Code int `json:"code"` + Details struct { + Identity string `json:"identity"` + } `json:"details"` + } + if json.Unmarshal(response.Body.Bytes(), &created) != nil || created.Code != 0 || created.Details.Identity == "" { + t.Fatal(response.Body.String()) + } + for i := 0; i < 2; i++ { + gasCtx, gasResponse := addressContext(`{"result":"测试答复,合同条款保持不变"}`, created.Details.Identity) + gasCtx.Set("Auth", &types.JwtClaims{Client: "gas_admin", Identity: gasAccount.Identity}) + gaslogic.ResolveContractRequest(gasCtx) + var reply struct { + Code int `json:"code"` + } + if json.Unmarshal(gasResponse.Body.Bytes(), &reply) != nil || reply.Code != 0 { + t.Fatal(gasResponse.Body.String()) + } + } + confirmCtx, confirmResponse := addressContext("", created.Details.Identity) + confirmCtx.Set("Auth", &types.JwtClaims{Client: "user_app", Identity: account.Identity}) + ConfirmTicket(confirmCtx) + var reply struct { + Code int `json:"code"` + } + if json.Unmarshal(confirmResponse.Body.Bytes(), &reply) != nil || reply.Code != 0 { + t.Fatal(confirmResponse.Body.String()) + } + var ticket models.CsTicket + if err := tx.Where("identity = ?", created.Details.Identity).First(&ticket).Error; err != nil || ticket.TicketStatus != 23 || ticket.GasorderContractID != contract.ID || ticket.Result != "测试答复,合同条款保持不变" { + t.Fatal("申请闭环数据错误", err) + } + var after models.GasorderContract + if err := tx.First(&after, contract.ID).Error; err != nil || after.Terms != contract.Terms || after.ContractStatus != 13 { + t.Fatal("申请流程意外改动合同", err) + } + if err := tx.Rollback().Error; err != nil { + t.Fatal(err) + } + var count int64 + if err := db.Model(&models.CsTicket{}).Where("request_no = ?", requestNo).Count(&count).Error; err != nil || count != 0 { + t.Fatal("申请回滚后仍有残留", err) + } + t.Log("远程申请、气站幂等答复、用户确认及整体回滚通过;原合同未修改") +} diff --git a/backend/api/internal/logic/client/user/gas_contract_request_test.go b/backend/api/internal/logic/client/user/gas_contract_request_test.go new file mode 100644 index 0000000..581217c --- /dev/null +++ b/backend/api/internal/logic/client/user/gas_contract_request_test.go @@ -0,0 +1,65 @@ +// 功能描述:合同申请归属、重试和进行中去重回归;版本:1.0.0。 +package user + +import ( + "github.com/DATA-DOG/go-sqlmock" + "strings" + "testing" +) + +func TestContractRequestRetryAndOwnership(t *testing.T) { + for _, tc := range []struct { + name string + owner bool + priorContract int + success bool + }{ + {"重试本人申请", true, 7, true}, {"请求号属于另一合同", true, 8, false}, {"非本人合同", false, 0, false}, + } { + t.Run(tc.name, func(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + expectAddressLock(mock) + rows := sqlmock.NewRows([]string{"id", "gas_basic_id"}) + if tc.owner { + rows.AddRow(7, 9) + } + mock.ExpectQuery(`SELECT .* FROM "gasorder_contract".*identity = \$1 AND user_account_id = \$2 AND status <> \$3`).WithArgs("contract", 1, 3, 1).WillReturnRows(rows) + if tc.owner { + mock.ExpectQuery(`SELECT .* FROM "cs_ticket".*request_no = \$1 AND user_account_id = \$2`).WithArgs("retry", 1, 1).WillReturnRows(sqlmock.NewRows([]string{"identity", "gasorder_contract_id", "category"}).AddRow("original", tc.priorContract, "contract_change")) + } + if tc.success { + mock.ExpectCommit() + } else { + mock.ExpectRollback() + } + ctx, response := addressContext(`{"request_no":"retry","description":"申请调整条款"}`, "contract") + CreateGasContractRequest(ctx) + if strings.Contains(response.Body.String(), `"code":0`) != tc.success { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestContractRequestReusesPending(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + expectAddressLock(mock) + mock.ExpectQuery(`SELECT .* FROM "gasorder_contract"`).WillReturnRows(sqlmock.NewRows([]string{"id", "gas_basic_id"}).AddRow(7, 9)) + mock.ExpectQuery(`SELECT .* FROM "cs_ticket".*request_no`).WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectQuery(`SELECT .* FROM "user_service_relation".*user_account_id = \$1 AND gas_basic_id = \$2 AND status = \$3`).WithArgs(1, 9, 1, 1).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) + mock.ExpectQuery(`SELECT .* FROM "cs_ticket".*gasorder_contract_id = \$1 AND user_account_id = \$2 AND category = \$3 AND status <> \$4 AND ticket_status NOT IN`).WithArgs(7, 1, "contract_change", 3, 22, 23, 1).WillReturnRows(sqlmock.NewRows([]string{"identity", "ticket_no"}).AddRow("pending", "TK1")) + mock.ExpectCommit() + ctx, response := addressContext(`{"request_no":"new","description":"再次申请"}`, "contract") + CreateGasContractRequest(ctx) + if !strings.Contains(response.Body.String(), `"existing":true`) || !strings.Contains(response.Body.String(), `"identity":"pending"`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/client/user/gas_detail.go b/backend/api/internal/logic/client/user/gas_detail.go new file mode 100644 index 0000000..207f2f6 --- /dev/null +++ b/backend/api/internal/logic/client/user/gas_detail.go @@ -0,0 +1,127 @@ +// 功能描述:本人供气订单详情、合同与不可变状态记录;版本:1.0.0。 +package user + +import ( + "errors" + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// GetGasOrder 先验证订单归属,再读取关联事实;不输出员工手机号和内部操作备注。 +func GetGasOrder(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var order models.GasorderBasic + if err := impl.DBService.Where("identity = ? AND user_account_id = ? AND status <> ?", ctx.Param("identity"), account.ID, common.StatusArchived).First(&order).Error; err != nil { + gasDetailError(ctx, err) + return + } + var items []models.GasorderItem + if err := impl.DBService.Where("gasorder_basic_id = ?", order.ID).Order("created_at, identity").Find(&items).Error; err != nil { + gasDetailError(ctx, err) + return + } + lines := make([]gin.H, 0, len(items)) + for _, item := range items { + lines = append(lines, gin.H{"identity": item.Identity, "name": item.ProductTypeName, "quantity": 1, "sale_amount": item.UnitPrice, "product_code": item.ProductCode}) + } + var histories []models.GasorderStatus + if err := impl.DBService.Where("gasorder_basic_id = ? AND status <> ?", order.ID, common.StatusArchived).Order("occurred_at, identity").Find(&histories).Error; err != nil { + gasDetailError(ctx, err) + return + } + timeline := make([]gin.H, 0, len(histories)) + for _, h := range histories { + timeline = append(timeline, gin.H{"identity": h.Identity, "status_code": h.ToStatus, "status_name": gasOrderStatusName(h.ToStatus), "occurred_at": h.OccurredAt}) + } + var station models.GasBasic + if order.GasBasicID != 0 { + if err := impl.DBService.Where("id = ? AND status = ?", order.GasBasicID, common.StatusEnable).Find(&station).Error; err != nil { + gasDetailError(ctx, err) + return + } + } + var contract models.GasorderContract + if order.GasorderContractID != 0 { + if err := impl.DBService.Where("id = ? AND user_account_id = ? AND gas_basic_id = ? AND status <> ?", order.GasorderContractID, account.ID, order.GasBasicID, common.StatusArchived).Find(&contract).Error; err != nil { + gasDetailError(ctx, err) + return + } + } + var staff models.StaffAccount + if order.StaffAccountID != 0 { + if err := impl.DBService.Where("id = ? AND status = ?", order.StaffAccountID, common.StatusEnable).Find(&staff).Error; err != nil { + gasDetailError(ctx, err) + return + } + } + // 支付状态是独立事实,只选择本订单、本付款人的成功支付,不能从履约状态推断已付款。 + var pays []models.PaymentOrder + if err := impl.DBService.Where("business_type = ? AND business_identity = ? AND user_identity = ? AND payment_status = ?", "gasorder", order.Identity, account.Identity, 23).Order("paid_at desc, identity").Find(&pays).Error; err != nil { + gasDetailError(ctx, err) + return + } + payments := make([]gin.H, 0, len(pays)) + for _, p := range pays { + payments = append(payments, gin.H{"identity": p.Identity, "channel": p.Channel, "amount": p.Amount, "paid_at": p.PaidAt}) + } + infra.Response.Success(ctx, gin.H{ + "identity": order.Identity, "order_no": order.OrderNo, "status_code": order.OrderStatus, "status_name": gasOrderStatusName(order.OrderStatus), "allowed_actions": gasOrderActions(order.OrderStatus), + "items": lines, "address": order.Address, "contact_name": order.ContactName, "contact_phone": order.ContactPhone, + "product_amount": order.ProductAmount, "delivery_fee": order.DeliveryFee, "discount_amount": order.DiscountAmount, "payable_amount": order.PayableAmount, + "created_at": order.CreatedAt, "remark": order.Remark, "timeline": timeline, "payments": payments, + "station_name": station.Name, "delivery_staff_name": staff.Name, "contract_identity": contract.Identity, "contract_no": contract.ContractNo, + }) +} + +// GetGasContract 只展示本人的合同正文;附件地址不作为公共URL透传。 +func GetGasContract(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var contract models.GasorderContract + if err := impl.DBService.Where("identity = ? AND user_account_id = ? AND status <> ?", ctx.Param("identity"), account.ID, common.StatusArchived).First(&contract).Error; err != nil { + gasDetailError(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"identity": contract.Identity, "contract_no": contract.ContractNo, "title": contract.Title, "terms": contract.Terms, "contract_status": contract.ContractStatus, "signed_at": contract.SignedAt, "effective_at": contract.EffectiveAt, "expired_at": contract.ExpiredAt, "has_attachment": contract.FileURI != ""}) +} + +func gasDetailError(ctx *gin.Context, err error) { + if errors.Is(err, gorm.ErrRecordNotFound) { + err = errcode.ErrRecordNotFound + } + infra.Response.Error(ctx, err) +} + +// gasOrderActions 保留原支付和退款规则,待用户确认时新增本人收货动作。 +func gasOrderActions(status int) []string { + switch status { + case common.StatusCreated: + return []string{"pay", "cancel"} + case common.StatusAssigned: + return []string{"pay", "refund", "cancel"} + case common.StatusPaid: + return []string{"refund"} + case common.StatusAwaitingConfirmation: + return []string{"confirm_receipt"} + default: + return []string{} + } +} + +func gasOrderStatusName(status int) string { + names := map[int]string{16: "待付款", 18: "已分配", 19: "充装中", 20: "待配送", 21: "配送异常", 22: "已取消", 23: "已完成", 33: "配送中", 34: "待签收", 35: "已支付"} + if name, ok := names[status]; ok { + return name + } + return "状态待更新" +} diff --git a/backend/api/internal/logic/client/user/gas_detail_test.go b/backend/api/internal/logic/client/user/gas_detail_test.go new file mode 100644 index 0000000..8b3a9b5 --- /dev/null +++ b/backend/api/internal/logic/client/user/gas_detail_test.go @@ -0,0 +1,77 @@ +// 功能描述:供气详情归属、历史明细、状态与合同正文隔离回归;版本:1.0.0。 +package user + +import ( + "github.com/DATA-DOG/go-sqlmock" + "strings" + "testing" + "time" +) + +func TestGasDetailOwnedFacts(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + now := time.Date(2026, 9, 8, 1, 0, 0, 0, time.UTC) + mock.ExpectQuery(`SELECT .* FROM "gasorder_basic".*identity = \$1 AND user_account_id = \$2 AND status <> \$3`).WithArgs("gas", 1, 3, 1).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "order_no", "order_status", "gas_basic_id", "gasorder_contract_id", "staff_account_id", "product_amount", "delivery_fee", "payable_amount"}).AddRow(7, "gas", "G1", 34, 3, 4, 5, 12800, 500, 13300)) + mock.ExpectQuery(`SELECT .* FROM "gasorder_item".*gasorder_basic_id = \$1`).WithArgs(7).WillReturnRows(sqlmock.NewRows([]string{"identity", "product_type_name", "unit_price", "product_code"}).AddRow("item", "成交气瓶", 12800, "C123")) + mock.ExpectQuery(`SELECT .* FROM "gasorder_status".*gasorder_basic_id = \$1 AND status <> \$2`).WithArgs(7, 3).WillReturnRows(sqlmock.NewRows([]string{"identity", "to_status", "occurred_at", "reason", "operator_identity"}).AddRow("event", 33, now, "内部备注不可公开", "internal-user")) + mock.ExpectQuery(`SELECT .* FROM "gas_basic".*id = \$1 AND status = \$2`).WithArgs(3, 1).WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("真实气站")) + mock.ExpectQuery(`SELECT .* FROM "gasorder_contract".*id = \$1 AND user_account_id = \$2 AND gas_basic_id = \$3 AND status <> \$4`).WithArgs(4, 1, 3, 3).WillReturnRows(sqlmock.NewRows([]string{"identity", "contract_no"}).AddRow("contract", "HT1")) + mock.ExpectQuery(`SELECT .* FROM "staff_account".*id = \$1 AND status = \$2`).WithArgs(5, 1).WillReturnRows(sqlmock.NewRows([]string{"name", "phone", "password_hash"}).AddRow("配送员", "13900009999", "internal-password")) + mock.ExpectQuery(`SELECT .* FROM "payment_order".*business_type = \$1 AND business_identity = \$2 AND user_identity = \$3 AND payment_status = \$4`).WithArgs("gasorder", "gas", sqlmock.AnyArg(), 23).WillReturnRows(sqlmock.NewRows([]string{"identity", "amount", "channel", "paid_at"}).AddRow("pay", 13300, "wechat", now)) + ctx, response := addressContext("", "gas") + GetGasOrder(ctx) + body := response.Body.String() + for _, expected := range []string{`"code":0`, `"name":"成交气瓶"`, `"quantity":1`, `"sale_amount":12800`, `"delivery_fee":500`, `"status_name":"待签收"`, `"confirm_receipt"`, `"contract_identity":"contract"`, `"amount":13300`} { + if !strings.Contains(body, expected) { + t.Fatal(body) + } + } + for _, secret := range []string{"内部备注不可公开", "internal-user", "internal-password", "13900009999", `"user_account_id"`, `"gas_basic_id"`} { + if strings.Contains(body, secret) { + t.Fatal("私有字段泄漏", secret) + } + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestGasDetailAndContractOwnerBoundary(t *testing.T) { + for _, contract := range []bool{false, true} { + mock := primaryTestDB(t) + expectAddressAccount(mock) + table := "gasorder_basic" + if contract { + table = "gasorder_contract" + } + mock.ExpectQuery(`SELECT .* FROM "`+table+`".*identity = \$1 AND user_account_id = \$2 AND status <> \$3`).WithArgs("other", 1, 3, 1).WillReturnRows(sqlmock.NewRows([]string{"id"})) + ctx, response := addressContext("", "other") + if contract { + GetGasContract(ctx) + } else { + GetGasOrder(ctx) + } + if !strings.Contains(response.Body.String(), `"code":1112`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + } +} + +func TestGasContractDoesNotExposeAttachment(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectQuery(`SELECT .* FROM "gasorder_contract"`).WithArgs("contract", 1, 3, 1).WillReturnRows(sqlmock.NewRows([]string{"identity", "contract_no", "title", "terms", "file_uri"}).AddRow("contract", "HT1", "供气合同", "真实正文", "/uploads/private.pdf")) + ctx, response := addressContext("", "contract") + GetGasContract(ctx) + body := response.Body.String() + if !strings.Contains(body, `"terms":"真实正文"`) || !strings.Contains(body, `"has_attachment":true`) || strings.Contains(body, "private.pdf") { + t.Fatal(body) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/client/user/gas_order_create.go b/backend/api/internal/logic/client/user/gas_order_create.go new file mode 100644 index 0000000..f492f4f --- /dev/null +++ b/backend/api/internal/logic/client/user/gas_order_create.go @@ -0,0 +1,308 @@ +// 功能描述:用户读取可订气瓶报价并创建幂等供气订单;版本:1.0.0。 +package user + +import ( + "errors" + "fmt" + "regexp" + "sort" + "strings" + "time" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var gasOrderPhonePattern = regexp.MustCompile(`^1\d{10}$`) + +type gasOrderCandidate struct { + models.GasorderContractProduct + ProductTypeID uint64 + DepositID uint64 + DepositAmount int64 + Orderable bool + UnavailableReason string +} + +// GetGasOrderOptions 返回本人有效合同下当前未被订单占用的气瓶及服务端计价。 +func GetGasOrderOptions(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + contract, station, candidates, err := loadGasOrderCandidates(impl.DBService, account) + if err != nil { + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + return + } + groups := map[string]gin.H{} + for _, item := range candidates { + key := fmt.Sprintf("%s\x00%d\x00%d\x00%t", item.ProductTypeName, item.UnitPrice, item.DepositAmount, item.Orderable) + group, exists := groups[key] + if !exists { + group = gin.H{ + "name": item.ProductTypeName, "description": gasOrderDescription(item.ProductTypeName), + "unit_price": item.UnitPrice, "deposit_amount": item.DepositAmount, + "orderable": item.Orderable, "unavailable_reason": item.UnavailableReason, + "item_identities": []string{}, + } + } + group["item_identities"] = append(group["item_identities"].([]string), item.Identity) + groups[key] = group + } + options := make([]gin.H, 0, len(groups)) + for _, group := range groups { + group["available_quantity"] = len(group["item_identities"].([]string)) + options = append(options, group) + } + sort.Slice(options, func(i, j int) bool { return options[i]["name"].(string) < options[j]["name"].(string) }) + addresses := []models.UserAddress{} + if err := impl.DBService.Where("user_account_id = ? AND status = ?", account.ID, common.StatusEnable). + Order("is_default desc, updated_at desc").Find(&addresses).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{ + "station_name": station.Name, "station_status": "营业中", "delivery_scope": station.Address, + "contract_identity": contract.Identity, "delivery_fee": contract.DefaultDeliveryFee, + "products": options, "addresses": common.ResourceResponse(addresses), "appointment_slots": gasOrderSlots(time.Now()), + }) +} + +// CreateGasOrder 按服务端价格、押金规则和本人地址创建待支付订单。 +func CreateGasOrder(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var request struct { + RequestNo string `json:"request_no" binding:"required,max=128"` + AddressIdentity string `json:"address_identity" binding:"required"` + ItemIdentities []string `json:"item_identities" binding:"required,min=1,max=10"` + AppointmentAt time.Time `json:"appointment_at" binding:"required"` + ExpectedAmount int64 `json:"expected_payable_amount" binding:"required"` + } + if ctx.ShouldBindJSON(&request) != nil || !validGasAppointment(request.AppointmentAt, time.Now()) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + if existing, found := existingGasOrder(account.ID, request.RequestNo); found { + if !sameGasOrderRequest(existing, request.AddressIdentity, request.ItemIdentities, request.AppointmentAt, request.ExpectedAmount) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, gasOrderCreateResponse(existing)) + return + } + contract, _, candidates, err := loadGasOrderCandidates(impl.DBService, account) + if err != nil { + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + return + } + byIdentity := make(map[string]gasOrderCandidate, len(candidates)) + for _, item := range candidates { + byIdentity[item.Identity] = item + } + selected := make([]gasOrderCandidate, 0, len(request.ItemIdentities)) + seen := map[string]bool{} + for _, identity := range request.ItemIdentities { + if seen[identity] || byIdentity[identity].Identity == "" || !byIdentity[identity].Orderable { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + seen[identity] = true + selected = append(selected, byIdentity[identity]) + } + var address models.UserAddress + if impl.DBService.Where("identity = ? AND user_account_id = ? AND status = ?", request.AddressIdentity, account.ID, common.StatusEnable). + First(&address).Error != nil || strings.TrimSpace(address.ContactName) == "" || !gasOrderPhonePattern.MatchString(address.ContactPhone) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var productAmount, depositAmount int64 + for _, item := range selected { + productAmount += item.UnitPrice + depositAmount += item.DepositAmount + } + payable := productAmount + depositAmount + contract.DefaultDeliveryFee + if payable <= 0 || payable != request.ExpectedAmount { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + order := models.GasorderBasic{ + Entity: common.NewEntity(common.StatusEnable), OrderStatus: common.StatusCreated, + OrderNo: common.RecordNo("GO"), RequestNo: request.RequestNo, GasorderContractID: contract.ID, + UserAccountID: account.ID, CreatorType: "user", CreatorID: account.ID, CreatorIdentity: account.Identity, + GasBasicID: contract.GasBasicID, DeliveryBasicID: contract.DeliveryBasicID, + UserAddressID: address.ID, + Address: address.Address, Longitude: address.Longitude, Latitude: address.Latitude, + ContactName: address.ContactName, ContactPhone: address.ContactPhone, AppointmentAt: request.AppointmentAt, + ProductAmount: productAmount, DepositAmount: depositAmount, DeliveryFee: contract.DefaultDeliveryFee, + PayableAmount: payable, OperatorIdentity: account.Identity, OperatorName: account.Name, + } + err = impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&order).Error; err != nil { + return err + } + for _, item := range selected { + var locked models.GasorderContractProduct + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND unbound_at IS NULL", item.ID).First(&locked).Error; err != nil { + return err + } + var occupied int64 + if err := tx.Model(&models.GasorderItem{}). + Joins("JOIN gasorder_basic ON gasorder_basic.id = gasorder_item.gasorder_basic_id"). + Where("gasorder_item.product_info_id = ? AND gasorder_item.active = true AND gasorder_basic.order_status NOT IN ?", locked.ProductInfoID, []int{common.StatusCompleted, common.StatusCancelled}). + Count(&occupied).Error; err != nil || occupied != 0 { + return errors.New("gas product is occupied") + } + orderItem := models.GasorderItem{Entity: common.NewEntity(common.StatusEnable), GasorderBasicID: order.ID, + GasorderContractProductID: locked.ID, ProductInfoID: locked.ProductInfoID, Active: true, + ProductCode: locked.ProductCode, ProductTypeName: locked.ProductTypeName, + ProductParams: locked.ProductParams, UnitPrice: locked.UnitPrice} + if err := tx.Create(&orderItem).Error; err != nil { + return err + } + if item.DepositAmount > 0 { + if err := tx.Create(&models.GasorderDeposit{Entity: common.NewEntity(common.StatusEnable), DepositStatus: common.StatusPending, + GasorderBasicID: order.ID, ProductInfoID: locked.ProductInfoID, PolicyID: item.DepositID, Amount: item.DepositAmount}).Error; err != nil { + return err + } + } + } + return nil + }) + if err != nil { + if existing, found := existingGasOrder(account.ID, request.RequestNo); found { + if sameGasOrderRequest(existing, request.AddressIdentity, request.ItemIdentities, request.AppointmentAt, request.ExpectedAmount) { + infra.Response.Success(ctx, gasOrderCreateResponse(existing)) + return + } + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, gasOrderCreateResponse(order)) +} + +func loadGasOrderCandidates(db *gorm.DB, account models.UserAccount) (models.GasorderContract, models.GasBasic, []gasOrderCandidate, error) { + now := time.Now() + var relation models.UserServiceRelation + if err := db.Where("user_account_id = ? AND status = ?", account.ID, common.StatusEnable).First(&relation).Error; err != nil { + return models.GasorderContract{}, models.GasBasic{}, nil, err + } + var contract models.GasorderContract + if err := db.Where("user_account_id = ? AND gas_basic_id = ? AND contract_status = ? AND effective_at <= ? AND (expired_at IS NULL OR expired_at > ?)", + account.ID, relation.GasBasicID, common.StatusActive, now, now).First(&contract).Error; err != nil { + return contract, models.GasBasic{}, nil, err + } + var station models.GasBasic + if err := db.Where("id = ? AND status = ?", contract.GasBasicID, common.StatusEnable).First(&station).Error; err != nil { + return contract, station, nil, err + } + type row struct { + models.GasorderContractProduct + ProductTypeID uint64 `gorm:"column:product_type_id"` + } + var rows []row + err := db.Model(&models.GasorderContractProduct{}). + Select("gasorder_contract_product.*, product_info.product_type_id"). + Joins("JOIN product_info ON product_info.id = gasorder_contract_product.product_info_id AND product_info.status = ?", common.StatusEnable). + Where("gasorder_contract_product.gasorder_contract_id = ? AND gasorder_contract_product.status = ? AND gasorder_contract_product.unbound_at IS NULL", contract.ID, common.StatusEnable). + Where("NOT EXISTS (SELECT 1 FROM gasorder_item JOIN gasorder_basic ON gasorder_basic.id = gasorder_item.gasorder_basic_id WHERE gasorder_item.product_info_id = gasorder_contract_product.product_info_id AND gasorder_item.active = true AND gasorder_basic.order_status NOT IN ?)", []int{common.StatusCompleted, common.StatusCancelled}). + Order("gasorder_contract_product.product_type_name, gasorder_contract_product.identity").Scan(&rows).Error + if err != nil { + return contract, station, nil, err + } + result := make([]gasOrderCandidate, 0, len(rows)) + for _, item := range rows { + candidate := gasOrderCandidate{GasorderContractProduct: item.GasorderContractProduct, ProductTypeID: item.ProductTypeID, Orderable: true} + var activeDeposit int64 + if err := db.Model(&models.DepositRecord{}).Where("user_account_id = ? AND product_info_id = ? AND status <> ? AND deposit_status IN ?", account.ID, item.ProductInfoID, common.StatusArchived, []int{10, 20}).Count(&activeDeposit).Error; err != nil { + return contract, station, nil, err + } + if activeDeposit == 0 { + var policy models.DepositPolicy + if err := db.Where("product_type_id = ? AND status = ?", item.ProductTypeID, common.StatusEnable).First(&policy).Error; err != nil { + candidate.Orderable = false + candidate.UnavailableReason = "押金规则暂未配置" + result = append(result, candidate) + continue + } + candidate.DepositID, candidate.DepositAmount = policy.ID, policy.Amount + } + result = append(result, candidate) + } + return contract, station, result, nil +} + +func gasOrderSlots(now time.Time) []gin.H { + local := now.In(time.Local) + result := make([]gin.H, 0, 6) + for day := 1; day <= 3; day++ { + date := local.AddDate(0, 0, day) + for _, hour := range []int{9, 14} { + start := time.Date(date.Year(), date.Month(), date.Day(), hour, 0, 0, 0, time.Local) + result = append(result, gin.H{"start_at": start, "label": start.Format("01月02日 15:04") + "-" + start.Add(2*time.Hour).Format("15:04")}) + } + } + return result +} + +func validGasAppointment(value, now time.Time) bool { + return value.After(now.Add(30*time.Minute)) && value.Before(now.AddDate(0, 0, 8)) +} + +func gasOrderDescription(name string) string { + name = strings.TrimSpace(name) + switch { + case strings.HasPrefix(name, "10"): + return "家庭日常使用,经济实惠" + case strings.HasPrefix(name, "5"): + return "小巧轻便,适合单人或短期使用" + default: + return "大容量更耐用,适合多人家庭" + } +} + +func existingGasOrder(userID uint64, requestNo string) (models.GasorderBasic, bool) { + var order models.GasorderBasic + err := impl.DBService.Where("user_account_id = ? AND request_no = ?", userID, requestNo).First(&order).Error + return order, err == nil +} + +// sameGasOrderRequest 拒绝同一幂等号承载不同地址、时段、金额或气瓶集合。 +func sameGasOrderRequest(order models.GasorderBasic, addressIdentity string, itemIdentities []string, appointment time.Time, expected int64) bool { + if order.PayableAmount != expected || !order.AppointmentAt.Equal(appointment) { + return false + } + var address models.UserAddress + if impl.DBService.First(&address, order.UserAddressID).Error != nil || address.Identity != addressIdentity { + return false + } + var saved []string + if impl.DBService.Model(&models.GasorderItem{}). + Select("gasorder_contract_product.identity"). + Joins("JOIN gasorder_contract_product ON gasorder_contract_product.id = gasorder_item.gasorder_contract_product_id"). + Where("gasorder_item.gasorder_basic_id = ?", order.ID).Scan(&saved).Error != nil { + return false + } + wanted := append([]string(nil), itemIdentities...) + sort.Strings(saved) + sort.Strings(wanted) + return strings.Join(saved, "\x00") == strings.Join(wanted, "\x00") +} + +func gasOrderCreateResponse(order models.GasorderBasic) gin.H { + return gin.H{"identity": order.Identity, "order_no": order.OrderNo, "order_status": order.OrderStatus, + "product_amount": order.ProductAmount, "deposit_amount": order.DepositAmount, + "delivery_fee": order.DeliveryFee, "payable_amount": order.PayableAmount, "appointment_at": order.AppointmentAt} +} diff --git a/backend/api/internal/logic/client/user/gas_order_create_test.go b/backend/api/internal/logic/client/user/gas_order_create_test.go new file mode 100644 index 0000000..ede93cc --- /dev/null +++ b/backend/api/internal/logic/client/user/gas_order_create_test.go @@ -0,0 +1,26 @@ +// 功能描述:气瓶下单预约边界与规格文案回归;版本:1.0.0。 +package user + +import ( + "testing" + "time" +) + +func TestValidGasAppointment(t *testing.T) { + now := time.Date(2026, 9, 11, 12, 0, 0, 0, time.Local) + if validGasAppointment(now.Add(29*time.Minute), now) { + t.Fatal("不足提前量的预约不得通过") + } + if !validGasAppointment(now.Add(2*time.Hour), now) { + t.Fatal("有效预约被拒绝") + } + if validGasAppointment(now.AddDate(0, 0, 9), now) { + t.Fatal("超出可预约范围的时段不得通过") + } +} + +func TestGasOrderDescription(t *testing.T) { + if gasOrderDescription("5kg 便携瓶") == gasOrderDescription("15kg 家用瓶") { + t.Fatal("不同规格不得展示相同用途说明") + } +} diff --git a/backend/api/internal/logic/client/user/gas_order_list_test.go b/backend/api/internal/logic/client/user/gas_order_list_test.go new file mode 100644 index 0000000..9f64c11 --- /dev/null +++ b/backend/api/internal/logic/client/user/gas_order_list_test.go @@ -0,0 +1,38 @@ +// 功能描述:验证本人气瓶订单列表返回气站名称及成交气瓶摘要。 +// 版本:1.0.0。 +package user + +import ( + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestGasOrderListIncludesStationName(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectQuery(`SELECT .* FROM "gasorder_basic".*user_account_id = \$1 AND status <> \$2`). + WithArgs(1, 3). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "order_no", "gas_basic_id", "order_status", "payable_amount"}). + AddRow(7, "gas-order", "GAS-001", 9, 16, 10300)) + mock.ExpectQuery(`SELECT .* FROM "gas_basic".*id IN .*status =`). + WithArgs(9, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(9, "示例气站")) + mock.ExpectQuery(`SELECT .* FROM "gasorder_item".*gasorder_basic_id = \$1`). + WithArgs(7). + WillReturnRows(sqlmock.NewRows([]string{"identity", "product_type_name", "unit_price"}). + AddRow("item", "15kg液化气", 9800)) + + ctx, response := addressContext("", "") + ListGasOrders(ctx) + body := response.Body.String() + for _, expected := range []string{`"station_name":"示例气站"`, `"product_type_name":"15kg液化气"`, `"status_name":"待付款"`, `"cancel"`} { + if !strings.Contains(body, expected) { + t.Fatalf("订单列表缺少字段 %s:%s", expected, body) + } + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/client/user/gas_receipt_remote_test.go b/backend/api/internal/logic/client/user/gas_receipt_remote_test.go new file mode 100644 index 0000000..97500e2 --- /dev/null +++ b/backend/api/internal/logic/client/user/gas_receipt_remote_test.go @@ -0,0 +1,152 @@ +// 功能描述:显式启用的远程签收事务回归,临时订单与证据全部回滚;版本:1.0.0。 +package user + +import ( + "git.apinb.com/bsm-sdk/core/database" + dbsql "git.apinb.com/bsm-sdk/core/database/sql" + "git.apinb.com/bsm-sdk/core/types" + "git.apinb.com/heqiapp/platforms/backend/api/internal/config" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "os" + "strings" + "testing" +) + +// 仅克隆授权测试账号的订单;默认跳过,不调用付款、短信或真实配送。 +func TestGasReceiptRemoteRollback(t *testing.T) { + if os.Getenv("HEQI_REMOTE_RECEIPT_TEST") != "1" { + t.Skip("显式启用后仅在回滚事务内验证") + } + config.New("heqi") + if config.Spec.Databases == nil { + t.Fatal("缺少远程数据库配置") + } + db, err := database.NewDatabase(config.Spec.Databases.Driver, config.Spec.Databases.Source, dbsql.SetOptions(nil)) + if err != nil { + t.Fatal("无法连接指定测试环境") + } + db = db.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}) + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + defer sqlDB.Close() + var account models.UserAccount + if err := db.Where("phone = ? AND status = ?", "13800000001", common.StatusEnable).First(&account).Error; err != nil { + t.Fatal("缺少授权测试账号") + } + var original models.GasorderBasic + if err := db.Where("user_account_id = ? AND status <> ?", account.ID, common.StatusArchived).First(&original).Error; err != nil { + t.Fatal("缺少可克隆的本人测试订单") + } + tx := db.Begin() + if tx.Error != nil { + t.Fatal(tx.Error) + } + defer tx.Rollback() + if err := tx.Exec(`SET LOCAL lock_timeout = '3s'`).Error; err != nil { + t.Fatal(err) + } + previous := impl.DBService + impl.DBService = tx + defer func() { impl.DBService = previous }() + clone := original + clone.Entity = common.NewEntity(common.StatusEnable) + clone.OrderNo = "VERIFY" + strings.ReplaceAll(clone.Identity, "-", "") + clone.RequestNo = "verify:" + clone.Identity + clone.OrderStatus = common.StatusAwaitingConfirmation + if err := tx.Create(&clone).Error; err != nil { + t.Fatal("创建事务内测试订单失败", err) + } + // 临时副本只存在于当前事务,实际气瓶与合同绑定不被占用或改写。 + var sourceItem models.GasorderItem + if err := tx.Where("gasorder_basic_id = ?", original.ID).First(&sourceItem).Error; err != nil { + t.Fatal("缺少本人订单商品", err) + } + var product models.ProductInfo + if err := tx.First(&product, sourceItem.ProductInfoID).Error; err != nil { + t.Fatal(err) + } + product.Entity = common.NewEntity(common.StatusEnable) + product.Code = "VERIFY" + strings.ReplaceAll(product.Identity, "-", "") + product.Name = "事务回滚验证气瓶" + product.UserAccountID = account.ID + if err := tx.Create(&product).Error; err != nil { + t.Fatal(err) + } + var binding models.GasorderContractProduct + if err := tx.Where("id = ? AND gasorder_contract_id = ?", sourceItem.GasorderContractProductID, original.GasorderContractID).First(&binding).Error; err != nil { + t.Fatal(err) + } + binding.Entity = common.NewEntity(common.StatusEnable) + binding.ProductInfoID = product.ID + binding.ProductCode = product.Code + binding.UnboundAt = nil + if err := tx.Create(&binding).Error; err != nil { + t.Fatal(err) + } + clonedItem := sourceItem + clonedItem.Entity = common.NewEntity(common.StatusEnable) + clonedItem.GasorderBasicID = clone.ID + clonedItem.ProductInfoID = product.ID + clonedItem.GasorderContractProductID = binding.ID + clonedItem.ProductCode = product.Code + clonedItem.Active = true + if err := tx.Create(&clonedItem).Error; err != nil { + t.Fatal(err) + } + for attempt := 0; attempt < 2; attempt++ { + ctx, response := addressContext(`{"request_no":"`+clone.RequestNo+`"}`, clone.Identity) + ctx.Set("Auth", &types.JwtClaims{Client: "user_app", Identity: account.Identity}) + ConfirmGasReceipt(ctx) + if !strings.Contains(response.Body.String(), `"confirmed":true`) { + t.Fatal(response.Body.String()) + } + } + var confirmation models.GasorderConfirm + if err := tx.Where("gasorder_basic_id = ?", clone.ID).First(&confirmation).Error; err != nil { + t.Fatal(err) + } + if confirmation.ConfirmType != "user_app" || confirmation.RecipientPhone != account.Phone || confirmation.ProofURI != "" { + t.Fatal("签收归属或证据类型不正确") + } + var count int64 + if err := tx.Model(&models.GasorderStatus{}).Where("gasorder_basic_id = ? AND from_status = ? AND to_status = ?", clone.ID, 34, 23).Count(&count).Error; err != nil || count != 1 { + t.Fatal("重复确认产生额外状态历史", err, count) + } + if err := tx.First(&clone, clone.ID).Error; err != nil || clone.OrderStatus != 23 { + t.Fatal("订单未完成", err) + } + if clonedItem.ID != 0 { + if err := tx.First(&clonedItem, clonedItem.ID).Error; err != nil || clonedItem.Active { + t.Fatal("气瓶占用未释放", err) + } + } + if err := tx.Rollback().Error; err != nil { + t.Fatal("回滚失败", err) + } + for _, model := range []any{&models.GasorderBasic{}, &models.GasorderConfirm{}, &models.GasorderStatus{}, &models.GasorderItem{}} { + query := db.Model(model) + if _, ok := model.(*models.GasorderBasic); ok { + query = query.Where("identity = ?", clone.Identity) + } else { + query = query.Where("gasorder_basic_id = ?", clone.ID) + } + if err := query.Count(&count).Error; err != nil || count != 0 { + t.Fatal("回滚后仍有临时记录", err, count) + } + } + for _, temporary := range []struct { + model any + identity string + }{{&models.ProductInfo{}, product.Identity}, {&models.GasorderContractProduct{}, binding.Identity}} { + if err := db.Model(temporary.model).Where("identity = ?", temporary.identity).Count(&count).Error; err != nil || count != 0 { + t.Fatal("临时气瓶或绑定未回滚", err, count) + } + } + t.Logf("远程签收、重复确认和回滚通过;验证气瓶占用释放=%t;无临时业务记录保留", clonedItem.ID != 0) +} diff --git a/backend/api/internal/logic/client/user/gasorder.go b/backend/api/internal/logic/client/user/gasorder.go index bb2d94a..6f12587 100644 --- a/backend/api/internal/logic/client/user/gasorder.go +++ b/backend/api/internal/logic/client/user/gasorder.go @@ -1,6 +1,9 @@ package user import ( + "errors" + "time" + "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" @@ -8,6 +11,8 @@ import ( "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/payment" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" + "gorm.io/gorm" + "gorm.io/gorm/clause" ) // ServiceRelation 返回当前唯一有效服务归属的公开 identity。 @@ -37,22 +42,31 @@ func ServiceRelation(ctx *gin.Context) { infra.Response.Success(ctx, response) } -// PayGasOrder 为本人未履约供气订单创建统一第三方支付单。 +// PayGasOrder 为本人未履约供气订单创建余额或第三方支付单。 func PayGasOrder(ctx *gin.Context) { account, ok := common.UserAccount(ctx) if !ok { return } var request struct { - RequestNo string `json:"request_no" binding:"required"` - Channel string `json:"channel" binding:"required,oneof=alipay wechat"` - PayType string `json:"pay_type" binding:"required"` - OpenID string `json:"openid"` + PaymentPassword string `json:"payment_password"` + RequestNo string `json:"request_no" binding:"required"` + Channel string `json:"channel" binding:"required,oneof=wallet alipay wechat"` + PayType string `json:"pay_type" binding:"required"` + OpenID string `json:"openid"` } if ctx.ShouldBindJSON(&request) != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } + if request.Channel == "wallet" { + if err := payGasOrderWithWallet(account, ctx.Param("identity"), request.RequestNo, request.PaymentPassword); err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"paid": true}) + return + } var order models.GasorderBasic if err := impl.DBService.Where("identity = ? AND user_account_id = ? AND order_status IN ?", ctx.Param("identity"), account.ID, []int{common.StatusCreated, common.StatusAssigned}).First(&order).Error; err != nil { infra.Response.Error(ctx, err) @@ -67,6 +81,68 @@ func PayGasOrder(ctx *gin.Context) { infra.Response.Success(ctx, payment.PublicResponse(payOrder)) } +// payGasOrderWithWallet 在同一事务内记录支付事实、扣减钱包并推进供气订单。 +func payGasOrderWithWallet(account models.UserAccount, identity, requestNo, password string) error { + return impl.DBService.Transaction(func(tx *gorm.DB) error { + var existing models.PaymentOrder + err := tx.Where("business_type = ? AND request_no = ?", "gasorder", requestNo).First(&existing).Error + if err == nil { + if existing.BusinessIdentity != identity || existing.UserIdentity != account.Identity || existing.Channel != "wallet" || existing.PaymentStatus != payment.StatusPaid { + return errors.New("idempotency conflict") + } + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + var order models.GasorderBasic + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("identity = ? AND user_account_id = ? AND order_status IN ?", identity, account.ID, []int{common.StatusCreated, common.StatusAssigned}).First(&order).Error; err != nil { + return err + } + var wallet models.WalletBasic + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("owner_type = ? AND owner_identity = ?", "user", account.Identity).First(&wallet).Error; err != nil { + return err + } + if !common.VerifyPaymentPassword(account.Identity, wallet, password) { + return gorm.ErrInvalidData + } + if err := common.SpendWalletBalance(&wallet, order.PayableAmount); err != nil { + return err + } + if err := common.SaveWalletBalances(tx, wallet); err != nil { + return err + } + now := time.Now() + if err := tx.Model(&order).Update("order_status", common.StatusPaid).Error; err != nil { + return err + } + if err := payment.CompleteGasDeposits(tx, order.Identity, now); err != nil { + return err + } + if err := tx.Create(&models.PaymentOrder{ + Entity: common.NewEntity(common.StatusEnable), PaymentStatus: payment.StatusPaid, + PaymentNo: common.RecordNo("PAY"), RequestNo: requestNo, BusinessType: "gasorder", + BusinessIdentity: order.Identity, UserIdentity: account.Identity, MerchantIdentity: "platform", + Channel: "wallet", PayType: "balance", Amount: order.PayableAmount, + Subject: "和气供气订单 " + order.OrderNo, ExpiresAt: now, PaidAt: &now, + }).Error; err != nil { + return err + } + date := now.In(time.Local) + return tx.Create(&models.WalletRecord{ + Entity: common.NewEntity(common.StatusEnable), WalletBasicID: wallet.ID, RecordNo: common.RecordNo("WR"), + RequestNo: requestNo, Direction: "expense", TradeType: "gasorder", Amount: order.PayableAmount, + BalanceAfter: wallet.Balance, WithdrawalBalanceAfter: wallet.WithdrawalBalance, + OutTradeNo: order.OrderNo, PayChannel: "wallet", OperatorIdentity: account.Identity, + Ymd: int32(date.Year()*10000 + int(date.Month())*100 + date.Day()), + Ym: int32(date.Year()*100 + int(date.Month())), + }).Error + }) +} + // ListGasContracts 返回用户自己的供气合同。 func ListGasContracts(ctx *gin.Context) { account, ok := common.UserAccount(ctx) @@ -79,14 +155,41 @@ func ListGasContracts(ctx *gin.Context) { return } response := make([]gin.H, 0, len(list)) - for _, order := range list { - var items []models.GasorderItem - if err := impl.DBService.Where("gasorder_basic_id = ?", order.ID).Find(&items).Error; err != nil { + contractIDs, stationIDs := []uint64{}, []uint64{} + for _, contract := range list { + contractIDs = append(contractIDs, contract.ID) + stationIDs = append(stationIDs, contract.GasBasicID) + } + // 合同绑定属于合同,不得把合同主键误作订单主键读取气瓶。 + var bindings []models.GasorderContractProduct + var stations []models.GasBasic + if len(list) > 0 { + if err := impl.DBService.Where("gasorder_contract_id IN ? AND status <> ?", contractIDs, common.StatusArchived).Find(&bindings).Error; err != nil { infra.Response.Error(ctx, err) return } + if err := impl.DBService.Where("id IN ? AND status = ?", stationIDs, common.StatusEnable).Find(&stations).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + } + stationNames := map[uint64]string{} + for _, station := range stations { + stationNames[station.ID] = station.Name + } + for _, order := range list { + items := []gin.H{} + for _, binding := range bindings { + if binding.GasorderContractID == order.ID { + items = append(items, gin.H{"identity": binding.Identity, "product_code": binding.ProductCode, "product_type_name": binding.ProductTypeName, "unit_price": binding.UnitPrice, "bound_at": binding.BoundAt, "unbound_at": binding.UnboundAt}) + } + } value := common.ResourceResponse(order).(map[string]any) - value["items"] = common.ResourceResponse(items) + // 旧字段保留空值以兼容客户端,私有附件不能以原始存储地址下发。 + value["file_uri"] = "" + value["has_attachment"] = order.FileURI != "" + value["station_name"] = stationNames[order.GasBasicID] + value["items"] = items response = append(response, value) } infra.Response.Success(ctx, response) @@ -98,12 +201,49 @@ func ListGasOrders(ctx *gin.Context) { if !ok { return } + query, page, ok := paginateClientList(ctx, impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, common.StatusArchived)) + if !ok { + return + } var list []models.GasorderBasic - if err := impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, common.StatusArchived).Order("created_at desc").Find(&list).Error; err != nil { + if err := query.Order("created_at desc, identity").Find(&list).Error; err != nil { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, common.ResourceResponse(list)) + stationIDs := make([]uint64, 0, len(list)) + seenStations := map[uint64]struct{}{} + for _, order := range list { + if _, exists := seenStations[order.GasBasicID]; !exists { + seenStations[order.GasBasicID] = struct{}{} + stationIDs = append(stationIDs, order.GasBasicID) + } + } + stationNames := map[uint64]string{} + if len(stationIDs) > 0 { + var stations []models.GasBasic + if err := impl.DBService.Where("id IN ? AND status = ?", stationIDs, common.StatusEnable).Find(&stations).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + for _, station := range stations { + stationNames[station.ID] = station.Name + } + } + response := make([]gin.H, 0, len(list)) + for _, order := range list { + var items []models.GasorderItem + if err := impl.DBService.Where("gasorder_basic_id = ?", order.ID).Find(&items).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + value := common.ResourceResponse(order).(map[string]any) + value["items"] = common.ResourceResponse(items) + value["station_name"] = stationNames[order.GasBasicID] + value["status_code"], value["status_name"] = order.OrderStatus, gasOrderStatusName(order.OrderStatus) + value["allowed_actions"] = gasOrderActions(order.OrderStatus) + response = append(response, value) + } + respondClientPage(ctx, page, response) } // CancelGasOrder 仅允许取消已创建或已分派的本人订单。 @@ -112,14 +252,20 @@ func CancelGasOrder(ctx *gin.Context) { if !ok { return } - result := impl.DBService.Model(&models.GasorderBasic{}). - Where("identity = ? AND user_account_id = ? AND order_status IN ?", ctx.Param("identity"), account.ID, []int{common.StatusCreated, common.StatusAssigned}). - Updates(map[string]any{"order_status": common.StatusCancelled, "operator_identity": account.Identity}) - if result.Error != nil { - infra.Response.Error(ctx, result.Error) - return - } - if result.RowsAffected != 1 { + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var order models.GasorderBasic + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND user_account_id = ? AND order_status IN ?", ctx.Param("identity"), account.ID, []int{common.StatusCreated, common.StatusAssigned}).First(&order).Error; err != nil { + return err + } + if err := tx.Model(&order).Updates(map[string]any{"order_status": common.StatusCancelled, "operator_identity": account.Identity}).Error; err != nil { + return err + } + if err := tx.Model(&models.GasorderItem{}).Where("gasorder_basic_id = ?", order.ID).Update("active", false).Error; err != nil { + return err + } + return tx.Model(&models.GasorderDeposit{}).Where("gasorder_basic_id = ? AND deposit_status = ?", order.ID, common.StatusPending).Update("deposit_status", common.StatusCancelled).Error + }) + if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } diff --git a/backend/api/internal/logic/client/user/list_response.go b/backend/api/internal/logic/client/user/list_response.go new file mode 100644 index 0000000..c71008d --- /dev/null +++ b/backend/api/internal/logic/client/user/list_response.go @@ -0,0 +1,50 @@ +// 功能描述:用户端列表的兼容分页与订单状态展示契约。 +// 版本:1.0.0 +package user + +import ( + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "strconv" +) + +// clientPage 保存经过校验的分页参数,零页码表示兼容旧数组响应。 +type clientPage struct{ Number, Size int } + +// paginateClientList 校验页码与大小;返回查询、分页信息及是否可继续。 +func paginateClientList(ctx *gin.Context, query *gorm.DB) (*gorm.DB, clientPage, bool) { + if ctx.Query("page") == "" && ctx.Query("page_size") == "" { + return query, clientPage{}, true + } + page, e1 := strconv.Atoi(ctx.DefaultQuery("page", "1")) + size, e2 := strconv.Atoi(ctx.DefaultQuery("page_size", "30")) + if e1 != nil || e2 != nil || page < 1 || page > 100000 || size < 1 || size > 100 { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return nil, clientPage{}, false + } + return query.Offset((page - 1) * size).Limit(size + 1), clientPage{page, size}, true +} + +// respondClientPage 多读一条判断下一页,旧客户端继续收到数组。 +func respondClientPage[T any](ctx *gin.Context, page clientPage, items []T) { + if page.Number == 0 { + infra.Response.Success(ctx, items) + return + } + more := len(items) > page.Size + if more { + items = items[:page.Size] + } + infra.Response.Success(ctx, gin.H{"items": items, "page": page.Number, "page_size": page.Size, "has_more": more}) +} + +// clientOrderState 返回中文展示状态;未知状态保留可解释名称。 +func clientOrderState(status int) string { + names := map[int]string{16: "待处理", 18: "待履约", 22: "已取消", 23: "已完成", 32: "待受理", 34: "待确认", 35: "处理中"} + if name, ok := names[status]; ok { + return name + } + return "处理中" +} diff --git a/backend/api/internal/logic/client/user/login_consent.go b/backend/api/internal/logic/client/user/login_consent.go new file mode 100644 index 0000000..89b9883 --- /dev/null +++ b/backend/api/internal/logic/client/user/login_consent.go @@ -0,0 +1,48 @@ +// 功能描述:登录时对用户明确同意的已发布协议版本进行幂等留痕。 +// 版本:1.0.0 +package user + +import ( + "fmt" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "time" +) + +// loginConsent 只接受公开内容标识与用户实际看到的版本。 +type loginConsent struct { + Identity string `json:"identity"` + Version int `json:"version"` + ShownAt time.Time `json:"shown_at"` // 保留客户端实际展示时间;确认时间另由服务端记录。 +} + +// recordLoginConsents 事务校验版本后写入既有阅读事实;同一用户同一版本重复登录不重复记录。 +func recordLoginConsents(userID uint64, userIdentity string, consents []loginConsent) error { + if len(consents) == 0 { + return nil + } + return impl.DBService.Transaction(func(tx *gorm.DB) error { + for _, consent := range consents { + if consent.Identity == "" || consent.Version < 1 || consent.ShownAt.IsZero() { + return gorm.ErrInvalidData + } + var content models.CmsContent + if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where( + "identity = ? AND version_no = ? AND content_type = ? AND publish_status = ? AND status = ?", + consent.Identity, consent.Version, "agreement", "published", common.StatusEnable).First(&content).Error; err != nil { + return err + } + now := time.Now() + record := models.CmsContentRead{Entity: common.NewEntity(common.StatusEnable), UserAccountID: userID, + CmsContentID: content.ID, VersionNo: content.VersionNo, ShownAt: consent.ShownAt, ConfirmedAt: &now, + ClientVersion: "user_app:A1", RequestNo: fmt.Sprintf("consent:%s:%s:%d", userIdentity, content.Identity, content.VersionNo)} + if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "user_account_id"}, {Name: "cms_content_id"}, {Name: "version_no"}}, DoNothing: true}).Create(&record).Error; err != nil { + return err + } + } + return nil + }) +} diff --git a/backend/api/internal/logic/client/user/messages.go b/backend/api/internal/logic/client/user/messages.go new file mode 100644 index 0000000..33e3cf6 --- /dev/null +++ b/backend/api/internal/logic/client/user/messages.go @@ -0,0 +1,166 @@ +// 功能:从本人业务事实生成消息中心列表,并持久化已读回执;版本:1.0.0。 +package user + +import ( + "fmt" + "sort" + "strings" + "time" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm/clause" +) + +type userMessage struct { + Key, Category, Title, Summary, StatusText, Target, TargetIdentity string + OccurredAt time.Time + Read bool +} + +// ListMessages 返回真实订单、工单和公告形成的消息,不生成不存在的安全告警。 +func ListMessages(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + messages, err := loadUserMessages(account.ID) + if err != nil { + infra.Response.Error(ctx, err) + return + } + var receipts []models.UserMessageRead + if err = impl.DBService.Where("user_account_id = ?", account.ID).Find(&receipts).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + read := make(map[string]bool, len(receipts)) + for _, receipt := range receipts { + read[receipt.MessageKey] = true + } + counts := map[string]int{"all": len(messages), "unread": 0, "safety": 0, "order": 0, "service": 0, "notice": 0} + rows := make([]gin.H, 0, len(messages)) + for _, item := range messages { + item.Read = read[item.Key] + counts[item.Category]++ + if !item.Read { + counts["unread"]++ + } + rows = append(rows, gin.H{"key": item.Key, "category": item.Category, "title": item.Title, "summary": item.Summary, + "status_text": item.StatusText, "target": item.Target, "target_identity": item.TargetIdentity, + "occurred_at": item.OccurredAt, "read": item.Read}) + } + infra.Response.Success(ctx, gin.H{"items": rows, "counts": counts}) +} + +// MarkMessagesRead 仅接受当前列表中存在的消息键,防止写入任意或他人对象标识。 +func MarkMessagesRead(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var request struct { + Keys []string `json:"keys" binding:"required,min=1,max=200,dive,required,max=96"` + } + if err := ctx.ShouldBindJSON(&request); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + messages, err := loadUserMessages(account.ID) + if err != nil { + infra.Response.Error(ctx, err) + return + } + allowed := make(map[string]bool, len(messages)) + for _, item := range messages { + allowed[item.Key] = true + } + rows := make([]models.UserMessageRead, 0, len(request.Keys)) + seen := map[string]bool{} + for _, key := range request.Keys { + key = strings.TrimSpace(key) + if !allowed[key] || seen[key] { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + seen[key] = true + rows = append(rows, models.UserMessageRead{Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable}, UserAccountID: account.ID, MessageKey: key, ReadAt: time.Now()}) + } + if err = impl.DBService.Clauses(clause.OnConflict{DoNothing: true}).Create(&rows).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"read_count": len(rows)}) +} + +func loadUserMessages(userID uint64) ([]userMessage, error) { + messages := make([]userMessage, 0) + var gasOrders []models.GasorderBasic + if err := impl.DBService.Where("user_account_id = ? AND status <> ?", userID, common.StatusArchived).Order("updated_at desc").Limit(50).Find(&gasOrders).Error; err != nil { + return nil, err + } + for _, row := range gasOrders { + messages = append(messages, orderMessage("gas", row.Identity, row.OrderNo, row.OrderStatus, row.UpdatedAt)) + } + var shopOrders []models.EcOrder + if err := impl.DBService.Where("user_account_id = ? AND status <> ?", userID, common.StatusArchived).Order("updated_at desc").Limit(50).Find(&shopOrders).Error; err != nil { + return nil, err + } + for _, row := range shopOrders { + messages = append(messages, orderMessage("shop", row.Identity, row.OrderNo, row.OrderStatus, row.UpdatedAt)) + } + var tickets []models.CsTicket + if err := impl.DBService.Where("user_account_id = ? AND status <> ? AND category <> ?", userID, common.StatusArchived, "contract_change").Order("updated_at desc").Limit(50).Find(&tickets).Error; err != nil { + return nil, err + } + for _, row := range tickets { + messages = append(messages, userMessage{Key: "ticket:" + row.Identity, Category: "service", Title: ticketTitle(row.TicketStatus), Summary: "报修工单 " + row.TicketNo, StatusText: statusText(row.TicketStatus), Target: "ticket", TargetIdentity: row.Identity, OccurredAt: row.UpdatedAt}) + } + var notices []models.CmsContent + if err := impl.DBService.Where("content_type = ? AND publish_status = ? AND status = ?", "notice", "published", common.StatusEnable).Order("updated_at desc").Limit(50).Find(¬ices).Error; err != nil { + return nil, err + } + for _, row := range notices { + messages = append(messages, userMessage{Key: "notice:" + row.Identity, Category: "notice", Title: row.Title, Summary: compactMessage(row.Body), StatusText: "平台公告", Target: "content", TargetIdentity: row.Identity, OccurredAt: row.UpdatedAt}) + } + sort.SliceStable(messages, func(i, j int) bool { return messages[i].OccurredAt.After(messages[j].OccurredAt) }) + return messages, nil +} + +func orderMessage(kind, identity, number string, status int, at time.Time) userMessage { + name, target := "燃气订单", "gas_order" + if kind == "shop" { + name, target = "商城订单", "shop_order" + } + return userMessage{Key: kind + ":" + identity, Category: "order", Title: orderTitle(name, status), Summary: fmt.Sprintf("您的%s(订单号:%s)状态已更新", name, number), StatusText: statusText(status), Target: target, TargetIdentity: identity, OccurredAt: at} +} + +func statusText(status int) string { + return map[int]string{16: "待支付", 18: "待配送", 21: "异常", 22: "已取消", 23: "已完成", 29: "运输中", 32: "待受理", 31: "处理中", 33: "配送中", 34: "待确认", 35: "已支付"}[status] +} +func orderTitle(name string, status int) string { + if value := statusText(status); value != "" { + return name + value + } + return name + "状态更新" +} +func ticketTitle(status int) string { + if status == common.StatusCompleted { + return "报修工单已完成" + } + if status == common.StatusRepairing { + return "报修工单处理中" + } + return "报修工单状态更新" +} +func compactMessage(value string) string { + value = strings.Join(strings.Fields(value), " ") + if len([]rune(value)) > 70 { + return string([]rune(value)[:70]) + "…" + } + return value +} diff --git a/backend/api/internal/logic/client/user/order_actions_test.go b/backend/api/internal/logic/client/user/order_actions_test.go new file mode 100644 index 0000000..087782a --- /dev/null +++ b/backend/api/internal/logic/client/user/order_actions_test.go @@ -0,0 +1,55 @@ +// 功能描述:验证取消/收货的状态边界、所有权和幂等重试,不连接真实数据库。 +// 版本:1.0.0。 +package user + +import ( + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" + "strings" + "testing" +) + +func TestShopActionStateBoundaries(t *testing.T) { + for _, tc := range []struct { + name string + action func(*gin.Context) + state, logistics int + found, success, update bool + }{ + {"取消重试不返第二次库存", CancelShopOrder, 22, 10, true, true, false}, + {"已付款不可取消", CancelShopOrder, 18, 10, true, false, false}, + {"非本人订单不可取消", CancelShopOrder, 16, 10, false, false, false}, + {"已取消订单不可收货", ConfirmShopReceipt, 22, 20, true, false, false}, + {"未发货不能收货", ConfirmShopReceipt, 18, 10, true, false, false}, + {"已发货确认收货", ConfirmShopReceipt, 18, 20, true, true, true}, + {"重复收货保留首次时间", ConfirmShopReceipt, 18, 30, true, true, false}, + {"非本人订单不可收货", ConfirmShopReceipt, 18, 20, false, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectBegin() + rows := sqlmock.NewRows([]string{"id", "identity", "order_status", "logistics_status"}) + if tc.found { + rows.AddRow(3, "owned", tc.state, tc.logistics) + } + mock.ExpectQuery(`SELECT .* FROM "ec_order".*identity = \$1 AND user_account_id = \$2.*FOR UPDATE`).WithArgs("owned", uint64(1), 1).WillReturnRows(rows) + if tc.update { + mock.ExpectExec(`UPDATE "ec_order" SET "logistics_status"`).WillReturnResult(sqlmock.NewResult(0, 1)) + } + if tc.success { + mock.ExpectCommit() + } else { + mock.ExpectRollback() + } + ctx, response := addressContext("", "owned") + tc.action(ctx) + if strings.Contains(response.Body.String(), `"code":0`) != tc.success { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/backend/api/internal/logic/client/user/owned_products.go b/backend/api/internal/logic/client/user/owned_products.go new file mode 100644 index 0000000..5894add --- /dev/null +++ b/backend/api/internal/logic/client/user/owned_products.go @@ -0,0 +1,61 @@ +// 功能:用户名下实体产品档案查询,严格隔离当前归属且不推断遥测;版本:1.0.0。 +package user + +import ( + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// ownedProductQuery 只查当前归属的启用档案,历史转移记录不授予现有访问权。 +func ownedProductQuery(accountID uint64) *gorm.DB { + return impl.DBService.Model(&models.ProductInfo{}).Where("user_account_id = ? AND status = ?", accountID, common.StatusEnable) +} + +// ListOwnedProducts 返回分页档案,不公开内部主键、自由参数或虚构设备状态。 +func ListOwnedProducts(ctx *gin.Context) { + listOwnedProducts(ctx, false) +} + +// ListDevices 仅显示后台明确分类且未报废的本人智能设备,钢瓶不混入设备数量。 +func ListDevices(ctx *gin.Context) { listOwnedProducts(ctx, true) } + +// listOwnedProducts 共享归属与分页边界,设备接口额外返回分类和映射是否配置。 +func listOwnedProducts(ctx *gin.Context, devicesOnly bool) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + owned := ownedProductQuery(account.ID) + if devicesOnly { + owned = owned.Where("device_kind IN ? AND product_status <> ?", []string{"valve", "alarm"}, common.StatusScrapped) + } + query, page, ok := paginateClientList(ctx, owned.Order("created_at desc, identity")) + if !ok { + return + } + // 新接口没有旧数组客户端,默认限制每页三十条,避免无界读取。 + if page.Number == 0 { + page = clientPage{Number: 1, Size: 30} + query = query.Limit(31) + } + var products []models.ProductInfo + if err := query.Find(&products).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + items := make([]gin.H, 0, len(products)) + for _, product := range products { + item := gin.H{"identity": product.Identity, "name": product.Name, "code": product.Code, + "product_status": product.ProductStatus, "control_available": false, "telemetry_available": false} + if devicesOnly { + item["kind"] = product.DeviceKind + item["mapping_configured"] = product.VendorDeviceID != "" + } + items = append(items, item) + } + respondClientPage(ctx, page, items) +} diff --git a/backend/api/internal/logic/client/user/owned_products_test.go b/backend/api/internal/logic/client/user/owned_products_test.go new file mode 100644 index 0000000..889beaf --- /dev/null +++ b/backend/api/internal/logic/client/user/owned_products_test.go @@ -0,0 +1,79 @@ +// 功能:验证当前产品归属、分页和最小响应字段;版本:1.0.0。 +package user + +import ( + "encoding/json" + "github.com/DATA-DOG/go-sqlmock" + "net/http/httptest" + "testing" +) + +// TestOwnedProductsBoundary 确保请求参数不能覆盖登录用户,并且不泄露内部数据。 +func TestOwnedProductsBoundary(t *testing.T) { + for _, search := range []string{"?user_account_id=99", "?page=1&page_size=1&user_account_id=99"} { + t.Run(search, func(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + limit := 31 + if search[1:5] == "page" { + limit = 2 + } + mock.ExpectQuery(`SELECT .* FROM "product_info" WHERE \(user_account_id = \$1 AND status = \$2\).*ORDER BY created_at desc, identity LIMIT \$3`).WithArgs(1, 1, limit). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "name", "code", "params", "user_account_id"}).AddRow(9, "owned", "产品", "code", "private", 1)) + ctx, response := addressContext("", "") + ctx.Request = httptest.NewRequest("GET", "/owned-products"+search, nil) + ListOwnedProducts(ctx) + var body struct { + Code int `json:"code"` + Details struct { + Items []map[string]any `json:"items"` + Page int `json:"page"` + } `json:"details"` + } + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Code != 0 || body.Details.Page != 1 || len(body.Details.Items) != 1 { + t.Fatal(response.Body.String()) + } + item := body.Details.Items[0] + if len(item) != 6 || item["identity"] != "owned" || item["control_available"] != false || item["telemetry_available"] != false { + t.Fatal(item) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} + +// TestDeviceListClassification 设备列表必须同时限定本人、启用分类及非报废,不返回厂商编号。 +func TestDeviceListClassification(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectQuery(`SELECT .* FROM "product_info" WHERE \(user_account_id = \$1 AND status = \$2\) AND \(device_kind IN \(\$3,\$4\) AND product_status <> \$5\)`). + WithArgs(1, 1, "valve", "alarm", 27, 31). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "name", "code", "device_kind", "vendor_device_id"}).AddRow(2, "valve", "测试阀门", "V01", "valve", "0000000000000001")) + ctx, response := addressContext("", "") + ctx.Request = httptest.NewRequest("GET", "/devices?user_account_id=99", nil) + ListDevices(ctx) + var body struct { + Code int `json:"code"` + Details struct { + Items []map[string]any `json:"items"` + } `json:"details"` + } + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Code != 0 || len(body.Details.Items) != 1 { + t.Fatal(response.Body.String()) + } + item := body.Details.Items[0] + if item["kind"] != "valve" || item["mapping_configured"] != true || item["control_available"] != false || len(item) != 8 { + t.Fatal(item) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/client/user/primary_api_test.go b/backend/api/internal/logic/client/user/primary_api_test.go new file mode 100644 index 0000000..3b531ea --- /dev/null +++ b/backend/api/internal/logic/client/user/primary_api_test.go @@ -0,0 +1,131 @@ +// 功能描述:验证公开商品数据隔离、兼容分页与登录协议版本保护。 +// 版本:1.0.0 +package user + +import ( + "encoding/json" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// primaryTestDB 安装可验证 SQL 的测试连接,结束后恢复原连接。 +func primaryTestDB(t *testing.T) sqlmock.Sqlmock { + t.Helper() + connection, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + db, err := gorm.Open(postgres.New(postgres.Config{Conn: connection}), &gorm.Config{SkipDefaultTransaction: true}) + if err != nil { + t.Fatal(err) + } + old := impl.DBService + impl.DBService = db + t.Cleanup(func() { impl.DBService = old; connection.Close() }) + return mock +} + +// TestPublicProductsNeverQueriesOrders 防止将商品内部编号误用为订单编号泄露私有明细。 +func TestPublicProductsNeverQueriesOrders(t *testing.T) { + mock := primaryTestDB(t) + mock.ExpectQuery(`SELECT .* FROM "ec_product"`).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "ec_category_id", "name", "price_amount", "stock_quantity"}).AddRow(7, "product-public", 2, "报警器", 16900, 10)) + mock.ExpectQuery(`SELECT .* FROM "ec_product_image"`).WithArgs(7, 1).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "image_uri"}).AddRow(9, "image-public", "https://example.com/product.png")) + mock.ExpectQuery(`SELECT .* FROM "ec_category"`).WithArgs(2, 1).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "name"}).AddRow(2, "category-public", "安全设备")) + response := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(response) + ctx.Request = httptest.NewRequest("GET", "/public/products", nil) + PublicProducts(ctx) + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + var body struct { + Code int `json:"code"` + Details []map[string]any `json:"details"` + } + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Code != 0 || len(body.Details) != 1 { + t.Fatalf("商品响应异常:%s", response.Body.String()) + } + value := body.Details[0] + if _, ok := value["items"]; ok { + t.Fatal("公开商品不允许携带订单明细") + } + if _, ok := value["id"]; ok { + t.Fatal("公开响应泄露内部主键") + } + if value["category_name"] != "安全设备" || value["image_url"] != "https://example.com/product.png" { + t.Fatal("未返回后台分类与图片") + } +} + +// TestClientPaginationRejectsInvalidBounds 验证畸形页码不会触发数据库查询。 +func TestClientPaginationRejectsInvalidBounds(t *testing.T) { + for _, query := range []string{"page=0", "page=-1", "page=x", "page_size=101", "page_size=0", "page=100001"} { + t.Run(query, func(t *testing.T) { + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest("GET", "/?"+query, nil) + _, _, ok := paginateClientList(ctx, nil) + if ok { + t.Fatal("无效分页被接受") + } + }) + } +} + +// TestClientPageCompatibility 验证数组兼容与多读一条的分页边界。 +func TestClientPageCompatibility(t *testing.T) { + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + respondClientPage(ctx, clientPage{Number: 1, Size: 2}, []string{"a", "b", "c"}) + if !strings.Contains(recorder.Body.String(), `"has_more":true`) || strings.Contains(recorder.Body.String(), `"c"`) { + t.Fatal(recorder.Body.String()) + } + recorder = httptest.NewRecorder() + ctx, _ = gin.CreateTestContext(recorder) + respondClientPage(ctx, clientPage{}, []string{"a"}) + if !strings.Contains(recorder.Body.String(), `"details":["a"]`) { + t.Fatal(recorder.Body.String()) + } +} + +// TestLoginConsentRejectsChangedVersion 已阅读版本过期时回滚,不能登记为新版本已同意。 +func TestLoginConsentRejectsChangedVersion(t *testing.T) { + mock := primaryTestDB(t) + mock.ExpectBegin() + mock.ExpectQuery(`SELECT .* FROM "cms_content"`).WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectRollback() + if err := recordLoginConsents(1, "user", []loginConsent{{Identity: "agreement", Version: 2, ShownAt: time.Now()}}); err == nil { + t.Fatal("过期协议被接受") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +// TestLoginConsentDuplicateIsIdempotent 重试必须使用用户和内容版本唯一键,不能重复确认事实。 +func TestLoginConsentDuplicateIsIdempotent(t *testing.T) { + mock := primaryTestDB(t) + shown := time.Date(2026, 9, 7, 1, 0, 0, 0, time.UTC) + for attempt := 0; attempt < 2; attempt++ { + mock.ExpectBegin() + mock.ExpectQuery(`SELECT .* FROM "cms_content"`).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "version_no"}).AddRow(2, "agreement", 3)) + mock.ExpectQuery(`INSERT INTO "cms_content_read" .* ON CONFLICT \("user_account_id","cms_content_id","version_no"\) DO NOTHING`).WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectCommit() + if err := recordLoginConsents(1, "user", []loginConsent{{Identity: "agreement", Version: 3, ShownAt: shown}}); err != nil { + t.Fatal(err) + } + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/client/user/product_detail.go b/backend/api/internal/logic/client/user/product_detail.go new file mode 100644 index 0000000..661dff0 --- /dev/null +++ b/backend/api/internal/logic/client/user/product_detail.go @@ -0,0 +1,53 @@ +// 功能描述:公开商品详情,仅返回已上架商品及其启用图片和属性,不关联交易私有数据。 +// 版本:1.0.0。 +package user + +import ( + "errors" + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// PublicProduct 允许查看已售罄的上架商品;是否能够下单仍由交易接口重新校验。 +func PublicProduct(ctx *gin.Context) { + var product models.EcProduct + if err := impl.DBService.Where("identity = ? AND status = ?", ctx.Param("identity"), common.StatusEnable).Take(&product).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + return + } + infra.Response.Error(ctx, err) + return + } + var pictures []models.EcProductImage + if err := impl.DBService.Where("ec_product_id = ? AND status = ?", product.ID, common.StatusEnable).Order("is_cover desc, sort_no, identity").Find(&pictures).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + var attributes []models.EcProductAttribute + if err := impl.DBService.Where("ec_product_id = ? AND status = ?", product.ID, common.StatusEnable).Order("sort_no, identity").Find(&attributes).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + var category models.EcCategory + if err := impl.DBService.Where("id = ? AND status = ?", product.EcCategoryID, common.StatusEnable).Find(&category).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + // 显式白名单避免新模型字段意外进入公开接口,不暴露任何内部主键。 + images := make([]gin.H, 0, len(pictures)) + for _, picture := range pictures { + images = append(images, gin.H{"identity": picture.Identity, "image_url": picture.ImageURI}) + } + parameters := make([]gin.H, 0, len(attributes)) + for _, attribute := range attributes { + parameters = append(parameters, gin.H{"name": attribute.Name, "value": attribute.Value}) + } + infra.Response.Success(ctx, gin.H{"identity": product.Identity, "name": product.Name, "price_amount": product.PriceAmount, "stock_quantity": product.StockQuantity, + "category_name": category.Name, "images": images, "attributes": parameters}) +} diff --git a/backend/api/internal/logic/client/user/product_detail_test.go b/backend/api/internal/logic/client/user/product_detail_test.go new file mode 100644 index 0000000..a51794e --- /dev/null +++ b/backend/api/internal/logic/client/user/product_detail_test.go @@ -0,0 +1,70 @@ +// 功能描述:公开详情权限、售罄查看及公开字段边界回归。 +// 版本:1.0.0。 +package user + +import ( + "encoding/json" + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" + "net/http/httptest" + "testing" +) + +func TestPublicProductDetail(t *testing.T) { + mock := primaryTestDB(t) + mock.ExpectQuery(`SELECT .* FROM "ec_product" WHERE \(identity = \$1 AND status = \$2\)`).WithArgs("product-public", 1, 1).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "ec_category_id", "name", "price_amount", "stock_quantity"}).AddRow(7, "product-public", 2, "报警器", 16900, 0)) + mock.ExpectQuery(`SELECT .* FROM "ec_product_image" WHERE \(ec_product_id = \$1 AND status = \$2\)`).WithArgs(7, 1).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "image_uri"}).AddRow(9, "picture-public", "https://example.com/product.png")) + mock.ExpectQuery(`SELECT .* FROM "ec_product_attribute" WHERE \(ec_product_id = \$1 AND status = \$2\)`).WithArgs(7, 1).WillReturnRows(sqlmock.NewRows([]string{"id", "name", "value"}).AddRow(11, "规格", "家用")) + mock.ExpectQuery(`SELECT .* FROM "ec_category"`).WithArgs(2, 1).WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(2, "安全设备")) + response := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(response) + ctx.Params = gin.Params{{Key: "identity", Value: "product-public"}} + ctx.Request = httptest.NewRequest("GET", "/public/products/product-public", nil) + PublicProduct(ctx) + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + var body struct { + Code int `json:"code"` + Details map[string]any `json:"details"` + } + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Code != 0 || body.Details["stock_quantity"] != float64(0) { + t.Fatal("售罄商品应允许查看") + } + for _, key := range []string{"id", "ec_category_id", "items", "user_account_id"} { + if _, ok := body.Details[key]; ok { + t.Fatal("泄露内部数据", key) + } + } + picture := body.Details["images"].([]any)[0].(map[string]any) + if len(picture) != 2 || picture["image_url"] != "https://example.com/product.png" { + t.Fatal("图片字段错误") + } + parameter := body.Details["attributes"].([]any)[0].(map[string]any) + if len(parameter) != 2 || parameter["value"] != "家用" { + t.Fatal("属性字段错误") + } +} + +func TestPublicProductUnavailable(t *testing.T) { + mock := primaryTestDB(t) + mock.ExpectQuery(`SELECT .* FROM "ec_product" WHERE \(identity = \$1 AND status = \$2\)`).WithArgs("hidden", 1, 1).WillReturnRows(sqlmock.NewRows([]string{"id"})) + response := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(response) + ctx.Params = gin.Params{{Key: "identity", Value: "hidden"}} + ctx.Request = httptest.NewRequest("GET", "/public/products/hidden", nil) + PublicProduct(ctx) + var body struct { + Code int `json:"code"` + } + json.Unmarshal(response.Body.Bytes(), &body) + if body.Code != 1112 { + t.Fatal("不存在或下架商品应返回明确不存在代码", body.Code) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/client/user/profile_test.go b/backend/api/internal/logic/client/user/profile_test.go new file mode 100644 index 0000000..087b94a --- /dev/null +++ b/backend/api/internal/logic/client/user/profile_test.go @@ -0,0 +1,50 @@ +// 功能描述:验证资料更新保留头像、拒绝跨账户图片及空昵称。 +// 版本:1.0.0。 +package user + +import ( + "git.apinb.com/bsm-sdk/core/types" + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" + "net/http/httptest" + "strings" + "testing" +) + +// TestProfileUpdateBoundaries 对数据库读写使用 SQL Mock,不连接远程数据。 +func TestProfileUpdateBoundaries(t *testing.T) { + for _, tc := range []struct { + name, body string + valid bool + }{ + {"昵称修改保留头像", `{"name":"新昵称"}`, true}, + {"允许保留旧头像", `{"name":"新昵称","avatar":"/uploads/avatars/old.png"}`, true}, + {"空昵称拒绝", `{"name":" "}`, false}, + {"他人头像拒绝", `{"name":"新昵称","avatar":"/uploads/avatars/other.png"}`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + mock := primaryTestDB(t) + mock.ExpectQuery(`SELECT .* FROM "user_account"`).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "name", "avatar"}).AddRow(1, "alice", "旧昵称", "/uploads/avatars/old.png")) + if tc.valid { + if strings.Contains(tc.body, "avatar") { + mock.ExpectExec(`UPDATE "user_account" SET "avatar"=.*"name"=`).WillReturnResult(sqlmock.NewResult(0, 1)) + } else { + mock.ExpectExec(`UPDATE "user_account" SET "name"=`).WillReturnResult(sqlmock.NewResult(0, 1)) + } + } + response := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(response) + ctx.Set("Auth", &types.JwtClaims{Client: "user_app", Identity: "alice"}) + ctx.Request = httptest.NewRequest("PUT", "/auth/profile", strings.NewReader(tc.body)) + ctx.Request.Header.Set("Content-Type", "application/json") + UpdateProfile(ctx) + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + success := strings.Contains(response.Body.String(), `"code":0`) + if success != tc.valid { + t.Fatalf("更新状态错误: %s", response.Body.String()) + } + }) + } +} diff --git a/backend/api/internal/logic/client/user/recommendation.go b/backend/api/internal/logic/client/user/recommendation.go new file mode 100644 index 0000000..f15ccb7 --- /dev/null +++ b/backend/api/internal/logic/client/user/recommendation.go @@ -0,0 +1,90 @@ +// 功能描述:按购物车或收藏关联分类推荐真实在售商品;版本:1.0.0。 +package user + +import ( + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// Recommendations 排除本人已入购物车商品,收藏场景另排除已收藏;相同分类优先,其余按最近更新排序。 +func Recommendations(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var request struct { + Source string `form:"source,default=cart" binding:"oneof=cart favorites"` + Page int `form:"page,default=1" binding:"gte=1,lte=10000"` + Size int `form:"page_size,default=2" binding:"gte=1,lte=20"` + } + if ctx.ShouldBindQuery(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + query := impl.DBService.Where("status = ? AND stock_quantity > 0", common.StatusEnable). + Where("NOT EXISTS (SELECT 1 FROM ec_cart c WHERE c.ec_product_id = ec_product.id AND c.user_account_id = ? AND c.status = ? AND c.deleted_at IS NULL)", account.ID, common.StatusEnable) + table := "ec_cart" + if request.Source == "favorites" { + table = "ec_favorite" + query = query.Where("NOT EXISTS (SELECT 1 FROM ec_favorite f WHERE f.ec_product_id = ec_product.id AND f.user_account_id = ? AND f.status = ? AND f.deleted_at IS NULL)", account.ID, common.StatusEnable) + } + // 表名来自固定枚举,用户输入不拼接进SQL;归属只使用JWT对应的内部账户键。 + rank := "CASE WHEN EXISTS (SELECT 1 FROM " + table + " r JOIN ec_product p ON p.id = r.ec_product_id WHERE r.user_account_id = ? AND r.status = ? AND r.deleted_at IS NULL AND p.ec_category_id = ec_product.ec_category_id) THEN 0 ELSE 1 END" + var products []models.EcProduct + if err := query.Clauses(clause.OrderBy{Expression: gorm.Expr(rank+", ec_product.updated_at desc, ec_product.identity", account.ID, common.StatusEnable)}).Offset((request.Page - 1) * request.Size).Limit(request.Size + 1).Find(&products).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + values, err := recommendationValues(impl.DBService, products) + if err != nil { + infra.Response.Error(ctx, err) + return + } + respondClientPage(ctx, clientPage{Number: request.Page, Size: request.Size}, values) +} + +// recommendationValues 批量读取主图和分类,公开结果只包含展示和下单所需字段。 +func recommendationValues(db *gorm.DB, products []models.EcProduct) ([]gin.H, error) { + result := make([]gin.H, 0, len(products)) + if len(products) == 0 { + return result, nil + } + ids := make([]uint64, 0, len(products)) + categoryIDs := []uint64{} + seen := map[uint64]bool{} + for _, p := range products { + ids = append(ids, p.ID) + if !seen[p.EcCategoryID] { + seen[p.EcCategoryID] = true + categoryIDs = append(categoryIDs, p.EcCategoryID) + } + } + var images []models.EcProductImage + if err := db.Where("ec_product_id IN ? AND status = ?", ids, common.StatusEnable).Order("is_cover desc, sort_no, identity").Find(&images).Error; err != nil { + return nil, err + } + imageMap := map[uint64]string{} + for _, i := range images { + if _, ok := imageMap[i.EcProductID]; !ok { + imageMap[i.EcProductID] = i.ImageURI + } + } + var categories []models.EcCategory + if err := db.Where("id IN ? AND status = ?", categoryIDs, common.StatusEnable).Find(&categories).Error; err != nil { + return nil, err + } + categoryMap := map[uint64]string{} + for _, c := range categories { + categoryMap[c.ID] = c.Name + } + for _, p := range products { + result = append(result, gin.H{"identity": p.Identity, "name": p.Name, "price_amount": p.PriceAmount, "stock_quantity": p.StockQuantity, "image_url": imageMap[p.ID], "category_name": categoryMap[p.EcCategoryID]}) + } + return result, nil +} diff --git a/backend/api/internal/logic/client/user/recommendation_test.go b/backend/api/internal/logic/client/user/recommendation_test.go new file mode 100644 index 0000000..64bcda5 --- /dev/null +++ b/backend/api/internal/logic/client/user/recommendation_test.go @@ -0,0 +1,96 @@ +// 功能描述:推荐归属过滤、分类排序、分页和公开资料回归;版本:1.0.0。 +package user + +import ( + "encoding/json" + "fmt" + "github.com/DATA-DOG/go-sqlmock" + "strings" + "testing" +) + +// 两种入口均排除本人购物车,收藏入口另排除本人收藏;ORDER CASE必须实际进入查询。 +func TestRecommendationsScopeAndPagination(t *testing.T) { + for _, source := range []string{"cart", "favorites"} { + t.Run(source, func(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + query := `SELECT .* FROM "ec_product" WHERE \(status = \$1 AND stock_quantity > 0\).*NOT EXISTS .*ec_cart c.*c.user_account_id = \$2 AND c.status = \$3` + if source == "favorites" { + query += `.*NOT EXISTS .*ec_favorite f.*f.user_account_id = \$4 AND f.status = \$5` + } + query += `.*ec_product"\."deleted_at" IS NULL ORDER BY CASE WHEN EXISTS .*FROM ec_` + if source == "cart" { + query += `cart` + } else { + query += `favorite` + } + query += ` r .*r.user_account_id = .*AND r.status = .*THEN 0 ELSE 1 END, ec_product.updated_at desc, ec_product.identity LIMIT .* OFFSET` + expected := mock.ExpectQuery(query) + if source == "favorites" { + expected.WithArgs(1, 1, 1, 1, 1, 1, 1, 3, 2) + } else { + expected.WithArgs(1, 1, 1, 1, 1, 3, 2) + } + expected.WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "name", "ec_category_id", "price_amount", "stock_quantity"}).AddRow(3, "p3", "减压阀", 7, 3900, 4).AddRow(5, "p5", "燃气管", 7, 5900, 9).AddRow(6, "p6", "更多", 8, 1900, 2)) + mock.ExpectQuery(`SELECT .* FROM "ec_product_image".*ec_product_id IN`).WithArgs(3, 5, 6, 1).WillReturnRows(sqlmock.NewRows([]string{"ec_product_id", "image_uri"}).AddRow(3, "/cover.png").AddRow(5, "/five.png").AddRow(3, "/other.png")) + mock.ExpectQuery(`SELECT .* FROM "ec_category".*id IN`).WithArgs(7, 8, 1).WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(7, "配件")) + ctx, response := addressContext("", "") + ctx.Request.URL.RawQuery = fmt.Sprintf("source=%s&page=2&page_size=2", source) + Recommendations(ctx) + var body struct { + Code int `json:"code"` + Details struct { + Items []map[string]any `json:"items"` + Page int `json:"page"` + More bool `json:"has_more"` + } `json:"details"` + } + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Code != 0 || body.Details.Page != 2 || !body.Details.More || len(body.Details.Items) != 2 { + t.Fatal(response.Body.String()) + } + first := body.Details.Items[0] + if first["identity"] != "p3" || first["image_url"] != "/cover.png" || first["category_name"] != "配件" || first["price_amount"] != float64(3900) || body.Details.Items[1]["image_url"] != "/five.png" { + t.Fatal(response.Body.String()) + } + for _, row := range body.Details.Items { + for _, key := range []string{"id", "ec_category_id", "user_account_id"} { + if _, ok := row[key]; ok { + t.Fatal("泄露内部字段", key) + } + } + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestRecommendationsEmptyAndInvalidQuery(t *testing.T) { + for _, query := range []string{"", "source=other", "page=0", "page=10001", "page_size=21", "page_size=-1"} { + t.Run(query, func(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + if query == "" { + mock.ExpectQuery(`SELECT .* FROM "ec_product".*ORDER BY CASE`).WithArgs(1, 1, 1, 1, 1, 3).WillReturnRows(sqlmock.NewRows([]string{"id"})) + } + ctx, response := addressContext("", "") + ctx.Request.URL.RawQuery = query + Recommendations(ctx) + if query == "" { + if !strings.Contains(response.Body.String(), `"items":[]`) || !strings.Contains(response.Body.String(), `"has_more":false`) { + t.Fatal(response.Body.String()) + } + } else if !strings.Contains(response.Body.String(), `"code":1704`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/backend/api/internal/logic/client/user/shop.go b/backend/api/internal/logic/client/user/shop.go index 48a75f3..7c0198a 100644 --- a/backend/api/internal/logic/client/user/shop.go +++ b/backend/api/internal/logic/client/user/shop.go @@ -2,6 +2,10 @@ package user import ( "encoding/json" + "errors" + "math" + "sort" + "strings" "time" "git.apinb.com/bsm-sdk/core/errcode" @@ -15,25 +19,55 @@ import ( "gorm.io/gorm/clause" ) +// 结算错误在包初始化时注册,避免并发请求修改 SDK 的全局错误表。 +var ( + errShopPriceChanged = errcode.NewError(2401, "商品价格已变化,请刷新后重新确认") + errShopStockChanged = errcode.NewError(2402, "商品库存不足或已下架,请调整商品后重试") +) + +// shopOrderProductSnapshot 固化成交时商品名称、编码和封面,历史订单不受后台后续改图影响。 +func shopOrderProductSnapshot(product models.EcProduct, imageURL string) string { + snapshot, _ := json.Marshal(gin.H{ + "identity": product.Identity, "name": product.Name, + "product_code": product.ProductCode, "image_url": imageURL, + }) + return string(snapshot) +} + // PublicProducts 返回上架且有库存的商品。 func PublicProducts(ctx *gin.Context) { + query, page, ok := paginateClientList(ctx, impl.DBService.Where("status = ? AND stock_quantity > 0", common.StatusEnable)) + if !ok { + return + } var list []models.EcProduct - if err := impl.DBService.Where("status = ? AND stock_quantity > 0", common.StatusEnable).Order("created_at desc").Find(&list).Error; err != nil { + if err := query.Order("created_at desc, identity").Find(&list).Error; err != nil { infra.Response.Error(ctx, err) return } response := make([]gin.H, 0, len(list)) - for _, order := range list { - var items []models.EcOrderItem - if err := impl.DBService.Where("ec_order_id = ?", order.ID).Find(&items).Error; err != nil { + for _, product := range list { + // 商品主键只能关联商品资源,禁止联查订单明细。 + var pictures []models.EcProductImage + if err := impl.DBService.Where("ec_product_id = ? AND status = ?", product.ID, common.StatusEnable). + Order("is_cover desc, sort_no, identity").Find(&pictures).Error; err != nil { infra.Response.Error(ctx, err) return } - value := common.ResourceResponse(order).(map[string]any) - value["items"] = common.ResourceResponse(items) + var category models.EcCategory + if err := impl.DBService.Where("id = ? AND status = ?", product.EcCategoryID, common.StatusEnable).Find(&category).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + value := common.ResourceResponse(product).(map[string]any) + value["category_identity"], value["category_name"] = category.Identity, category.Name + value["image_url"] = "" + if len(pictures) > 0 { + value["image_url"] = pictures[0].ImageURI + } response = append(response, value) } - infra.Response.Success(ctx, response) + respondClientPage(ctx, page, response) } // CreateShopOrder 按服务端价格创建订单并原子扣减库存。 @@ -43,21 +77,40 @@ func CreateShopOrder(ctx *gin.Context) { return } var request struct { - RequestNo string `json:"request_no" binding:"required"` - AddressIdentity string `json:"address_identity" binding:"required"` - ContactName string `json:"contact_name" binding:"required"` - ContactPhone string `json:"contact_phone" binding:"required"` - Remark string `json:"remark"` - Items []struct { + RequestNo string `json:"request_no" binding:"required,max=128"` + AddressIdentity string `json:"address_identity" binding:"required"` + ContactName string `json:"contact_name" binding:"required,max=64"` + ContactPhone string `json:"contact_phone" binding:"required"` + Remark string `json:"remark" binding:"max=2000"` + ExpectedPayableAmount *int64 `json:"expected_payable_amount"` // 可选报价确认;旧客户端省略仍兼容。 + Items []struct { ProductIdentity string `json:"product_identity" binding:"required"` - Quantity int `json:"quantity" binding:"required,gt=0"` - } `json:"items" binding:"required,min=1"` + Quantity int `json:"quantity" binding:"required,gt=0,lte=999"` + CartRevision string `json:"cart_revision" binding:"max=128"` // 非空时原子消费指定购物车版本。 + } `json:"items" binding:"required,min=1,max=100,dive"` } - if ctx.ShouldBindJSON(&request) != nil || !common.ValidPhone(request.ContactPhone) { + if ctx.ShouldBindJSON(&request) != nil || !common.ValidPhone(request.ContactPhone) || strings.TrimSpace(request.ContactName) == "" { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } var address models.UserAddress + // 固定商品锁顺序避免多商品订单交叉死锁;重复商品必须由客户端先合并。 + sort.Slice(request.Items, func(i, j int) bool { return request.Items[i].ProductIdentity < request.Items[j].ProductIdentity }) + for i := 1; i < len(request.Items); i++ { + if request.Items[i-1].ProductIdentity == request.Items[i].ProductIdentity { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + } + // 优先恢复已创建结果,地址随后归档或库存归零也不影响原请求重试。 + var previous models.EcOrder + if err := impl.DBService.Where("request_no = ? AND user_account_id = ?", request.RequestNo, account.ID).First(&previous).Error; err == nil { + infra.Response.Success(ctx, common.ResourceResponse(previous)) + return + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + infra.Response.Error(ctx, err) + return + } if impl.DBService.Where("identity = ? AND user_account_id = ? AND status <> ?", request.AddressIdentity, account.ID, common.StatusArchived).First(&address).Error != nil { infra.Response.Error(ctx, errcode.ErrRecordNotFound) return @@ -69,6 +122,15 @@ func CreateShopOrder(ctx *gin.Context) { ContactName: request.ContactName, ContactPhone: request.ContactPhone, Remark: request.Remark, LogisticsStatus: 10, } err := impl.DBService.Transaction(func(tx *gorm.DB) error { + for _, requested := range request.Items { + if requested.CartRevision != "" { + var owner models.UserAccount + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", account.ID).Take(&owner).Error; err != nil { + return err + } + break + } + } var amount int64 items := make([]models.EcOrderItem, 0, len(request.Items)) for _, requested := range request.Items { @@ -76,19 +138,45 @@ func CreateShopOrder(ctx *gin.Context) { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("identity = ? AND status = ? AND stock_quantity >= ?", requested.ProductIdentity, common.StatusEnable, requested.Quantity). First(&product).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errShopStockChanged + } return err } + // 金额必须在扣库存前校验,防止乘加溢出形成错误订单。 + if requested.CartRevision != "" { + var cart models.EcCart + if err := tx.Where("user_account_id = ? AND ec_product_id = ?", account.ID, product.ID).Find(&cart).Error; err != nil { + return err + } + if cart.Status != common.StatusEnable || !cart.Selected || cart.Quantity != requested.Quantity || cartRevision(cart) != requested.CartRevision { + return errCartChanged + } + if err := tx.Model(&cart).Update("status", common.StatusArchived).Error; err != nil { + return err + } + } + if product.PriceAmount < 0 || product.PriceAmount > (math.MaxInt64-amount)/int64(requested.Quantity) { + return errcode.ErrOutOfRange + } if err := tx.Model(&product).Update("stock_quantity", gorm.Expr("stock_quantity - ?", requested.Quantity)).Error; err != nil { return err } - snapshot, _ := json.Marshal(gin.H{"identity": product.Identity, "name": product.Name, "product_code": product.ProductCode}) + imageURL, err := productCoverURL(tx, product.ID) + if err != nil { + return err + } items = append(items, models.EcOrderItem{ - Entity: common.NewEntity(common.StatusEnable), EcProductID: product.ID, ProductSnapshot: string(snapshot), - Quantity: requested.Quantity, SaleAmount: product.PriceAmount, + Entity: common.NewEntity(common.StatusEnable), EcProductID: product.ID, + ProductSnapshot: shopOrderProductSnapshot(product, imageURL), + Quantity: requested.Quantity, SaleAmount: product.PriceAmount, }) amount += product.PriceAmount * int64(requested.Quantity) } order.ProductAmount, order.TotalAmount, order.PayableAmount = amount, amount, amount + if request.ExpectedPayableAmount != nil && *request.ExpectedPayableAmount != amount { + return errShopPriceChanged + } if err := tx.Create(&order).Error; err != nil { return err } @@ -117,12 +205,30 @@ func ListShopOrders(ctx *gin.Context) { if !ok { return } + query, page, ok := paginateClientList(ctx, impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, common.StatusArchived)) + if !ok { + return + } var list []models.EcOrder - if err := impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, common.StatusArchived).Order("created_at desc").Find(&list).Error; err != nil { + if err := query.Order("created_at desc, identity").Find(&list).Error; err != nil { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, common.ResourceResponse(list)) + response := make([]gin.H, 0, len(list)) + for _, order := range list { + var items []models.EcOrderItem + if err := impl.DBService.Where("ec_order_id = ?", order.ID).Find(&items).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + value := common.ResourceResponse(order).(map[string]any) + value["items"] = common.ResourceResponse(items) + name, actions := shopOrderState(order) + value["status_code"], value["status_name"] = order.OrderStatus, name + value["allowed_actions"] = actions + response = append(response, value) + } + respondClientPage(ctx, page, response) } // CancelShopOrder 取消未支付订单并恢复库存。 @@ -134,9 +240,16 @@ func CancelShopOrder(ctx *gin.Context) { err := impl.DBService.Transaction(func(tx *gorm.DB) error { var order models.EcOrder if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("identity = ? AND user_account_id = ? AND order_status = ?", ctx.Param("identity"), account.ID, 16).First(&order).Error; err != nil { + Where("identity = ? AND user_account_id = ?", ctx.Param("identity"), account.ID).First(&order).Error; err != nil { return err } + // 重试取消不重复返库存;已支付或履约中的订单仍禁止取消。 + if order.OrderStatus == 22 { + return nil + } + if order.OrderStatus != 16 { + return errcode.ErrInvalidArgument + } var items []models.EcOrderItem if err := tx.Where("ec_order_id = ?", order.ID).Find(&items).Error; err != nil { return err @@ -187,10 +300,32 @@ func PayShopOrder(ctx *gin.Context) { infra.Response.Success(ctx, payment.PublicResponse(payOrder)) return } - err := impl.DBService.Transaction(func(tx *gorm.DB) error { + err := payShopOrderWithWallet(account, ctx.Param("identity"), request.RequestNo, request.PaymentPassword) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"paid": true}) +} + +// payShopOrderWithWallet 将支付单、钱包流水和商城订单状态作为同一原子事实保存。 +func payShopOrderWithWallet(account models.UserAccount, identity, requestNo, password string) error { + return impl.DBService.Transaction(func(tx *gorm.DB) error { + var existing models.PaymentOrder + err := tx.Where("business_type = ? AND request_no = ?", "ec_order", requestNo).First(&existing).Error + if err == nil { + if existing.BusinessIdentity != identity || existing.UserIdentity != account.Identity || existing.Channel != "wallet" || existing.PaymentStatus != payment.StatusPaid { + return errors.New("idempotency conflict") + } + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + var order models.EcOrder if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("identity = ? AND user_account_id = ? AND order_status = ?", ctx.Param("identity"), account.ID, 16).First(&order).Error; err != nil { + Where("identity = ? AND user_account_id = ? AND order_status = ?", identity, account.ID, common.StatusCreated).First(&order).Error; err != nil { return err } var wallet models.WalletBasic @@ -198,7 +333,7 @@ func PayShopOrder(ctx *gin.Context) { Where("owner_type = ? AND owner_identity = ?", "user", account.Identity).First(&wallet).Error; err != nil { return err } - if !common.VerifyPaymentPassword(account.Identity, wallet, request.PaymentPassword) { + if !common.VerifyPaymentPassword(account.Identity, wallet, password) { return gorm.ErrInvalidData } if err := common.SpendWalletBalance(&wallet, order.PayableAmount); err != nil { @@ -208,23 +343,28 @@ func PayShopOrder(ctx *gin.Context) { return err } now := time.Now() - if err := tx.Model(&order).Updates(map[string]any{"order_status": 18, "paid_at": &now}).Error; err != nil { + if err := tx.Model(&order).Updates(map[string]any{"order_status": common.StatusAssigned, "paid_at": &now}).Error; err != nil { + return err + } + if err := tx.Create(&models.PaymentOrder{ + Entity: common.NewEntity(common.StatusEnable), PaymentStatus: payment.StatusPaid, + PaymentNo: common.RecordNo("PAY"), RequestNo: requestNo, BusinessType: "ec_order", + BusinessIdentity: order.Identity, UserIdentity: account.Identity, MerchantIdentity: "platform", + Channel: "wallet", PayType: "balance", Amount: order.PayableAmount, + Subject: "和气商城订单 " + order.OrderNo, ExpiresAt: now, PaidAt: &now, + }).Error; err != nil { return err } date := now.In(time.Local) return tx.Create(&models.WalletRecord{ Entity: common.NewEntity(common.StatusEnable), WalletBasicID: wallet.ID, RecordNo: common.RecordNo("WR"), - RequestNo: request.RequestNo, Direction: "expense", TradeType: "ec_order", + RequestNo: requestNo, Direction: "expense", TradeType: "ec_order", Amount: order.PayableAmount, BalanceAfter: wallet.Balance, WithdrawalBalanceAfter: wallet.WithdrawalBalance, OutTradeNo: order.OrderNo, PayChannel: "wallet", OperatorIdentity: account.Identity, - Ymd: int32(date.Year()*10000 + int(date.Month())*100 + date.Day()), Ym: int32(date.Year()*100 + int(date.Month())), + Ymd: int32(date.Year()*10000 + int(date.Month())*100 + date.Day()), + Ym: int32(date.Year()*100 + int(date.Month())), }).Error }) - if err != nil { - infra.Response.Error(ctx, err) - return - } - infra.Response.Success(ctx, gin.H{"paid": true}) } // ConfirmShopReceipt 只推进独立物流状态,不伪造支付状态。 @@ -233,16 +373,26 @@ func ConfirmShopReceipt(ctx *gin.Context) { if !ok { return } - now := time.Now() - result := impl.DBService.Model(&models.EcOrder{}). - Where("identity = ? AND user_account_id = ? AND logistics_status = ?", ctx.Param("identity"), account.ID, 20). - Updates(map[string]any{"logistics_status": 30, "received_at": &now}) - if result.Error != nil { - infra.Response.Error(ctx, result.Error) - return - } - if result.RowsAffected != 1 { - infra.Response.Error(ctx, errcode.ErrInvalidArgument) + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var order models.EcOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND user_account_id = ?", ctx.Param("identity"), account.ID).First(&order).Error; err != nil { + return err + } + // 必须仍是已支付订单;取消/退款中的历史物流状态不能成为收货权限。 + if order.OrderStatus != 18 { + return errcode.ErrInvalidArgument + } + if order.LogisticsStatus == 30 { + return nil + } + if order.LogisticsStatus != 20 { + return errcode.ErrInvalidArgument + } + now := time.Now() + return tx.Model(&order).Updates(map[string]any{"logistics_status": 30, "received_at": &now}).Error + }) + if err != nil { + infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, gin.H{"received": true}) diff --git a/backend/api/internal/logic/client/user/shop_detail.go b/backend/api/internal/logic/client/user/shop_detail.go new file mode 100644 index 0000000..158f5e5 --- /dev/null +++ b/backend/api/internal/logic/client/user/shop_detail.go @@ -0,0 +1,73 @@ +// 功能描述:本人商城订单详情、成交快照和履约事实;版本:1.0.0。 +package user + +import ( + "encoding/json" + "errors" + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// GetShopOrder 只允许本人读取,商品名称和成交单价来自历史快照,不以当前目录覆盖。 +func GetShopOrder(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var order models.EcOrder + if err := impl.DBService.Where("identity = ? AND user_account_id = ?", ctx.Param("identity"), account.ID).First(&order).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + err = errcode.ErrRecordNotFound + } + infra.Response.Error(ctx, err) + return + } + var items []models.EcOrderItem + if err := impl.DBService.Where("ec_order_id = ?", order.ID).Order("created_at, identity").Find(&items).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + values := make([]gin.H, 0, len(items)) + for _, item := range items { + var snapshot struct { + Identity string `json:"identity"` + Name string `json:"name"` + Image string `json:"image_url"` + } + // 历史快照损坏时保留成交数量和价格,不能拿其他商品数据补写订单。 + _ = json.Unmarshal([]byte(item.ProductSnapshot), &snapshot) + values = append(values, gin.H{"identity": item.Identity, "product_identity": snapshot.Identity, "name": snapshot.Name, "image_url": snapshot.Image, "quantity": item.Quantity, "sale_amount": item.SaleAmount}) + } + name, actions := shopOrderState(order) + infra.Response.Success(ctx, gin.H{ + "identity": order.Identity, "order_no": order.OrderNo, "status_code": order.OrderStatus, "status_name": name, "allowed_actions": actions, + "items": values, "address": order.Address, "contact_name": order.ContactName, "contact_phone": order.ContactPhone, + "product_amount": order.ProductAmount, "discount_amount": order.DiscountAmount, "payable_amount": order.PayableAmount, + "created_at": order.CreatedAt, "paid_at": order.PaidAt, "shipped_at": order.ShippedAt, "received_at": order.ReceivedAt, "remark": order.Remark, + "logistics_no": order.LogisticsNo, "logistics_company": order.LogisticsCompany, "logistics_status": order.LogisticsStatus, + }) +} + +// shopOrderState 列表与详情共享服务端动作,支付和履约状态不得由客户端推断权限。 +func shopOrderState(order models.EcOrder) (string, []string) { + name := clientOrderState(order.OrderStatus) + actions := []string{} + if order.OrderStatus == 16 { + return "待付款", []string{"pay", "cancel"} + } + if order.OrderStatus == 18 { + actions = append(actions, "refund") + if order.LogisticsStatus == 20 { + name = "待收货" + actions = append(actions, "confirm_receipt") + } else if order.LogisticsStatus == 30 { + name = "已收货" + } + } + return name, actions +} diff --git a/backend/api/internal/logic/client/user/shop_detail_test.go b/backend/api/internal/logic/client/user/shop_detail_test.go new file mode 100644 index 0000000..13846df --- /dev/null +++ b/backend/api/internal/logic/client/user/shop_detail_test.go @@ -0,0 +1,73 @@ +// 功能描述:订单详情所有权、历史成交快照和状态动作回归;版本:1.0.0。 +package user + +import ( + "encoding/json" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/DATA-DOG/go-sqlmock" + "strings" + "testing" + "time" +) + +func TestShopDetailOwnedSnapshot(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + now := time.Date(2026, 9, 5, 1, 20, 0, 0, time.UTC) + mock.ExpectQuery(`SELECT .* FROM "ec_order" WHERE \(identity = \$1 AND user_account_id = \$2\).*deleted_at.*ORDER BY .*LIMIT`).WithArgs("order", 1, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "order_no", "order_status", "logistics_status", "product_amount", "payable_amount", "contact_name", "address", "created_at"}).AddRow(7, "order", "EC123", 18, 20, 3980, 3980, "历史联系人", "历史地址", now)) + mock.ExpectQuery(`SELECT .* FROM "ec_order_item".*ec_order_id = \$1.*ORDER BY created_at, identity`).WithArgs(7). + WillReturnRows(sqlmock.NewRows([]string{"identity", "ec_product_id", "product_snapshot", "quantity", "sale_amount"}).AddRow("item", 999, `{"identity":"product","name":"成交时名称","image_url":"/old.png","internal_secret":"不得返回"}`, 2, 1990)) + ctx, response := addressContext("", "order") + GetShopOrder(ctx) + var result struct { + Code int `json:"code"` + Details map[string]any `json:"details"` + } + if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result.Code != 0 { + t.Fatal(response.Body.String()) + } + item := result.Details["items"].([]any)[0].(map[string]any) + if item["name"] != "成交时名称" || item["sale_amount"] != float64(1990) || item["quantity"] != float64(2) || result.Details["address"] != "历史地址" || result.Details["status_name"] != "待收货" { + t.Fatal(response.Body.String()) + } + for _, key := range []string{`"user_account_id"`, `"ec_product_id"`, `"internal_secret"`, `"product_snapshot"`, `"id"`} { + if strings.Contains(response.Body.String(), key) { + t.Fatal("详情泄露内部字段", key) + } + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestShopDetailMissingOrOtherOwner(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectQuery(`SELECT .* FROM "ec_order".*identity = \$1 AND user_account_id = \$2`).WithArgs("other", 1, 1).WillReturnRows(sqlmock.NewRows([]string{"id"})) + ctx, response := addressContext("", "other") + GetShopOrder(ctx) + if !strings.Contains(response.Body.String(), `"code":1112`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestShopDetailActions(t *testing.T) { + for _, tc := range []struct { + status, logistics int + name, actions string + }{ + {16, 10, "待付款", "pay,cancel"}, {18, 10, "待履约", "refund"}, {18, 20, "待收货", "refund,confirm_receipt"}, {18, 30, "已收货", "refund"}, {22, 10, "已取消", ""}, + } { + name, actions := shopOrderState(models.EcOrder{OrderStatus: tc.status, LogisticsStatus: tc.logistics}) + if name != tc.name || strings.Join(actions, ",") != tc.actions { + t.Fatalf("状态不匹配: %v %v", name, actions) + } + } +} diff --git a/backend/api/internal/logic/client/user/ticket_actions_test.go b/backend/api/internal/logic/client/user/ticket_actions_test.go new file mode 100644 index 0000000..eb09796 --- /dev/null +++ b/backend/api/internal/logic/client/user/ticket_actions_test.go @@ -0,0 +1,94 @@ +// 功能描述:工单动作的所有权、状态边界及幂等验证,不连接真实数据库。 +// 版本:1.0.0。 +package user + +import ( + common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" + "strings" + "testing" +) + +func TestTicketActions(t *testing.T) { + for _, tc := range []struct { + name string + action func(*gin.Context) + state int + found, success, update bool + }{ + {"取消待受理", CancelTicket, 32, true, true, true}, + {"重复取消", CancelTicket, 22, true, true, false}, + {"完成不能取消", CancelTicket, 23, true, false, false}, + {"越权取消", CancelTicket, 32, false, false, false}, + {"处理中不能确认", ConfirmTicket, 11, true, false, false}, + {"异常不能确认", ConfirmTicket, 21, true, false, false}, + {"待确认可完成", ConfirmTicket, 34, true, true, true}, + {"重复完成保留时间", ConfirmTicket, 23, true, true, false}, + {"取消后不能完成", ConfirmTicket, 22, true, false, false}, + {"越权确认", ConfirmTicket, 34, false, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectBegin() + rows := sqlmock.NewRows([]string{"id", "identity", "ticket_status"}) + if tc.found { + rows.AddRow(3, "owned", tc.state) + } + mock.ExpectQuery(`SELECT .* FROM "cs_ticket".*identity = \$1 AND user_account_id = \$2 AND status <> \$3.*FOR UPDATE`).WithArgs("owned", uint64(1), common.StatusArchived, 1).WillReturnRows(rows) + if tc.update { + mock.ExpectExec(`UPDATE "cs_ticket"`).WillReturnResult(sqlmock.NewResult(0, 1)) + } + if tc.success { + mock.ExpectCommit() + } else { + mock.ExpectRollback() + } + ctx, response := addressContext("", "owned") + tc.action(ctx) + if strings.Contains(response.Body.String(), `"code":0`) != tc.success { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestTicketStatePermissions(t *testing.T) { + for _, status := range []int{22, 23, 0, 999} { + _, actions := ticketState(status) + if len(actions) != 0 { + t.Fatalf("终态或未知状态 %d 不应允许新操作", status) + } + } + name, actions := ticketState(34) + if name != "待确认" || len(actions) != 2 { + t.Fatal(name, actions) + } +} + +// 本人列表保留地址快照和处理结果,但不泄露内部数据库主键。 +func TestTicketListContract(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectQuery(`SELECT .* FROM "cs_ticket".*user_account_id = \$1 AND status <> \$2`).WithArgs(uint64(1), common.StatusArchived).WillReturnRows( + sqlmock.NewRows([]string{"id", "identity", "user_account_id", "ticket_status", "result", "address"}).AddRow(3, "owned", 1, 34, "处理结果", "本人地址")) + ctx, response := addressContext("", "") + mock.ExpectQuery(`SELECT .* FROM "cs_ticket_evidence"`).WillReturnRows(sqlmock.NewRows([]string{"id"})) + ListTickets(ctx) + body := response.Body.String() + for _, required := range []string{`"allowed_actions":["cancel","confirm"]`, `"status_name":"待确认"`, `"result":"处理结果"`, `"address":"本人地址"`} { + if !strings.Contains(body, required) { + t.Fatalf("缺少字段 %s: %s", required, body) + } + } + if strings.Contains(body, `"id":`) || strings.Contains(body, `"user_account_id":`) { + t.Fatal("泄露内部主键") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/client/user/ticket_create_test.go b/backend/api/internal/logic/client/user/ticket_create_test.go new file mode 100644 index 0000000..b3ae424 --- /dev/null +++ b/backend/api/internal/logic/client/user/ticket_create_test.go @@ -0,0 +1,62 @@ +// 功能描述:验证报修创建幂等、地址归属、空输入与预约边界。 +// 版本:1.0.0。 +package user + +import ( + "github.com/DATA-DOG/go-sqlmock" + "strings" + "testing" +) + +func TestTicketCreateRetryReturnsOriginal(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + expectAddressLock(mock) + mock.ExpectQuery(`SELECT .* FROM "cs_ticket".*request_no = \$1 AND user_account_id = \$2`).WithArgs("retry", uint64(1), 1).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "ticket_no", "ticket_status"}).AddRow(2, "original", "TK1", 32)) + mock.ExpectCommit() + ctx, response := addressContext(`{"request_no":"retry","category":"repair","description":"同一请求","address_identity":"已删除地址","appointment_at":"2020-01-01T00:00:00Z"}`, "") + CreateTicket(ctx) + if !strings.Contains(response.Body.String(), `"identity":"original"`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestTicketCreateRejectsInvalidFields(t *testing.T) { + for _, body := range []string{ + `{"request_no":"r","category":"repair","description":" "}`, + `{"request_no":" ","category":"repair","description":"故障"}`, + `{"request_no":"r","category":"repair","description":"故障","fault_type":"invented"}`, + } { + mock := primaryTestDB(t) + expectAddressAccount(mock) + ctx, response := addressContext(body, "") + CreateTicket(ctx) + if strings.Contains(response.Body.String(), `"code":0`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + } +} + +func TestTicketCreateRejectsOtherAddress(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + expectAddressLock(mock) + mock.ExpectQuery(`SELECT .* FROM "cs_ticket"`).WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectQuery(`SELECT .* FROM "user_service_relation"`).WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectQuery(`SELECT .* FROM "user_address".*identity = \$1 AND user_account_id = \$2`).WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectRollback() + ctx, response := addressContext(`{"request_no":"r","category":"repair","description":"故障","address_identity":"other"}`, "") + CreateTicket(ctx) + if strings.Contains(response.Body.String(), `"code":0`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/client/user/ticket_photo.go b/backend/api/internal/logic/client/user/ticket_photo.go new file mode 100644 index 0000000..7f52a9a --- /dev/null +++ b/backend/api/internal/logic/client/user/ticket_photo.go @@ -0,0 +1,99 @@ +// 功能描述:报修现场照片关联与本人受保护读取;不公开照片文件目录。 +// 版本:1.0.0。 +package user + +import ( + "crypto/sha256" + "fmt" + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/upload" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "time" +) + +// ticketPhotoRequest 记录添加时间,导入照片不冒充已验证的原始拍摄时间。 +type ticketPhotoRequest struct { + URI string `json:"uri" binding:"required"` + AddedAt time.Time `json:"added_at" binding:"required"` + Source string `json:"source" binding:"required,oneof=camera gallery"` +} + +func UploadTicketPhoto(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + upload.UploadTicketPhoto(ctx, account.Identity) +} + +// UploadedTicketPhoto 恢复本人草稿照片,不接受其他账户或任意路径。 +func UploadedTicketPhoto(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + upload.ServeOwnedTicketPhoto(ctx, account.Identity, ctx.Param("name")) +} + +// saveTicketPhotos 与工单创建共用事务,任一图片不属于本人时全部回滚。 +func saveTicketPhotos(tx *gorm.DB, ticket models.CsTicket, owner string, photos []ticketPhotoRequest) error { + seen := map[string]bool{} + for index, photo := range photos { + if seen[photo.URI] || !upload.OwnsTicketPhoto(owner, photo.URI) || photo.AddedAt.IsZero() || photo.AddedAt.After(time.Now().Add(5*time.Minute)) { + return errcode.ErrInvalidArgument + } + seen[photo.URI] = true + evidence := models.CsTicketEvidence{ + Entity: common.NewEntity(common.StatusEnable), CsTicketID: ticket.ID, EvidenceType: "reported", MediaType: "image", FileURI: photo.URI, + CapturedAt: photo.AddedAt, ReceivedAt: time.Now(), Source: "user_" + photo.Source, IntegrityStatus: "capture_time_unknown", OperatorIdentity: owner, + RequestNo: fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("user-photo:%s:%d", ticket.Identity, index)))), + } + if err := tx.Create(&evidence).Error; err != nil { + return err + } + } + return nil +} + +// ticketPhotosForList 一次查询本人列表涉及的证据,只发布元数据与公开证据UUID。 +func ticketPhotosForList(tickets []models.CsTicket) (map[uint64][]gin.H, error) { + result := map[uint64][]gin.H{} + ids := make([]uint64, 0, len(tickets)) + for _, ticket := range tickets { + ids = append(ids, ticket.ID) + } + if len(ids) == 0 { + return result, nil + } + var photos []models.CsTicketEvidence + if err := impl.DBService.Where("cs_ticket_id IN ? AND evidence_type = ? AND status <> ?", ids, "reported", common.StatusArchived).Order("created_at asc").Find(&photos).Error; err != nil { + return nil, err + } + for _, photo := range photos { + result[photo.CsTicketID] = append(result[photo.CsTicketID], gin.H{"identity": photo.Identity, "added_at": photo.CapturedAt, "source": photo.Source, "integrity_status": photo.IntegrityStatus}) + } + return result, nil +} + +// TicketPhoto 不允许借工单UUID读取其他工单照片,也不允许读取他人报修。 +func TicketPhoto(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var ticket models.CsTicket + if impl.DBService.Where("identity = ? AND user_account_id = ? AND status <> ?", ctx.Param("identity"), account.ID, common.StatusArchived).First(&ticket).Error != nil { + ctx.Status(404) + return + } + var photo models.CsTicketEvidence + if impl.DBService.Where("identity = ? AND cs_ticket_id = ? AND evidence_type = ? AND status <> ?", ctx.Param("photoIdentity"), ticket.ID, "reported", common.StatusArchived).First(&photo).Error != nil { + ctx.Status(404) + return + } + upload.ServeTicketPhoto(ctx, account.Identity, photo.FileURI) +} diff --git a/backend/api/internal/logic/client/user/ticket_photo_test.go b/backend/api/internal/logic/client/user/ticket_photo_test.go new file mode 100644 index 0000000..7fa61bc --- /dev/null +++ b/backend/api/internal/logic/client/user/ticket_photo_test.go @@ -0,0 +1,63 @@ +// 功能描述:照片读取必须同时满足工单归属与证据关联,不可通过UUID越权。 +// 版本:1.0.0。 +package user + +import ( + common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" + "strings" + "testing" + "time" +) + +func TestTicketPhotoReadOwnership(t *testing.T) { + for _, ownTicket := range []bool{false, true} { + mock := primaryTestDB(t) + expectAddressAccount(mock) + tickets := sqlmock.NewRows([]string{"id", "identity"}) + if ownTicket { + tickets.AddRow(3, "owned") + } + mock.ExpectQuery(`SELECT .* FROM "cs_ticket".*identity = \$1 AND user_account_id = \$2 AND status <> \$3`).WithArgs("owned", uint64(1), common.StatusArchived, 1).WillReturnRows(tickets) + if ownTicket { + mock.ExpectQuery(`SELECT .* FROM "cs_ticket_evidence".*identity = \$1 AND cs_ticket_id = \$2`).WithArgs("other-photo", uint64(3), "reported", common.StatusArchived, 1).WillReturnRows(sqlmock.NewRows([]string{"id"})) + } + ctx, response := addressContext("", "owned") + ctx.Params = append(ctx.Params, gin.Param{Key: "photoIdentity", Value: "other-photo"}) + TicketPhoto(ctx) + ctx.Writer.WriteHeaderNow() + if response.Code != 404 { + t.Fatal(response.Code) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + } +} + +func TestTicketPhotoRejectsUnownedAssociation(t *testing.T) { + err := saveTicketPhotos(nil, models.CsTicket{}, "alice", []ticketPhotoRequest{{URI: "/uploads/avatars/other.png", AddedAt: time.Now(), Source: "gallery"}}) + if err == nil { + t.Fatal("错误目录图片可以关联工单") + } +} + +func TestInvalidPhotoRollsBackTicket(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + expectAddressLock(mock) + mock.ExpectQuery(`SELECT .* FROM "cs_ticket"`).WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectQuery(`SELECT .* FROM "user_service_relation"`).WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectQuery(`INSERT INTO "cs_ticket"`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(7)) + mock.ExpectRollback() + ctx, response := addressContext(`{"request_no":"photo-invalid","category":"repair","description":"故障","photos":[{"uri":"/uploads/avatars/other.png","source":"gallery","added_at":"2026-09-07T01:00:00Z"}]}`, "") + CreateTicket(ctx) + if !strings.Contains(response.Body.String(), `"code":1704`) { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/client/user/usage_statistics.go b/backend/api/internal/logic/client/user/usage_statistics.go new file mode 100644 index 0000000..088de4b --- /dev/null +++ b/backend/api/internal/logic/client/user/usage_statistics.go @@ -0,0 +1,105 @@ +// 功能:向当前用户返回本人设备的可追溯用气统计;版本:1.0.0。 +package user + +import ( + "math" + "strings" + "time" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" +) + +// GetUsageStatistics 严格限定当前归属设备,没有计量数据时返回明确不可用状态。 +func GetUsageStatistics(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + identity := strings.TrimSpace(ctx.Query("device_identity")) + if identity == "" { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var device models.ProductInfo + if err := ownedProductQuery(account.ID).Where("identity = ? AND device_kind IN ?", identity, []string{"valve", "alarm"}).First(&device).Error; err != nil { + common.RespondRecordError(ctx, err) + return + } + period := strings.TrimSpace(ctx.DefaultQuery("period", "month")) + anchor := time.Now() + if raw := strings.TrimSpace(ctx.Query("anchor")); raw != "" { + parsed, err := time.Parse("2006-01-02", raw) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + anchor = parsed + } + start, end, valid := usagePeriod(period, anchor) + if !valid { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var rows []models.DevUsageStat + if err := impl.DBService.Where("product_info_id = ? AND stat_date >= ? AND stat_date < ? AND status = ?", device.ID, start, end, common.StatusEnable). + Order("stat_date").Find(&rows).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + details := gin.H{"available": len(rows) > 0, "device": gin.H{"identity": device.Identity, "name": device.Name, "code": device.Code}, + "period": period, "period_start": start.Format("2006-01-02"), "period_end": end.AddDate(0, 0, -1).Format("2006-01-02"), "unit": "kg", "points": []gin.H{}} + if len(rows) == 0 { + details["unavailable_reason"] = "该设备尚无可用计量数据" + infra.Response.Success(ctx, details) + return + } + points := make([]gin.H, 0, len(rows)) + total, breakfast, lunch, dinner := 0.0, 0.0, 0.0, 0.0 + latest := rows[0] + for _, row := range rows { + total += row.UsageKg + breakfast += row.BreakfastKg + lunch += row.LunchKg + dinner += row.DinnerKg + points = append(points, gin.H{"date": row.StatDate.Format("2006-01-02"), "usage": roundUsage(row.UsageKg)}) + if row.CalculatedAt.After(latest.CalculatedAt) { + latest = row + } + } + details["total_usage"] = roundUsage(total) + details["average_usage"] = roundUsage(total / float64(len(rows))) + details["composition"] = gin.H{"breakfast": roundUsage(breakfast), "lunch": roundUsage(lunch), "dinner": roundUsage(dinner)} + details["points"] = points + details["source"] = latest.Source + details["calc_version"] = latest.CalcVersion + details["updated_at"] = latest.CalculatedAt + infra.Response.Success(ctx, details) +} + +// usagePeriod 返回左闭右开的统计时间范围。 +func usagePeriod(period string, anchor time.Time) (time.Time, time.Time, bool) { + day := time.Date(anchor.Year(), anchor.Month(), anchor.Day(), 0, 0, 0, 0, anchor.Location()) + switch period { + case "day": + return day, day.AddDate(0, 0, 1), true + case "week": + offset := (int(day.Weekday()) + 6) % 7 + start := day.AddDate(0, 0, -offset) + return start, start.AddDate(0, 0, 7), true + case "month": + start := time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, day.Location()) + return start, start.AddDate(0, 1, 0), true + case "year": + start := time.Date(day.Year(), 1, 1, 0, 0, 0, 0, day.Location()) + return start, start.AddDate(1, 0, 0), true + default: + return time.Time{}, time.Time{}, false + } +} + +func roundUsage(value float64) float64 { return math.Round(value*1000) / 1000 } diff --git a/backend/api/internal/logic/client/user/usage_statistics_test.go b/backend/api/internal/logic/client/user/usage_statistics_test.go new file mode 100644 index 0000000..b242354 --- /dev/null +++ b/backend/api/internal/logic/client/user/usage_statistics_test.go @@ -0,0 +1,46 @@ +// 功能:验证用气统计的用户归属、数据口径和无数据边界;版本:1.0.0。 +package user + +import ( + "encoding/json" + "net/http/httptest" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" +) + +// TestUsageStatisticsOwnedDevice 确保查询不接受客户端传入的用户主键。 +func TestUsageStatisticsOwnedDevice(t *testing.T) { + mock := primaryTestDB(t) + expectAddressAccount(mock) + mock.ExpectQuery(`SELECT .* FROM "product_info" WHERE \(user_account_id = \$1 AND status = \$2\) AND \(identity = \$3 AND device_kind IN \(\$4,\$5\)\).*LIMIT \$6`). + WithArgs(1, 1, "device", "valve", "alarm", 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "name", "code", "device_kind"}).AddRow(9, "device", "厨房阀", "V01", "valve")) + calculated := time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC) + mock.ExpectQuery(`SELECT .* FROM "dev_usage_stat" WHERE \(product_info_id = \$1 AND stat_date >= \$2 AND stat_date < \$3 AND status = \$4\).*ORDER BY stat_date`). + WithArgs(9, sqlmock.AnyArg(), sqlmock.AnyArg(), 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "stat_date", "usage_kg", "breakfast_kg", "lunch_kg", "dinner_kg", "source", "calc_version", "calculated_at"}). + AddRow(1, time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), 0.3, 0.1, 0.1, 0.1, "device", "v1", calculated). + AddRow(2, time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC), 0.5, 0.1, 0.2, 0.2, "device", "v1", calculated)) + ctx, response := addressContext("", "") + ctx.Request = httptest.NewRequest("GET", "/usage-statistics?device_identity=device&period=month&anchor=2026-09-12&user_account_id=99", nil) + GetUsageStatistics(ctx) + var body struct { + Code int `json:"code"` + Details struct { + Available bool `json:"available"` + TotalUsage float64 `json:"total_usage"` + Points []any `json:"points"` + } `json:"details"` + } + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Code != 0 || !body.Details.Available || body.Details.TotalUsage != 0.8 || len(body.Details.Points) != 2 { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/client/user/wallet_order_payment_test.go b/backend/api/internal/logic/client/user/wallet_order_payment_test.go new file mode 100644 index 0000000..305b5a1 --- /dev/null +++ b/backend/api/internal/logic/client/user/wallet_order_payment_test.go @@ -0,0 +1,39 @@ +// 功能描述:验证商城与供气余额支付重试只读取既有支付事实,不重复扣款;版本:1.0.0。 +package user + +import ( + "testing" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/payment" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/DATA-DOG/go-sqlmock" +) + +func TestWalletOrderPaymentRetryIsIdempotent(t *testing.T) { + tests := []struct { + name, business string + pay func(models.UserAccount, string, string, string) error + }{ + {name: "商城订单", business: "ec_order", pay: payShopOrderWithWallet}, + {name: "供气订单", business: "gasorder", pay: payGasOrderWithWallet}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mock := primaryTestDB(t) + mock.ExpectBegin() + mock.ExpectQuery(`SELECT .* FROM "payment_order".*business_type = \$1 AND request_no = \$2`). + WithArgs(test.business, "request-1", 1). + WillReturnRows(sqlmock.NewRows([]string{"identity", "business_identity", "user_identity", "channel", "payment_status"}). + AddRow("payment-1", "order-1", "alice", "wallet", payment.StatusPaid)) + mock.ExpectCommit() + + err := test.pay(models.UserAccount{Entity: models.Entity{ID: 1, Identity: "alice"}}, "order-1", "request-1", "123456") + if err != nil { + t.Fatal(err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/backend/api/internal/logic/common/client_auth.go b/backend/api/internal/logic/common/client_auth.go index 3f6fda5..ef366e8 100644 --- a/backend/api/internal/logic/common/client_auth.go +++ b/backend/api/internal/logic/common/client_auth.go @@ -16,10 +16,13 @@ import ( "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" + redis "github.com/redis/go-redis/v9" ) var phonePattern = regexp.MustCompile(`^1[3-9]\d{9}$`) +var ErrVerificationUnavailable = errcode.NewError(2410, "短信验证服务尚未配置") + var verificationPurposes = map[string]struct{}{ "login": {}, "register": {}, "reset_login_password": {}, "set_payment_password": {}, "reset_payment_password": {}, "bind_bank": {}, "unbind_bank": {}, @@ -50,6 +53,14 @@ func SendVerificationCode(client string) gin.HandlerFunc { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } + if !config.Spec.Global.MockVerificationEnabled { + infra.Response.Error(ctx, ErrVerificationUnavailable) + return + } + if impl.RedisService == nil || impl.RedisService.Client == nil { + infra.Response.Error(ctx, errcode.ErrRedis) + return + } phone := strings.TrimSpace(request.Phone) throttleKey := impl.RedisService.BuildKey("client-verification-throttle", client, phone) var sent bool @@ -65,26 +76,33 @@ func SendVerificationCode(client string) gin.HandlerFunc { return } _ = impl.RedisService.Set(throttleKey, true, time.Duration(config.Spec.Global.VerificationSendIntervalSeconds)*time.Second) - infra.Response.Success(ctx, gin.H{"request_identity": requestIdentity, "expires_in": config.Spec.Global.VerificationTTLSeconds}) + infra.Response.Success(ctx, gin.H{"request_identity": requestIdentity, "expires_in": config.Spec.Global.VerificationTTLSeconds, + "delivery_mode": "mock", "delivery_status": "not_sent", "retry_after": config.Spec.Global.VerificationSendIntervalSeconds}) } } // VerifyCode 校验并消费验证码。 func VerifyCode(client, phone, purpose, requestIdentity, code string) bool { - if !config.Spec.Global.MockVerificationEnabled || requestIdentity == "" || code == "" { + if !config.Spec.Global.MockVerificationEnabled || requestIdentity == "" || code == "" || impl.RedisService == nil || impl.RedisService.Client == nil { return false } key := verificationKey(requestIdentity) - var value verificationValue - if impl.RedisService.Get(key, &value) != nil { - return false - } - if value.Client != client || value.Phone != strings.TrimSpace(phone) || value.Purpose != purpose || value.Code != code { - return false - } - return impl.RedisService.Delete(key) == nil + result, err := consumeVerificationCode.Run(impl.RedisService.Ctx, impl.RedisService.Client, + []string{key}, client, strings.TrimSpace(phone), purpose, code).Int() + return err == nil && result == 1 } +// 一次性验证码在Redis内完成校验和消费,两个并发请求只能有一个成功。 +var consumeVerificationCode = redis.NewScript(` +local raw = redis.call('GET', KEYS[1]) +if not raw then return 0 end +local ok, value = pcall(cjson.decode, raw) +if not ok or type(value) ~= 'table' then return 0 end +if value.client ~= ARGV[1] or value.phone ~= ARGV[2] or value.purpose ~= ARGV[3] or value.code ~= ARGV[4] then return 0 end +redis.call('DEL', KEYS[1]) +return 1 +`) + func verificationKey(identity string) string { return impl.RedisService.BuildKey("client-verification", identity) } diff --git a/backend/api/internal/logic/common/client_wallet.go b/backend/api/internal/logic/common/client_wallet.go index a34d88a..f515053 100644 --- a/backend/api/internal/logic/common/client_wallet.go +++ b/backend/api/internal/logic/common/client_wallet.go @@ -23,6 +23,7 @@ import ( "golang.org/x/crypto/hkdf" "gorm.io/gorm" "gorm.io/gorm/clause" + "gorm.io/gorm/logger" ) type walletOwner struct { @@ -89,7 +90,7 @@ func SetPaymentPassword(client string) gin.HandlerFunc { Code string `json:"code"` RequestIdentity string `json:"request_identity"` } - if ctx.ShouldBindJSON(&request) != nil { + if ctx.ShouldBindJSON(&request) != nil || !paymentPasswordDigits.MatchString(request.NewPassword) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } @@ -100,19 +101,38 @@ func SetPaymentPassword(client string) gin.HandlerFunc { } valid := wallet.PayPasswordHash == "" && VerifyCode(client, owner.Phone, "set_payment_password", request.RequestIdentity, request.Code) if wallet.PayPasswordHash != "" { - valid = bcrypt.CompareHashAndPassword([]byte(wallet.PayPasswordHash), []byte(request.CurrentPassword)) == nil || - VerifyCode(client, owner.Phone, "reset_payment_password", request.RequestIdentity, request.Code) + // 验证码找回不尝试空旧密码,避免把找回动作计入支付密码输错次数。 + if request.CurrentPassword != "" { + valid = VerifyPaymentPassword(owner.Identity, wallet, request.CurrentPassword) + } + if !valid { + valid = VerifyCode(client, owner.Phone, "reset_payment_password", request.RequestIdentity, request.Code) + } } if !valid { infra.Response.Error(ctx, errcode.ErrPassword) return } - hash, _ := bcrypt.GenerateFromPassword([]byte(request.NewPassword), bcrypt.DefaultCost) - if err := impl.DBService.Model(&wallet).Update("pay_password_hash", string(hash)).Error; err != nil { + hash, err := bcrypt.GenerateFromPassword([]byte(request.NewPassword), bcrypt.DefaultCost) + if err != nil { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, gin.H{"changed": true}) + // 条件更新避免两个已验证请求互相覆盖;散列不得进入SQL日志。 + result := impl.DBService.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Model(&wallet). + Where("pay_password_hash = ?", wallet.PayPasswordHash).Update("pay_password_hash", string(hash)) + if result.Error != nil { + infra.Response.Error(ctx, result.Error) + return + } + if result.RowsAffected != 1 { + infra.Response.Error(ctx, errcode.ErrPassword) + return + } + // 经过旧密码或手机验证码验证后,解除本次主体的既有密码锁;失败不否认已完成的改密。 + cleared := impl.RedisService != nil && impl.RedisService.Client != nil && impl.RedisService.Client.Del(impl.RedisService.Ctx, + impl.RedisService.BuildKey("payment-password-lock", owner.Identity), impl.RedisService.BuildKey("payment-password-failures", owner.Identity)).Err() == nil + infra.Response.Success(ctx, gin.H{"changed": true, "lock_cleared": cleared}) } } @@ -145,10 +165,19 @@ func CreateRecharge(client string) gin.HandlerFunc { RechargeNo: RecordNo("RC"), RequestNo: request.RequestNo, Amount: request.Amount, Channel: request.Channel, OwnerType: owner.Type, OwnerIdentity: owner.Identity, } - if err := impl.DBService.Create(&order).Error; err != nil { + created := impl.DBService.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "request_no"}}, DoNothing: true}).Create(&order) + if created.Error != nil { + infra.Response.Error(ctx, created.Error) + return + } + if created.RowsAffected == 0 { var existing models.WalletRechargeOrder - if impl.DBService.Where("request_no = ? AND owner_identity = ?", request.RequestNo, owner.Identity).First(&existing).Error != nil { - infra.Response.Error(ctx, err) + if impl.DBService.Where("request_no = ? AND owner_type = ? AND owner_identity = ?", request.RequestNo, owner.Type, owner.Identity).First(&existing).Error != nil { + infra.Response.Error(ctx, ErrRechargeRequestConflict) + return + } + if existing.Amount != request.Amount || existing.Channel != request.Channel { + infra.Response.Error(ctx, ErrRechargeRequestConflict) return } order = existing @@ -160,7 +189,11 @@ func CreateRecharge(client string) gin.HandlerFunc { infra.Response.Error(ctx, payErr) return } - infra.Response.Success(ctx, payment.PublicResponse(payOrder)) + response := payment.PublicResponse(payOrder) + response["recharge_identity"] = order.Identity + response["recharge_no"] = order.RechargeNo + response["recharge_status"] = order.RechargeStatus + infra.Response.Success(ctx, response) return } infra.Response.Success(ctx, ResourceResponse(order)) @@ -181,7 +214,7 @@ func ConfirmMockRecharge(client string) gin.HandlerFunc { var response models.WalletRechargeOrder err := impl.DBService.Transaction(func(tx *gorm.DB) error { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("identity = ? AND owner_identity = ?", ctx.Param("identity"), owner.Identity).First(&response).Error; err != nil { + Where("identity = ? AND owner_type = ? AND owner_identity = ?", ctx.Param("identity"), owner.Type, owner.Identity).First(&response).Error; err != nil { return err } if response.RechargeStatus == 23 { @@ -253,13 +286,14 @@ func ListBanks(client string) gin.HandlerFunc { return } var banks []models.WalletBank - if err := impl.DBService.Where("wallet_basic_id = ? AND status <> ?", wallet.ID, StatusArchived).Find(&banks).Error; err != nil { + if err := impl.DBService.Where("wallet_basic_id = ? AND status <> ?", wallet.ID, StatusArchived). + Order("is_default desc, created_at desc").Find(&banks).Error; err != nil { infra.Response.Error(ctx, err) return } list := make([]gin.H, 0, len(banks)) for _, bank := range banks { - list = append(list, gin.H{"identity": bank.Identity, "card_no_masked": "**** **** **** " + bank.CardNoLast4, "bank_name": bank.BankName, "card_owner": bank.CardOwner, "bank_type": bank.BankType}) + list = append(list, gin.H{"identity": bank.Identity, "card_no_masked": "**** **** **** " + bank.CardNoLast4, "bank_name": bank.BankName, "card_owner": bank.CardOwner, "bank_type": bank.BankType, "is_default": bank.IsDefault}) } infra.Response.Success(ctx, list) } @@ -304,17 +338,53 @@ func BindBank(client string) gin.HandlerFunc { } idCipher, _, _ := protectField(request.IDCard) phoneCipher, _, _ := protectField(request.Phone) + var activeCount int64 + if err := impl.DBService.Model(&models.WalletBank{}). + Where("wallet_basic_id = ? AND status <> ?", wallet.ID, StatusArchived).Count(&activeCount).Error; err != nil { + infra.Response.Error(ctx, err) + return + } bank := models.WalletBank{ Entity: NewEntity(StatusEnable), WalletBasicID: wallet.ID, CardNoCiphertext: cardCipher, CardFingerprint: fingerprint, CardNoLast4: request.CardNo[len(request.CardNo)-4:], BankName: request.BankName, CardOwner: request.CardOwner, IDCardCiphertext: idCipher, - PhoneCiphertext: phoneCipher, BankType: request.BankType, Bank: request.Bank, + PhoneCiphertext: phoneCipher, BankType: request.BankType, Bank: request.Bank, IsDefault: activeCount == 0, } if err := impl.DBService.Create(&bank).Error; err != nil { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, gin.H{"identity": bank.Identity, "card_no_masked": "**** **** **** " + bank.CardNoLast4}) + infra.Response.Success(ctx, gin.H{"identity": bank.Identity, "card_no_masked": "**** **** **** " + bank.CardNoLast4, "is_default": bank.IsDefault}) + } +} + +// SetDefaultBank 将本人已绑定卡设为默认到账卡,其他卡同时清除默认状态。 +func SetDefaultBank(client string) gin.HandlerFunc { + return func(ctx *gin.Context) { + owner, ok := currentOwner(ctx, client) + if !ok { + return + } + wallet, err := ensureWallet(impl.DBService, owner) + if err != nil { + infra.Response.Error(ctx, err) + return + } + err = impl.DBService.Transaction(func(tx *gorm.DB) error { + var bank models.WalletBank + if err := tx.Where("identity = ? AND wallet_basic_id = ? AND status = ?", ctx.Param("identity"), wallet.ID, StatusEnable).First(&bank).Error; err != nil { + return err + } + if err := tx.Model(&models.WalletBank{}).Where("wallet_basic_id = ?", wallet.ID).Update("is_default", false).Error; err != nil { + return err + } + return tx.Model(&bank).Update("is_default", true).Error + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"changed": true}) } } @@ -339,18 +409,38 @@ func UnbindBank(client string) gin.HandlerFunc { infra.Response.Error(ctx, errcode.ErrPassword) return } - var bank models.WalletBank - if impl.DBService.Where("identity = ? AND wallet_basic_id = ? AND status <> ?", ctx.Param("identity"), wallet.ID, StatusArchived).First(&bank).Error != nil { - infra.Response.Error(ctx, errcode.ErrRecordNotFound) - return - } - var count int64 - impl.DBService.Model(&models.WalletApplyCash{}).Where("wallet_bank_id = ? AND apply_status IN ?", bank.ID, []int{10, 18}).Count(&count) - if count > 0 { - infra.Response.Error(ctx, errcode.ErrInvalidArgument) - return - } - if err := impl.DBService.Model(&bank).Update("status", StatusArchived).Error; err != nil { + err = impl.DBService.Transaction(func(tx *gorm.DB) error { + var bank models.WalletBank + if err := tx.Where("identity = ? AND wallet_basic_id = ? AND status <> ?", ctx.Param("identity"), wallet.ID, StatusArchived).First(&bank).Error; err != nil { + return err + } + var count int64 + if err := tx.Model(&models.WalletApplyCash{}).Where("wallet_bank_id = ? AND apply_status IN ?", bank.ID, []int{10, 18}).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return gorm.ErrInvalidData + } + if err := tx.Model(&bank).Updates(map[string]any{"status": StatusArchived, "is_default": false}).Error; err != nil { + return err + } + if !bank.IsDefault { + return nil + } + var next models.WalletBank + if err := tx.Where("wallet_basic_id = ? AND status = ?", wallet.ID, StatusEnable).Order("created_at desc").First(&next).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil + } + return err + } + return tx.Model(&next).Update("is_default", true).Error + }) + if err != nil { + if err == gorm.ErrInvalidData { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } infra.Response.Error(ctx, err) return } @@ -438,28 +528,6 @@ func walletProof(wallet models.WalletBasic, client, phone, purpose, password, re VerifyCode(client, phone, purpose, requestIdentity, code) } -// VerifyPaymentPassword 校验支付密码,并在 Redis 中累计失败次数、短时锁定。 -func VerifyPaymentPassword(ownerIdentity string, wallet models.WalletBasic, password string) bool { - lockKey := impl.RedisService.BuildKey("payment-password-lock", ownerIdentity) - var locked bool - if impl.RedisService.Get(lockKey, &locked) == nil && locked { - return false - } - if wallet.PayPasswordHash != "" && bcrypt.CompareHashAndPassword([]byte(wallet.PayPasswordHash), []byte(password)) == nil { - _ = impl.RedisService.Delete(impl.RedisService.BuildKey("payment-password-failures", ownerIdentity)) - return true - } - failureKey := impl.RedisService.BuildKey("payment-password-failures", ownerIdentity) - var failures int - _ = impl.RedisService.Get(failureKey, &failures) - failures++ - _ = impl.RedisService.Set(failureKey, failures, 15*time.Minute) - if failures >= 5 { - _ = impl.RedisService.Set(lockKey, true, 15*time.Minute) - } - return false -} - func protectField(value string) (string, string, error) { reader := hkdf.New(sha256.New, []byte(config.Spec.Global.FieldEncryptionKey), nil, []byte("heqi-wallet-field-v1")) key := make([]byte, 64) diff --git a/backend/api/internal/logic/common/payment_password_guard.go b/backend/api/internal/logic/common/payment_password_guard.go new file mode 100644 index 0000000..7966c16 --- /dev/null +++ b/backend/api/internal/logic/common/payment_password_guard.go @@ -0,0 +1,44 @@ +// 功能描述:支付密码原子输错计数与限时锁定,Redis不可用时拒绝验证;版本:1.0.0。 +package common + +import ( + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + redis "github.com/redis/go-redis/v9" + "golang.org/x/crypto/bcrypt" + "regexp" +) + +var paymentPasswordDigits = regexp.MustCompile(`^[0-9]{6}$`) + +// 同时检查锁、更新次数与设置过期时间,防止并发请求覆盖失败计数。 +var paymentPasswordAttempt = redis.NewScript(` +if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end +if ARGV[1] == '1' then + redis.call('DEL', KEYS[2]) + return 1 +end +local count = redis.call('INCR', KEYS[2]) +redis.call('EXPIRE', KEYS[2], 900) +if count >= 5 then redis.call('SET', KEYS[1], 'true', 'EX', 900) end +return 0 +`) + +// VerifyPaymentPassword 校验当前主体支付密码;输错五次锁定十五分钟。 +func VerifyPaymentPassword(ownerIdentity string, wallet models.WalletBasic, password string) bool { + cache := impl.RedisService + if cache == nil || cache.Client == nil || ownerIdentity == "" { + return false + } + lockKey := cache.BuildKey("payment-password-lock", ownerIdentity) + locked, err := cache.Client.Exists(cache.Ctx, lockKey).Result() + if err != nil || locked != 0 { + return false + } + valid := 0 + if wallet.PayPasswordHash != "" && bcrypt.CompareHashAndPassword([]byte(wallet.PayPasswordHash), []byte(password)) == nil { + valid = 1 + } + result, err := paymentPasswordAttempt.Run(cache.Ctx, cache.Client, []string{lockKey, cache.BuildKey("payment-password-failures", ownerIdentity)}, valid).Int() + return err == nil && result == 1 +} diff --git a/backend/api/internal/logic/common/payment_password_remote_test.go b/backend/api/internal/logic/common/payment_password_remote_test.go new file mode 100644 index 0000000..c2bf8ac --- /dev/null +++ b/backend/api/internal/logic/common/payment_password_remote_test.go @@ -0,0 +1,173 @@ +// 功能描述:远程回滚验证支付密码三种流程与Redis并发保护;版本:1.0.0。 +package common + +import ( + "context" + "encoding/json" + "fmt" + sdkredis "git.apinb.com/bsm-sdk/core/cache/redis" + "git.apinb.com/bsm-sdk/core/database" + dbsql "git.apinb.com/bsm-sdk/core/database/sql" + "git.apinb.com/bsm-sdk/core/types" + "git.apinb.com/heqiapp/platforms/backend/api/internal/config" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "os" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestPaymentPasswordRemoteRollback(t *testing.T) { + if os.Getenv("HEQI_REMOTE_PAYMENT_PASSWORD_TEST") != "1" { + t.Skip("显式开启的远程事务与临时Redis键验证") + } + config.New("heqi") + if config.Spec.Databases == nil || config.Spec.Cache == "" { + t.Fatal("缺少远程环境") + } + db, err := database.NewDatabase(config.Spec.Databases.Driver, config.Spec.Databases.Source, dbsql.SetOptions(nil)) + if err != nil { + t.Fatal("远程数据库连接失败") + } + db = db.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}) + conn, err := db.DB() + if err != nil { + t.Fatal(err) + } + defer conn.Close() + cache, err := sdkredis.NewWithContext(context.Background(), config.Spec.Cache, "heqi") + if err != nil { + t.Fatal("远程Redis连接失败") + } + defer cache.Client.Close() + tx := db.Begin() + if tx.Error != nil { + t.Fatal(tx.Error) + } + defer tx.Rollback() + if err := tx.Exec(`SET LOCAL lock_timeout = '3s'`).Error; err != nil { + t.Fatal(err) + } + oldDB, oldCache, oldMock := impl.DBService, impl.RedisService, config.Spec.Global.MockVerificationEnabled + impl.DBService, impl.RedisService, config.Spec.Global.MockVerificationEnabled = tx, cache, true + defer func() { + impl.DBService, impl.RedisService, config.Spec.Global.MockVerificationEnabled = oldDB, oldCache, oldMock + }() + // 独立事务内测试账户和零余额钱包,不修改既有用户、资金或密码。 + account := models.UserAccount{Entity: NewEntity(1), Name: "支付密码回滚验证", Phone: fmt.Sprintf("199%08d", time.Now().UnixNano()%100000000)} + account.Username = "verify-payment-" + account.Identity + if err := tx.Create(&account).Error; err != nil { + t.Fatal("创建事务夹具失败") + } + owner := walletOwner{Type: "user", Identity: account.Identity, ID: account.ID, Phone: account.Phone} + wallet, err := ensureWallet(tx, owner) + if err != nil { + t.Fatal(err) + } + keys := []string{cache.BuildKey("payment-password-lock", account.Identity), cache.BuildKey("payment-password-failures", account.Identity)} + defer func() { + if err := cache.Client.Del(cache.Ctx, keys...).Err(); err != nil { + t.Error("临时Redis键清理失败") + } + }() + makeCode := func(purpose string) string { + identity := models.NewIdentity() + key := verificationKey(identity) + keys = append(keys, key) + if err := cache.Set(key, verificationValue{Client: "user_app", Phone: account.Phone, Purpose: purpose, Code: "314159"}, time.Minute); err != nil { + t.Fatal("测试验证码写入失败") + } + return identity + } + update := func(body string, success bool) { + ctx, response := paymentTestContext(body) + ctx.Set("Auth", &types.JwtClaims{Client: "user_app", Identity: account.Identity}) + SetPaymentPassword("user_app")(ctx) + var result struct { + Code int `json:"code"` + Details json.RawMessage `json:"details"` + } + if decodeErr := json.Unmarshal(response.Body.Bytes(), &result); decodeErr != nil || (result.Code == 0) != success { + t.Fatalf("支付密码响应状态不符:期望成功%v,错误码%d,解码错误%v", success, result.Code, decodeErr) + } + if success { + var details struct { + Changed bool `json:"changed"` + Cleared bool `json:"lock_cleared"` + } + if json.Unmarshal(result.Details, &details) != nil || !details.Changed || !details.Cleared { + t.Fatal("改密或解除锁定未确认") + } + } + } + first := makeCode("set_payment_password") + update(`{"new_password":"728391","request_identity":"`+first+`","code":"000000"}`, false) + update(`{"new_password":"728391","request_identity":"`+first+`","code":"314159"}`, true) + update(`{"new_password":"728391","request_identity":"`+first+`","code":"314159"}`, false) + update(`{"new_password":"839172","current_password":"728391"}`, true) + update(`{"new_password":"12.345","current_password":"839172"}`, false) + if err := tx.First(&wallet, wallet.ID).Error; err != nil { + t.Fatal(err) + } + // 验证码用途不符不消费,16个并发请求仅允许一次消费。 + once := makeCode("reset_payment_password") + if VerifyCode("user_app", account.Phone, "set_payment_password", once, "314159") { + t.Fatal("验证码用途隔离失败") + } + var winners atomic.Int32 + var group sync.WaitGroup + for i := 0; i < 16; i++ { + group.Add(1) + go func() { + defer group.Done() + if VerifyCode("user_app", account.Phone, "reset_payment_password", once, "314159") { + winners.Add(1) + } + }() + } + group.Wait() + if winners.Load() != 1 { + t.Fatalf("验证码消费次数为%d", winners.Load()) + } + for i := 0; i < 8; i++ { + group.Add(1) + go func() { + defer group.Done() + if VerifyPaymentPassword(account.Identity, wallet, "000000") { + t.Error("错误支付密码通过") + } + }() + } + group.Wait() + if VerifyPaymentPassword(account.Identity, wallet, "839172") { + t.Fatal("锁定没有生效") + } + var count int + if cache.Get(keys[1], &count) != nil || count != 5 { + t.Fatal("并发失败计数错误") + } + reset := makeCode("reset_payment_password") + update(`{"new_password":"941827","request_identity":"`+reset+`","code":"314159"}`, true) + if tx.First(&wallet, wallet.ID).Error != nil || bcrypt.CompareHashAndPassword([]byte(wallet.PayPasswordHash), []byte("941827")) != nil || wallet.Balance != 0 || wallet.WithdrawalBalance != 0 { + t.Fatal("密码或资金状态错误") + } + if !VerifyPaymentPassword(account.Identity, wallet, "941827") { + t.Fatal("重置后仍无法验证") + } + if err := tx.Rollback().Error; err != nil { + t.Fatal(err) + } + var remaining int64 + if db.Model(&models.UserAccount{}).Where("identity = ?", account.Identity).Count(&remaining).Error != nil || remaining != 0 { + t.Fatal("测试账户回滚残留") + } + if db.Model(&models.WalletBasic{}).Where("owner_identity = ?", account.Identity).Count(&remaining).Error != nil || remaining != 0 { + t.Fatal("测试钱包回滚残留") + } + t.Log("首次设置、旧密码修改、验证码重置、并发验证码单次消费、五次锁定与解锁及数据库回滚通过") +} diff --git a/backend/api/internal/logic/common/payment_password_test.go b/backend/api/internal/logic/common/payment_password_test.go new file mode 100644 index 0000000..675dbff --- /dev/null +++ b/backend/api/internal/logic/common/payment_password_test.go @@ -0,0 +1,125 @@ +// 功能描述:支付密码本人范围、条件更新、严格数字和Redis失败关闭测试;版本:1.0.0。 +package common + +import ( + "context" + "errors" + sdkredis "git.apinb.com/bsm-sdk/core/cache/redis" + "git.apinb.com/bsm-sdk/core/types" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" + redis "github.com/redis/go-redis/v9" + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "net/http/httptest" + "strings" + "testing" +) + +// 不连接本机Redis,直接在客户端命令钩子中提供异常或锁状态。 +type paymentRedisHook struct { + locked int64 + fail bool +} + +func (h paymentRedisHook) DialHook(next redis.DialHook) redis.DialHook { return next } +func (h paymentRedisHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook { + return next +} +func (h paymentRedisHook) ProcessHook(_ redis.ProcessHook) redis.ProcessHook { + return func(_ context.Context, cmd redis.Cmder) error { + if h.fail { + return errors.New("test cache unavailable") + } + switch cmd.Name() { + case "exists": + cmd.(*redis.IntCmd).SetVal(h.locked) + case "evalsha": + cmd.(*redis.Cmd).SetVal(int64(1)) + case "del": + cmd.(*redis.IntCmd).SetVal(2) + default: + return errors.New("unexpected redis command") + } + return nil + } +} + +func paymentTestCache(t *testing.T, hook paymentRedisHook) { + t.Helper() + client := redis.NewClient(&redis.Options{Addr: "unused:0", MaxRetries: -1}) + client.AddHook(hook) + old := impl.RedisService + impl.RedisService = &sdkredis.RedisClient{Client: client, Ctx: context.Background()} + t.Cleanup(func() { impl.RedisService = old; _ = client.Close() }) +} + +func paymentTestContext(body string) (*gin.Context, *httptest.ResponseRecorder) { + response := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(response) + ctx.Request = httptest.NewRequest("PUT", "/", strings.NewReader(body)) + ctx.Request.Header.Set("Content-Type", "application/json") + ctx.Set("Auth", &types.JwtClaims{Client: "user_app", Identity: "alice"}) + return ctx, response +} + +func TestSetPaymentPasswordOwnerAndVersion(t *testing.T) { + hash, _ := bcrypt.GenerateFromPassword([]byte("748291"), bcrypt.MinCost) + for _, updated := range []int64{0, 1} { + t.Run(string(rune('0'+updated)), func(t *testing.T) { + connection, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer connection.Close() + db, err := gorm.Open(postgres.New(postgres.Config{Conn: connection}), &gorm.Config{SkipDefaultTransaction: true, Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + old := impl.DBService + impl.DBService = db + defer func() { impl.DBService = old }() + paymentTestCache(t, paymentRedisHook{}) + mock.ExpectQuery(`SELECT .* FROM "user_account".*identity = \$1 AND status = \$2`).WithArgs("alice", 1, 1).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "phone"}).AddRow(7, "alice", "13800000001")) + mock.ExpectQuery(`SELECT .* FROM "wallet_basic".*owner_type = \$1 AND owner_identity = \$2`).WithArgs("user", "alice", 1).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "pay_password_hash"}).AddRow(9, "wallet", string(hash))) + mock.ExpectExec(`UPDATE "wallet_basic" SET .* WHERE pay_password_hash = \$3.*"id" = \$4`).WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), string(hash), 9).WillReturnResult(sqlmock.NewResult(0, updated)) + ctx, response := paymentTestContext(`{"owner_identity":"victim","current_password":"748291","new_password":"829173"}`) + SetPaymentPassword("user_app")(ctx) + if strings.Contains(response.Body.String(), `"code":0`) != (updated == 1) { + t.Fatal(response.Body.String()) + } + if strings.Contains(response.Body.String(), "829173") { + t.Fatal("响应泄漏支付密码") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestPaymentPasswordDigits(t *testing.T) { + for _, input := range []string{"12.345", "-12345", "+12345", "123456", "12345", "1234567", "abcdef"} { + if paymentPasswordDigits.MatchString(input) { + t.Fatalf("错误接受非六位数字:%s", input) + } + } + if !paymentPasswordDigits.MatchString("012345") { + t.Fatal("应支持前导零") + } +} + +func TestPaymentPasswordFailClosed(t *testing.T) { + for _, hook := range []paymentRedisHook{{locked: 1}, {fail: true}} { + t.Run("拒绝", func(t *testing.T) { + paymentTestCache(t, hook) + if VerifyPaymentPassword("alice", models.WalletBasic{}, "123456") { + t.Fatal("锁定或缓存故障时验证通过") + } + }) + } +} diff --git a/backend/api/internal/logic/common/recharge_options.go b/backend/api/internal/logic/common/recharge_options.go new file mode 100644 index 0000000..77a3ca3 --- /dev/null +++ b/backend/api/internal/logic/common/recharge_options.go @@ -0,0 +1,51 @@ +// 功能描述:充值限额、渠道配置就绪状态及已发布充值协议;版本:1.0.0。 +package common + +import ( + "os" + + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/config" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// rechargeFileReady 仅检查渠道配置文件是否存在,不读取或返回私钥内容。 +func rechargeFileReady(path string) bool { + if path == "" { + return false + } + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +// GetRechargeOptions 不提供Mock支付,不将配置存在等同于渠道支付一定成功。 +func GetRechargeOptions(client string) gin.HandlerFunc { + return func(ctx *gin.Context) { + if _, ok := currentOwner(ctx, client); !ok { + return + } + ali, wx := config.Spec.Payment.Alipay, config.Spec.Payment.Wechat + alipayReady := ali.Enabled && ali.AppID != "" && ali.NotifyURL != "" && + rechargeFileReady(ali.PrivateKeyPath) && rechargeFileReady(ali.AppPublicCertPath) && + rechargeFileReady(ali.AlipayPublicCertPath) && rechargeFileReady(ali.AlipayRootCertPath) + wechatReady := wx.Enabled && wx.MerchantID != "" && wx.MerchantCertificateSerial != "" && + len(wx.APIv3Key) == 32 && wx.AppAppID != "" && wx.NotifyURL != "" && rechargeFileReady(wx.MerchantPrivateKeyPath) + response := gin.H{"min_amount": 1, "max_amount": config.Spec.Global.ManualRechargeMaxAmount, + "channels": []gin.H{{"channel": "wechat", "app": wechatReady, "wap": false}, + {"channel": "alipay", "app": alipayReady, "wap": alipayReady}}} + var agreement models.CmsContent + err := impl.DBService.Where("content_type = ? AND title = ? AND publish_status = ? AND status = ?", + "agreement", "充值协议", "published", StatusEnable).Order("version_no desc, id desc").First(&agreement).Error + if err != nil && err != gorm.ErrRecordNotFound { + infra.Response.Error(ctx, err) + return + } + if err == nil && agreement.Body != "" { + response["agreement"] = gin.H{"identity": agreement.Identity, "version": agreement.VersionNo, "title": agreement.Title, "body": agreement.Body} + } + infra.Response.Success(ctx, response) + } +} diff --git a/backend/api/internal/logic/common/recharge_options_test.go b/backend/api/internal/logic/common/recharge_options_test.go new file mode 100644 index 0000000..db6c6ed --- /dev/null +++ b/backend/api/internal/logic/common/recharge_options_test.go @@ -0,0 +1,62 @@ +// 功能描述:充值配置缺项、协议缺失及鉴权失败回归;版本:1.0.0。 +package common + +import ( + "git.apinb.com/heqiapp/platforms/backend/api/internal/config" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "strings" + "testing" +) + +// TestRechargeOptionsUnavailable 未配置渠道不能被标记可支付,无协议不伪造正文。 +func TestRechargeOptionsUnavailable(t *testing.T) { + connection, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer connection.Close() + db, err := gorm.Open(postgres.New(postgres.Config{Conn: connection}), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + oldDB, oldPayment := impl.DBService, config.Spec.Payment + impl.DBService = db + config.Spec.Payment.Alipay.Enabled = false + config.Spec.Payment.Wechat.Enabled = false + defer func() { impl.DBService = oldDB; config.Spec.Payment = oldPayment }() + mock.ExpectQuery(`SELECT .* FROM "user_account".*identity = \$1 AND status = \$2`).WithArgs("alice", 1, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "phone"}).AddRow(7, "alice", "13800000001")) + mock.ExpectQuery(`SELECT .* FROM "cms_content".*content_type = \$1 AND title = \$2 AND publish_status = \$3 AND status = \$4.*ORDER BY version_no desc, id desc`). + WithArgs("agreement", "充值协议", "published", 1, 1).WillReturnRows(sqlmock.NewRows([]string{"id"})) + ctx, response := paymentTestContext("") + GetRechargeOptions("user_app")(ctx) + body := response.Body.String() + if !strings.Contains(body, `"code":0`) || strings.Contains(body, `"app":true`) || strings.Contains(body, `"wap":true`) || strings.Contains(body, `"agreement":`) || strings.Contains(body, `"mock"`) { + t.Fatal(body) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + // 账户查不到时不得继续查询协议或返回支付配置信息。 + mock.ExpectQuery(`SELECT .* FROM "user_account".*identity = \$1 AND status = \$2`).WithArgs("alice", 1, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity"})) + deniedContext, deniedResponse := paymentTestContext("") + GetRechargeOptions("user_app")(deniedContext) + if strings.Contains(deniedResponse.Body.String(), `"channels"`) || strings.Contains(deniedResponse.Body.String(), `"code":0`) { + t.Fatal("未鉴权账户取得充值配置") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +// TestRechargeFileReadyRejectsDirectory 路径存在但为目录时不得宣告密钥配置就绪。 +func TestRechargeFileReadyRejectsDirectory(t *testing.T) { + if rechargeFileReady("") || rechargeFileReady(t.TempDir()) || rechargeFileReady("missing-recharge-key-file") { + t.Fatal("错误接受缺失或目录配置") + } +} diff --git a/backend/api/internal/logic/common/recharge_query.go b/backend/api/internal/logic/common/recharge_query.go new file mode 100644 index 0000000..6245033 --- /dev/null +++ b/backend/api/internal/logic/common/recharge_query.go @@ -0,0 +1,100 @@ +// 功能描述:本人充值结果查询、记录分页与请求冲突错误;版本:1.0.0。 +package common + +import ( + "strconv" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +var ErrRechargeRequestConflict = errcode.NewError(2411, "充值请求已存在,请保持原金额和支付方式") + +// rechargePublic 只投影本人查看所需字段,不暴露钱包主键、渠道密钥和原始支付参数。 +func rechargePublic(order models.WalletRechargeOrder) gin.H { + return gin.H{"identity": order.Identity, "recharge_no": order.RechargeNo, + "recharge_status": order.RechargeStatus, "amount": order.Amount, "channel": order.Channel, + "created_at": order.CreatedAt, "completed_at": order.CompletedAt} +} + +// GetRecharge 以本地已验签回调的入账事实确认结果;支付渠道已付与钱包已入账分别返回。 +func GetRecharge(client string) gin.HandlerFunc { + return func(ctx *gin.Context) { + owner, ok := currentOwner(ctx, client) + if !ok { + return + } + var order models.WalletRechargeOrder + query := impl.DBService.Where("owner_type = ? AND owner_identity = ?", owner.Type, owner.Identity) + if request := ctx.Param("request"); request != "" { + query = query.Where("request_no = ?", request) + } else { + query = query.Where("identity = ?", ctx.Param("identity")) + } + if err := query.First(&order).Error; err != nil { + if err == gorm.ErrRecordNotFound { + ctx.Status(404) + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + } else { + infra.Response.Error(ctx, err) + } + return + } + response := rechargePublic(order) + var paymentOrder models.PaymentOrder + err := impl.DBService.Where("business_type = ? AND business_identity = ? AND user_identity = ?", "recharge", order.Identity, owner.Identity). + Order("id desc").First(&paymentOrder).Error + if err != nil && err != gorm.ErrRecordNotFound { + infra.Response.Error(ctx, err) + return + } + // 缺少支付单不推断为支付成功或失败,前端应保留待确认状态。 + if err == nil { + response["payment_status"] = paymentOrder.PaymentStatus + response["expires_at"] = paymentOrder.ExpiresAt + } + infra.Response.Success(ctx, response) + } +} + +// ListRecharges 每次至多50条;主体类型和标识双重过滤,游标只影响本人范围。 +func ListRecharges(client string) gin.HandlerFunc { + return func(ctx *gin.Context) { + var before uint64 + if raw := ctx.Query("cursor"); raw != "" { + var err error + before, err = strconv.ParseUint(raw, 10, 64) + if err != nil || before == 0 { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + } + owner, ok := currentOwner(ctx, client) + if !ok { + return + } + query := impl.DBService.Where("owner_type = ? AND owner_identity = ?", owner.Type, owner.Identity) + if before != 0 { + query = query.Where("id < ?", before) + } + var orders []models.WalletRechargeOrder + if err := query.Order("id desc").Limit(51).Find(&orders).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + next := "" + if len(orders) > 50 { + orders = orders[:50] + next = strconv.FormatUint(orders[49].ID, 10) + } + items := make([]gin.H, 0, len(orders)) + for _, order := range orders { + items = append(items, rechargePublic(order)) + } + infra.Response.Success(ctx, gin.H{"items": items, "next_cursor": next}) + } +} diff --git a/backend/api/internal/logic/common/recharge_remote_test.go b/backend/api/internal/logic/common/recharge_remote_test.go new file mode 100644 index 0000000..b914dcd --- /dev/null +++ b/backend/api/internal/logic/common/recharge_remote_test.go @@ -0,0 +1,141 @@ +// 功能描述:远程事务内验证充值幂等、本人查询、Mock入账及整体回滚;版本:1.0.0。 +package common + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + "os" + "testing" + "time" + + "git.apinb.com/bsm-sdk/core/database" + dbsql "git.apinb.com/bsm-sdk/core/database/sql" + "git.apinb.com/bsm-sdk/core/types" + "git.apinb.com/heqiapp/platforms/backend/api/internal/config" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// TestRechargeRemoteRollback 不调真实支付渠道,所有资金与账户夹具均处于最终回滚事务内。 +func TestRechargeRemoteRollback(t *testing.T) { + if os.Getenv("HEQI_REMOTE_RECHARGE_TEST") != "1" { + t.Skip("需显式开启远程回滚测试") + } + config.New("heqi") + db, err := database.NewDatabase(config.Spec.Databases.Driver, config.Spec.Databases.Source, dbsql.SetOptions(nil)) + if err != nil { + t.Fatal("远程数据库连接失败") + } + db = db.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}) + connection, err := db.DB() + if err != nil { + t.Fatal(err) + } + defer connection.Close() + tx := db.Begin() + if tx.Error != nil { + t.Fatal(tx.Error) + } + defer tx.Rollback() + if err := tx.Exec(`SET LOCAL lock_timeout = '3s'`).Error; err != nil { + t.Fatal(err) + } + oldDB, oldMock := impl.DBService, config.Spec.Global.MockPaymentEnabled + impl.DBService, config.Spec.Global.MockPaymentEnabled = tx, true + defer func() { impl.DBService, config.Spec.Global.MockPaymentEnabled = oldDB, oldMock }() + account := models.UserAccount{Entity: NewEntity(1), Name: "充值回滚验证", Phone: fmt.Sprintf("199%08d", time.Now().UnixNano()%100000000)} + account.Username = "verify-recharge-" + account.Identity + if err := tx.Create(&account).Error; err != nil { + t.Fatal("创建独立测试账号失败") + } + request := "verify-recharge-" + models.NewIdentity() + call := func(handler gin.HandlerFunc, body string, params gin.Params, query string, expected int) map[string]any { + ctx, response := paymentTestContext(body) + ctx.Set("Auth", &types.JwtClaims{Client: "user_app", Identity: account.Identity}) + ctx.Params = params + if query != "" { + ctx.Request = httptest.NewRequest("GET", query, nil) + } + handler(ctx) + var result struct { + Code int `json:"code"` + Details json.RawMessage `json:"details"` + } + if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil || result.Code != expected { + t.Fatalf("接口结果不符,期望%d,响应%s", expected, response.Body.String()) + } + details := map[string]any{} + if expected == 0 { + if err := json.Unmarshal(result.Details, &details); err != nil { + t.Fatal(err) + } + } + return details + } + body := fmt.Sprintf(`{"amount":2300,"channel":"mock","request_no":%q}`, request) + first := call(CreateRecharge("user_app"), body, nil, "", 0) + identity, ok := first["identity"].(string) + if !ok || identity == "" { + t.Fatal("缺少充值标识") + } + second := call(CreateRecharge("user_app"), body, nil, "", 0) + if second["identity"] != identity { + t.Fatal("幂等重试生成了另一充值单") + } + call(CreateRecharge("user_app"), fmt.Sprintf(`{"amount":2400,"channel":"mock","request_no":%q}`, request), nil, "", 2411) + call(CreateRecharge("user_app"), fmt.Sprintf(`{"amount":2300,"channel":"alipay","request_no":%q}`, request), nil, "", 2411) + params := gin.Params{{Key: "identity", Value: identity}} + pending := call(GetRecharge("user_app"), "", params, "/wallet/recharges/owned", 0) + if pending["recharge_status"] != float64(10) || pending["payment_status"] != nil { + t.Fatal("待确认充值被误报为已支付") + } + recovered := call(GetRecharge("user_app"), "", gin.Params{{Key: "request", Value: request}}, "/wallet/recharge-requests/owned", 0) + if recovered["identity"] != identity || recovered["wallet_basic_id"] != nil || recovered["owner_identity"] != nil { + t.Fatal("恢复查询标识不一致或暴露内部字段") + } + // 临时改变夹具归属类型,验证不能跨主体类型读取同一个身份值的记录。 + if err := tx.Model(&models.WalletRechargeOrder{}).Where("identity = ?", identity).Update("owner_type", "staff").Error; err != nil { + t.Fatal(err) + } + call(GetRecharge("user_app"), "", params, "/wallet/recharges/foreign", 1112) + call(CreateRecharge("user_app"), body, nil, "", 2411) + if err := tx.Model(&models.WalletRechargeOrder{}).Where("identity = ?", identity).Update("owner_type", "user").Error; err != nil { + t.Fatal(err) + } + call(ConfirmMockRecharge("user_app"), "", params, "", 0) + call(ConfirmMockRecharge("user_app"), "", params, "", 0) + paid := call(GetRecharge("user_app"), "", params, "/wallet/recharges/owned", 0) + if paid["recharge_status"] != float64(23) || paid["completed_at"] == nil { + t.Fatal("入账完成状态不正确") + } + list := call(ListRecharges("user_app"), "", nil, "/wallet/recharges", 0) + if len(list["items"].([]any)) != 1 { + t.Fatal("充值记录重复或归属异常") + } + var wallet models.WalletBasic + if err := tx.Where("owner_type = ? AND owner_identity = ?", "user", account.Identity).First(&wallet).Error; err != nil { + t.Fatal(err) + } + if wallet.Balance != 2300 || wallet.WithdrawalBalance != 0 { + t.Fatal("重复入账或可提现余额被改变") + } + var records int64 + if err := tx.Model(&models.WalletRecord{}).Where("wallet_basic_id = ?", wallet.ID).Count(&records).Error; err != nil || records != 1 { + t.Fatal("流水数量错误") + } + if err := tx.Rollback().Error; err != nil { + t.Fatal(err) + } + var remaining int64 + if err := db.Model(&models.WalletRechargeOrder{}).Where("request_no = ?", request).Count(&remaining).Error; err != nil || remaining != 0 { + t.Fatal("充值夹具未完整回滚") + } + if err := db.Model(&models.WalletBasic{}).Where("owner_identity = ?", account.Identity).Count(&remaining).Error; err != nil || remaining != 0 { + t.Fatal("钱包夹具未完整回滚") + } + t.Log("创建幂等、金额渠道冲突、本人查询恢复、跨主体拒绝、一次入账与回滚通过") +} diff --git a/backend/api/internal/logic/common/resource.go b/backend/api/internal/logic/common/resource.go index b1724b2..5483fa8 100644 --- a/backend/api/internal/logic/common/resource.go +++ b/backend/api/internal/logic/common/resource.go @@ -315,6 +315,13 @@ func ValidateResourceValues(model any, values map[string]any, creating bool) err if !positive("amount") || !nonEmpty("channel") { return errors.New("invalid payment") } + case *models.DevUsageStat: + source, sourceExists := values["source"].(string) + allowedSource := source == "meter" || source == "device" || source == "manual" + if !nonNegative("usage_kg") || !nonNegative("breakfast_kg") || !nonNegative("lunch_kg") || !nonNegative("dinner_kg") || + !nonEmpty("stat_date") || !nonEmpty("calc_version") || !nonEmpty("calculated_at") || (sourceExists && !allowedSource) || (creating && !sourceExists) { + return errors.New("invalid usage statistic") + } } return nil } diff --git a/backend/api/internal/logic/common/verification_availability_test.go b/backend/api/internal/logic/common/verification_availability_test.go new file mode 100644 index 0000000..51dc471 --- /dev/null +++ b/backend/api/internal/logic/common/verification_availability_test.go @@ -0,0 +1,34 @@ +// 功能描述:未配置短信及缓存不可用时不能返回虚假验证码成功;版本:1.0.0。 +package common + +import ( + "net/http/httptest" + "strings" + "testing" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/config" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "github.com/gin-gonic/gin" +) + +// TestVerificationUnavailable 覆盖关闭Mock及缺少缓存的拒绝路径,不连接外部服务。 +func TestVerificationUnavailable(t *testing.T) { + oldEnabled, oldCache := config.Spec.Global.MockVerificationEnabled, impl.RedisService + defer func() { config.Spec.Global.MockVerificationEnabled, impl.RedisService = oldEnabled, oldCache }() + impl.RedisService = nil + for _, enabled := range []bool{false, true} { + config.Spec.Global.MockVerificationEnabled = enabled + response := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(response) + ctx.Request = httptest.NewRequest("POST", "/auth/verification-code", strings.NewReader(`{"phone":"13800000001","purpose":"set_payment_password"}`)) + ctx.Request.Header.Set("Content-Type", "application/json") + SendVerificationCode("user_app")(ctx) + body := response.Body.String() + if strings.Contains(body, "request_identity") || strings.Contains(body, `"code":0`) { + t.Fatalf("不可用服务不应创建成功请求:%s", body) + } + if !enabled && !strings.Contains(body, "2410") { + t.Fatalf("关闭Mock必须说明短信尚未配置:%s", body) + } + } +} diff --git a/backend/api/internal/logic/common/wallet_bills.go b/backend/api/internal/logic/common/wallet_bills.go new file mode 100644 index 0000000..1d3377c --- /dev/null +++ b/backend/api/internal/logic/common/wallet_bills.go @@ -0,0 +1,78 @@ +// 功能描述:当前钱包账单的白名单输出、收支筛选和游标分页;版本:1.0.0。 +package common + +import ( + "strconv" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" +) + +// ListWalletBills 仅按当前主体钱包读取,不允许请求参数覆盖归属;旧records接口保持兼容。 +func ListWalletBills(client string) gin.HandlerFunc { + return func(ctx *gin.Context) { + direction := ctx.Query("direction") + if direction != "" && direction != "income" && direction != "expense" { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var before uint64 + if raw := ctx.Query("cursor"); raw != "" { + var err error + before, err = strconv.ParseUint(raw, 10, 64) + if err != nil || before == 0 { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + } + owner, ok := currentOwner(ctx, client) + if !ok { + return + } + wallet, err := ensureWallet(impl.DBService, owner) + if err != nil { + infra.Response.Error(ctx, err) + return + } + query := impl.DBService.Where("wallet_basic_id = ?", wallet.ID) + if direction != "" { + // 旧种子使用in,当前记账使用income;读取兼容,不重写历史资金流水。 + if direction == "income" { + query = query.Where("direction IN ?", []string{"income", "in"}) + } else { + query = query.Where("direction = ?", direction) + } + } + if before != 0 { + query = query.Where("id < ?", before) + } + var records []models.WalletRecord + // 固定按入账序号倒序,新增流水不会导致后续页重复或错位。 + if err := query.Order("id desc").Limit(51).Find(&records).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + next := "" + if len(records) > 50 { + records = records[:50] + next = strconv.FormatUint(records[49].ID, 10) + } + items := make([]gin.H, 0, len(records)) + for _, record := range records { + flow := record.Direction + if flow == "in" { + flow = "income" + } + items = append(items, gin.H{ + "identity": record.Identity, "record_no": record.RecordNo, + "direction": flow, "trade_type": record.TradeType, + "amount": record.Amount, "fee": record.Fee, "balance_after": record.BalanceAfter, + "created_at": record.CreatedAt, "pay_channel": record.PayChannel, + }) + } + infra.Response.Success(ctx, gin.H{"items": items, "next_cursor": next}) + } +} diff --git a/backend/api/internal/logic/common/wallet_bills_test.go b/backend/api/internal/logic/common/wallet_bills_test.go new file mode 100644 index 0000000..09c039e --- /dev/null +++ b/backend/api/internal/logic/common/wallet_bills_test.go @@ -0,0 +1,64 @@ +// 功能描述:账单归属、分页、筛选和白名单测试;版本:1.0.0。 +package common + +import ( + "fmt" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// TestWalletBillsScopedPagination 将伪造归属参数与正常游标一起传入,SQL必须保持本人范围。 +func TestWalletBillsScopedPagination(t *testing.T) { + connection, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer connection.Close() + db, err := gorm.Open(postgres.New(postgres.Config{Conn: connection}), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + old := impl.DBService + impl.DBService = db + defer func() { impl.DBService = old }() + mock.ExpectQuery(`SELECT .* FROM "user_account".*identity = \$1 AND status = \$2`).WithArgs("alice", 1, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "phone"}).AddRow(7, "alice", "13800000001")) + mock.ExpectQuery(`SELECT .* FROM "wallet_basic".*owner_type = \$1 AND owner_identity = \$2`).WithArgs("user", "alice", 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(9, "wallet")) + rows := sqlmock.NewRows([]string{"id", "identity", "created_at", "amount", "direction", "operator_identity", "request_no"}) + for i := 99; i >= 49; i-- { + rows.AddRow(i, fmt.Sprint(i), time.Now(), 100, "in", "PRIVATE-OPERATOR", "PRIVATE-REQUEST") + } + mock.ExpectQuery(`SELECT .* FROM "wallet_record" WHERE wallet_basic_id = \$1 AND direction IN \(\$2,\$3\) AND id < \$4 AND "wallet_record"."deleted_at" IS NULL ORDER BY id desc LIMIT \$5`). + WithArgs(9, "income", "in", 100, 51).WillReturnRows(rows) + ctx, response := paymentTestContext("") + ctx.Request = httptest.NewRequest("GET", "/wallet/bills?direction=income&cursor=100&owner_identity=victim", nil) + ListWalletBills("user_app")(ctx) + body := response.Body.String() + if !strings.Contains(body, `"next_cursor":"50"`) || strings.Contains(body, "PRIVATE") || strings.Contains(body, `"identity":"49"`) || !strings.Contains(body, `"direction":"income"`) { + t.Fatal(body) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +// TestWalletBillsInvalidFilter 无效参数在访问账户前拒绝,不放宽为全量查询。 +func TestWalletBillsInvalidFilter(t *testing.T) { + for _, query := range []string{"direction=other", "cursor=-1", "cursor=0", "cursor=x"} { + ctx, response := paymentTestContext("") + ctx.Request = httptest.NewRequest("GET", "/wallet/bills?"+query, nil) + ListWalletBills("user_app")(ctx) + if strings.Contains(response.Body.String(), `"code":0`) { + t.Fatal("错误接受无效筛选") + } + } +} diff --git a/backend/api/internal/logic/gas/contract_request.go b/backend/api/internal/logic/gas/contract_request.go new file mode 100644 index 0000000..35243e0 --- /dev/null +++ b/backend/api/internal/logic/gas/contract_request.go @@ -0,0 +1,47 @@ +// 功能描述:供气单位答复本人合同变更申请,不自动改动合同条款;版本:1.0.0。 +package gas + +import ( + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "strings" +) + +// ResolveContractRequest 按原合同气站锁定申请,提交处理结果后等待用户确认。 +func ResolveContractRequest(ctx *gin.Context) { + account, station, ok := CurrentGasAccount(ctx) + if !ok { + return + } + var request struct { + Result string `json:"result" binding:"required,max=2000"` + } + if ctx.ShouldBindJSON(&request) != nil || strings.TrimSpace(request.Result) == "" { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + result := strings.TrimSpace(request.Result) + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var ticket models.CsTicket + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND gas_basic_id = ? AND gasorder_contract_id <> 0 AND category = ? AND status <> ?", ctx.Param("identity"), station.ID, "contract_change", 3).First(&ticket).Error; err != nil { + return err + } + if (ticket.TicketStatus == 34 || ticket.TicketStatus == 23) && ticket.Result == result { + return nil + } + if ticket.TicketStatus != 32 && ticket.TicketStatus != 11 { + return errcode.ErrInvalidArgument + } + return tx.Model(&ticket).Updates(map[string]any{"result": result, "ticket_status": 34, "operator_identity": account.Identity}).Error + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"updated": true}) +} diff --git a/backend/api/internal/logic/gas/contract_request_test.go b/backend/api/internal/logic/gas/contract_request_test.go new file mode 100644 index 0000000..1627e99 --- /dev/null +++ b/backend/api/internal/logic/gas/contract_request_test.go @@ -0,0 +1,71 @@ +// 功能描述:合同申请答复的原气站归属、状态锁与幂等;版本:1.0.0。 +package gas + +import ( + "git.apinb.com/bsm-sdk/core/types" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "net/http/httptest" + "strings" + "testing" +) + +func TestResolveContractRequestScopeAndState(t *testing.T) { + for _, tc := range []struct { + name string + state int + result string + found, success, update bool + }{ + {"待受理答复", 32, "", true, true, true}, {"处理中答复", 11, "", true, true, true}, + {"重复答复", 34, "已处理", true, true, false}, {"不得覆盖答复", 34, "原答复", true, false, false}, + {"取消后拒绝", 22, "", true, false, false}, {"其他气站", 0, "", false, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + connection, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer connection.Close() + db, err := gorm.Open(postgres.New(postgres.Config{Conn: connection}), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + previous := impl.DBService + impl.DBService = db + defer func() { impl.DBService = previous }() + mock.ExpectQuery(`SELECT .* FROM "gas_account"`).WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "gas_basic_id"}).AddRow(2, "gas", 9)) + mock.ExpectQuery(`SELECT .* FROM "gas_basic"`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(9)) + mock.ExpectBegin() + rows := sqlmock.NewRows([]string{"id", "ticket_status", "result"}) + if tc.found { + rows.AddRow(7, tc.state, tc.result) + } + mock.ExpectQuery(`SELECT .* FROM "cs_ticket".*identity = \$1 AND gas_basic_id = \$2 AND gasorder_contract_id <> 0 AND category = \$3 AND status <> \$4.*FOR UPDATE`).WithArgs("request", 9, "contract_change", 3, 1).WillReturnRows(rows) + if tc.update { + mock.ExpectExec(`UPDATE "cs_ticket" SET .*"result"=.*"ticket_status"=`).WillReturnResult(sqlmock.NewResult(0, 1)) + } + if tc.success { + mock.ExpectCommit() + } else { + mock.ExpectRollback() + } + response := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(response) + ctx.Set("Auth", &types.JwtClaims{Client: "gas_admin", Identity: "gas"}) + ctx.Params = gin.Params{{Key: "identity", Value: "request"}} + ctx.Request = httptest.NewRequest("POST", "/requests", strings.NewReader(`{"result":"已处理"}`)) + ctx.Request.Header.Set("Content-Type", "application/json") + ResolveContractRequest(ctx) + if strings.Contains(response.Body.String(), `"code":0`) != tc.success { + t.Fatal(response.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/backend/api/internal/logic/gas/ticket.go b/backend/api/internal/logic/gas/ticket.go index b1c3b62..ad7aa5e 100644 --- a/backend/api/internal/logic/gas/ticket.go +++ b/backend/api/internal/logic/gas/ticket.go @@ -19,12 +19,13 @@ func ticketQueryWithDB(databaseService *gorm.DB, gasID uint64) *gorm.DB { return common.ActiveRecords(databaseService.Model(&models.CsTicket{})). Joins("JOIN user_service_relation ON user_service_relation.user_account_id = cs_ticket.user_account_id AND user_service_relation.status <> ?", common.StatusArchived). - Where("user_service_relation.gas_basic_id = ?", gasID) + Where("((cs_ticket.gasorder_contract_id = 0 AND user_service_relation.gas_basic_id = ?) OR (cs_ticket.gasorder_contract_id <> 0 AND cs_ticket.gas_basic_id = ?))", gasID, gasID) } // ticketDetailDisplay 为工单详情附加当前记录所引用关系的可读名称,不修改工单归属数据。 type ticketDetailDisplay struct { models.CsTicket + ContractRequestOpen int `gorm:"column:contract_request_open" json:"contract_request_open"` UserAccountDisplayName string `gorm:"column:user_account_display_name" json:"user_account_display_name"` DeliveryBasicDisplayName string `gorm:"column:delivery_basic_display_name" json:"delivery_basic_display_name"` StaffAccountDisplayName string `gorm:"column:staff_account_display_name" json:"staff_account_display_name"` @@ -40,6 +41,7 @@ func ticketDetailQuery(gasID uint64) *gorm.DB { func ticketDetailQueryWithDB(databaseService *gorm.DB, gasID uint64) *gorm.DB { return ticketQueryWithDB(databaseService, gasID). Select(`cs_ticket.*, + CASE WHEN cs_ticket.gasorder_contract_id <> 0 AND cs_ticket.category = 'contract_change' AND cs_ticket.ticket_status IN (32, 11) THEN 1 ELSE 0 END AS contract_request_open, COALESCE(NULLIF(ticket_user.name, ''), NULLIF(ticket_user.real_name, ''), NULLIF(ticket_user.username, ''), '用户记录已失效') AS user_account_display_name, CASE WHEN cs_ticket.delivery_basic_id = 0 THEN '' ELSE COALESCE(NULLIF(ticket_delivery.name, ''), '配送点记录已失效') END AS delivery_basic_display_name, CASE WHEN cs_ticket.staff_account_id = 0 THEN '' ELSE COALESCE(NULLIF(ticket_staff.name, ''), NULLIF(ticket_staff.username, ''), '工作人员记录已失效') END AS staff_account_display_name, @@ -122,6 +124,10 @@ func UpdateTicket(ctx *gin.Context) { if !ok { return } + if ticket.GasorderContractID != 0 && (user.ID != ticket.UserAccountID || request.Category != "contract_change") { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } if err := impl.DBService.Model(&ticket).Updates(map[string]any{ "user_account_id": user.ID, "ticket_no": request.TicketNo, "category": request.Category, "priority": request.Priority, }).Error; err != nil { diff --git a/backend/api/internal/logic/gas/ticket_values.go b/backend/api/internal/logic/gas/ticket_values.go index 9ef8d90..630cb62 100644 --- a/backend/api/internal/logic/gas/ticket_values.go +++ b/backend/api/internal/logic/gas/ticket_values.go @@ -5,6 +5,7 @@ package gas var supportedTicketCategories = map[string]struct{}{ "delivery": {}, "installation": {}, "repair": {}, "inspection": {}, "reinspection": {}, "customer_service": {}, + "contract_change": {}, } var supportedTicketPriorities = map[string]struct{}{ diff --git a/backend/api/internal/logic/gas/ticket_values_test.go b/backend/api/internal/logic/gas/ticket_values_test.go index 107b799..f57a2bc 100644 --- a/backend/api/internal/logic/gas/ticket_values_test.go +++ b/backend/api/internal/logic/gas/ticket_values_test.go @@ -44,6 +44,9 @@ func TestTicketDetailQueryIncludesScopedRelationNames(t *testing.T) { Find(&ticketDetailDisplay{}).Statement.SQL.String() for _, fragment := range []string{ "user_service_relation.gas_basic_id =", + "cs_ticket.gasorder_contract_id = 0", + "cs_ticket.gasorder_contract_id <> 0 AND cs_ticket.gas_basic_id =", + "contract_request_open", "LEFT JOIN user_account AS ticket_user", "LEFT JOIN delivery_basic AS ticket_delivery", "LEFT JOIN staff_account AS ticket_staff", diff --git a/backend/api/internal/logic/payment/callback.go b/backend/api/internal/logic/payment/callback.go index 35b8d57..a4fa6d5 100644 --- a/backend/api/internal/logic/payment/callback.go +++ b/backend/api/internal/logic/payment/callback.go @@ -105,7 +105,10 @@ func complete(paymentNo, tradeNo string, amount int64, channel, callbackDigest s result := tx.Model(&models.EcOrder{}).Where("identity = ? AND order_status = ?", order.BusinessIdentity, 16).Updates(map[string]any{"order_status": 18, "paid_at": &now}) return requireSingleBusinessUpdate(result) case "gasorder": - return requireSingleBusinessUpdate(tx.Model(&models.GasorderBasic{}).Where("identity = ? AND order_status IN ?", order.BusinessIdentity, []int{16, 18}).Update("order_status", 35)) + if err := requireSingleBusinessUpdate(tx.Model(&models.GasorderBasic{}).Where("identity = ? AND order_status IN ?", order.BusinessIdentity, []int{16, 18}).Update("order_status", 35)); err != nil { + return err + } + return CompleteGasDeposits(tx, order.BusinessIdentity, now) case "recharge": return completeRecharge(tx, order, now) } diff --git a/backend/api/internal/logic/payment/gas_deposit.go b/backend/api/internal/logic/payment/gas_deposit.go new file mode 100644 index 0000000..1f9f7c5 --- /dev/null +++ b/backend/api/internal/logic/payment/gas_deposit.go @@ -0,0 +1,41 @@ +// 功能描述:供气订单支付完成后原子生成正式押金事实;版本:1.0.0。 +package payment + +import ( + "fmt" + "time" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// CompleteGasDeposits 将下单时锁定的押金快照转为用户押金记录,重复回调不会重复入账。 +func CompleteGasDeposits(tx *gorm.DB, orderIdentity string, paidAt time.Time) error { + var order models.GasorderBasic + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ?", orderIdentity).First(&order).Error; err != nil { + return err + } + var snapshots []models.GasorderDeposit + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("gasorder_basic_id = ? AND deposit_status = ?", order.ID, 10).Find(&snapshots).Error; err != nil { + return err + } + for _, snapshot := range snapshots { + var existing int64 + if err := tx.Model(&models.DepositRecord{}).Where("gasorder_id = ? AND product_info_id = ? AND status <> ?", order.ID, snapshot.ProductInfoID, 3).Count(&existing).Error; err != nil { + return err + } + if existing == 0 { + record := models.DepositRecord{Entity: models.Entity{Identity: models.NewIdentity(), Status: 1}, DepositStatus: 10, + DepositNo: fmt.Sprintf("DEP%s-%s", paidAt.Format("20060102150405.000000"), snapshot.Identity), UserAccountID: order.UserAccountID, ProductInfoID: snapshot.ProductInfoID, + PolicyID: snapshot.PolicyID, GasorderID: order.ID, Amount: snapshot.Amount, PaidAt: paidAt} + if err := tx.Create(&record).Error; err != nil { + return err + } + } + if err := tx.Model(&snapshot).Where("deposit_status = ?", 10).Update("deposit_status", 23).Error; err != nil { + return err + } + } + return nil +} diff --git a/backend/api/internal/logic/platform/contract_ticket_guard.go b/backend/api/internal/logic/platform/contract_ticket_guard.go new file mode 100644 index 0000000..9ea5903 --- /dev/null +++ b/backend/api/internal/logic/platform/contract_ticket_guard.go @@ -0,0 +1,51 @@ +// 功能描述:平台治理编辑不能改变合同申请的用户和分类关联;版本:1.0.0。 +package platform + +import ( + "bytes" + "encoding/json" + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "io" +) + +// GuardContractTicketUpdate 保留普通工单编辑;合同申请允许治理字段编辑但禁止转移个人资料。 +func GuardContractTicketUpdate(next gin.HandlerFunc) gin.HandlerFunc { + return func(ctx *gin.Context) { + var ticket models.CsTicket + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&ticket).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + if ticket.GasorderContractID == 0 { + next(ctx) + return + } + body, err := io.ReadAll(io.LimitReader(ctx.Request.Body, 65537)) + if err != nil || len(body) > 65536 { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var input map[string]any + if json.Unmarshal(body, &input) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + if value, found := input["category"]; found && value != "contract_change" { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + if value, found := input["user_account_identity"]; found { + var owner models.UserAccount + if err := impl.DBService.First(&owner, ticket.UserAccountID).Error; err != nil || value != owner.Identity { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + } + ctx.Request.Body = io.NopCloser(bytes.NewReader(body)) + next(ctx) + } +} diff --git a/backend/api/internal/logic/platform/contract_ticket_guard_test.go b/backend/api/internal/logic/platform/contract_ticket_guard_test.go new file mode 100644 index 0000000..7a3e56f --- /dev/null +++ b/backend/api/internal/logic/platform/contract_ticket_guard_test.go @@ -0,0 +1,62 @@ +// 功能描述:平台编辑保留合同申请归属并保持请求体可读;版本:1.0.0。 +package platform + +import ( + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "net/http/httptest" + "strings" + "testing" +) + +func TestContractTicketGuardPreservesOwner(t *testing.T) { + for _, tc := range []struct { + body string + ownerQuery, allowed bool + }{ + {`{"category":"repair"}`, false, false}, + {`{"user_account_identity":"other"}`, true, false}, + {`{"user_account_identity":"owner","priority":"high","category":"contract_change"}`, true, true}, + } { + t.Run(tc.body, func(t *testing.T) { + connection, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer connection.Close() + db, err := gorm.Open(postgres.New(postgres.Config{Conn: connection}), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + previous := impl.DBService + impl.DBService = db + defer func() { impl.DBService = previous }() + mock.ExpectQuery(`SELECT .* FROM "cs_ticket"`).WillReturnRows(sqlmock.NewRows([]string{"gasorder_contract_id", "user_account_id"}).AddRow(7, 1)) + if tc.ownerQuery { + mock.ExpectQuery(`SELECT .* FROM "user_account"`).WillReturnRows(sqlmock.NewRows([]string{"identity"}).AddRow("owner")) + } + response := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(response) + ctx.Params = gin.Params{{Key: "identity", Value: "ticket"}} + ctx.Request = httptest.NewRequest("PUT", "/cs_ticket/ticket", strings.NewReader(tc.body)) + ctx.Request.Header.Set("Content-Type", "application/json") + called := false + GuardContractTicketUpdate(func(ctx *gin.Context) { + called = true + var body map[string]any + if ctx.ShouldBindJSON(&body) != nil || body["priority"] != "high" { + t.Error("原请求体丢失") + } + })(ctx) + if called != tc.allowed { + t.Fatalf("allowed=%v", called) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/backend/api/internal/logic/platform/deposit_return.go b/backend/api/internal/logic/platform/deposit_return.go new file mode 100644 index 0000000..aeff00a --- /dev/null +++ b/backend/api/internal/logic/platform/deposit_return.go @@ -0,0 +1,147 @@ +// 功能描述:平台处理退瓶回收、验收扣减和押金入账;版本:1.0.0。 +package platform + +import ( + "errors" + "time" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + sdkmiddleware "git.apinb.com/bsm-sdk/core/middleware" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// ConfirmDepositPickup 确认实物已回收,进入待验收状态。 +func ConfirmDepositPickup(ctx *gin.Context) { + transitionDepositReturn(ctx, 10, 20, func(request *models.DepositReturnRequest, now time.Time, operator string) map[string]any { + return map[string]any{"return_status": 20, "picked_up_at": &now, "operator_identity": operator} + }) +} + +// InspectDepositReturn 记录可审计的扣减金额和理由。 +func InspectDepositReturn(ctx *gin.Context) { + var input struct { + DeductionAmount int64 `json:"deduction_amount" binding:"gte=0"` + Remark string `json:"remark" binding:"required,max=2000"` + } + if ctx.ShouldBindJSON(&input) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + transitionDepositReturn(ctx, 20, 30, func(request *models.DepositReturnRequest, now time.Time, operator string) map[string]any { + if input.DeductionAmount > request.EstimatedAmount { + return nil + } + return map[string]any{ + "return_status": 30, "deduction_amount": input.DeductionAmount, + "refund_amount": request.EstimatedAmount - input.DeductionAmount, + "inspection_remark": input.Remark, "inspected_at": &now, "operator_identity": operator, + } + }) +} + +// CompleteDepositRefund 在同一事务内退入余额、写不可变流水并完成押金状态。 +func CompleteDepositRefund(ctx *gin.Context) { + claims, err := sdkmiddleware.ParseAuth(ctx) + if err != nil { + infra.Response.Error(ctx, errcode.ErrPermissionDenied) + return + } + now := time.Now() + err = impl.DBService.Transaction(func(tx *gorm.DB) error { + var request models.DepositReturnRequest + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ?", ctx.Param("identity")).First(&request).Error; err != nil { + return err + } + if request.ReturnStatus == 40 { + return nil + } + if request.ReturnStatus != 30 { + return gorm.ErrInvalidData + } + var user models.UserAccount + if err := tx.First(&user, request.UserAccountID).Error; err != nil { + return err + } + var wallet models.WalletBasic + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("owner_type = ? AND owner_identity = ?", "user", user.Identity).First(&wallet).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + wallet = models.WalletBasic{Entity: common.NewEntity(common.StatusEnable), OwnerType: "user", OwnerID: user.ID, OwnerIdentity: user.Identity} + if err := tx.Create(&wallet).Error; err != nil { + return err + } + } + if request.RefundAmount > 0 { + wallet.Balance += request.RefundAmount + if err := tx.Model(&wallet).Update("balance", wallet.Balance).Error; err != nil { + return err + } + year, month, day := now.Date() + flow := models.WalletRecord{ + Entity: common.NewEntity(common.StatusEnable), WalletBasicID: wallet.ID, + RecordNo: common.RecordNo("DR"), RequestNo: "deposit-refund:" + request.Identity, + Direction: "income", TradeType: "deposit_refund", Amount: request.RefundAmount, + BalanceAfter: wallet.Balance, WithdrawalBalanceAfter: wallet.WithdrawalBalance, + InTradeNo: request.Identity, PayChannel: "wallet", OperatorIdentity: claims.Identity, + Ymd: int32(year*10000 + int(month)*100 + day), Ym: int32(year*100 + int(month)), Remark: "退瓶验收后押金退回", + } + if err := tx.Create(&flow).Error; err != nil { + return err + } + } + depositUpdate := tx.Model(&models.DepositRecord{}).Where("id = ? AND deposit_status = ?", request.DepositRecordID, 20). + Updates(map[string]any{"deposit_status": 23, "refunded_at": &now}) + if depositUpdate.Error != nil { + return depositUpdate.Error + } + if depositUpdate.RowsAffected != 1 { + return gorm.ErrInvalidData + } + return tx.Model(&request).Updates(map[string]any{"return_status": 40, "completed_at": &now, "operator_identity": claims.Identity}).Error + }) + if err != nil { + if errors.Is(err, gorm.ErrInvalidData) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + common.RespondRecordError(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"completed": true}) +} + +func transitionDepositReturn(ctx *gin.Context, from, to int, values func(*models.DepositReturnRequest, time.Time, string) map[string]any) { + claims, err := sdkmiddleware.ParseAuth(ctx) + if err != nil { + infra.Response.Error(ctx, errcode.ErrPermissionDenied) + return + } + var request models.DepositReturnRequest + err = impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND return_status = ?", ctx.Param("identity"), from).First(&request).Error; err != nil { + return err + } + updates := values(&request, time.Now(), claims.Identity) + if updates == nil { + return gorm.ErrInvalidData + } + updates["return_status"] = to + return tx.Model(&request).Updates(updates).Error + }) + if err != nil { + if errors.Is(err, gorm.ErrInvalidData) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + common.RespondRecordError(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"changed": true, "return_status": to}) +} diff --git a/backend/api/internal/logic/platform/gasorder/contract_attachment.go b/backend/api/internal/logic/platform/gasorder/contract_attachment.go index 92950e4..abbe254 100644 --- a/backend/api/internal/logic/platform/gasorder/contract_attachment.go +++ b/backend/api/internal/logic/platform/gasorder/contract_attachment.go @@ -147,6 +147,11 @@ func ServeGasorderContractAttachment(ctx *gin.Context) { common.RespondRecordError(ctx, err) return } + ServeAuthorizedContractPDF(ctx, contract, operator) +} + +// ServeAuthorizedContractPDF 输出已由调用方完成归属鉴权的合同;不得直接注册为路由。 +func ServeAuthorizedContractPDF(ctx *gin.Context, contract models.GasorderContract, operator string) { path, err := contractAttachmentPath(contract.FileURI, false) if err != nil || !validStoredContractPDF(path) { logContractAttachment(operator, contract.Identity, "download", "unavailable", contract.FileURI) diff --git a/backend/api/internal/logic/platform/menu.go b/backend/api/internal/logic/platform/menu.go index a21d352..4dce33c 100644 --- a/backend/api/internal/logic/platform/menu.go +++ b/backend/api/internal/logic/platform/menu.go @@ -48,6 +48,7 @@ var PlatformMenus = [][]Menu{ {Identity: "product_type", ParentIdentity: "device", GroupCode: "device", Name: "类型管理", Path: "/product/product-type", SortNo: 2, Status: common.StatusEnable}, {Identity: "product_warehouse", ParentIdentity: "device", GroupCode: "device", Name: "库房管理", Path: "/product/warehouse", SortNo: 3, Status: common.StatusEnable}, {Identity: "product_info", ParentIdentity: "device", GroupCode: "device", Name: "智能气阀管理", Path: "/product/product-info", SortNo: 4, Status: common.StatusEnable}, + {Identity: "dev_usage_stat", ParentIdentity: "device", GroupCode: "device", Name: "用气统计数据", Path: "/product/usage-statistics", SortNo: 5, Status: common.StatusEnable}, }, { {Identity: "gasorder", GroupCode: "gasorder", Name: "气体配送订单管理", Icon: "icon-list", Path: "/gasorder", SortNo: 70, Status: common.StatusEnable}, @@ -62,6 +63,9 @@ var PlatformMenus = [][]Menu{ {Identity: "ec_cart", ParentIdentity: "ec", GroupCode: "ec", Name: "购物车", Path: "/ec/carts", SortNo: 3, Status: common.StatusEnable}, {Identity: "ec_order", ParentIdentity: "ec", GroupCode: "ec", Name: "商城订单", Path: "/ec/orders", SortNo: 4, Status: common.StatusEnable}, {Identity: "ec_review", ParentIdentity: "ec", GroupCode: "ec", Name: "商品评价", Path: "/ec/reviews", SortNo: 5, Status: common.StatusEnable}, + {Identity: "deposit_policy", ParentIdentity: "ec", GroupCode: "ec", Name: "押金规则", Path: "/ec/deposit-policies", SortNo: 6, Status: common.StatusEnable}, + {Identity: "deposit_record", ParentIdentity: "ec", GroupCode: "ec", Name: "押金记录", Path: "/ec/deposit-records", SortNo: 7, Status: common.StatusEnable}, + {Identity: "deposit_return_request", ParentIdentity: "ec", GroupCode: "ec", Name: "退瓶处理", Path: "/ec/deposit-returns", SortNo: 8, Status: common.StatusEnable}, }, { {Identity: "finance", GroupCode: "finance", Name: "财务管理", Icon: "icon-bar-chart", Path: "/finance", SortNo: 100, Status: common.StatusEnable}, diff --git a/backend/api/internal/logic/platform/platform/access.go b/backend/api/internal/logic/platform/platform/access.go index 665afe3..b9e6a06 100644 --- a/backend/api/internal/logic/platform/platform/access.go +++ b/backend/api/internal/logic/platform/platform/access.go @@ -93,6 +93,8 @@ func platformRouteMenuIdentity(resource string) string { return resource case strings.HasPrefix(resource, "product_"): return "product_info" + case resource == "dev_usage_stat": + return "dev_usage_stat" case strings.HasPrefix(resource, "cms_"): return "cms_content" case strings.HasPrefix(resource, "cs_"): diff --git a/backend/api/internal/logic/platform/product/device_mapping.go b/backend/api/internal/logic/platform/product/device_mapping.go new file mode 100644 index 0000000..523807d --- /dev/null +++ b/backend/api/internal/logic/platform/product/device_mapping.go @@ -0,0 +1,21 @@ +// 功能:校验平台实体分类和厂商编号,禁止用名称推断设备能力;版本:1.0.0。 +package product + +import ( + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "regexp" +) + +var vendorDevicePattern = regexp.MustCompile(`^[0-9]{16}$`) + +// validDeviceMapping 空编号允许先建立档案;钢瓶和未分类不能占用设备编号。 +func validDeviceMapping(product models.ProductInfo) bool { + switch product.DeviceKind { + case "", "unknown", "cylinder": + return product.VendorDeviceID == "" + case "valve", "alarm": + return product.VendorDeviceID == "" || (vendorDevicePattern.MatchString(product.VendorDeviceID) && product.VendorDeviceID != "0000000000000000") + default: + return false + } +} diff --git a/backend/api/internal/logic/platform/product/device_mapping_test.go b/backend/api/internal/logic/platform/product/device_mapping_test.go new file mode 100644 index 0000000..9ff4031 --- /dev/null +++ b/backend/api/internal/logic/platform/product/device_mapping_test.go @@ -0,0 +1,24 @@ +// 功能:实体设备分类和厂商编号边界测试;版本:1.0.0。 +package product + +import ( + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "testing" +) + +// TestDeviceMappingBoundary 检查未知分类、普通钢瓶、非十进制和缺失编号。 +func TestDeviceMappingBoundary(t *testing.T) { + for _, tc := range []struct { + kind, id string + valid bool + }{ + {"unknown", "", true}, {"cylinder", "", true}, {"valve", "", true}, {"alarm", "1234567890123456", true}, + {"valve", "0000000000000001", true}, {"cylinder", "0000000000000001", false}, + {"unknown", "0000000000000001", false}, {"valve", "0000000000000000", false}, + {"valve", "1234", false}, {"alarm", "123456789012345x", false}, {"invented", "", false}, + } { + if got := validDeviceMapping(models.ProductInfo{DeviceKind: tc.kind, VendorDeviceID: tc.id}); got != tc.valid { + t.Errorf("分类%s编号%s结果%v", tc.kind, tc.id, got) + } + } +} diff --git a/backend/api/internal/logic/platform/product/product.go b/backend/api/internal/logic/platform/product/product.go index 95fe192..0390873 100644 --- a/backend/api/internal/logic/platform/product/product.go +++ b/backend/api/internal/logic/platform/product/product.go @@ -1,3 +1,4 @@ +// 功能:平台产品档案、厂商映射与生命周期事务;版本:1.1.0。 package product import ( @@ -28,7 +29,7 @@ var productLifecycleStatuses = map[int]bool{ } func ProductInfoHandlers(relations ...common.ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { - fields := []string{"code", "name", "params", "produced_at", "action", "reason", "remark"} + fields := []string{"code", "name", "params", "produced_at", "action", "reason", "remark", "device_kind", "vendor_device_id"} return listProductInfo, func(ctx *gin.Context) { createProductInfo(ctx, fields, relations) }, func(ctx *gin.Context) { common.GetResource(ctx, &models.ProductInfo{}) }, @@ -61,11 +62,15 @@ func createProductInfo(ctx *gin.Context, fields []string, relations []common.Res delete(values, "action") delete(values, "reason") delete(values, "remark") - data := models.ProductInfo{Entity: common.NewEntity(common.StatusDisable), ProductStatus: common.StatusPending, Params: "{}"} + data := models.ProductInfo{Entity: common.NewEntity(common.StatusDisable), ProductStatus: common.StatusPending, Params: "{}", DeviceKind: "unknown"} if err := decodeValues(values, &data); err != nil || data.Code == "" || data.Name == "" || !validProductProducer(data) || data.ProducedAt.IsZero() || !validProductOwnership(data) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } + if !validDeviceMapping(data) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } operatorIdentity, operatorName := productOperator(ctx) err = impl.DBService.Transaction(func(tx *gorm.DB) error { if err := tx.Create(&data).Error; err != nil { @@ -112,10 +117,17 @@ func updateProductInfo(ctx *gin.Context, fields []string, relations []common.Res if err := decodeValues(values, &preview); err != nil || !validProductProducer(preview) || !validProductOwnership(preview) { return errors.New("product can have at most one current owner") } + if !validDeviceMapping(preview) { + return errcode.ErrInvalidArgument + } ownershipChanged := ownershipValuesChanged(current, values) if ownershipChanged && !productOwnerActions[action] { return errors.New("invalid ownership action") } + if ownershipChanged { + // 设备分组只属于原用户;转移产品时必须解除归组,避免新用户继承旧用户结构。 + values["device_group_id"] = uint64(0) + } if err := tx.Model(¤t).Updates(values).Error; err != nil { return err } diff --git a/backend/api/internal/logic/platform/resource_contract.go b/backend/api/internal/logic/platform/resource_contract.go index f1f375c..02bdd7d 100644 --- a/backend/api/internal/logic/platform/resource_contract.go +++ b/backend/api/internal/logic/platform/resource_contract.go @@ -82,8 +82,9 @@ func ExpectedResources() []ResourceContract { resourceContract("delivery", "delivery_basic", Writable, "list"), resourceContract("delivery", "delivery_account", Writable, "list"), resourceContract("staff", "staff_account", Writable, "list"), resourceContract("staff", "staff_credential", Writable, "list"), resourceContract("user", "user_account", Writable, "list"), resourceContract("user", "user_address", Writable, "list"), resourceContract("user", "user_service_relation", Writable, "list"), - resourceContract("product", "producer_account", Writable, "list"), resourceContract("product", "product_type", Editable, "list"), resourceContract("product", "product_warehouse", Editable, "list"), resourceContract("product", "product_info", Editable, "list"), resourceContract("product", "product_repair", Editable, "list"), resourceContract("product", "product_owner", ReadOnly, "list"), + resourceContract("product", "producer_account", Writable, "list"), resourceContract("product", "product_type", Editable, "list"), resourceContract("product", "product_warehouse", Editable, "list"), resourceContract("product", "product_info", Editable, "list"), resourceContract("product", "product_repair", Editable, "list"), resourceContract("product", "product_owner", ReadOnly, "list"), resourceContract("product", "dev_usage_stat", Editable, "list"), resourceContract("ec", "ec_category", Writable, "list"), resourceContract("ec", "ec_product", Writable, "list"), resourceContract("ec", "ec_product_attribute", Writable, "list"), resourceContract("ec", "ec_product_image", Writable, "list"), resourceContract("ec", "ec_cart", ReadOnly, "list"), resourceContract("ec", "ec_order", ReadOnly, "list"), resourceContract("ec", "ec_order_item", ReadOnly, "list"), resourceContract("ec", "ec_review", ReadOnly, "list"), + resourceContract("ec", "deposit_policy", Writable, "list"), resourceContract("ec", "deposit_record", ReadOnly, "list"), resourceContract("ec", "deposit_return_request", ReadOnly, "list"), resourceContract("gasorder", "gasorder_contract", Managed, "list"), resourceContract("gasorder", "gasorder_contract_product", AppendOnly, "list"), resourceContract("gasorder", "gasorder_contract_revision", ReadOnly, "list"), resourceContract("gasorder", "gasorder_basic", AppendOnly, "list"), resourceContract("gasorder", "gasorder_item", ReadOnly, "list"), resourceContract("gasorder", "gasorder_assign", ReadOnly, "list"), resourceContract("gasorder", "gasorder_status", ReadOnly, "list"), resourceContract("gasorder", "gasorder_track", ReadOnly, "list"), resourceContract("gasorder", "gasorder_track_point", ReadOnly, "list"), resourceContract("gasorder", "gasorder_confirm", ReadOnly, "list"), resourceContract("gasorder", "gasorder_payment", ReadOnly, "list"), diff --git a/backend/api/internal/logic/upload/avatar.go b/backend/api/internal/logic/upload/avatar.go index 3828740..068d361 100644 --- a/backend/api/internal/logic/upload/avatar.go +++ b/backend/api/internal/logic/upload/avatar.go @@ -4,7 +4,9 @@ package upload import ( "bytes" + "crypto/sha256" "errors" + "fmt" "image" _ "image/jpeg" _ "image/png" @@ -30,6 +32,11 @@ const ( // UploadAvatar 接收 JPG/PNG 头像,验证真实图片内容后写入受控目录。 func UploadAvatar(ctx *gin.Context) { + claims, parseErr := sdkmiddleware.ParseAuth(ctx) + if parseErr != nil || claims.Identity == "" || claims.Client == "" { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxAvatarSize+(256<<10)) fileHeader, err := ctx.FormFile("file") if err != nil || fileHeader == nil || fileHeader.Size <= 0 || fileHeader.Size > maxAvatarSize { @@ -55,7 +62,8 @@ func UploadAvatar(ctx *gin.Context) { return } - datePath := time.Now().Format("2006/01/02") + // 将上传文件绑定到登录主体,后续资料更新不能引用其他账户的头像。 + datePath := avatarOwnerDirectory(claims.Client, claims.Identity) + "/" + time.Now().Format("2006/01/02") filename := models.NewIdentity() + extension directory := filepath.Join(uploadRoot(), "avatars", filepath.FromSlash(datePath)) if err := os.MkdirAll(directory, 0o750); err != nil { @@ -92,6 +100,25 @@ func UploadAvatar(ctx *gin.Context) { } } +// avatarOwnerDirectory 对客户端和账户组合取摘要,避免身份内容成为文件系统路径。 +func avatarOwnerDirectory(client, identity string) string { + return fmt.Sprintf("owners/%x", sha256.Sum256([]byte(client+"\x00"+identity))) +} + +// OwnsAvatar 验证规范 URI 属于当前账户且指向已上传的普通文件。 +func OwnsAvatar(client, identity, uri string) bool { + prefix := "/uploads/avatars/" + avatarOwnerDirectory(client, identity) + "/" + if !strings.HasPrefix(uri, prefix) || strings.Contains(uri, "\\") || strings.Contains(uri, "..") { + return false + } + path, err := avatarPathFromURI(uri) + if err != nil { + return false + } + info, err := os.Lstat(path) + return err == nil && info.Mode().IsRegular() +} + // ServeAvatar 仅从头像受控目录读取文件,拒绝外部 URL 与目录穿越路径。 func ServeAvatar(ctx *gin.Context, uri string) { path, err := avatarPathFromURI(uri) diff --git a/backend/api/internal/logic/upload/avatar_test.go b/backend/api/internal/logic/upload/avatar_test.go index ea917f3..4f1468b 100644 --- a/backend/api/internal/logic/upload/avatar_test.go +++ b/backend/api/internal/logic/upload/avatar_test.go @@ -7,6 +7,7 @@ import ( "image" "image/color" "image/png" + "os" "path/filepath" "strings" "testing" @@ -24,6 +25,33 @@ func pngBytes(t *testing.T, width, height int) []byte { return buffer.Bytes() } +// TestAvatarOwnership 拒绝跨用户、跨客户端及穿越伪造路径,兼容读取旧头像路径。 +func TestAvatarOwnership(t *testing.T) { + t.Setenv("HEQI_UPLOAD_DIR", t.TempDir()) + uri := "/uploads/avatars/" + avatarOwnerDirectory("user_app", "alice") + "/2026/09/07/avatar.png" + path, err := avatarPathFromURI(uri) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, pngBytes(t, 2, 2), 0o640); err != nil { + t.Fatal(err) + } + if !OwnsAvatar("user_app", "alice", uri) { + t.Fatal("当前用户上传资源未识别") + } + if OwnsAvatar("user_app", "bob", uri) || OwnsAvatar("platform_admin", "alice", uri) { + t.Fatal("允许跨账户引用") + } + for _, unsafe := range []string{uri + "/../avatar.png", strings.ReplaceAll(uri, "/", "\\"), "/uploads/avatars/2026/09/07/avatar.png"} { + if OwnsAvatar("user_app", "alice", unsafe) { + t.Fatalf("接受未绑定 URI:%s", unsafe) + } + } +} + // TestValidateAvatarAcceptsRealPNG 验证真实 PNG 可通过并规范化类型。 func TestValidateAvatarAcceptsRealPNG(t *testing.T) { extension, contentType, err := validateAvatar("头像.PNG", pngBytes(t, 2, 2)) diff --git a/backend/api/internal/logic/upload/product_image.go b/backend/api/internal/logic/upload/product_image.go new file mode 100644 index 0000000..7bf5e3c --- /dev/null +++ b/backend/api/internal/logic/upload/product_image.go @@ -0,0 +1,88 @@ +// 功能描述:后台商品图片上传及公开读取,独立于私有头像和工单照片;版本:1.0.0。 +package upload + +import ( + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + sdkmiddleware "git.apinb.com/bsm-sdk/core/middleware" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "io" + "net/http" + "os" + "path/filepath" + "regexp" +) + +var productImageName = regexp.MustCompile(`^[a-fA-F0-9-]{36}\.(jpg|png)$`) + +// UploadProductImage 仅平台后台上传公开商品素材,限制2MB和4096像素并完整解码。 +func UploadProductImage(ctx *gin.Context) { + claims, err := sdkmiddleware.ParseAuth(ctx) + if err != nil || claims.Client != "platform_admin" || claims.Identity == "" { + infra.Response.Error(ctx, errcode.ErrPermissionDenied) + return + } + ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxAvatarSize+(256<<10)) + header, err := ctx.FormFile("file") + if err != nil || header.Size <= 0 || header.Size > maxAvatarSize { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + file, err := header.Open() + if err != nil { + infra.Response.Error(ctx, err) + return + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, maxAvatarSize+1)) + if err != nil || int64(len(data)) > maxAvatarSize { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + ext, mime, err := validateAvatar(header.Filename, data) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + name := models.NewIdentity() + ext + directory := filepath.Join(uploadRoot(), "product-images") + if err := os.MkdirAll(directory, 0750); err != nil { + infra.Response.Error(ctx, err) + return + } + path := filepath.Join(directory, name) + target, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0640) + if err != nil { + infra.Response.Error(ctx, err) + return + } + _, writeErr := target.Write(data) + closeErr := target.Close() + if writeErr != nil || closeErr != nil { + _ = os.Remove(path) + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, UploadFileReply{URI: "/uploads/product-images/" + name, OriginalName: header.Filename, ContentType: mime, Size: int64(len(data))}) +} + +// ServeProductImage 仅公开独立商品图片目录内的普通文件,不暴露通用上传根目录。 +func ServeProductImage(ctx *gin.Context) { + name := ctx.Param("name") + if !productImageName.MatchString(name) { + ctx.Status(http.StatusNotFound) + return + } + path := filepath.Join(uploadRoot(), "product-images", name) + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() { + ctx.Status(http.StatusNotFound) + return + } + ctx.Header("X-Content-Type-Options", "nosniff") + // 普通img请求也必须按Origin区分缓存,避免无跨域头的缓存被Flutter跨域下载复用。 + ctx.Header("Vary", "Origin") + ctx.Header("Cache-Control", "public, max-age=86400") + ctx.File(path) +} diff --git a/backend/api/internal/logic/upload/product_image_test.go b/backend/api/internal/logic/upload/product_image_test.go new file mode 100644 index 0000000..6c2294d --- /dev/null +++ b/backend/api/internal/logic/upload/product_image_test.go @@ -0,0 +1,82 @@ +// 功能描述:商品图片上传读取闭环及权限边界;版本:1.0.0。 +package upload + +import ( + "bytes" + "encoding/json" + "git.apinb.com/bsm-sdk/core/types" + "github.com/gin-gonic/gin" + "mime/multipart" + "net/http/httptest" + "path/filepath" + "testing" +) + +// TestProductImageRoundTrip 验证后台上传后匿名图片读取字节一致,普通用户不能上传。 +func TestProductImageRoundTrip(t *testing.T) { + t.Setenv("HEQI_UPLOAD_DIR", t.TempDir()) + picture := pngBytes(t, 8, 8) + for _, client := range []string{"user_app", "platform_admin"} { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, _ := writer.CreateFormFile("file", "product.png") + _, _ = part.Write(picture) + _ = writer.Close() + response := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(response) + ctx.Set("Auth", &types.JwtClaims{Client: client, Identity: "test-admin"}) + ctx.Request = httptest.NewRequest("POST", "/upload/product-image", &body) + ctx.Request.Header.Set("Content-Type", writer.FormDataContentType()) + UploadProductImage(ctx) + var reply struct { + Code int `json:"code"` + Details json.RawMessage `json:"details"` + } + if err := json.Unmarshal(response.Body.Bytes(), &reply); err != nil { + t.Fatal(err) + } + if client == "user_app" { + if reply.Code == 0 { + t.Fatal("普通用户上传成功") + } + continue + } + if reply.Code != 0 { + t.Fatal(response.Body.String()) + } + var uploaded UploadFileReply + if err := json.Unmarshal(reply.Details, &uploaded); err != nil { + t.Fatal(err) + } + readResponse := httptest.NewRecorder() + readContext, _ := gin.CreateTestContext(readResponse) + readContext.Request = httptest.NewRequest("GET", uploaded.URI, nil) + readContext.Params = gin.Params{{Key: "name", Value: filepath.Base(uploaded.URI)}} + ServeProductImage(readContext) + if readResponse.Header().Get("Vary") != "Origin" { + t.Fatal("无Origin的图片请求也必须区分跨域缓存") + } + if readResponse.Code != 200 || !bytes.Equal(readResponse.Body.Bytes(), picture) { + t.Fatal("商品图片读取不一致") + } + if readResponse.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Fatal("缺少类型保护") + } + } +} + +// TestProductImageRejectsForeignPath 商品公开入口不能读取头像、工单或任意磁盘路径。 +func TestProductImageRejectsForeignPath(t *testing.T) { + t.Setenv("HEQI_UPLOAD_DIR", t.TempDir()) + for _, name := range []string{"../avatars/private.png", `..\ticket-photos\private.png`, "C:\\private.png", "photo.svg", ""} { + response := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(response) + ctx.Request = httptest.NewRequest("GET", "/uploads/product-images/invalid", nil) + ctx.Params = gin.Params{{Key: "name", Value: name}} + ServeProductImage(ctx) + ctx.Writer.WriteHeaderNow() + if response.Code != 404 || response.Body.Len() != 0 { + t.Fatalf("错误读取非商品资源: %q", name) + } + } +} diff --git a/backend/api/internal/logic/upload/ticket_photo.go b/backend/api/internal/logic/upload/ticket_photo.go new file mode 100644 index 0000000..b93f52d --- /dev/null +++ b/backend/api/internal/logic/upload/ticket_photo.go @@ -0,0 +1,114 @@ +// 功能描述:独立的报修照片受控存储,内容摘要使同一账户重复上传幂等。 +// 版本:1.0.0。 +package upload + +import ( + "crypto/sha256" + "fmt" + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "github.com/gin-gonic/gin" + "io" + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" +) + +var ticketPhotoName = regexp.MustCompile(`^[a-f0-9]{64}\.(jpg|png)$`) + +// ServeOwnedTicketPhoto 恢复尚未关联工单的照片,只解析当前账户目录内的摘要文件名。 +func ServeOwnedTicketPhoto(ctx *gin.Context, identity, name string) { + if !ticketPhotoName.MatchString(name) { + ctx.Status(http.StatusNotFound) + return + } + ServeTicketPhoto(ctx, identity, "/uploads/ticket-photos/"+avatarOwnerDirectory("user_app", identity)+"/"+name) +} + +// ticketPhotoPath 只接受当前账户目录中的摘要文件名,不允许用户指定路径。 +func ticketPhotoPath(identity, uri string) (string, bool) { + prefix := "/uploads/ticket-photos/" + avatarOwnerDirectory("user_app", identity) + "/" + name := strings.TrimPrefix(uri, prefix) + if !strings.HasPrefix(uri, prefix) || !ticketPhotoName.MatchString(name) { + return "", false + } + return filepath.Join(uploadRoot(), "ticket-photos", filepath.FromSlash(avatarOwnerDirectory("user_app", identity)), name), true +} + +// OwnsTicketPhoto 校验工单创建引用的资源归属和完整普通文件。 +func OwnsTicketPhoto(identity, uri string) bool { + path, ok := ticketPhotoPath(identity, uri) + if !ok { + return false + } + info, err := os.Lstat(path) + return err == nil && info.Mode().IsRegular() && info.Size() > 0 && info.Size() <= maxAvatarSize +} + +// UploadTicketPhoto 由用户Client鉴权入口调用,仅复用图片字节校验,不复用头像存储或权限。 +func UploadTicketPhoto(ctx *gin.Context, identity string) { + ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxAvatarSize+(256<<10)) + header, err := ctx.FormFile("file") + if err != nil || header.Size <= 0 || header.Size > maxAvatarSize { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + file, err := header.Open() + if err != nil { + infra.Response.Error(ctx, err) + return + } + defer file.Close() + content, err := io.ReadAll(io.LimitReader(file, maxAvatarSize+1)) + if err != nil || int64(len(content)) > maxAvatarSize { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + extension, contentType, err := validateAvatar(header.Filename, content) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + name := fmt.Sprintf("%x%s", sha256.Sum256(content), extension) + uri := "/uploads/ticket-photos/" + avatarOwnerDirectory("user_app", identity) + "/" + name + path, _ := ticketPhotoPath(identity, uri) + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + infra.Response.Error(ctx, err) + return + } + // 先完整写临时文件再原子移动,避免并发重试读到半张图片。 + temp, err := os.CreateTemp(filepath.Dir(path), ".pending-") + if err != nil { + infra.Response.Error(ctx, err) + return + } + defer os.Remove(temp.Name()) + _, writeErr := temp.Write(content) + closeErr := temp.Close() + if writeErr != nil || closeErr != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + if err := os.Rename(temp.Name(), path); err != nil && !OwnsTicketPhoto(identity, uri) { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, UploadFileReply{URI: uri, ContentType: contentType, Size: int64(len(content))}) + log.Printf("ticket photo upload account=%s digest=%s bytes=%d", identity, name, len(content)) +} + +// ServeTicketPhoto 在工单归属和证据关联已经校验后读取,禁止公开缓存和MIME嗅探。 +func ServeTicketPhoto(ctx *gin.Context, identity, uri string) { + if !OwnsTicketPhoto(identity, uri) { + ctx.Status(http.StatusNotFound) + return + } + path, _ := ticketPhotoPath(identity, uri) + ctx.Header("Cache-Control", "private, no-store") + ctx.Header("X-Content-Type-Options", "nosniff") + ctx.File(path) + log.Printf("ticket photo read account=%s digest=%s", identity, filepath.Base(path)) +} diff --git a/backend/api/internal/logic/upload/ticket_photo_test.go b/backend/api/internal/logic/upload/ticket_photo_test.go new file mode 100644 index 0000000..7305bd6 --- /dev/null +++ b/backend/api/internal/logic/upload/ticket_photo_test.go @@ -0,0 +1,119 @@ +// 功能描述:报修上传的内容幂等、格式、跨账户和路径边界回归。 +// 版本:1.0.0。 +package upload + +import ( + "bytes" + "encoding/json" + "github.com/gin-gonic/gin" + "mime/multipart" + "net/http/httptest" + "path" + "strings" + "testing" +) + +func uploadPhotoForTest(t *testing.T, owner, filename string, content []byte) (string, int) { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", filename) + if err != nil { + t.Fatal(err) + } + part.Write(content) + writer.Close() + response := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(response) + ctx.Request = httptest.NewRequest("POST", "/ticket-photos", &body) + ctx.Request.Header.Set("Content-Type", writer.FormDataContentType()) + UploadTicketPhoto(ctx, owner) + var result struct { + Code int `json:"code"` + Details json.RawMessage `json:"details"` + } + if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + var details struct { + URI string `json:"uri"` + } + if result.Code == 0 { + if err := json.Unmarshal(result.Details, &details); err != nil { + t.Fatal(err) + } + } + return details.URI, result.Code +} + +// TestDraftPhotoRead 验证草稿读取只能访问当前账户已上传的文件名。 +func TestDraftPhotoRead(t *testing.T) { + t.Setenv("HEQI_UPLOAD_DIR", t.TempDir()) + picture := pngBytes(t, 2, 2) + uri, code := uploadPhotoForTest(t, "alice", "image.png", picture) + if code != 0 { + t.Fatal("上传失败", code) + } + for _, scenario := range []struct { + owner, name string + status int + }{ + {"alice", path.Base(uri), 200}, + {"bob", path.Base(uri), 404}, + {"alice", "../" + path.Base(uri), 404}, + {"alice", "unknown.png", 404}, + } { + response := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(response) + ctx.Request = httptest.NewRequest("GET", "/ticket-photos/file", nil) + ServeOwnedTicketPhoto(ctx, scenario.owner, scenario.name) + ctx.Writer.WriteHeaderNow() + if response.Code != scenario.status { + t.Fatalf("%s: 状态 %d", scenario.owner, response.Code) + } + if scenario.status == 200 && !bytes.Equal(response.Body.Bytes(), picture) { + t.Fatal("图片内容不一致") + } + } +} + +func TestTicketPhotoStorage(t *testing.T) { + t.Setenv("HEQI_UPLOAD_DIR", t.TempDir()) + picture := pngBytes(t, 2, 2) + first, code := uploadPhotoForTest(t, "alice", "image.png", picture) + if code != 0 || !OwnsTicketPhoto("alice", first) { + t.Fatal("上传失败", code) + } + second, code := uploadPhotoForTest(t, "alice", "other.png", picture) + if code != 0 || first != second { + t.Fatal("重复上传未复用") + } + if OwnsTicketPhoto("bob", first) { + t.Fatal("跨账户引用") + } + for _, uri := range []string{first + "/../secret.png", strings.ReplaceAll(first, "/", "\\"), "https://example.com/a.png", "/uploads/avatars/a.png"} { + if OwnsTicketPhoto("alice", uri) { + t.Fatal("非法路径", uri) + } + } + for _, data := range [][]byte{[]byte("fake"), pngBytes(t, 4097, 1), make([]byte, (2<<20)+1)} { + if _, code := uploadPhotoForTest(t, "alice", "x.png", data); code == 0 { + t.Fatal("错误图片被接受") + } + } + response := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(response) + ctx.Request = httptest.NewRequest("GET", "/photo", nil) + ServeTicketPhoto(ctx, "alice", first) + if response.Code != 200 || response.Header().Get("Cache-Control") != "private, no-store" || !bytes.Equal(response.Body.Bytes(), picture) { + t.Fatal("读取或缓存保护失败") + } + response = httptest.NewRecorder() + ctx, _ = gin.CreateTestContext(response) + ctx.Request = httptest.NewRequest("GET", "/photo", nil) + ServeTicketPhoto(ctx, "bob", first) + ctx.Writer.WriteHeaderNow() + if response.Code != 404 { + t.Fatal("越权读取") + } +} diff --git a/backend/api/internal/models/cs_ticket.go b/backend/api/internal/models/cs_ticket.go index a1c865e..46d1d01 100644 --- a/backend/api/internal/models/cs_ticket.go +++ b/backend/api/internal/models/cs_ticket.go @@ -7,23 +7,27 @@ import ( // CsTicket 对应 cs_ticket,保存客服工单。 type CsTicket struct { - Entity // 公共实体字段 - TicketStatus int `gorm:"column:ticket_status;not null;default:32;index" json:"ticket_status"` // 工单业务状态 - TicketNo string `gorm:"column:ticket_no;type:varchar(64);not null;uniqueIndex" json:"ticket_no"` // ticket_no 业务字段 - RequestNo string `gorm:"column:request_no;type:varchar(128);not null;default:'';uniqueIndex:,where:request_no <> ''" json:"request_no"` // 创建幂等号 - UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段 - StaffAccountID uint64 `gorm:"column:staff_account_id;not null;default:0;index" json:"staff_account_id"` // 分派工作人员内部主键 - GasBasicID uint64 `gorm:"column:gas_basic_id;not null;default:0;index" json:"gas_basic_id"` // 服务气站内部主键 - DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"` // 关联配送点内部主键 - Category string `gorm:"column:category;type:varchar(64);not null" json:"category"` // category 业务字段 - Priority string `gorm:"column:priority;type:varchar(16);not null;default:'normal'" json:"priority"` // priority 业务字段 - Description string `gorm:"column:description;type:text;not null;default:''" json:"description"` // 用户问题描述 - Address string `gorm:"column:address;type:varchar(255);not null;default:''" json:"address"` // 上门地址快照 - AppointmentAt *time.Time `gorm:"column:appointment_at;type:timestamptz;index" json:"appointment_at"` // 预约服务时间 - StartedAt *time.Time `gorm:"column:started_at;type:timestamptz" json:"started_at"` // 开始处理时间 - CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"` // 用户确认完成时间 - Result string `gorm:"column:result;type:text;not null;default:''" json:"result"` // 工作人员处理结果 - OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;default:'';index" json:"operator_identity"` // 最近操作人业务标识 + Entity // 公共实体字段 + TicketStatus int `gorm:"column:ticket_status;not null;default:32;index" json:"ticket_status"` // 工单业务状态 + TicketNo string `gorm:"column:ticket_no;type:varchar(64);not null;uniqueIndex" json:"ticket_no"` // ticket_no 业务字段 + RequestNo string `gorm:"column:request_no;type:varchar(128);not null;default:'';uniqueIndex:,where:request_no <> ''" json:"request_no"` // 创建幂等号 + UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段 + GasorderContractID uint64 `gorm:"column:gasorder_contract_id;not null;default:0;index" json:"gasorder_contract_id"` // 关联供气合同内部主键,0表示非合同申请;由用户端归属校验后写入 + StaffAccountID uint64 `gorm:"column:staff_account_id;not null;default:0;index" json:"staff_account_id"` // 分派工作人员内部主键 + GasBasicID uint64 `gorm:"column:gas_basic_id;not null;default:0;index" json:"gas_basic_id"` // 服务气站内部主键 + DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"` // 关联配送点内部主键 + Category string `gorm:"column:category;type:varchar(64);not null" json:"category"` // category 业务字段 + Priority string `gorm:"column:priority;type:varchar(16);not null;default:'normal'" json:"priority"` // priority 业务字段 + Description string `gorm:"column:description;type:text;not null;default:''" json:"description"` // 用户问题描述 + FaultType string `gorm:"column:fault_type;type:varchar(32);not null;default:''" json:"fault_type"` // 故障类型:leak泄漏、valve阀门、alarm报警器、other其他;历史为空 + ContactName string `gorm:"column:contact_name;type:varchar(64);not null;default:''" json:"contact_name"` // 提交时联系人快照 + ContactPhone string `gorm:"column:contact_phone;type:varchar(32);not null;default:''" json:"contact_phone"` // 提交时联系电话快照 + Address string `gorm:"column:address;type:varchar(255);not null;default:''" json:"address"` // 上门地址快照 + AppointmentAt *time.Time `gorm:"column:appointment_at;type:timestamptz;index" json:"appointment_at"` // 预约服务时间 + StartedAt *time.Time `gorm:"column:started_at;type:timestamptz" json:"started_at"` // 开始处理时间 + CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"` // 用户确认完成时间 + Result string `gorm:"column:result;type:text;not null;default:''" json:"result"` // 工作人员处理结果 + OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;default:'';index" json:"operator_identity"` // 最近操作人业务标识 } func init() { database.AppendMigrate(&CsTicket{}) } diff --git a/backend/api/internal/models/deposit_policy.go b/backend/api/internal/models/deposit_policy.go new file mode 100644 index 0000000..c44cea8 --- /dev/null +++ b/backend/api/internal/models/deposit_policy.go @@ -0,0 +1,16 @@ +// 功能描述:气瓶押金规则与运营说明;版本:1.0.0。 +package models + +import "git.apinb.com/bsm-sdk/core/database" + +// DepositPolicy 保存气瓶类型对应的押金金额,金额单位为分。 +type DepositPolicy struct { + Entity + ProductTypeID uint64 `gorm:"column:product_type_id;not null;uniqueIndex;comment:适用产品类型内部主键" json:"product_type_id"` + Name string `gorm:"column:name;type:varchar(128);not null;comment:押金规则名称" json:"name"` + Amount int64 `gorm:"column:amount;not null;check:amount >= 0;comment:单只气瓶押金金额,单位分" json:"amount"` + RuleText string `gorm:"column:rule_text;type:text;not null;default:'';comment:用户端展示的押金退还规则正文" json:"rule_text"` +} + +func init() { database.AppendMigrate(&DepositPolicy{}) } +func (table *DepositPolicy) TableName() string { return "deposit_policy" } diff --git a/backend/api/internal/models/deposit_record.go b/backend/api/internal/models/deposit_record.go new file mode 100644 index 0000000..d38359f --- /dev/null +++ b/backend/api/internal/models/deposit_record.go @@ -0,0 +1,25 @@ +// 功能描述:用户气瓶押金事实及退款状态;版本:1.0.0。 +package models + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) + +// DepositRecord 记录每只气瓶独立押金,不以钱包余额推算押金。 +type DepositRecord struct { + Entity + DepositStatus int `gorm:"column:deposit_status;not null;default:10;index;comment:押金状态:10=使用中,20=退款中,23=已退回" json:"deposit_status"` + DepositNo string `gorm:"column:deposit_no;type:varchar(64);not null;uniqueIndex;comment:押金业务编号" json:"deposit_no"` + UserAccountID uint64 `gorm:"column:user_account_id;not null;index;comment:押金所属用户内部主键" json:"user_account_id"` + ProductInfoID uint64 `gorm:"column:product_info_id;not null;index;comment:绑定实体气瓶内部主键" json:"product_info_id"` + PolicyID uint64 `gorm:"column:policy_id;not null;index;comment:计费时采用的押金规则内部主键" json:"policy_id"` + GasorderID uint64 `gorm:"column:gasorder_id;not null;default:0;index;comment:来源供气订单内部主键,0表示历史导入" json:"gasorder_id"` + Amount int64 `gorm:"column:amount;not null;check:amount >= 0;comment:实收押金金额,单位分" json:"amount"` + PaidAt time.Time `gorm:"column:paid_at;type:timestamptz;not null;comment:押金缴纳时间" json:"paid_at"` + RefundedAt *time.Time `gorm:"column:refunded_at;type:timestamptz;comment:押金实际退回时间" json:"refunded_at"` +} + +func init() { database.AppendMigrate(&DepositRecord{}) } +func (table *DepositRecord) TableName() string { return "deposit_record" } diff --git a/backend/api/internal/models/deposit_return_request.go b/backend/api/internal/models/deposit_return_request.go new file mode 100644 index 0000000..783e9ae --- /dev/null +++ b/backend/api/internal/models/deposit_return_request.go @@ -0,0 +1,36 @@ +// 功能描述:记录退瓶上门回收、验收扣减和押金退回闭环;版本:1.0.0。 +package models + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) + +// DepositReturnRequest 以独立状态机保留用户申请和平台验收事实。 +type DepositReturnRequest struct { + Entity + ReturnStatus int `gorm:"column:return_status;not null;default:10;index;comment:退瓶状态:10=待上门,20=已回收待验收,30=已验收待退款,40=已完成,50=已取消" json:"return_status"` + RequestNo string `gorm:"column:request_no;type:varchar(128);not null;uniqueIndex;comment:用户端幂等请求号" json:"request_no"` + DepositRecordID uint64 `gorm:"column:deposit_record_id;not null;uniqueIndex;comment:对应押金记录内部主键" json:"deposit_record_id"` + UserAccountID uint64 `gorm:"column:user_account_id;not null;index;comment:申请用户内部主键" json:"user_account_id"` + UserAddressID uint64 `gorm:"column:user_address_id;not null;comment:上门回收地址内部主键" json:"user_address_id"` + AddressSnapshot string `gorm:"column:address_snapshot;type:varchar(255);not null;comment:申请时上门地址快照" json:"address_snapshot"` + ContactName string `gorm:"column:contact_name;type:varchar(64);not null;comment:申请时联系人快照" json:"contact_name"` + ContactPhone string `gorm:"column:contact_phone;type:varchar(32);not null;comment:申请时联系手机快照" json:"contact_phone"` + AppointmentStart time.Time `gorm:"column:appointment_start;type:timestamptz;not null;comment:预约上门时段开始时间" json:"appointment_start"` + AppointmentEnd time.Time `gorm:"column:appointment_end;type:timestamptz;not null;comment:预约上门时段结束时间" json:"appointment_end"` + EstimatedAmount int64 `gorm:"column:estimated_amount;not null;check:estimated_amount >= 0;comment:申请时预计可退押金,单位分" json:"estimated_amount"` + DeductionAmount int64 `gorm:"column:deduction_amount;not null;default:0;check:deduction_amount >= 0;comment:验收后损坏或缺件扣减,单位分" json:"deduction_amount"` + RefundAmount int64 `gorm:"column:refund_amount;not null;default:0;check:refund_amount >= 0;comment:最终退入余额账户金额,单位分" json:"refund_amount"` + InspectionRemark string `gorm:"column:inspection_remark;type:text;not null;default:'';comment:验收结果及扣减理由" json:"inspection_remark"` + OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;default:'';comment:最后操作的平台账号标识" json:"operator_identity"` + PickedUpAt *time.Time `gorm:"column:picked_up_at;type:timestamptz;comment:实际回收时间" json:"picked_up_at"` + InspectedAt *time.Time `gorm:"column:inspected_at;type:timestamptz;comment:完成验收时间" json:"inspected_at"` + CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz;comment:押金退回完成时间" json:"completed_at"` +} + +func init() { database.AppendMigrate(&DepositReturnRequest{}) } + +// TableName 返回稳定表名。 +func (*DepositReturnRequest) TableName() string { return "deposit_return_request" } diff --git a/backend/api/internal/models/dev_usage_stat.go b/backend/api/internal/models/dev_usage_stat.go new file mode 100644 index 0000000..10a4978 --- /dev/null +++ b/backend/api/internal/models/dev_usage_stat.go @@ -0,0 +1,25 @@ +// 功能:保存设备的日用气统计事实,供用户端查询与平台维护;版本:1.0.0。 +package models + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) + +// DevUsageStat 对应dev_usage_stat,每条记录表示单个设备一个自然日的已校验用气量。 +type DevUsageStat struct { + Entity // 公共实体字段 + ProductInfoID uint64 `gorm:"column:product_info_id;not null;index;uniqueIndex:ux_dev_usage_stat_day;comment:统计所属实体设备内部主键" json:"product_info_id"` // 实体设备内部主键 + StatDate time.Time `gorm:"column:stat_date;type:date;not null;uniqueIndex:ux_dev_usage_stat_day;comment:统计日期,按用户服务时区划分" json:"stat_date"` // 统计自然日 + UsageKg float64 `gorm:"column:usage_kg;type:numeric(12,3);not null;default:0;comment:当日用气量,单位千克,不得为负数" json:"usage_kg"` // 当日用气量 + BreakfastKg float64 `gorm:"column:breakfast_kg;type:numeric(12,3);not null;default:0;comment:早餐时段用气量,单位千克" json:"breakfast_kg"` // 早餐时段用气量 + LunchKg float64 `gorm:"column:lunch_kg;type:numeric(12,3);not null;default:0;comment:午餐时段用气量,单位千克" json:"lunch_kg"` // 午餐时段用气量 + DinnerKg float64 `gorm:"column:dinner_kg;type:numeric(12,3);not null;default:0;comment:晚餐时段用气量,单位千克" json:"dinner_kg"` // 晚餐时段用气量 + Source string `gorm:"column:source;type:varchar(32);not null;comment:数据来源:meter=计量表,device=设备上报,manual=人工导入" json:"source"` // 统计数据来源 + CalcVersion string `gorm:"column:calc_version;type:varchar(32);not null;comment:生成该统计的计算口径版本" json:"calc_version"` // 计算口径版本 + CalculatedAt time.Time `gorm:"column:calculated_at;type:timestamptz;not null;comment:统计计算完成时间" json:"calculated_at"` // 统计计算时间 +} + +func init() { database.AppendMigrate(&DevUsageStat{}) } +func (table *DevUsageStat) TableName() string { return "dev_usage_stat" } diff --git a/backend/api/internal/models/ec_favorite.go b/backend/api/internal/models/ec_favorite.go new file mode 100644 index 0000000..e5b3dcb --- /dev/null +++ b/backend/api/internal/models/ec_favorite.go @@ -0,0 +1,17 @@ +// 功能描述:电商商品收藏关系与并发版本;版本:1.0.0。 +package models + +import "git.apinb.com/bsm-sdk/core/database" + +// EcFavorite 保存每个账户对商品的唯一收藏关系;取消收藏归档,商品下架保留关系。 +type EcFavorite struct { + Entity // 公开标识、创建更新时间及归档状态。 + UserAccountID uint64 `gorm:"column:user_account_id;not null;uniqueIndex:ux_ec_favorite_owner_product" json:"user_account_id"` // 收藏账户内部关联键。 + EcProductID uint64 `gorm:"column:ec_product_id;not null;uniqueIndex:ux_ec_favorite_owner_product" json:"ec_product_id"` // 收藏商品内部关联键。 + Revision uint64 `gorm:"column:revision;not null;default:1" json:"revision"` // 每次状态变化递增,用于拒绝旧请求覆盖。 +} + +func init() { database.AppendMigrate(&EcFavorite{}) } + +// TableName 返回收藏关系的唯一表名。 +func (*EcFavorite) TableName() string { return "ec_favorite" } diff --git a/backend/api/internal/models/gasorder_basic.go b/backend/api/internal/models/gasorder_basic.go index 4351129..6960285 100644 --- a/backend/api/internal/models/gasorder_basic.go +++ b/backend/api/internal/models/gasorder_basic.go @@ -1,34 +1,41 @@ package models -import "git.apinb.com/bsm-sdk/core/database" +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) // GasorderBasic 对应 gasorder_basic,保存气体配送订单当前快照。 type GasorderBasic struct { - Entity // 公共实体字段 - OrderStatus int `gorm:"column:order_status;not null;default:16;index" json:"order_status"` // 订单业务状态 - OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex" json:"order_no"` // 订单编号 - RequestNo string `gorm:"column:request_no;type:varchar(128);not null;uniqueIndex" json:"request_no"` // 创建幂等号 - GasorderContractID uint64 `gorm:"column:gasorder_contract_id;not null;index" json:"gasorder_contract_id"` // 合同自增主键 - UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // 服务用户自增主键 - CreatorType string `gorm:"column:creator_type;type:varchar(32);not null" json:"creator_type"` // 业务创建方类型 - CreatorID uint64 `gorm:"column:creator_id;not null;default:0;index" json:"creator_id"` // 业务创建方自增主键 - CreatorIdentity string `gorm:"column:creator_identity;type:varchar(36);not null;index" json:"creator_identity"` // 业务创建方标识 - GasBasicID uint64 `gorm:"column:gas_basic_id;not null;index" json:"gas_basic_id"` // 履约气站自增主键 - DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"` // 当前配送点自增主键 - StaffAccountID uint64 `gorm:"column:staff_account_id;not null;default:0;index" json:"staff_account_id"` // 当前配送人员自增主键 - Address string `gorm:"column:address;type:varchar(255);not null" json:"address"` // 配送地址快照 - Longitude string `gorm:"column:longitude;type:varchar(32);not null;default:''" json:"longitude"` // 配送经度快照 - Latitude string `gorm:"column:latitude;type:varchar(32);not null;default:''" json:"latitude"` // 配送纬度快照 - ContactName string `gorm:"column:contact_name;type:varchar(64);not null" json:"contact_name"` // 联系人快照 - ContactPhone string `gorm:"column:contact_phone;type:varchar(32);not null" json:"contact_phone"` // 联系电话快照 - ProductAmount int64 `gorm:"column:product_amount;not null;check:product_amount >= 0" json:"product_amount"` // 商品金额,单位分 - DeliveryFee int64 `gorm:"column:delivery_fee;not null;default:0;check:delivery_fee >= 0" json:"delivery_fee"` // 配送费,单位分 - DiscountAmount int64 `gorm:"column:discount_amount;not null;default:0;check:discount_amount >= 0" json:"discount_amount"` // 优惠金额,单位分 - PayableAmount int64 `gorm:"column:payable_amount;not null;check:payable_amount > 0" json:"payable_amount"` // 应付金额,单位分 - PreviousOrderStatus int `gorm:"column:previous_order_status;not null;default:0" json:"previous_order_status"` // 异常前订单状态 - OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;default:''" json:"operator_identity"` // 建单操作人标识 - OperatorName string `gorm:"column:operator_name;type:varchar(64);not null;default:''" json:"operator_name"` // 建单操作人姓名快照 - Remark string `gorm:"column:remark;type:text;not null;default:''" json:"remark"` // 订单备注 + Entity // 公共实体字段 + OrderStatus int `gorm:"column:order_status;not null;default:16;index" json:"order_status"` // 订单业务状态 + OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex" json:"order_no"` // 订单编号 + RequestNo string `gorm:"column:request_no;type:varchar(128);not null;uniqueIndex" json:"request_no"` // 创建幂等号 + GasorderContractID uint64 `gorm:"column:gasorder_contract_id;not null;index" json:"gasorder_contract_id"` // 合同自增主键 + UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // 服务用户自增主键 + CreatorType string `gorm:"column:creator_type;type:varchar(32);not null" json:"creator_type"` // 业务创建方类型 + CreatorID uint64 `gorm:"column:creator_id;not null;default:0;index" json:"creator_id"` // 业务创建方自增主键 + CreatorIdentity string `gorm:"column:creator_identity;type:varchar(36);not null;index" json:"creator_identity"` // 业务创建方标识 + GasBasicID uint64 `gorm:"column:gas_basic_id;not null;index" json:"gas_basic_id"` // 履约气站自增主键 + DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"` // 当前配送点自增主键 + StaffAccountID uint64 `gorm:"column:staff_account_id;not null;default:0;index" json:"staff_account_id"` // 当前配送人员自增主键 + UserAddressID uint64 `gorm:"column:user_address_id;not null;default:0;index;comment:用户下单时选择的地址内部主键" json:"user_address_id"` // 下单地址内部主键 + Address string `gorm:"column:address;type:varchar(255);not null" json:"address"` // 配送地址快照 + Longitude string `gorm:"column:longitude;type:varchar(32);not null;default:''" json:"longitude"` // 配送经度快照 + Latitude string `gorm:"column:latitude;type:varchar(32);not null;default:''" json:"latitude"` // 配送纬度快照 + ContactName string `gorm:"column:contact_name;type:varchar(64);not null" json:"contact_name"` // 联系人快照 + ContactPhone string `gorm:"column:contact_phone;type:varchar(32);not null" json:"contact_phone"` // 联系电话快照 + AppointmentAt time.Time `gorm:"column:appointment_at;type:timestamptz;not null;default:CURRENT_TIMESTAMP;comment:用户选择的预约配送开始时间" json:"appointment_at"` // 预约配送开始时间 + ProductAmount int64 `gorm:"column:product_amount;not null;check:product_amount >= 0" json:"product_amount"` // 商品金额,单位分 + DepositAmount int64 `gorm:"column:deposit_amount;not null;default:0;check:deposit_amount >= 0;comment:本单待收气瓶押金合计,单位分" json:"deposit_amount"` // 本单押金合计,单位分 + DeliveryFee int64 `gorm:"column:delivery_fee;not null;default:0;check:delivery_fee >= 0" json:"delivery_fee"` // 配送费,单位分 + DiscountAmount int64 `gorm:"column:discount_amount;not null;default:0;check:discount_amount >= 0" json:"discount_amount"` // 优惠金额,单位分 + PayableAmount int64 `gorm:"column:payable_amount;not null;check:payable_amount > 0" json:"payable_amount"` // 应付金额,单位分 + PreviousOrderStatus int `gorm:"column:previous_order_status;not null;default:0" json:"previous_order_status"` // 异常前订单状态 + OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;default:''" json:"operator_identity"` // 建单操作人标识 + OperatorName string `gorm:"column:operator_name;type:varchar(64);not null;default:''" json:"operator_name"` // 建单操作人姓名快照 + Remark string `gorm:"column:remark;type:text;not null;default:''" json:"remark"` // 订单备注 } func init() { database.AppendMigrate(&GasorderBasic{}) } diff --git a/backend/api/internal/models/gasorder_confirm.go b/backend/api/internal/models/gasorder_confirm.go index 527b8d9..b0ea8d7 100644 --- a/backend/api/internal/models/gasorder_confirm.go +++ b/backend/api/internal/models/gasorder_confirm.go @@ -11,7 +11,7 @@ type GasorderConfirm struct { Entity // 公共实体字段 GasorderBasicID uint64 `gorm:"column:gasorder_basic_id;not null;uniqueIndex" json:"gasorder_basic_id"` // 订单自增主键 RequestNo string `gorm:"column:request_no;type:varchar(128);not null;default:'';uniqueIndex:,where:request_no <> ''" json:"request_no"` // 客户端提交幂等号 - ConfirmType string `gorm:"column:confirm_type;type:varchar(32);not null" json:"confirm_type"` // 签收类型 + ConfirmType string `gorm:"column:confirm_type;type:varchar(32);not null" json:"confirm_type"` // 签收类型:signature签名、receipt_code签收码、user_app本人认证确认;平台保留自定义类型 RecipientName string `gorm:"column:recipient_name;type:varchar(64);not null" json:"recipient_name"` // 签收人姓名快照 RecipientPhone string `gorm:"column:recipient_phone;type:varchar(32);not null;default:''" json:"recipient_phone"` // 签收人手机号快照 ProofURI string `gorm:"column:proof_uri;type:varchar(512);not null;default:''" json:"proof_uri"` // 签名或凭证地址 diff --git a/backend/api/internal/models/gasorder_deposit.go b/backend/api/internal/models/gasorder_deposit.go new file mode 100644 index 0000000..575c6ec --- /dev/null +++ b/backend/api/internal/models/gasorder_deposit.go @@ -0,0 +1,17 @@ +// 功能描述:保存供气订单创建时锁定的押金规则快照;版本:1.0.0。 +package models + +import "git.apinb.com/bsm-sdk/core/database" + +// GasorderDeposit 在订单支付后转换为正式押金事实,防止计费规则在支付期间变化。 +type GasorderDeposit struct { + Entity + DepositStatus int `gorm:"column:deposit_status;not null;default:10;index;comment:订单押金状态:10=待支付,22=已取消,23=已转正式押金" json:"deposit_status"` + GasorderBasicID uint64 `gorm:"column:gasorder_basic_id;not null;index;uniqueIndex:idx_gasorder_deposit_product;comment:供气订单内部主键" json:"gasorder_basic_id"` + ProductInfoID uint64 `gorm:"column:product_info_id;not null;index;uniqueIndex:idx_gasorder_deposit_product;comment:实体气瓶内部主键" json:"product_info_id"` + PolicyID uint64 `gorm:"column:policy_id;not null;index;comment:下单时采用的押金规则内部主键" json:"policy_id"` + Amount int64 `gorm:"column:amount;not null;check:amount >= 0;comment:下单时锁定的押金金额,单位分" json:"amount"` +} + +func init() { database.AppendMigrate(&GasorderDeposit{}) } +func (table *GasorderDeposit) TableName() string { return "gasorder_deposit" } diff --git a/backend/api/internal/models/product_info.go b/backend/api/internal/models/product_info.go index 17ebc5b..88c1dc8 100644 --- a/backend/api/internal/models/product_info.go +++ b/backend/api/internal/models/product_info.go @@ -1,3 +1,4 @@ +// 功能:实体产品档案、当前归属及明确的厂商设备映射;版本:1.1.0。 package models import ( @@ -9,18 +10,21 @@ import ( // ProductInfo 对应 product_info,保存一物一码的实体产品档案。 type ProductInfo struct { Entity // 公共实体字段 - ProductStatus int `gorm:"column:product_status;not null;default:10;index" json:"product_status"` // 产品业务状态 - Code string `gorm:"column:code;type:varchar(64);not null;uniqueIndex" json:"code"` // 产品唯一标识 - Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // 产品名称 - ProducerAccountID uint64 `gorm:"column:producer_account_id;not null;default:0;index" json:"producer_account_id"` // 生产商自增主键;历史数据迁移前可为 0,新建和编辑必须关联有效生产商 - ProductTypeID uint64 `gorm:"column:product_type_id;not null;index" json:"product_type_id"` // 产品类型自增主键 - Params string `gorm:"column:params;type:text;not null;default:'{}'" json:"params"` // 产品参数 JSON 对象文本 - WarehouseID uint64 `gorm:"column:warehouse_id;not null;default:0;index" json:"warehouse_id"` // 当前实际库房自增主键 - GasBasicID uint64 `gorm:"column:gas_basic_id;not null;default:0;index" json:"gas_basic_id"` // 当前归属气站自增主键 - DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"` // 当前归属配送站自增主键 - UserAccountID uint64 `gorm:"column:user_account_id;not null;default:0;index" json:"user_account_id"` // 当前归属用户自增主键 - ProducedAt time.Time `gorm:"column:produced_at;type:timestamptz;not null" json:"produced_at"` // 生产时间 - EnabledAt *time.Time `gorm:"column:enabled_at;type:timestamptz" json:"enabled_at"` // 首次启用时间 + ProductStatus int `gorm:"column:product_status;not null;default:10;index" json:"product_status"` // 产品业务状态 + Code string `gorm:"column:code;type:varchar(64);not null;uniqueIndex" json:"code"` // 产品唯一标识 + Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // 产品名称 + DeviceKind string `gorm:"column:device_kind;type:varchar(16);not null;default:'unknown';comment:设备分类:unknown=未分类,valve=智能阀,alarm=报警器,cylinder=钢瓶" json:"device_kind"` // 由后台明确分类,不按名称猜测 + VendorDeviceID string `gorm:"column:vendor_device_id;type:varchar(16);not null;default:'';comment:厂商16位十进制设备编号;空表示未建立物联网映射" json:"vendor_device_id"` // 平台档案到厂商设备的映射 + ProducerAccountID uint64 `gorm:"column:producer_account_id;not null;default:0;index" json:"producer_account_id"` // 生产商自增主键;历史数据迁移前可为 0,新建和编辑必须关联有效生产商 + ProductTypeID uint64 `gorm:"column:product_type_id;not null;index" json:"product_type_id"` // 产品类型自增主键 + Params string `gorm:"column:params;type:text;not null;default:'{}'" json:"params"` // 产品参数 JSON 对象文本 + WarehouseID uint64 `gorm:"column:warehouse_id;not null;default:0;index" json:"warehouse_id"` // 当前实际库房自增主键 + GasBasicID uint64 `gorm:"column:gas_basic_id;not null;default:0;index" json:"gas_basic_id"` // 当前归属气站自增主键 + DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"` // 当前归属配送站自增主键 + UserAccountID uint64 `gorm:"column:user_account_id;not null;default:0;index" json:"user_account_id"` // 当前归属用户自增主键 + DeviceGroupID uint64 `gorm:"column:device_group_id;not null;default:0;index;comment:当前用户设备分组内部主键;0表示未分组,产品归属变化时清空" json:"-"` // 当前用户自定义分组,不对平台资源接口公开 + ProducedAt time.Time `gorm:"column:produced_at;type:timestamptz;not null" json:"produced_at"` // 生产时间 + EnabledAt *time.Time `gorm:"column:enabled_at;type:timestamptz" json:"enabled_at"` // 首次启用时间 } func init() { database.AppendMigrate(&ProductInfo{}) } diff --git a/backend/api/internal/models/user_address.go b/backend/api/internal/models/user_address.go index 5090f9e..a92a0a0 100644 --- a/backend/api/internal/models/user_address.go +++ b/backend/api/internal/models/user_address.go @@ -1,3 +1,5 @@ +// 功能描述:保存用户收货地址及独立联系人,兼容历史未填写联系人记录。 +// 版本:1.1.0。 package models import "git.apinb.com/bsm-sdk/core/database" @@ -5,6 +7,9 @@ import "git.apinb.com/bsm-sdk/core/database" // UserAddress 对应 user_address,保存用户地址。 type UserAddress struct { Entity // 公共实体字段 + ContactName string `gorm:"column:contact_name;type:varchar(64);not null;default:'';comment:收货联系人姓名" json:"contact_name"` // 收货联系人姓名 + ContactPhone string `gorm:"column:contact_phone;type:varchar(32);not null;default:'';comment:收货联系人手机号" json:"contact_phone"` // 收货联系人手机号 + RequestNo string `gorm:"column:request_no;type:varchar(64);not null;default:'';comment:客户端新增请求幂等标识,历史记录为空" json:"-"` // 新增地址请求幂等标识,不对外返回 UserAccountID uint64 `gorm:"column:user_account_id;not null;index;uniqueIndex:idx_default_user_address,where:is_default = true" json:"user_account_id"` // user_account_id 业务字段 Address string `gorm:"column:address;type:varchar(255);not null" json:"address"` // address 业务字段 Longitude string `gorm:"column:longitude;type:varchar(32);not null;default:''" json:"longitude"` // longitude 业务字段 diff --git a/backend/api/internal/models/user_device_group.go b/backend/api/internal/models/user_device_group.go new file mode 100644 index 0000000..337fa09 --- /dev/null +++ b/backend/api/internal/models/user_device_group.go @@ -0,0 +1,14 @@ +// 功能:保存用户自定义设备分组,设备归组关系由产品档案记录;版本:1.0.0。 +package models + +// UserDeviceGroup 是本人设备的展示分组,不承载物联网控制权限。 +type UserDeviceGroup struct { + Entity // 公共实体字段 + UserAccountID uint64 `gorm:"column:user_account_id;not null" json:"-"` // 分组所属用户内部主键 + Name string `gorm:"column:name;type:varchar(32);not null" json:"name"` // 用户自定义分组名称 + RequestNo string `gorm:"column:request_no;type:varchar(36);not null" json:"-"` // 新增请求幂等标识 + SortNo int `gorm:"column:sort_no;not null;default:0" json:"sort_no"` // 同一用户内的显示顺序 +} + +// TableName 返回独立设备分组表,不注册全库自动迁移。 +func (*UserDeviceGroup) TableName() string { return "user_device_group" } diff --git a/backend/api/internal/models/user_emergency_contact.go b/backend/api/internal/models/user_emergency_contact.go new file mode 100644 index 0000000..8eb0e64 --- /dev/null +++ b/backend/api/internal/models/user_emergency_contact.go @@ -0,0 +1,15 @@ +// 功能:保存本人紧急联系人资料,不隐含设备授权或通知权限;版本:1.0.0。 +package models + +// UserEmergencyContact 联系人最多五位;归档保留幂等标识以防重试复活。 +type UserEmergencyContact struct { + Entity // 公共实体字段 + UserAccountID uint64 `gorm:"column:user_account_id;not null"` // 联系人资料所属用户内部主键 + Name string `gorm:"column:name;type:varchar(64);not null"` // 联系人姓名 + Phone string `gorm:"column:phone;type:varchar(32);not null"` // 联系人手机号 + Relationship string `gorm:"column:relationship;type:varchar(32);not null"` // 与当前用户的关系描述 + RequestNo string `gorm:"column:request_no;type:varchar(36);not null"` // 新增请求幂等标识 +} + +// TableName 返回独立用户资料表,不注册全库自动迁移。 +func (*UserEmergencyContact) TableName() string { return "user_emergency_contact" } diff --git a/backend/api/internal/models/user_family_member.go b/backend/api/internal/models/user_family_member.go new file mode 100644 index 0000000..0f83f1b --- /dev/null +++ b/backend/api/internal/models/user_family_member.go @@ -0,0 +1,45 @@ +// 功能:家庭成员邀请、接受状态和设备共享授权;版本:1.0.0。 +package models + +import "time" + +// UserFamilyMember 保存房主发出的成员邀请,成员确认后才建立家庭关系。 +type UserFamilyMember struct { + Entity + OwnerUserAccountID uint64 `gorm:"column:owner_user_account_id;not null;index" json:"-"` // 房主用户内部主键 + MemberUserAccountID uint64 `gorm:"column:member_user_account_id;not null;default:0;index" json:"-"` // 接受邀请的成员用户内部主键,0表示尚未接受 + Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` // 邀请时填写的成员称呼 + Phone string `gorm:"column:phone;type:varchar(32);not null;index" json:"-"` // 受邀手机号,仅向房主或本人脱敏返回 + Relationship string `gorm:"column:relationship;type:varchar(32);not null;default:''" json:"relationship"` // 与房主关系 + InviteStatus int `gorm:"column:invite_status;not null;default:10;index" json:"invite_status"` // 邀请状态:10=待确认,20=已接受,30=已拒绝,40=已撤销 + RequestNo string `gorm:"column:request_no;type:varchar(36);not null" json:"-"` // 邀请幂等请求号 + ExpiresAt time.Time `gorm:"column:expires_at;type:timestamptz;not null" json:"expires_at"` // 邀请失效时间 + AcceptedAt *time.Time `gorm:"column:accepted_at;type:timestamptz" json:"accepted_at"` // 成员接受时间 + RevokedAt *time.Time `gorm:"column:revoked_at;type:timestamptz" json:"revoked_at"` // 房主撤销时间 +} + +func (*UserFamilyMember) TableName() string { return "user_family_member" } + +// UserDeviceShare 保存单个家庭成员对单个设备的最小授权范围。 +type UserDeviceShare struct { + Entity + FamilyMemberID uint64 `gorm:"column:family_member_id;not null;index;uniqueIndex:ux_family_device_share" json:"-"` // 家庭成员关系内部主键 + ProductInfoID uint64 `gorm:"column:product_info_id;not null;index;uniqueIndex:ux_family_device_share" json:"-"` // 房主名下设备内部主键 + CanView bool `gorm:"column:can_view;not null;default:false" json:"can_view"` // 是否允许查看设备资料和状态 + CanAlert bool `gorm:"column:can_alert;not null;default:false" json:"can_alert"` // 是否允许接收该设备危险告警 + CanControl bool `gorm:"column:can_control;not null;default:false" json:"can_control"` // 是否允许发起远程控制,控制仍需二次确认 +} + +func (*UserDeviceShare) TableName() string { return "user_device_share" } + +// UserFamilyAudit 保存邀请和授权变化,供安全追溯。 +type UserFamilyAudit struct { + Entity + OwnerUserAccountID uint64 `gorm:"column:owner_user_account_id;not null;index" json:"-"` // 房主用户内部主键 + FamilyMemberID uint64 `gorm:"column:family_member_id;not null;default:0;index" json:"-"` // 关联成员内部主键 + ActorUserAccountID uint64 `gorm:"column:actor_user_account_id;not null;index" json:"-"` // 执行操作的用户内部主键 + Action string `gorm:"column:action;type:varchar(32);not null;index" json:"action"` // 操作类型:invite/accept/reject/update_permissions/revoke + Summary string `gorm:"column:summary;type:varchar(512);not null;default:''" json:"summary"` // 不含手机号的变更摘要 +} + +func (*UserFamilyAudit) TableName() string { return "user_family_audit" } diff --git a/backend/api/internal/models/user_message_read.go b/backend/api/internal/models/user_message_read.go new file mode 100644 index 0000000..0f1df78 --- /dev/null +++ b/backend/api/internal/models/user_message_read.go @@ -0,0 +1,19 @@ +// 功能:保存用户对真实业务消息的已读回执;版本:1.0.0。 +package models + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) + +// UserMessageRead 仅保存已读状态,消息正文仍来自订单、工单或公告事实表。 +type UserMessageRead struct { + Entity // 公共实体字段 + UserAccountID uint64 `gorm:"column:user_account_id;not null;index;uniqueIndex:ux_user_message_read;comment:读取消息的用户账号内部主键" json:"user_account_id"` // 用户账号内部主键 + MessageKey string `gorm:"column:message_key;type:varchar(96);not null;uniqueIndex:ux_user_message_read;comment:消息来源类型与业务唯一标识组成的稳定键" json:"message_key"` // 稳定消息键 + ReadAt time.Time `gorm:"column:read_at;type:timestamptz;not null;comment:用户首次确认已读的时间" json:"read_at"` // 首次已读时间 +} + +func init() { database.AppendMigrate(&UserMessageRead{}) } +func (table *UserMessageRead) TableName() string { return "user_message_read" } diff --git a/backend/api/internal/models/wallet_bank.go b/backend/api/internal/models/wallet_bank.go index 1ceb67a..4de4cd0 100644 --- a/backend/api/internal/models/wallet_bank.go +++ b/backend/api/internal/models/wallet_bank.go @@ -16,6 +16,7 @@ type WalletBank struct { BindID string `gorm:"column:bind_id;type:varchar(128);not null;default:'';uniqueIndex:,where:bind_id <> ''" json:"bind_id"` // 支付渠道绑定标识 BankType string `gorm:"column:bank_type;type:varchar(20);not null;default:''" json:"bank_type"` // 银行卡类型 Bank string `gorm:"column:bank;type:varchar(64);not null;default:''" json:"bank"` // 所属银行编码 + IsDefault bool `gorm:"column:is_default;not null;default:false;index;comment:是否为当前钱包默认提现到账卡" json:"is_default"` // 是否为当前钱包默认提现到账卡 } func init() { database.AppendMigrate(&WalletBank{}) } diff --git a/backend/api/internal/routers/client.go b/backend/api/internal/routers/client.go index 3cb481a..8eb9026 100644 --- a/backend/api/internal/routers/client.go +++ b/backend/api/internal/routers/client.go @@ -26,34 +26,85 @@ func registerUserClient(serviceKey string, engine *gin.Engine) { anonymous.GET("/public/gas-stations", userlogic.PublicGasStations) anonymous.GET("/public/delivery-points", userlogic.PublicDeliveryPoints) anonymous.GET("/public/contents", userlogic.PublicContents) + anonymous.GET("/public/contents/:identity", userlogic.PublicSafetyContent) anonymous.GET("/public/products", userlogic.PublicProducts) + anonymous.GET("/public/products/:identity", userlogic.PublicProduct) protected := engine.Group(basePath) protected.Use(sdkmiddleware.JwtAuth(true), common.RequireClient("user_app")) protected.GET("/auth/profile", userlogic.Profile) + protected.GET("/owned-products", userlogic.ListOwnedProducts) + protected.GET("/devices", userlogic.ListDevices) + protected.PUT("/devices/:identity/group", userlogic.AssignDeviceGroup) + protected.GET("/device-groups", userlogic.ListDeviceGroups) + protected.GET("/usage-statistics", userlogic.GetUsageStatistics) + protected.GET("/messages", userlogic.ListMessages) + protected.PUT("/messages/read", userlogic.MarkMessagesRead) + protected.POST("/device-groups", userlogic.SaveDeviceGroup) + protected.PUT("/device-groups/:identity", userlogic.SaveDeviceGroup) + protected.DELETE("/device-groups/:identity", userlogic.DeleteDeviceGroup) + protected.GET("/emergency-contacts", userlogic.ListEmergencyContacts) + protected.POST("/emergency-contacts", userlogic.SaveEmergencyContact) + protected.PUT("/emergency-contacts/:identity", userlogic.SaveEmergencyContact) + protected.DELETE("/emergency-contacts/:identity", userlogic.DeleteEmergencyContact) + protected.GET("/family", userlogic.FamilyDashboard) + protected.POST("/family-members", userlogic.InviteFamilyMember) + protected.PUT("/family-members/:identity/device-permissions", userlogic.UpdateFamilyPermissions) + protected.DELETE("/family-members/:identity", userlogic.RevokeFamilyMember) + protected.GET("/family-invitations", userlogic.ListFamilyInvitations) + protected.POST("/family-invitations/:identity/respond", userlogic.RespondFamilyInvitation) + protected.POST("/ticket-photos", userlogic.UploadTicketPhoto) + protected.GET("/ticket-photos/:name", userlogic.UploadedTicketPhoto) + protected.GET("/tickets/:identity/photos/:photoIdentity", userlogic.TicketPhoto) protected.GET("/auth/avatar", userlogic.Avatar) protected.PUT("/auth/profile", userlogic.UpdateProfile) protected.PUT("/auth/password", userlogic.ChangePassword) protected.GET("/addresses", userlogic.ListAddresses) protected.POST("/addresses", userlogic.SaveAddress) + protected.PUT("/addresses/:identity", userlogic.UpdateAddress) + protected.DELETE("/addresses/:identity", userlogic.DeleteAddress) + protected.POST("/addresses/:identity/default", userlogic.SetDefaultAddress) protected.POST("/contents/read-confirmations", userlogic.ConfirmContentRead) protected.GET("/service-relation", userlogic.ServiceRelation) protected.GET("/gas/contracts", userlogic.ListGasContracts) protected.GET("/gas/orders", userlogic.ListGasOrders) + protected.GET("/gas/order-options", userlogic.GetGasOrderOptions) + protected.POST("/gas/orders", userlogic.CreateGasOrder) + protected.GET("/gas/orders/:identity", userlogic.GetGasOrder) + protected.POST("/gas/orders/:identity/confirm-receipt", userlogic.ConfirmGasReceipt) + protected.GET("/gas/contracts/:identity", userlogic.GetGasContract) + protected.GET("/gas/contracts/:identity/attachment", userlogic.DownloadGasContractAttachment) + protected.GET("/gas/contracts/:identity/history", userlogic.GetGasContractHistory) + protected.GET("/gas/contracts/:identity/requests", userlogic.ListGasContractRequests) + protected.POST("/gas/contracts/:identity/requests", userlogic.CreateGasContractRequest) protected.POST("/gas/orders/:identity/cancel", userlogic.CancelGasOrder) protected.POST("/gas/orders/:identity/pay", userlogic.PayGasOrder) + protected.GET("/gas/orders/:identity/delivery", userlogic.GetGasOrderDelivery) + protected.GET("/gas/orders/:identity/delivery/track", userlogic.GetGasOrderDeliveryTrack) protected.POST("/gas/orders/:identity/refunds", userlogic.CreateRefund("gasorder")) protected.GET("/tickets", userlogic.ListTickets) protected.POST("/tickets", userlogic.CreateTicket) protected.POST("/tickets/:identity/confirm", userlogic.ConfirmTicket) protected.POST("/tickets/:identity/cancel", userlogic.CancelTicket) protected.GET("/shop/orders", userlogic.ListShopOrders) + protected.GET("/shop/orders/:identity", userlogic.GetShopOrder) + protected.GET("/shop/cart", userlogic.ListCart) + protected.GET("/shop/favorites", userlogic.ListFavorites) + protected.GET("/shop/recommendations", userlogic.Recommendations) + protected.GET("/shop/favorites/items/:identity", userlogic.FavoriteState) + protected.PUT("/shop/favorites/items/:identity", userlogic.SetFavorite) + protected.GET("/shop/cart/items/:identity", userlogic.GetCartItem) + protected.PUT("/shop/cart/items/:identity", userlogic.SetCartItem) protected.POST("/shop/orders", userlogic.CreateShopOrder) protected.POST("/shop/orders/:identity/cancel", userlogic.CancelShopOrder) protected.POST("/shop/orders/:identity/pay", userlogic.PayShopOrder) protected.POST("/shop/orders/:identity/refunds", userlogic.CreateRefund("ec_order")) protected.POST("/shop/orders/:identity/confirm-receipt", userlogic.ConfirmShopReceipt) protected.GET("/refunds", userlogic.ListRefunds) + protected.GET("/deposits", userlogic.ListDeposits) + protected.POST("/deposit-returns", userlogic.CreateDepositReturn) + protected.GET("/deposit-returns/:identity", userlogic.GetDepositReturn) + protected.POST("/deposit-returns/:identity/cancel", userlogic.CancelDepositReturn) registerClientWalletRoutes(protected, "user_app") } @@ -91,10 +142,16 @@ func registerClientWalletRoutes(group *gin.RouterGroup, client string) { group.GET("/wallet", common.GetWallet(client)) group.PUT("/wallet/payment-password", common.SetPaymentPassword(client)) group.GET("/wallet/records", common.ListWalletRecords(client)) + group.GET("/wallet/bills", common.ListWalletBills(client)) group.POST("/wallet/recharges", common.CreateRecharge(client)) + group.GET("/wallet/recharge-options", common.GetRechargeOptions(client)) + group.GET("/wallet/recharges", common.ListRecharges(client)) + group.GET("/wallet/recharges/:identity", common.GetRecharge(client)) + group.GET("/wallet/recharge-requests/:request", common.GetRecharge(client)) group.POST("/wallet/recharges/:identity/mock-confirm", common.ConfirmMockRecharge(client)) group.GET("/wallet/banks", common.ListBanks(client)) group.POST("/wallet/banks", common.BindBank(client)) + group.POST("/wallet/banks/:identity/default", common.SetDefaultBank(client)) group.DELETE("/wallet/banks/:identity", common.UnbindBank(client)) group.GET("/wallet/withdrawals", common.ListWithdrawals(client)) group.POST("/wallet/withdrawals", common.CreateWithdrawal(client)) diff --git a/backend/api/internal/routers/client_test.go b/backend/api/internal/routers/client_test.go index 4e1a6f6..2ad17e1 100644 --- a/backend/api/internal/routers/client_test.go +++ b/backend/api/internal/routers/client_test.go @@ -12,10 +12,50 @@ func TestRegisterClientRoutes(t *testing.T) { RegisterClient("heqi", engine) expected := map[string]bool{ + "GET /heqi/client/v1/user/devices": false, + "PUT /heqi/client/v1/user/devices/:identity/group": false, + "GET /heqi/client/v1/user/device-groups": false, + "POST /heqi/client/v1/user/device-groups": false, + "PUT /heqi/client/v1/user/device-groups/:identity": false, + "DELETE /heqi/client/v1/user/device-groups/:identity": false, + "GET /heqi/client/v1/user/gas/orders/:identity": false, + "GET /heqi/client/v1/user/gas/order-options": false, + "POST /heqi/client/v1/user/gas/orders": false, + "POST /heqi/client/v1/user/gas/orders/:identity/pay": false, + "POST /heqi/client/v1/user/gas/orders/:identity/confirm-receipt": false, + "GET /heqi/client/v1/user/gas/contracts/:identity": false, + "GET /heqi/client/v1/user/gas/contracts/:identity/attachment": false, + "GET /heqi/client/v1/user/gas/contracts/:identity/history": false, + "GET /heqi/client/v1/user/gas/contracts/:identity/requests": false, + "POST /heqi/client/v1/user/gas/contracts/:identity/requests": false, + "GET /heqi/client/v1/user/shop/orders/:identity": false, + "GET /heqi/client/v1/user/shop/recommendations": false, + "GET /heqi/client/v1/user/shop/favorites": false, + "GET /heqi/client/v1/user/shop/favorites/items/:identity": false, + "PUT /heqi/client/v1/user/shop/favorites/items/:identity": false, + "GET /heqi/client/v1/user/shop/cart": false, + "GET /heqi/client/v1/user/shop/cart/items/:identity": false, + "PUT /heqi/client/v1/user/shop/cart/items/:identity": false, + "POST /heqi/client/v1/user/ticket-photos": false, + "GET /heqi/client/v1/user/ticket-photos/:name": false, + "GET /heqi/client/v1/user/tickets/:identity/photos/:photoIdentity": false, + "PUT /heqi/client/v1/user/addresses/:identity": false, + "DELETE /heqi/client/v1/user/addresses/:identity": false, + "POST /heqi/client/v1/user/addresses/:identity/default": false, "POST /heqi/client/v1/user/auth/register": false, "POST /heqi/client/v1/user/auth/login": false, "GET /heqi/client/v1/user/auth/avatar": false, "POST /heqi/client/v1/user/wallet/recharges": false, + "GET /heqi/client/v1/user/wallet/banks": false, + "POST /heqi/client/v1/user/wallet/banks": false, + "POST /heqi/client/v1/user/wallet/banks/:identity/default": false, + "DELETE /heqi/client/v1/user/wallet/banks/:identity": false, + "GET /heqi/client/v1/user/wallet/withdrawals": false, + "POST /heqi/client/v1/user/wallet/withdrawals": false, + "GET /heqi/client/v1/user/deposits": false, + "POST /heqi/client/v1/user/deposit-returns": false, + "GET /heqi/client/v1/user/deposit-returns/:identity": false, + "POST /heqi/client/v1/user/deposit-returns/:identity/cancel": false, "POST /heqi/client/v1/user/shop/orders/:identity/pay": false, "POST /heqi/client/v1/staff/auth/login": false, "GET /heqi/client/v1/staff/preflight": false, diff --git a/backend/api/internal/routers/gas_business.go b/backend/api/internal/routers/gas_business.go index 4e92324..d36eabb 100644 --- a/backend/api/internal/routers/gas_business.go +++ b/backend/api/internal/routers/gas_business.go @@ -117,6 +117,7 @@ func registerGasBusinessRoutes(group *gin.RouterGroup) { ticket.GET("", gaslogic.ListTicket) ticket.POST("", gaslogic.CreateTicket) ticket.GET("/:identity", gaslogic.GetTicket) + ticket.POST("/:identity/resolve-contract-request", gaslogic.ResolveContractRequest) ticket.PUT("/:identity", gaslogic.UpdateTicket) ticket.PATCH("/:identity/status", gaslogic.UpdateTicketStatus) ticket.DELETE("/:identity", gaslogic.ArchiveTicket) diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index 233912a..79b70a6 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -142,6 +142,14 @@ func registerProductRoute(group *gin.RouterGroup) { info.PATCH("/:identity/status", product.UpdateProductInfoRecordStatus) info.PATCH("/:identity/lifecycle", product.UpdateProductInfoLifecycle) + registerRestrictedNoDeleteResource( + group, + "/dev_usage_stat", + &models.DevUsageStat{}, + []string{"stat_date", "usage_kg", "breakfast_kg", "lunch_kg", "dinner_kg", "source", "calc_version", "calculated_at"}, + requiredRelation("product_info_identity", "product_info_id", &models.ProductInfo{}), + ) + repairList, repairCreate, repairGet, repairUpdate := product.ProductRepairHandlers( requiredRelation("product_info_identity", "product_info_id", &models.ProductInfo{}), ) @@ -190,6 +198,13 @@ func registerCommerceRoute(group *gin.RouterGroup) { registerReadOnlyHandlers(group, "/ec_order", func(ctx *gin.Context) { common.ListResource(ctx, &models.EcOrder{}) }, ec.GetEcOrder) registerReadOnlyResource(group, "/ec_order_item", &models.EcOrderItem{}) registerReadOnlyResource(group, "/ec_review", &models.EcReview{}) + registerRestrictedWritableResource(group, "/deposit_policy", &models.DepositPolicy{}, []string{"name", "amount", "rule_text"}, requiredRelation("product_type_identity", "product_type_id", &models.ProductType{})) + registerReadOnlyResource(group, "/deposit_record", &models.DepositRecord{}) + registerReadOnlyResource(group, "/deposit_return_request", &models.DepositReturnRequest{}) + returns := group.Group("/deposit_return_request") + returns.POST("/:identity/confirm-pickup", platformbase.ConfirmDepositPickup) + returns.POST("/:identity/inspect", platformbase.InspectDepositReturn) + returns.POST("/:identity/complete-refund", platformbase.CompleteDepositRefund) } func registerStaffRoute(group *gin.RouterGroup) { @@ -249,7 +264,8 @@ func registerFinanceRoute(group *gin.RouterGroup) { func registerContentRoute(group *gin.RouterGroup) { registerRestrictedWritableResource(group, "/cms_content", &models.CmsContent{}, []string{"content_type", "title", "body", "version_no", "publish_status"}) - registerRestrictedWritableResource(group, "/cs_ticket", &models.CsTicket{}, []string{"ticket_no", "category", "priority"}, requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{})) + ticketList, ticketCreate, ticketGet, ticketUpdate := common.ResourceHandlers(&models.CsTicket{}, []string{"ticket_no", "category", "priority"}, []string{"ticket_no", "category", "priority"}, requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{})) + registerWritableResource(group, "/cs_ticket", ticketList, ticketCreate, ticketGet, platformbase.GuardContractTicketUpdate(ticketUpdate), &models.CsTicket{}) } func registerWritableResource(group *gin.RouterGroup, path string, list, create, get, update gin.HandlerFunc, model any) { diff --git a/backend/api/internal/routers/platform_test.go b/backend/api/internal/routers/platform_test.go index 6293280..ee952af 100644 --- a/backend/api/internal/routers/platform_test.go +++ b/backend/api/internal/routers/platform_test.go @@ -142,6 +142,15 @@ func TestPlatformProductCommerceAndDeliveryRoutesFollowTheirContracts(t *testing assertNoRouteMethods(t, routes, path, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete) assertNoRouteMethods(t, routes, path+"/:identity", http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete) } + for _, resource := range []string{"/deposit_record", "/deposit_return_request"} { + path := "/heqi/platform/v1" + resource + assertRouteMethods(t, routes, path, http.MethodGet) + assertRouteMethods(t, routes, path+"/:identity", http.MethodGet) + } + assertRouteMethods(t, routes, "/heqi/platform/v1/deposit_policy", http.MethodGet, http.MethodPost) + assertRouteMethods(t, routes, "/heqi/platform/v1/deposit_return_request/:identity/confirm-pickup", http.MethodPost) + assertRouteMethods(t, routes, "/heqi/platform/v1/deposit_return_request/:identity/inspect", http.MethodPost) + assertRouteMethods(t, routes, "/heqi/platform/v1/deposit_return_request/:identity/complete-refund", http.MethodPost) for _, resource := range []string{"/gasorder_item", "/gasorder_assign", "/gasorder_status", "/gasorder_track", "/gasorder_track_point", "/gasorder_confirm", "/gasorder_payment", "/gasorder_contract_revision"} { path := "/heqi/platform/v1" + resource diff --git a/backend/api/internal/routers/upload.go b/backend/api/internal/routers/upload.go index f824080..6040d88 100644 --- a/backend/api/internal/routers/upload.go +++ b/backend/api/internal/routers/upload.go @@ -12,4 +12,6 @@ func registerUploadRoute(serviceKey string, engine *gin.Engine) { authorized.Use(sdkmiddleware.JwtAuth(true)) authorized.POST("/file", upload.UploadFile) authorized.POST("/avatar", upload.UploadAvatar) + authorized.POST("/product-image", upload.UploadProductImage) + engine.GET("/uploads/product-images/:name", upload.ServeProductImage) } diff --git a/backend/api/internal/seed/mock.go b/backend/api/internal/seed/mock.go index d6122e0..83eefb8 100644 --- a/backend/api/internal/seed/mock.go +++ b/backend/api/internal/seed/mock.go @@ -15,6 +15,25 @@ import ( const mockIdentityPrefix = "00000000-0000-7000-8000-" +// RepairMockUserPassword 仅恢复固定开发用户的演示密码,不修改普通用户凭据。 +func RepairMockUserPassword(database *gorm.DB) (int64, error) { + passwordHash, err := bcrypt.GenerateFromPassword([]byte("Mock@123456"), bcrypt.DefaultCost) + if err != nil { + return 0, fmt.Errorf("hash mock user password: %w", err) + } + result := database.Model(&models.UserAccount{}).Where( + "identity = ? AND username = ? AND phone = ? AND status = ?", + mockIdentityPrefix+"000000000007", "mock_customer", "13800000001", common.StatusEnable, + ).Update("password_hash", string(passwordHash)) + if result.Error != nil { + return 0, fmt.Errorf("repair mock user password: %w", result.Error) + } + if result.RowsAffected != 1 { + return result.RowsAffected, errors.New("fixed mock user was not found") + } + return result.RowsAffected, nil +} + // MockData idempotently writes one connected development scenario across all // domain tables. Fixed identities and unique business numbers make reruns safe. func MockData(database *gorm.DB) error { diff --git a/docs/03-用户端App需求.md b/docs/03-用户端App需求.md index ba0ee5f..0d2b041 100644 --- a/docs/03-用户端App需求.md +++ b/docs/03-用户端App需求.md @@ -153,7 +153,7 @@ apps/user_app/lib/ - `/shop`、`/shop/products/:identity`、`/cart`、`/checkout` - `/orders`、`/orders/:identity`、`/orders/:identity/delivery` - `/me`、`/me/wallet`、`/me/records`、`/me/settings` -- `/valves` 与 `/favorites` 属于后续路由;对应 Client API 未落地前不得注册可操作页面或用 Mock 数据占据主导航。 +- `/valves` 仍属于后续路由,API未落地前不得注册可操作页面。`/favorites` 于2026-09-08随真实用户收藏API落地进入个人中心与商城流程;禁止使用Mock数据替代远程收藏状态。 - 登录、协议版本确认和首次安全宣导使用根级守卫;涉及设备、订单、钱包、地址的页面必须在 redirect 中校验会话,不能依靠按钮隐藏。 - 邀请二维码、订单通知、支付结果和安全通知使用白名单深链。Android App Links 与 iOS Universal Links 均须校验域名归属;深链参数只接受 `identity` 和短期签名上下文。 - 高风险开阀被拦截时导航到可解释的限制页面或安全事件详情,不允许通过返回栈、群控入口或手工深链绕过。 diff --git a/docs/11-数据接口与安全.md b/docs/11-数据接口与安全.md index 488a5fa..3e0d711 100644 --- a/docs/11-数据接口与安全.md +++ b/docs/11-数据接口与安全.md @@ -125,6 +125,25 @@ - 支付密码独立于登录密码,仅允许六位数字,使用 bcrypt 保存;连续失败达到阈值后在 Redis 短时锁定。绑卡、解绑、余额支付和提现均要求支付密码或限定用途的一次性验证码。 - 公共上传接口 `/upload/file` 必须携带平台、气站、配送点、用户或工作人员任一合法 JWT;图片/PDF 最大 10MB,视频上限从配置读取。上传只返回资源 URI,业务接口负责建立关联并记录操作者、采集与接收时间。 - 平台账户资料头像使用专用 `/upload/avatar` 上传入口,仅允许真实 JPG/PNG、最大 2MB、最大 4096×4096,并在服务端完成扩展名、MIME、尺寸和完整图片解码校验。后台头像读取通过 `/heqi/platform/v1/{staff_account|user_account|platform_account}/:identity/avatar` 受 JWT、菜单和对象角色权限保护;用户 App 仅可通过 `/heqi/client/v1/user/auth/avatar` 读取当前登录用户自己的头像。通用列表及详情响应继续移除 `avatar` 字段,头像文件目录不得作为公开静态目录。平台总后台的工作人员、用户和平台账户列表可对当前可视记录调用受控接口展示缩略图,必须限制并发、按页缓存、离页取消请求并释放本地 Blob URL。普通资料更新未提交 `avatar` 时保持原头像,只有明确上传或恢复默认头像时才修改该字段。 +- 用户 App 地址维护的最小例外:`GET /heqi/client/v1/user/addresses` 仅在用户 JWT 和账户归属过滤后返回本人的完整地址、收货联系人、联系电话及坐标,供本人编辑和下单使用;App 列表遮蔽手机号中间四位。此例外不允许读取他人地址,也不改变订单列表、其他终端资源的脱敏规则。地址新增可携带 request_no 幂等号,编辑、归档及默认切换均先锁本账户并校验对象归属;归档保留历史订单快照。 - 充值、支付、提现、工单证据、轨迹点、内容确认等写入均携带幂等号;资金入账在数据库事务内锁定钱包并同时写不可变流水。 +- 用户商城结算可提交 expected_payable_amount(整数分)作为报价确认,金额始终由数据库单价和数量计算。价格变化返回 2401 并回滚,库存不足或下架返回 2402。金额乘加先检查溢出;请求最多100项,每项1至999件。本人同请求号已创建订单优先恢复,不再依赖当前地址和库存;此行为不会返回他人订单。 +- 用户商城订单的取消/收货动作由 allowed_actions 发布,客户端确认后提交,服务端锁定本人订单再校验。取消仅允许订单状态16,状态22重试不再返库存;收货仅允许订单状态18且物流20,物流30重试不覆盖首次收货时间。取消或退款中的订单不能凭旧物流状态确认收货。 +- 用户工单列表兼容原数组与原字段,新增 `status_name`、`allowed_actions`,仅返回本人且未归档记录,移除内部主键。取消沿用既有32/18/11/21/34状态范围,22重复返回成功;确认仅允许34,23重复返回成功且不覆盖首次完成时间。两种动作在事务内锁定本人工单,其他用户、归档和非法状态均拒绝。App详情当前复用本人列表查询,不另外开放人员隐私或照片静态链接。 +- 报修创建新增可选fault_type(leak/valve/alarm/other),contact_name/contact_phone由服务端从本人地址取快照,历史空联系人回退本人账户,不能任意提交他人地址。创建事务按账户串行;本人重复request_no返回首次工单,不因原地址已归档或预约时间已过而重复创建。首次创建拒绝空白描述、空白/过长请求号和过去预约时间。新增cs_ticket字段使用migrate-ticket-contact定向迁移,原接口兼容省略fault_type和address_identity。 +- 报修照片使用用户JWT保护的POST ticket-photos,真实JPG/PNG、每张2MiB以内、4096像素边界,内容摘要绑定账户目录保证重复上传复用。POST tickets可附photos(最多3项,旧客户端允许省略),只关联本人已上传资源并与工单同事务;非法照片整体回滚。读取通过GET tickets/:identity/photos/:photoIdentity同时校验本人、工单和证据关联,返回private/no-store,禁止将ticket-photos文件夹映射为公开静态资源。 +- 用户添加照片复用cs_ticket_evidence,evidence_type=reported、source=user_camera/user_gallery、integrity_status=capture_time_unknown。此类记录的captured_at暂存客户端添加时间,API明确输出added_at,不能被解释为已验证原始拍摄时间;longitude/latitude未采集则为空,不能伪造现场位置。后续原始时间/定位能力须保留来源和完整性标记,不能覆盖工作人员原有证据语义。 - 钱包可提现余额是当前总余额的子集,始终满足 `0 <= 可提现余额 <= 总余额`。普通消费扣减总余额后,必须同步把可提现余额限制在剩余总余额以内。 - 提现申请在同一数据库事务内锁定钱包、同时预扣总余额和可提现余额并写入不可变流水;驳回只返还该申请实际预扣的两类余额,完成打款只确认外部结果,不得再次扣款。 +- 商品详情 `GET /heqi/client/v1/user/public/products/:identity` 为匿名只读接口,只读取启用且未删除商品及关联的启用图片/属性。售罄仍可查看,下架/不存在返回1112。响应白名单不包含内部主键或订单数据。详情价格不作为交易授权,结算重新读价,创建订单仍校验库存、报价、归属及幂等号。 +- 收藏`GET shop/favorites`、`GET/PUT shop/favorites/items/:identity`均使用用户JWT。identity为商品公开标识;EcFavorite以(user_account_id,ec_product_id)唯一关联,取消设置status=3,已收藏status=1。PUT接受favorite布尔值及原revision,相同目标无写入,过期版本返回2404;版本为收藏UUID与正整数序号,每次状态变化递增。下架商品不允许新增收藏,但已收藏内容继续展示并允许取消;列表用available禁用加购,不伪造销量、优惠或规格。上限1000条有效收藏;所有字段和表有中文数据库注释。 +- 推荐`GET shop/recommendations`要求用户JWT;参数source为cart(默认)或favorites,page为1至10000,page_size为1至20(默认2)。返回items、page、page_size、has_more;item字段为identity、name、price_amount(整数分)、stock_quantity、image_url、category_name。排除本人有效购物车、已删除/停用/无库存商品;favorites另排除本人有效收藏,同场景关联分类优先。账户ID只由JWT读取,不接收调用方传入;分类排序表名为固定枚举,图片/分类批量查询。接口只读,无数据库迁移或外部推荐服务。客户端丢弃跨账号迟到响应,加购继续使用原购物车条件版本接口。 +- 商城订单详情`GET shop/orders/:identity`要求用户JWT,查询绑定订单公开identity与当前账户,跨账户与不存在均返回1112。只公开订单号、业务状态/允许动作、成交明细、收货联系人/地址快照、商品/优惠/应付整数分金额、实际支付/发货/收货时间、备注和物流公司/单号。明细名称及商品公开identity来自成交JSON快照,单价为sale_amount;不联查当前商品替换历史值,也不直接输出完整原始快照或内部主键。列表与详情共享shopOrderState动作定义,详情写操作仍复用原状态校验接口。没有已记录的付款时间时不显示“实付成功”,缺失资料不编造。 +- 收藏列表支持既有page/page_size分页协议。状态写入按账户锁串行,不接受他人关系主键。客户端仅广播服务端已确认状态,若账号已切换则丢弃旧请求的迟到结果;取消收藏不影响购物车或订单。 +- 购物车 `GET shop/cart`、`GET/PUT shop/cart/items/:identity` 均要求用户JWT,identity为商品公开标识;数据库按当前账户关联条目。PUT提交quantity(0—999)、selected和读取时的revision,零数量归档。相同目标重试不写入,过期目标返回2403;新增/增加数量受库存限制,最多100条有效商品。revision由购物车公开UUID和微秒精度更新时间构成,归档仍保留版本,旧重试不能复活已删除商品。全选/批量删除逐项提交,部分失败后重读真实状态。 +- POST shop/orders可为每个item附cart_revision;服务端核对本人有效勾选条目的版本和数量,并在订单事务中归档,任何价格/库存/版本失败均回滚。多商品按公开identity固定顺序锁定,拒绝重复商品、零或负数量。金额仍为整数分,expected_payable_amount、request_no及旧单商品调用兼容;已创建请求优先返回原订单。未配置押金、优惠与配送费用时不将设计样例金额写入订单。 +- `GET /heqi/client/v1/user/deposits` 只依据当前 JWT 用户查询押金汇总和明细,支持 `all/using/refunding/returned` 状态筛选;不接受客户端传入的用户标识。总后台 `/deposit_policy` 允许维护规则,`/deposit_record` 只读,避免后台通用表单直接改写资金事实。 +- `POST /heqi/client/v1/user/deposit-returns` 只锁定本人使用中押金和本人完整地址,校验预约时段、四项确认与幂等号。后台退瓶处理按 `10待上门→20待验收→30待退款→40已完成` 单向流转;扣减不得超过原押金。退款时锁定钱包和押金记录,余额、押金状态、申请状态及不可变 `deposit_refund` 流水同事务提交。 +- `GET /heqi/client/v1/user/gas/order-options` 只返回JWT用户有效供气合同下未被未完成订单占用的合同气瓶,以及服务端合同价、押金规则、本人地址和可预约时段。缺押金规则的规格必须返回明确不可下单原因,客户端不得推测金额。 +- `POST /heqi/client/v1/user/gas/orders` 接收地址、合同气瓶公开标识集合、预约时间、客户端预期金额和幂等号;服务端重新校验归属、占用、价格、押金及金额后创建待支付订单。下单时以 `gasorder_deposit` 锁定每只气瓶的押金规则快照;余额或第三方支付成功后,在同一事务内生成 `deposit_record`。取消未支付订单同时释放气瓶占用并取消待支付押金快照。 +- 报修草稿照片通过 `GET /heqi/client/v1/user/ticket-photos/:name` 读取;JWT校验后用当前账户构造文件目录,仅接受内容摘要文件名,不能由请求指定账户目录。响应禁止公开缓存。草稿在平台安全存储中按API地址和认证账户隔离,仅含表单、地址快照、照片URI和幂等号,不含令牌及图片字节。恢复不会自动提交;未知提交结果须沿用原请求号。删除草稿只清理本地记录,不删除可能已经关联工单的照片文件。 diff --git a/docs/13-移动端Design-System.md b/docs/13-移动端Design-System.md index eb64df7..0e1c15e 100644 --- a/docs/13-移动端Design-System.md +++ b/docs/13-移动端Design-System.md @@ -116,7 +116,7 @@ Design System 不负责: - 登录/注册:品牌区 → 表单 → 单一主操作 → 协议与辅助入口。 - 首页:页面介绍 → 当前服务/安全概览 → 内容区块。 -- 商城:商品列表为同组 Surface,商品是独立 Row;价格和购买操作优先于装饰。 +- 商城:2026-09 用户端最新设计优先采用双列商品网格;窄屏保持可读,宽屏最多三列。价格和购买操作优先于装饰。 - 订单:顶层 Tab 管理分类,子列表使用 embedded 模式,不嵌套 AppBar。 - 我的:身份概览 → 资产概览 → 账户与服务列表 → 独立退出操作。 @@ -156,4 +156,5 @@ Design System 不负责: | 版本 | 日期 | 内容 | | --- | --- | --- | +| 0.1.1 | 2026-09-07 | 用户端明确蓝白容器与文字对比色、一级标题居中;Surface 使用 Material 保证点击墨水层可见;筛选标签显式文字颜色与边框。公共 API 保持兼容,两个 App 回归通过。 | | 0.1.0 | 2026-08-10 | 建立共享 Flutter 包、双品牌 Material 3 主题、基础 Token 与布局/状态组件。 | diff --git a/docs/README.md b/docs/README.md index 2dcbff9..021563c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,7 +23,8 @@ | 文档 | 用途 | | --- | --- | -| [用户端 App 全量功能开发](项目文档_用户端APP全量功能开发_v1.0.md) | 将最新 58 张用户端产品设计图落实为页面路由、UI 规范、Client API、数据模型、测试和分阶段交付方案 | +| [用户端 App 全量功能开发](项目文档_用户端APP全量功能开发_v1.1.md) | 58 张设计图的实施基线;v1.1 增补 A1 代码、兼容接口和远程联调事实,v1.0 保留归档 | +| [用户端 App 开发进度](开发进度_用户端APP全量功能开发.md) | 58 张逐页状态、测试证据、未完成视觉项和下一批起点 | | [用户端 App 全量开发 AI 提示词](开发提示词_用户端APP全量功能开发_v1.0.md) | 可直接交给开发 AI 使用,包含项目路径、设计与需求事实源、开发批次、接口规则、视觉验收、测试和交付要求 | | [服务端 App 三岗位全量功能开发](项目文档_服务端APP三岗位全量功能开发_v1.0.md) | 将安装维修、配送、安全检查共 122 张最新设计图落实为角色路由、UI、Staff Client API、状态机、离线、安全、测试和交付方案 | diff --git a/docs/交付记录_用户APP首期功能_20260913.md b/docs/交付记录_用户APP首期功能_20260913.md new file mode 100644 index 0000000..2b0f1b0 --- /dev/null +++ b/docs/交付记录_用户APP首期功能_20260913.md @@ -0,0 +1,27 @@ +# 用户APP首期功能交付记录 + +操作日期:2026-09-13 + +交付说明:已完成用户APP首期功能开发,保存本地仓库并提交远端。此处首期指本次已实现代码范围,不代表全部58张设计图、真实支付渠道、设备联调及严格视觉验收全部完成。 + +## 本次范围 + +- 用户端登录、首页、商城、购物车、订单、合同、报修、资料、地址、钱包及相关页面和接口。 +- 设备列表、设备分组、家庭共享、消息和用气统计等当前实现。 +- 配套后端模型、路由、管理后台资源、测试与视觉对照资料。 +- 具体文件、函数和行数以本次Git提交差异及各功能开发日志为准。 + +## 本次验证 + +- 用户APP `flutter analyze --no-pub` 通过。 +- 用户APP `flutter build web --release --no-pub` 构建通过。 +- 用户APP完整测试:187项通过,2项失败,尚未全部通过。 +- 后端 `go test ./...` 未全部通过:模型中文字段注释检查失败;用户业务、公共逻辑及路由等测试通过。 +- 后端 `go vet ./...` 与构建通过。 +- 平台后台52项资源契约检查通过;平台、气站、配送点后台发布构建通过。 + +## 保留事项 + +- 失败测试及模型注释需后续修复,当前提交作为首期开发成果快照。 +- 真实支付、设备及双账号联调和严格视觉验收以开发进度文档中的实际状态为准。 +- 本地运行日志及可执行文件备份不纳入提交;本次推送不等于生产部署。 diff --git a/docs/开发日志_一键报修视觉与紧急入口_20260911.md b/docs/开发日志_一键报修视觉与紧急入口_20260911.md new file mode 100644 index 0000000..7fe958e --- /dev/null +++ b/docs/开发日志_一键报修视觉与紧急入口_20260911.md @@ -0,0 +1,23 @@ +# 一键报修视觉与紧急入口开发日志 + +操作时间:2026-09-11 +操作类型:修改、扩展 +影响模块:用户端 App 一键报修 + +操作前状态:图05已有三步报修、照片上传、本人地址、草稿恢复和幂等提交,但顶部更多入口、故障图标和紧急电话缺失;表单纵向尺寸与最新设计稿存在明显偏差。 + +具体操作:重校步骤条与表单间距、故障行、描述区、照片区、联系地址和底部按钮;增加报修说明与本人报修工单菜单;增加燃气泄漏安全提示。紧急电话只在用户点击后请求系统拨号器打开119,无法打开时显示人工拨号提示。菜单离开前先保存当前草稿,避免丢失填写内容。 + +操作后状态:图05的首屏结构与最新设计稿基本一致。照片数量继续显示真实状态,视觉夹具没有伪造原稿中的阀门照片和联系人。 + +代码变更: + +- `apps/user_app/lib/ui/features/tickets/repair_page.dart`:顶部菜单、草稿安全跳转、紧急拨号、故障图标、固定底部操作。 +- `apps/user_app/lib/ui/features/tickets/repair_layout.dart`:紧凑联系地址区和燃气泄漏提示。 +- `apps/user_app/lib/ui/features/tickets/repair_speech_button.dart`:压缩语音按钮占位。 +- `apps/user_app/lib/ui/features/tickets/repair_photos.dart`:照片采集状态文案与布局对齐。 +- `apps/user_app/test/ui/repair_page_test.dart`:验证拨号仅由用户点击触发且目标为119。 + +验证结果:图05在320、360、390、430像素宽度及1.0、1.3文字倍率下共8组视觉测试通过;报修、草稿与语音共12项定向测试通过,Flutter全量162项测试及静态分析通过,带本地API地址的Web Release构建通过。内置浏览器完成密码登录,验证必填提示、报修说明以及跳转本人报修工单;紧急电话不在浏览器中实际拨打。已重新生成等比并排对照图。 + +风险评估:系统拨号、语音识别、相机原始拍摄时间和定位需要Android/iOS真机权限与系统应用配合。当前不自动拨号,也不伪造未采集的拍摄元数据。 diff --git a/docs/开发日志_个人中心视觉与真实资料_20260911.md b/docs/开发日志_个人中心视觉与真实资料_20260911.md new file mode 100644 index 0000000..4bbaffa --- /dev/null +++ b/docs/开发日志_个人中心视觉与真实资料_20260911.md @@ -0,0 +1,42 @@ +# 个人中心视觉与真实资料开发日志 + +操作时间:2026-09-11 +操作类型:修改、扩展 +影响模块:用户资料模型、个人中心、个人资料、视觉fixture与验收文档 + +## 操作前状态 + +图25的主要分组已经存在,但个人中心未读取服务端 `real_name`,实名认证始终缺失;紧急联系人没有真实数量。常用功能行高偏大,390×844只能显示部分入口,底部还有设计稿之外的重复维修和退出按钮。个人资料页即使接口已有实名也显示“认证状态尚未接入”。 + +## 具体操作 + +- `UserProfile` 增加向下兼容的 `realName` 字段并解析服务端 `real_name`。 +- 个人中心在实名存在时显示绿色“已认证”,个人资料页同步显示实名,不再与接口事实矛盾。 +- 并行读取紧急联系人数量;该辅助接口失败时不阻断资料、钱包和其他入口。 +- 常用功能行高、资产区内边距和标题字号按图25收敛,使全部入口在390×844首屏显示。 +- 删除设计稿外重复的“申请维修”和“退出登录”按钮;报修入口及设置页退出保持可用。 +- 当前未接入的家庭共享、用气统计、电子发票、押金、客服和关于入口继续显示“暂未开放”,点击给出明确原因。 + +## 操作后状态 + +远程资料中的姓名、脱敏手机号、实名、钱包余额和联系人数量均按接口事实展示。当前测试账号头像为空、联系人数量为0,因此页面显示首字头像和“0人”;头像编辑页仍提供拍照和相册上传,不把设计稿头像当成用户头像。 + +## 代码变更 + +- `apps/user_app/lib/domain/models/client_models.dart`:增加实名字段解析。 +- `apps/user_app/lib/ui/features/profile/profile_page.dart`:实名、联系人数量和图25布局密度。 +- `apps/user_app/lib/ui/features/profile/profile_edit_page.dart`:同步显示真实认证状态。 +- `apps/user_app/test/support/a1_fixture.dart`:增加认证与联系人视觉fixture。 +- `apps/user_app/test/domain/client_models_test.dart`、`test/ui/profile_edit_page_test.dart`:实名解析和页面回归。 + +## 验证结果 + +- Flutter静态分析通过;全量166项测试通过。 +- 图25在320、360、390、430宽及1.0/1.3文本比例共8组fixture生成通过;已查看390×844官方并排图。 +- Flutter Web release构建通过。 +- 内置浏览器读取远程数据,确认“已认证 陈示例”、联系人0人、全部入口、暂未开放弹层以及拍照/相册头像菜单。 + +## 风险评估 + +- 设计稿中的头像、200元可退押金、2张优惠券和2条消息属于示例或尚无接口数据,当前页面不伪造。 +- 常用功能采用较紧凑行高以匹配图25;320宽和1.3倍文字已通过无溢出fixture,仍需Android/iOS真机复核系统字体差异。 diff --git a/docs/开发日志_供气订单详情视觉与功能状态_20260911.md b/docs/开发日志_供气订单详情视觉与功能状态_20260911.md new file mode 100644 index 0000000..c59fa74 --- /dev/null +++ b/docs/开发日志_供气订单详情视觉与功能状态_20260911.md @@ -0,0 +1,34 @@ +# 供气订单详情视觉与功能状态开发日志 + +操作时间:2026-09-11 22:15 +操作类型:修改、扩展 +影响模块:用户端供气订单详情、视觉验收 + +## 操作前状态 + +订单详情能读取地址、费用、气站、配送员、合同与状态历史,但页面分组和密度与图21差异明显,联系、配送与售后入口缺失,单个确认收货按钮没有形成设计稿操作区。 + +## 具体操作 + +- 按设计稿重排配送状态与时间轴、地址、气站商品、费用明细、订单信息、四项服务和底部操作。 +- 订单信息展示真实支付渠道和供气合同编号,订单号及合同编号均可复制;合同正文继续通过本人鉴权接口读取。 +- 查看配送、联系气站、联系配送员及押金保留首期入口并显示“暂未开放”;申请售后明确属于二期并显示“即将开放”。 +- 待签收订单保留幂等确认收货,并补充封签和编号核对提示。 +- 气瓶订单接口没有商品图片字段时使用标准缺图图标,不截取设计图或伪造商品图片。 + +## 代码变更 + +- `apps/user_app/lib/ui/features/orders/shop_order_detail_page.dart`:新增供气订单专用视觉分组、功能状态弹层、服务操作及紧凑信息行。 +- `apps/user_app/lib/ui/features/orders/gas_order_sections.dart`:压缩四节点时间轴并保持窄屏纵向降级。 +- `apps/user_app/test/ui/gas_order_detail_test.dart`、`apps/user_app/test/ui/shop_order_detail_test.dart`:适配可滚动详情与文字复制操作。 + +## 验证结果 + +- `flutter analyze` 通过。 +- Flutter全量171项测试通过;订单详情定向3项通过。 +- 320、360、390、430像素宽度与1.0、1.3文字缩放共8种供气详情视觉检查通过;并排图已重新生成。 +- Web以本地API地址重新构建成功。内置浏览器读取远程订单 `MOCK-GASORDER-001`,现场展示主页面、“查看配送暂未开放”和“申请售后即将开放”弹层;未确认收货或写入远程数据。 + +## 风险评估 + +远程示例订单完成时间早于下单时间,属于现有数据质量问题;客户端按服务端事实展示,没有改写时间。供气订单详情接口尚无气瓶图片、气站服务电话、受控配送员电话、配送详情和押金明细,相关区域不能判定全功能完成。 diff --git a/docs/开发日志_商品详情视觉与后台数据_20260911.md b/docs/开发日志_商品详情视觉与后台数据_20260911.md new file mode 100644 index 0000000..1c9856b --- /dev/null +++ b/docs/开发日志_商品详情视觉与后台数据_20260911.md @@ -0,0 +1,32 @@ +# 商品详情视觉与后台数据开发日志 + +操作时间:2026-09-11 22:35 +操作类型:修改、扩展 +影响模块:用户端商城商品详情、后台商品图片与属性展示、视觉验收 + +## 操作前状态 + +商品详情只有图片、标题、价格库存、数量和参数的纵向列表,缺少设计稿中的商品信息卡、规格、配送服务、四项保障和带文字的底部入口。分享入口不存在,后台未配置数据与未开放服务没有明确状态。 + +## 具体操作 + +- 按图12重排商品图片、信息、规格、配送服务、保障、说明和底部购买区。 +- 分享按钮复制当前商品深链;收藏、购物车、数量、加购和立即购买继续使用真实功能。 +- 读取登录用户的真实气站服务归属;规格与商品说明继续读取已有商品属性后台资源。 +- 后台未配置规格或参数时显示“暂未配置”;预约送达、配送前联系及四项服务标准作为首期缺口显示“暂未开放”。 +- 后台商品图片直接渲染并支持点击预览,不展示图片地址。 + +## 代码变更 + +- `apps/user_app/lib/ui/features/shop/product_detail_page.dart`:重建商品详情布局,新增分享、服务归属、规格状态、配送及保障提示。 +- `apps/user_app/test/ui/product_detail_page_test.dart`:保留数量和参数验证并适配完整页面滚动。 + +## 验证结果 + +- `flutter analyze` 通过,Flutter全量171项测试通过。 +- 320、360、390、430像素宽度及1.0、1.3文字缩放共8种视觉检查通过,并排图已重新生成。 +- Web使用本地API地址构建成功。内置浏览器读取真实商品10,后台上传图片显示成功;分享显示“商品链接已复制”,配送入口显示“配送服务暂未开放”。未加购或提交订单。 + +## 风险评估 + +真实商品10未配置规格和参数,上传图片内容是商城页面截图,并非设计稿中的正式气瓶商品素材。客户端只能忠实显示后台数据;要达到素材级1:1,需要后台上传对应商品原图并补充商品属性。配送预约、配送前联系和服务标准仍缺服务端契约,因此本页保持部分实现状态。 diff --git a/docs/开发日志_商城分类拖动修复_20260913.md b/docs/开发日志_商城分类拖动修复_20260913.md new file mode 100644 index 0000000..7a660bc --- /dev/null +++ b/docs/开发日志_商城分类拖动修复_20260913.md @@ -0,0 +1,10 @@ +# 商城分类拖动修复 + +- 操作时间:2026-09-13 +- 操作类型:修复 +- 影响模块:用户端商城分类栏 +- 原因:Flutter 默认滚动行为不接受鼠标拖动,桌面内置浏览器无法按住分类栏横向滑动。 +- 代码变更:`apps/user_app/lib/ui/features/shop/shop_page.dart` 的分类栏局部添加 ScrollConfiguration,保留现有输入设备并加入鼠标;不改变全局滚动行为和分类筛选逻辑。 +- 验证:`test/ui/shop_category_scroll_test.dart` 两项测试通过,分别验证鼠标与触屏拖动后的滚动位置和分类筛选结果;Web release 构建通过。 +- 风险范围:仅分类横向滚动容器;未修改 API、数据库或商品数据。 +- 浏览器验证:内置浏览器 18578 商城实际鼠标拖动成功,从分类10、9移动至分类4、3、2;随后点击分类2,仅显示对应商品,筛选正常。 diff --git a/docs/开发日志_商城商品图片上传_20260911.md b/docs/开发日志_商城商品图片上传_20260911.md new file mode 100644 index 0000000..384a573 --- /dev/null +++ b/docs/开发日志_商城商品图片上传_20260911.md @@ -0,0 +1,53 @@ +# 商城商品图片上传 + +## 2026年9月11日最终浏览器复核 + +使用带本地API地址的最新Release Web构建,在内置浏览器重新登录并读取远程数据库。公开商品接口中“示例配送商品 10”的 `image_url` 为有效受控路径,商城卡片和商品详情均已显示该图片;其余当前可见商品的 `image_url` 为空,因此页面正确显示“暂无商品图片”。这不是数据库崩溃,也不是App仍然无法加载图片,而是只有一个商品完成了图片关联。 + +本轮没有用设计稿或生成素材批量覆盖远程商品。后续需由后台为每个正式商品分别上传并启用素材,App会按接口结果显示;商品图片地址继续只在接口内部传输,后台列表、详情和App均以图片预览呈现。 + +## App图片跨域缓存故障定位与修复 + +2026年9月11日内置浏览器对照验证:商品接口code0并返回正确上传路径;后台img显示正常。关闭HTML回退后,Flutter报HTTP request failed, statusCode: 0;原图片响应无Origin请求时缺少Vary,但Cache-Control为public,max-age=86400。这使后台普通图片缓存可被后续App跨域XHR复用,缓存中缺少允许跨域响应头。 + +仅为同一图片追加image_cache=2避开旧缓存,保持Flutter默认解码(无HTML回退),详情立即显示成功,DOM中img数量为0,支持缓存污染结论。修复ServeProductImage为所有成功图片响应添加Vary: Origin;ProductImage仅为/uploads/product-images/路径附加固定缓存版本,迁移旧24小时缓存。移除临时诊断开关、异常打印及先前未解决根因的HTML回退。 + +新增TestProductImageRoundTrip无Origin缓存头回归断言;go test ./internal/logic/upload ./internal/routers通过。API已重建并启动PID39956,继续使用既有远程数据库配置。线上本机接口实测普通请求与Origin=18572请求均200、Vary: Origin,后者具有正确Access-Control-Allow-Origin。不修改商品、图片或交易数据。正式商品素材、购物车浏览器验证及全量视觉验收仍不属于此次根因修复通过结论。 + +## 商品名称展示修正 + +resources.ts中ec_product_image的ec_product_identity关系字段增加商品名称展示配置,列表与表单标题统一为“商品”,保留内部标识和复制能力,不变更API或数据。修正前列表显示截短编号,修正后显示“示例配送商品 10”;内置浏览器确认名称可点击并进入对应商品详情(尾号002015)。pnpm run type:check通过。改动仅影响商品图片资源的展示配置。 + +## 商品图片入口补正 + +此前仅取消路由隐藏不足以让服务端菜单显示图片入口:store/modules/app/index.ts的indexClientMenus按menuCode只保留首个路由,商品与商品图片共用ec_product,图片路由被去重。此前“菜单已显示”的结论不准确。为保留现有权限和菜单契约,在CrudListPage.vue商品列表工具栏新增“商品图片”按钮,导航至ec-images。内置浏览器已从商品管理点击该按钮到达图片列表,类型检查通过。使用路径:电商平台管理→商品管理→右上角商品图片→新建/编辑。App图片显示问题仍待定位。 + +## 2026年9月11日后台实测与展示修正 + +后续联调:测试图片已通过后台“审核→启用”操作;App指定示例配送商品10仍显示图片加载失败。公开图片GET返回200、image/png、正确跨域响应头,后台图片可见。shop_page.dart的ProductImage增加WebHtmlElementStrategy.fallback,作为公开商品图的Web显示回退;Flutter analyze通过,Web构建通过(51.3秒),但浏览器刷新后仍未验证成功,不能视作故障已修复。 + +继续核对时,商品详情公开接口返回业务code500,原因为远程PostgreSQL连接超时。按项目测试环境停止条件暂停依赖数据库的验证,不切换本机数据库。测试图片记录最后已执行启用,尚未恢复待审核或清除;恢复环境后优先核对该记录当前状态和真实图片URI,再完成商城、详情、购物车显示和测试数据清理。上传素材为设计截图,仅用于联调,不是正式商品素材。 + +后台18573已由用户启动;内置浏览器使用初始化模拟管理员成功登录。真实上传 shop-source.png(仅作为联调测试素材,不是正式商品照片),接口返回图片路径,保存到示例配送商品10,图片记录01a08f0f-d398-728a-b344-ff04bfbb8ec1,状态待审核。未审核上线,未验证App展示,不能称为商城全链路通过。 + +用户要求直接上传、显示图片而非地址。ProductImageField.vue删除地址输入框,保留选图、重传及预览;新增ProductImagePreview.vue,将CrudListPage.vue商品图片列及ResourceDetailContent.vue商品图片字段改为缩略图和大图预览,失败显示“图片加载失败”。resources.ts将本资源字段名称改为“商品图片”。platform.ts取消商品图片列表菜单隐藏,仍沿用ec_product权限。 + +验证:pnpm run type:check通过;内置浏览器确认列表缩略图、详情160像素图片和编辑页上传按钮,编辑页已无地址输入框。历史mock图片路径不存在,正确显示加载失败,未用测试图片覆盖原记录。图片仍通过原image_uri字段保存,接口与既有数据兼容。本次测试记录保持待审核,后续需用实际商品素材替换或删除;App商城、详情和购物车显示仍待验证。 + +后台已有ec_product_image资源及商品关联,但仅手填image_uri;新增平台后台ProductImageField.vue,提供选图、上传、预览、错误提示和URI回填。资源保存继续绑定ec_product_identity、sort_no、is_cover,不变更数据库结构。 + +新增POST /upload/product-image,仅允许platform_admin鉴权主体上传公开商品素材,限制JPG/PNG、2MB、4096像素并完整解码。GET /uploads/product-images/:name仅读取独立商品目录内UUID文件名的普通文件,匿名可读并设置nosniff;不公开头像、工单或整个上传目录。 + +涉及文件:backend/api/internal/logic/upload/product_image.go、product_image_test.go、routers/upload.go;frontend/platform_admin/src/views/resource/ProductImageField.vue及ResourceFieldForm.vue。 + +Go上传与路由测试通过。新增测试在临时目录执行后台上传、匿名读回、字节一致和普通用户上传拒绝;不连接远程数据库或缓存。平台后台pnpm run type:check通过。 + +尚未部署新构建或在内置浏览器完成后台上传→保存商品关联→App商城/详情/购物车显示的真实联调。封面唯一性、删除关联后的文件清理策略及运营素材仍需核查。现有业务素材不以生成的假商品照片替代;功能属于首期补齐,不划到二期。 + +上传成功但未保存记录会留下未关联图片,本批不擅自删除资源。上传控件支持保留旧地址,失败不覆盖;不同账号迟到响应不回填。 + +后续修复:关闭编辑窗口时AbortController取消上传,组件卸载后不回填。新增公开读取路径测试,拒绝头像/工单路径穿越、绝对路径、SVG及空文件名,上传与路由测试通过(上传包0.751秒)。后台正式pnpm run build通过,后端新可执行文件构建通过;git diff --check通过。 + +部署操作未执行成功:自动审批拒绝了停止旧API并启动新API及后台预览的命令,理由仅为blocked by policy。未绕过拒绝;端口复核12426仍为原进程1060,18572仍为34588。故不能声称新上传功能已在内置浏览器完成联调。正式构建默认API地址不是本次本机联调地址,部署前必须按实际环境明确配置,不能直接用构建成功替代连接核查。 + +用户明确要求重启API及启动预览后:旧API已停止,新商品图片API首次启动因initdb返回unexpected EOF退出;确认退出后重试,进程31980已监听12426。后台预览启动命令再次被自动审批拒绝,仅返回策略阻止,未绕过。商品图片后台浏览器联调仍未完成。 diff --git a/docs/开发日志_安全内容中心_20260911.md b/docs/开发日志_安全内容中心_20260911.md new file mode 100644 index 0000000..9d87b18 --- /dev/null +++ b/docs/开发日志_安全内容中心_20260911.md @@ -0,0 +1,27 @@ +# 安全内容中心开发记录 + +## 单篇详情接口 + +随后内置浏览器在contents=3新构建加载公告列表,点击“模拟公告9”成功进入内容详情并显示远程正文,真实阅读路径已验证。首轮超时未持续阻断,仍不代表远程环境稳定性已验收。 + +新增GET /heqi/client/v1/user/public/contents/:identity,使用identity、status=1、publish_status=published及notice/safety_article/law类型约束读取,软删除沿用GORM过滤;不存在返回1112。响应白名单仅identity/title/body/content_type/version_no。ClientRepository.safetyContent与页面详情加载接入该接口,避免每次读取全部内容,1112显示已下架说明,其他错误保留重试。 + +content_detail.go/对应测试及routers/client.go为后端变更;App仓储、页面和fixture测试同步更新。后端SQLMock覆盖发布约束和最小字段、无记录返回1112;用户逻辑与路由测试通过;三个Flutter页面测试、analyze及Web构建通过。API已更新至PID45776,仍连接原远程数据库,无迁移或数据写入。首次HTTP联调超时,不以单元测试替代真实联调证据。 + +## 后续扩展:安全宣传与法律法规文本内容 + +后台resources.ts的cms_content新增safety_article(安全宣传)和law(法律法规)选项,沿用已有varchar内容类型及发布/启用流程,不改变原notice/agreement接口契约,无数据库迁移。App安全内容中心支持这两类的筛选与独立正文;协议仍不进入安全内容,视频仍暂未开放。后台表单浏览器已看到新增选项,未发布测试法规或安全知识。三个widget测试、后台类型检查、Flutter analyze与Web构建通过。 + +注意:这仅补齐文本分类维护,不代表图43-1/43-3视觉和完整功能通过;宣传配图、法规级别/文号/施行日期/原文链接、内容分页和公开响应最小化仍待完成。正式发布内容需由运营提供,不能用自动编写的安全法律内容替代。 + +## 范围与实现 + +对照43-4-平台公告设计图,新增安全内容中心与独立公告正文路由。当前后台仅支持notice、agreement,因此仅开放真实公告搜索和阅读,安全宣传、视频、法律法规保留入口并标“暂未开放”,不自行归为二期。 + +核心文件:apps/user_app/lib/ui/features/home/safety_contents_page.dart负责已发布公告筛选、标题搜索、正文、空状态及下架提示;app/router.dart增加公开/contents及/contents/:identity路由;首页新增查看更多并把公告弹窗改为独立详情;设置增加安全内容入口。沿用PrimaryRepository与公开已发布内容接口,无数据库结构或内容变更。刷新详情会重新读取发布状态,协议不能通过公告详情标识被展示。 + +## 验证与缺项 + +Flutter analyze通过,两项widget测试通过,覆盖公告搜索、协议隔离和非公告详情拒绝。Web构建通过;内置浏览器实测列表显示远程模拟公告,搜索“公告 9”仅剩匹配记录。 + +此页未完成1:1验收:缺少设计稿的公告子分类、置顶、摘要、已读状态、发布时间及配图,后台字段和接口仍需补齐。当前列表为已发布内容全集本地搜索,需后续增加分页与服务端筛选。无新增依赖,不将测试公告当正式安全知识内容。图43及43-4只计新增部分实现,不计验收完成。 diff --git a/docs/开发日志_家庭成员与设备共享首期闭环_20260913.md b/docs/开发日志_家庭成员与设备共享首期闭环_20260913.md new file mode 100644 index 0000000..2b6cd00 --- /dev/null +++ b/docs/开发日志_家庭成员与设备共享首期闭环_20260913.md @@ -0,0 +1,51 @@ +# 家庭成员与设备共享首期开发日志 + +操作时间:2026-09-13 +操作类型:新增、扩展 +影响模块:用户端 App、用户端 API、远程开发数据库 + +## 操作前状态 + +个人中心仅显示“家庭成员与设备共享”暂未开放,没有独立页面、家庭关系、邀请确认、设备授权或审计接口。紧急联系人只能维护资料,并明确不代表设备控制或告警通知授权。 + +## 具体操作 + +- 新增家庭成员邀请模型。邀请七天有效,受邀手机号对应的账号本人接受后才建立成员关系。 +- 新增按成员、按设备保存的查看、告警和控制权限。告警或控制权限必须同时具备查看权限。 +- 新增邀请、接受、拒绝、权限调整和撤销审计。审计摘要不记录完整手机号。 +- 新增 `/family` 页面,按设计稿展示家庭概览、成员状态、共享设备、安全规则和固定邀请按钮。 +- 个人中心入口改为真实路由。邀请、撤回和权限编辑均以服务端成功响应为准。 +- 远程开发数据库执行 `migrate-family-sharing` 增量迁移,只创建家庭共享三张表、索引和中文注释。 + +## 行为变化 + +变更前:点击入口只显示“暂未开放”。 + +变更后:房主可以邀请手机号、预设设备范围、撤回待确认邀请、调整成员权限或移除成员;受邀账号可以接受或拒绝。普通紧急联系人不会自动成为家庭成员,也不会自动获得设备权限。 + +## 代码变更 + +- `backend/api/internal/models/user_family_member.go`:家庭成员、设备共享和审计模型。 +- `backend/api/internal/logic/client/user/family.go`:本人隔离、邀请确认、权限替换、撤销和审计业务。 +- `backend/api/internal/routers/client.go`:家庭共享用户端路由。 +- `backend/api/cmd/cli/family_migration.go`、`main.go`:可重复执行的增量迁移命令。 +- `apps/user_app/lib/domain/models/family_sharing.dart`:页面领域模型。 +- `apps/user_app/lib/data/repositories/client_repository.dart`:家庭共享仓储调用。 +- `apps/user_app/lib/ui/features/profile/family_sharing_page.dart`:独立页面及邀请、权限、撤销交互。 +- `apps/user_app/lib/app/router.dart`、`profile_page.dart`:路由和个人中心入口。 +- `apps/user_app/test/ui/family_sharing_page_test.dart`、`test/support/a1_fixture.dart`、`tool/a1_visual_test.dart`:页面和视觉验证。 + +## 验证结果 + +- Go:`go test ./internal/logic/client/user ./internal/routers ./cmd/cli` 通过。 +- Flutter:`flutter analyze` 无问题;家庭共享页面 2 项测试通过;320/360/390/430 宽度及 1.0/1.3 字体倍率共 8 组无溢出。 +- 构建:带本地 API 地址的 Flutter Web Release 构建通过。 +- 远程联调:真实账号创建待确认邀请,响应状态为 10,列表仅显示脱敏手机号;撤回成功并从活动列表消失。 +- 内置浏览器:完成登录、打开 `/family`、打开邀请表单、发送邀请、查看待确认状态、二次确认撤回,并停留在撤回后的真实页面。 + +## 风险评估 + +- 远程测试账号当前没有智能设备,未在真实数据上执行按设备授权。 +- 当前只有一个已知用户端测试账号,接受/拒绝接口尚未完成双账号浏览器联调。 +- 页面使用真实姓名首字头像;成员受控头像资源接口尚未接入,因此与设计稿人物照片存在差异。 +- 设备远程控制能力本身仍由设备控制页面和物联网映射决定,本页授权不会绕过二次确认或设备可用性检查。 diff --git a/docs/开发日志_我的收藏视觉与真实推荐_20260911.md b/docs/开发日志_我的收藏视觉与真实推荐_20260911.md new file mode 100644 index 0000000..98b8b3a --- /dev/null +++ b/docs/开发日志_我的收藏视觉与真实推荐_20260911.md @@ -0,0 +1,27 @@ +# 我的收藏视觉与真实推荐开发日志 + +操作时间:2026-09-11 22:05 +操作类型:修改、扩展 +影响模块:用户端我的收藏 + +## 操作前状态 + +图15已有真实收藏、分类筛选、取消收藏、下架保留、加购和推荐,但顶部多出购物车按钮,商品卡过高,首屏看不到“猜你喜欢”;缺图时还以文字占据图片区域。 + +## 具体操作 + +- 移除产品设计中不存在的顶部购物车按钮,保留返回和管理操作。 +- 压缩筛选区、商品卡边距和图片槽高度,使五条设计状态下的“猜你喜欢”入口进入首屏。 +- 规格使用浅灰标签;接口没有规格时显示“规格暂未配置”。月销量缺少可信统计接口,显示“月售暂未开放”,不伪造设计示例数值。 +- 商品图片继续读取后台封面;无图使用标准缺图图标,不显示图片地址。 +- 保留真实分类筛选、详情、取消收藏、下架删除、条件版本加购和推荐分页。 + +## 验证结果 + +- Flutter analyze通过;收藏2项交互测试通过;320/360/390/430宽度及1.0/1.3文本缩放的8项视觉测试通过。 +- 内置浏览器真实账号显示1条收藏、后台封面、实际分类和价格;分类筛选可切换,“猜你喜欢”弹层加载真实推荐商品9和8。未加购或取消收藏。 +- Web使用`API_BASE_URL=http://127.0.0.1:12426`重新构建并在18572展示。 + +## 风险评估 + +当前收藏数量与产品图示例不同属于真实数据差异。商品10封面是此前上传的联调截图,不是正式商品素材;月销量没有统计口径。页面结构已接近原稿,但在正式素材与销量接口补齐前不判定严格1:1通过。 diff --git a/docs/开发日志_我的记录聚合页_20260912.md b/docs/开发日志_我的记录聚合页_20260912.md new file mode 100644 index 0000000..2da05c4 --- /dev/null +++ b/docs/开发日志_我的记录聚合页_20260912.md @@ -0,0 +1,28 @@ +# 我的记录聚合页开发日志 + +操作时间:2026-09-12(Asia/Shanghai) +操作类型:新增、扩展 +影响模块:用户端 App 个人中心、记录聚合、视觉验收 + +## 操作前状态 + +图26只在需求和设计图中存在,App没有独立路由和可操作页面。供气订单与报修工单已有本人查询接口,设备操作和告警聚合接口尚未落地,发票记录明确属于二期。 + +## 具体操作 + +- 新增`UserRecordsPage`和`/records`路由,并在个人中心常用功能中增加“我的记录”入口。 +- 并行读取本人供气订单和报修工单,按时间排序,支持全部、用气、报修筛选及真实详情跳转。 +- 按图26还原页头、本月概览、六宫格和最近记录。未获取的设备操作数量显示“—”,不写虚假零值。 +- 角阀操作、告警记录保留入口并提示“暂未开放”;发票记录提示“即将开放”。 + +## 验证结果 + +- `flutter analyze`通过。 +- 页面聚合、筛选和两类未开放提示共2项定向测试通过。 +- 320、360、390、430宽度及1.0、1.3文字倍率共8组视觉测试通过,已生成并检查图26并排对照。 +- Web Release按`API_BASE_URL=http://127.0.0.1:12426`构建通过。 +- 内置浏览器从个人中心实际点入,远程返回3条本月报修记录、共5条最近记录;报修筛选成功,首期缺项和二期提示均已点击确认。 + +## 风险评估 + +图26已有可操作页面,但仍为部分实现。设备操作、告警、押金和发票记录尚无统一服务端聚合事实;真实账号数据与设计示例不同,不能据此判定严格1:1和全功能验收通过。 diff --git a/docs/开发日志_我的设备列表_20260911.md b/docs/开发日志_我的设备列表_20260911.md new file mode 100644 index 0000000..5fddcf4 --- /dev/null +++ b/docs/开发日志_我的设备列表_20260911.md @@ -0,0 +1,28 @@ +# 我的设备列表 + +## 实现 + +新增用户鉴权接口 `GET /heqi/client/v1/user/devices`,与产品档案查询共享归属和分页边界,仅返回当前用户启用且未报废、分类为智能阀或报警器的档案。返回分类和是否已配置厂商映射,不返回厂商编号、内部主键或自由参数。未分类和钢瓶均不计入设备数量。 + +图34新增 `/devices` 页面,个人中心“我的设备”进入。支持真实设备总数、名称或编号搜索、全部/角阀/报警器筛选、基本资料查看,并按用户真实分组展示。未知在线、离线和告警数量用缺省符号,不填零;控制、扫码添加与状态筛选明确暂未开放。 + +## 变更文件 + +- `backend/api/internal/logic/client/user/owned_products.go`:分类设备查询。 +- `backend/api/internal/logic/client/user/owned_products_test.go`:本人、分类、状态与响应字段约束。 +- `backend/api/internal/routers/client.go`:设备接口。 +- `apps/user_app/lib/data/repositories/client_repository.dart`:分页读取与会话保护。 +- `apps/user_app/lib/ui/features/profile/devices_page.dart`:新设备页面。 +- `apps/user_app/lib/app/router.dart`、`ui/features/profile/profile_page.dart`:路由和入口。 +- `apps/user_app/test/ui/devices_page_test.dart`:筛选、未知状态和三种宽度大字体。 + +## 验证 + +Go用户逻辑和路由测试通过;Flutter静态检查、四项设备页面测试通过;Web构建通过。初轮测试发现背景容器遮挡ListTile点击反馈,补透明Material后通过。顺带修正此前联系人页面及测试自身的三处lint。 +新版API为 `heqi-user-devices-api.exe`,继续连接原远程数据库,无新增迁移。 + +## 联调记录与限制 + +后台经实际表单创建独立测试档案:`QA-DEVICE-20260911-01`,identity `01a08f7f-5110-7268-93e6-342fce9eccc0`,分类智能阀,归属模拟账户陈女士,不关联厂商硬件编号。停用时用户页显示零设备;已在后台审核启用,继续验证用户页。 +实际后续验证:启用后从个人中心“我的设备”进入,显示1台及测试名称/编号;切换报警器为空,切换角阀恢复记录;点击控制出现暂未开放说明。测试结束通过后台审核恢复停用,测试档案保留用于追溯,不删除或改动原有产品。 +设备分组已在后续批次接入真实新建、改名、删除和设备归组,见 `开发日志_设备分组_20260911.md`。尚未接通真实遥测、远程控制与设备添加;当前远程空数据与设计稿5台设备状态不同,图34不判完整通过。 diff --git a/docs/开发日志_押金管理首期底座与真实空态_20260911.md b/docs/开发日志_押金管理首期底座与真实空态_20260911.md new file mode 100644 index 0000000..27d8015 --- /dev/null +++ b/docs/开发日志_押金管理首期底座与真实空态_20260911.md @@ -0,0 +1,33 @@ +# 开发日志:押金管理首期底座与真实空态 + +操作时间:2026-09-11 22:25 + +操作类型:新增/扩展 + +影响模块:用户端 App、Client API、平台总后台、远程 PostgreSQL 表结构 + +## 操作前状态 + +个人中心的押金入口只能提示缺失,没有独立页、用户端接口、押金规则或押金记录后台入口。 + +## 具体操作 + +- 新增 `deposit_policy` 和 `deposit_record` 模型及增量迁移,迁移为表和每个字段添加中文注释。 +- 新增登录用户的 `GET /heqi/client/v1/user/deposits`,只按 JWT 当前账号返回可退金额、在押数量、状态明细和规则。 +- 新增 `/deposits` 页面,按最新图16实现汇总、四筛选项、明细卡和规则查看。 +- 总后台新增“押金规则”和“押金记录”菜单;规则可新建编辑,记录只读。 +- 修复后台新资源的字段中文名和显式编辑规则,消除启动时空白页。 + +## 操作后状态 + +内置浏览器可见后台押金规则空列表,App 真实账号显示 `¥0.00`、`0个`和“暂无押金记录”。没有伪造产品稿的示例金额或记录。 + +## 验证结果 + +- Flutter analyze、押金页定向测试、8组视觉尺寸和 Web release 构建通过。 +- Go 客户逻辑、路由和平台资源测试通过,真实登录读取返回空列表。 +- 后台资源合同检查、类型检查和生产构建通过;既有 lint 仍有2个旧错误和203个旧警告。 + +## 风险评估 + +后续图17批次已建立退瓶申请、上门回收、验收扣减、余额退款和不可变资金流水,本日志中的初始缺口已关闭。当前剩余风险为远程账号没有真实押金数据,尚不能进行一次不伪造数据的端到端演练。 diff --git a/docs/开发日志_提交订单视觉与支付衔接_20260911.md b/docs/开发日志_提交订单视觉与支付衔接_20260911.md new file mode 100644 index 0000000..52df83e --- /dev/null +++ b/docs/开发日志_提交订单视觉与支付衔接_20260911.md @@ -0,0 +1,34 @@ +# 提交订单视觉与支付衔接开发日志 + +操作时间:2026-09-11 21:20 +操作类型:修改、扩展 +影响模块:用户端商城结算、订单支付路由、视觉验收 + +## 操作前状态 + +提交订单页仅覆盖基础地址、数量、金额和备注,页面分组与最新产品设计差异明显;创建订单后返回订单列表,没有进入支付确认流程。预约、优惠、押金和发票缺少可见的交付状态。 + +## 具体操作 + +- 按图14重排地址、配送时间、气站商品、费用明细、订单服务、支付方式和底部提交栏。 +- 单品与购物车结算读取真实商品、气站、地址和钱包余额;商品存在后台图片时直接显示图片。 +- 下单成功校验订单公开标识并进入 `/payment/shop/:identity`;未知结果保留幂等重试状态。 +- 预约、优惠和押金显示“暂未开放”;明确属于二期的电子发票显示“即将开放”,点击均给出说明。 +- 内置浏览器检查远程默认地址,确认旧数据缺少联系人时如实显示“待补充联系人”,未新增、编辑或提交地址。 + +## 代码变更 + +- `apps/user_app/lib/ui/features/shop/checkout_page.dart`:重建结算布局、接入气站与钱包读取、补充功能状态及支付跳转。 +- `apps/user_app/tool/a1_visual_test.dart`:图14改用双商品购物车状态做视觉夹具。 +- `apps/user_app/test/ui/shop_address_selection_test.dart`、`apps/user_app/test/ui/cart_page_test.dart`:验证下单成功进入支付确认页。 + +## 验证结果 + +- Flutter全量测试171项通过。 +- 图14在320、360、390、430像素宽度及1.0、1.3文字缩放共8种组合通过视觉溢出检查。 +- Web构建通过;390×844并排图已生成。 +- 内置浏览器真实检查地址、商品图片、气站、钱包余额、“暂未开放”和“即将开放”弹层。未点击提交订单,未产生远程写入。 + +## 风险评估 + +配送预约、押金、优惠仍缺服务端契约;当前配送费按既有接口结果显示0元。电子发票属于二期。远程默认地址缺联系人,需用户在地址管理补全后才能用于下单。以上差异均在页面明示,没有伪造设计稿金额或业务状态。 diff --git a/docs/开发日志_气瓶下单首期闭环_20260911.md b/docs/开发日志_气瓶下单首期闭环_20260911.md new file mode 100644 index 0000000..0fd170e --- /dev/null +++ b/docs/开发日志_气瓶下单首期闭环_20260911.md @@ -0,0 +1,23 @@ +# 开发日志:气瓶下单首期闭环 + +操作时间:2026-09-11 23:25 +操作类型:新增、扩展 +影响模块:用户端 App、Client API、供气订单、支付与押金、平台字段展示 + +操作前状态:设计图18没有独立页面和用户创建供气订单接口;合同气瓶、价格、押金、地址和预约时段无法形成用户下单闭环,钢瓶只可用近似图标表示。 + +具体操作: + +- 新增 `/gas/order`,实现气站信息、规格数量、真实钢瓶商品图、配送信息、押金说明、费用明细和二次确认。 +- 新增 `GET /gas/order-options` 与 `POST /gas/orders`,按JWT账户校验有效合同、气瓶占用、本人地址、预约时间、服务端金额和幂等冲突。 +- 为供气订单增加地址、预约时间和押金金额快照;新增 `gasorder_deposit`,支付成功后原子生成正式押金记录。 +- 取消未支付订单时同步释放气瓶占用并取消待支付押金;缺押金规则时返回可见规格和明确原因,但禁止提交。 +- 生成透明背景液化气钢瓶商品图并纳入 Flutter 资产,完成390×844并排比较和8组响应式视觉检查。 + +操作后状态:用户可从首页进入真实气瓶下单页;配置完整时可创建待支付订单并进入支付确认,配置不完整时明确显示首期缺项。远程账号未写入演示订单。 + +代码变更:`gas_order_create.go`、`gasorder_basic.go`、`gasorder_deposit.go`、`gas_deposit.go`、`gasorder.go`、`callback.go`、`client.go`、`gas_order_checkout.dart`、`gas_order_create_page.dart`、`client_repository.dart`、`router.dart`、`home_page.dart`、支付确认和订单详情页面、三套后台字段配置及测试文件。 + +验证结果:Go用户逻辑、支付及路由测试通过;Go Vet与构建通过;Flutter Analyze、气瓶下单Widget测试和8组视觉变体通过;远程只读接口确认返回气站、15kg合同气瓶、地址及6个预约时段,并准确指出押金规则未配置。 + +风险评估:下单范围受现有供气合同及合同已绑定实体气瓶约束。未配置押金规则的首次用瓶不得下单;需运营人员在平台后台“押金规则”完成配置后再做真实创建与支付验收。 diff --git a/docs/开发日志_消息中心首期闭环_20260912.md b/docs/开发日志_消息中心首期闭环_20260912.md new file mode 100644 index 0000000..b93263d --- /dev/null +++ b/docs/开发日志_消息中心首期闭环_20260912.md @@ -0,0 +1,29 @@ +# 消息中心首期闭环 + +操作时间:2026-09-12 23:50(北京时间) +操作类型:新增、扩展 +影响模块:用户消息 API、消息已读回执、用户端消息中心 + +## 操作前状态 + +个人中心消息按钮仅弹出“暂未开放”,仓库没有用户消息表、查询接口、已读状态和独立页面。产品设计图30要求安全、订单、服务、公告总览,分类筛选、未读状态、全部已读和对象跳转。 + +## 具体操作 + +- 新增 `user_message_read`,只保存本人对稳定消息键的已读回执;消息标题和正文继续由订单、工单及已发布公告事实生成。 +- 新增 `GET /heqi/client/v1/user/messages` 和 `PUT /heqi/client/v1/user/messages/read`。查询按当前令牌账号限制订单和工单;公告仅取启用且已发布记录。写入前重新生成本人可见键集合,拒绝任意或他人对象。 +- 新增 `/messages` 页面和个人中心真实入口,完成四类总览、全部/未读/安全/订单/服务筛选、单条已读、全部已读及订单、工单、公告跳转。 +- 安全告警当前没有权威事件表,真实账号显示“暂无消息”,不构造演示报警。通知偏好入口跳转设置页;设置页继续将尚无服务端能力的项目标为“暂未开放”。 +- 通过增量迁移在远程数据库创建 `dev_usage_stat` 与 `user_message_read`,包含表级、字段级中文注释及必要索引;未写入虚构计量或消息数据。 + +## 验证结果 + +- Go 用户端逻辑及路由测试通过。 +- Flutter 消息中心两项测试通过,覆盖分类渲染、未读筛选和真实仓储已读动作。 +- 320、360、390、430宽度与1.0、1.3文字比例共八组视觉测试通过。 +- 内置浏览器读取远程账号:6条订单、4条服务、9条公告消息;点击第一条进入本人商城订单详情,返回后订单未读由6降为5;订单筛选只保留订单消息。 +- 对照图:[30_并排对照.png](视觉验收/A1/30_并排对照.png)。 + +## 风险与剩余项 + +消息是业务快照的当前状态,不是每次状态迁移的历史事件;后续接入 Outbox/Worker 后需改为不可变事件消息。安全告警无权威用户事件接口,首期仍显示真实空状态。通知偏好服务端及严格1:1验收尚未完成,因此图30判为部分实现。 diff --git a/docs/开发日志_用户产品归属查询_20260911.md b/docs/开发日志_用户产品归属查询_20260911.md new file mode 100644 index 0000000..0e84ca3 --- /dev/null +++ b/docs/开发日志_用户产品归属查询_20260911.md @@ -0,0 +1,25 @@ +# 用户产品归属查询 + +操作时间:2026-09-11 +操作类型:新增 +影响模块:用户 App API + +## 行为与接口 + +新增受用户端 JWT 和客户端类型校验保护的 `GET /heqi/client/v1/user/owned-products`。 +只读取当前登录用户拥有且启用、未软删除的 `product_info`,不使用历史归属授予访问权,也不接受请求参数指定其他用户。 + +返回 `items`、`page`、`page_size`、`has_more`。默认每页 30 条,显式分页沿用现有范围校验,最多 100 条。 +每项仅含 `identity`、`name`、`code`、`product_status`、`control_available`、`telemetry_available`。 +两个能力标志均为 false;产品档案同时涵盖钢瓶和瓶阀,不能据此宣称存在可控制设备或实时遥测。 + +## 代码和验证 + +- `backend/api/internal/logic/client/user/owned_products.go`:新增当前归属查询和分页响应。 +- `backend/api/internal/routers/client.go`:新增一个鉴权路由。 +- `backend/api/internal/logic/client/user/owned_products_test.go`:校验登录用户隔离、默认和显式分页、响应白名单及未开放能力。 +- `go test ./internal/logic/client/user ./internal/routers` 通过。 + +## 当前限制 + +此轮仅完成接口基础,不代表“我的设备”页面完成或通过设计验收。尚未接入前端、重启 API 或进行远程数据和浏览器联调。未改动远程数据库结构或数据,也没有启动本地数据库。设备类型分类和真实遥测来源还需继续核对现有协议与实现。 diff --git a/docs/开发日志_用气统计历史日期_20260912.md b/docs/开发日志_用气统计历史日期_20260912.md new file mode 100644 index 0000000..77fd97a --- /dev/null +++ b/docs/开发日志_用气统计历史日期_20260912.md @@ -0,0 +1,24 @@ +# 用气统计历史日期查询 + +操作时间:2026-09-12 +操作类型:补齐首期功能 + +## 变更 + +`apps/user_app/lib/ui/features/profile/usage_statistics_page.dart` 新增 `_anchor` 和 `_selectDate()`,右上角日历由“暂未开放”改为日期选择器。确认选择后将日期传给既有统计接口;切换设备和日、周、月、年周期时保留该日期。取消或重复选择不额外查询,不能选择未来日期。 + +接口继续使用既有 `anchor=YYYY-MM-DD` 参数,无接口兼容性变更,无数据库写入。 + +## 验证 + +`apps/user_app/test/ui/usage_statistics_page_test.dart` 新增数据层参数捕获测试,覆盖取消不查询、确认传入日期、切换周期保留日期。三项页面测试通过。 + +## 尚未完成 + +后续修复:应用此前没有配置 Flutter 本地化委托,日期控件默认英文。已在 `lib/app/app.dart` 配置简体中文区域和官方本地化委托,在 `pubspec.yaml` / `pubspec.lock` 添加 SDK 自带 `flutter_localizations` 及其 `intl` 依赖,统一应用日期、时间和系统控件文案。日期页面三项回归测试通过。 + +中文修复验收:静态检查无问题,Release 构建通过。内置浏览器18575预览已打开日历,截图和语义树均显示“2026年9月”“周六”“上个月”“取消”“确定”,未来日期禁用。仅对该日历完成实际中文核对,其他日期页面仍需逐页验收。 + +补充验证:Flutter analyze 无问题,Web Release 构建通过。内置浏览器在18574独立预览中实际打开日期选择器,选择9月11日并确认返回;18572仍存在旧静态资源缓存。日期控件的月份和星期仍为英文,需要补齐应用中文本地化。当前账号无设备,浏览器验证未覆盖有数据查询,参数传递由页面测试覆盖。 + +本项完成不代表图27全页验收。真实计量数据闭环、安全趋势和严格视觉还原仍需继续;没有设备的账号继续显示真实空态。 diff --git a/docs/开发日志_用气统计联调恢复_20260912.md b/docs/开发日志_用气统计联调恢复_20260912.md new file mode 100644 index 0000000..9150bca --- /dev/null +++ b/docs/开发日志_用气统计联调恢复_20260912.md @@ -0,0 +1,23 @@ +# 用气统计联调恢复 + +操作时间:2026-09-12 23:30(北京时间) +操作类型:修复、验证 +影响模块:平台后台资源初始化、用气统计预览 + +## 原因与变更 + +后台白屏由 `src/api/resources.ts` 的 `f()` 初始化校验触发:新增 `stat_date` 等字段虽设置局部 label,但未注册公共 `fieldLabels`,抛出“资源字段缺少中文名称”并阻止整个后台加载。 + +在 `frontend/platform_admin/src/api/resources.ts` 的字段字典补充统计日期、四项用气量、口径版本和计算时间,共七个名称。保留公共字段校验,未改动权限和业务写入接口。 + +## 验证 + +- 新版 API 已启动于 12426,沿用远程数据库与 Redis 配置。 +- 后台预览 18573 和 App 预览 18572 已恢复。 +- 内置浏览器登录后台,进入“智能气阀管理 → 用气统计数据”,正常显示列表及 0 条记录。 +- 内置浏览器点击新建,设备、日期、用气量、来源、口径版本、计算时间全部显示;未提交虚构计量数据。 +- App 测试账号登录后进入 `/usage`,显示“尚无可统计设备”;历史日期按钮显示“暂未开放”。 + +## 剩余验收 + +本次证明后台初始化和两个预览已恢复,不证明有设备账号的统计闭环。真实设备归属、计量数据、历史日期选择、安全趋势及严格视觉对照仍待完成。图27仍为部分实现,全量目标继续执行。 diff --git a/docs/开发日志_电子发票二期入口_20260912.md b/docs/开发日志_电子发票二期入口_20260912.md new file mode 100644 index 0000000..a734a34 --- /dev/null +++ b/docs/开发日志_电子发票二期入口_20260912.md @@ -0,0 +1,29 @@ +# 开发日志:电子发票二期入口 + +操作时间:2026-09-12(Asia/Shanghai) +操作类型:新增、扩展 +影响模块:用户端订单、电子发票占位页、视觉验收 + +## 操作前状态 + +订单中心“开发票”只弹出通用二期提示,图24没有独立页面,用户无法看到该功能的产品形态和真实订单关联。 + +## 具体操作 + +- 新增`/invoice/:business/:identity`页面,从本人商城或供气订单详情读取订单号、商品和订单商品金额。 +- 供气订单明确展示“押金不计入发票金额”,但不开具或推算可开票金额;开票金额显示“待二期规则确认”。 +- 按产品图保留发票类型、抬头类型、单位名称、税号、发票内容、邮箱、手机号、常用抬头和提交按钮。 +- 页面顶部、字段状态和提交弹窗统一标识“二期功能 · 即将开放”;不创建静态成功响应,不新增发票记录或税务接口。 +- 已完成订单列表和订单详情均可进入该页面。 + +## 验证结果 + +- `flutter analyze`通过。 +- 电子发票页面、订单列表及订单详情共9项定向组件测试通过。 +- 320、360、390、430宽度与1.0、1.3文字倍率共8组视觉检查通过;已生成图24并排对照图。 +- Web Release构建通过。 +- 内置浏览器从远程真实已完成气瓶订单点击“开发票”进入,显示真实订单`MOCK-GASORDER-001`和订单商品金额98元;点击提交后显示“电子发票即将开放”,没有远程写入。 + +## 风险评估 + +发票抬头、税号校验、开票资格、金额口径、申请、税务回执、作废红冲、预览与下载均属于二期,本轮没有伪造。页面视觉已按相同结构对照,但因二期真实字段为空,不能判定严格1:1验收完成。 diff --git a/docs/开发日志_登录页视觉对照_20260911.md b/docs/开发日志_登录页视觉对照_20260911.md new file mode 100644 index 0000000..21f7ec2 --- /dev/null +++ b/docs/开发日志_登录页视觉对照_20260911.md @@ -0,0 +1,43 @@ +# 登录页视觉对照 + +## 本轮修改 + +来源:`doc/用户端APP-最新参考产品设计/01-登录页.png`。 +在内置浏览器以 390×844 核对旧页面,发现登录方式分隔线、输入框和主按钮整体偏下,输入框边框偏淡,主按钮和登录方式文字偏小。 + +修改 `apps/user_app/lib/ui/features/auth/login_page.dart` 的 `build`、`_modeTab`: + +- 品牌宽度从 150 调整到 140,缩短品牌到登录方式的垂直间距。 +- 标签 14、登录方式 16、主按钮 18 字号;输入区约 50 高,边框改为设计图的浅灰色。 +- 主按钮使用更接近来源图的蓝色及五像素圆角。 +- 协议说明移回忘记密码下方,保留真实协议查看;注册入口移到其后,未删除原有注册功能。 +- 调整协议区域按钮内边距,避免手机宽度下多余换行。 + +额外修正此前新增设备卡测试中的一处字符串插值 lint,不改变测试行为。 + +## 验证 + +登录页八项现有测试通过,涵盖登录方式切换、凭据和焦点保护、校验、提交及验证码请求关联。 +Flutter 静态检查通过。 +内置浏览器实际切换到密码登录,并空提交,确认显示手机号和密码必填提示,没有提交登录请求。 +390×844 的主布局已实际截图核对,主要控件位置比修改前接近来源图。 + +## 验收边界 + +尚未判定图 01 完全通过:仍需更细的字体与来源图差异核对,以及其他宽度、放大文字、真实登录和协议内容验收。原图没有注册入口,现有注册功能保留在协议区下方,此处是明确保留的功能差异。 +短信供应商尚未接入,验证码不能宣称已真实发送。 + +设备方面,本轮检查 `product_info` 与 `iot_device_message`:前者含钢瓶等实体档案,后者使用厂商 `device_id`,尚未确认可直接用于用户端的可靠映射。没有将产品档案冒充设计图 34 的在线设备列表,控制和遥测继续提示未开放。此前新增归属接口仍未部署到当前运行 API。 + +## 最终复核:2026-09-11 17:00后 + +操作类型:修改、验证 +影响模块:登录页视觉、验证码交付状态、视觉验收夹具 + +- 事实源929×1693保持比例缩放到390像素宽,放入390×844画布;没有再用满高拉伸图判断间距。 +- 登录页恢复与事实源一致的紧凑纵向节奏,并修正按钮文字样式继承。视觉夹具中的标签、验证码按钮、登录和忘记密码不再显示方框。 +- `UserSession.sendCode`识别服务端`delivery_status=not_sent`,抛出稳定2410提示;页面显示“短信验证码暂未开放”,不启动60秒倒计时。 +- 内置浏览器真实验证:Mock验证码请求显示暂未开放;切换密码模式后使用授权测试账号登录成功并进入首页。 +- 九项登录页面测试、八组320/360/390/430宽度及1.3倍文字视觉用例、Flutter静态检查和Web Release构建通过;全量Flutter 160项测试通过。 + +最终共同区域未发现P0、P1或P2布局问题,品牌尺寸、标签分隔线和局部字重存在P3差异。“首次使用?注册账号”按用户明确要求保留,但不在设计源中;真实短信供应商仍未配置,因此图01最终状态为blocked,不能宣称整页严格1:1和完整功能通过。 diff --git a/docs/开发日志_紧急联系人_20260911.md b/docs/开发日志_紧急联系人_20260911.md new file mode 100644 index 0000000..9f28149 --- /dev/null +++ b/docs/开发日志_紧急联系人_20260911.md @@ -0,0 +1,34 @@ +# 紧急联系人开发 + +## 范围与行为 + +参照图10新增 `/safety/contacts`,个人中心入口进入真实列表。支持姓名、手机号、关系资料的新增、编辑、归档删除,最多五位,列表隐藏手机号中间四位。告警通知、设备查看、远程控制和顺延通知均明确“暂未开放”,不因联系人保存而授权。 + +后端新增鉴权 `GET/POST /emergency-contacts`、`PUT/DELETE /emergency-contacts/:identity`。所有查询限定当前登录账户;写入持有账户行锁,保证五人上限及电话号码去重。新增必须带UUID请求号,相同请求重试返回原记录,已归档记录不能因重试恢复。删除为状态归档。 + +独立迁移命令 `migrate-emergency-contacts` 已对现有远程配置执行成功,只创建 `user_emergency_contact` 及相应注释和唯一索引,未执行全库迁移或初始化。 + +## 文件 + +- `backend/api/internal/models/user_emergency_contact.go`:联系人资料模型。 +- `backend/api/internal/logic/client/user/emergency_contacts.go`:本人CRUD与边界校验。 +- `backend/api/internal/logic/client/user/emergency_contacts_test.go`:输入、上限、越权与响应能力测试。 +- `backend/api/internal/routers/client.go`:四条用户鉴权路由。 +- `backend/api/cmd/cli/emergency_contact_migration.go`、`main.go`:独立事务迁移命令。 +- `apps/user_app/lib/data/repositories/client_repository.dart`:联系人接口和会话保护。 +- `apps/user_app/lib/ui/features/profile/emergency_contacts_page.dart`:列表、编辑和删除确认。 +- `apps/user_app/lib/app/router.dart`、`ui/features/profile/profile_page.dart`:路由及入口。 +- `apps/user_app/test/ui/emergency_contacts_page_test.dart`:失败保留输入和请求号、成功刷新及权限未开放。 + +## 验证及限制 + +Go用户逻辑、路由和CLI测试通过;Flutter静态检查、联系人页面测试、Web构建通过。当前API进程已切换为 `heqi-contacts-api.exe`,仍连接原远程数据库。 +尚未完成告警联动、联系人顺序调整、设备授权审计、后台治理及真机拨号验收;不将这些首期缺项归为二期。图10仍需完善图标、字体、布局和真实状态对照,未判完整通过。 + +内置浏览器390×844实际完成:模拟账号登录后空列表→新增测试联系人→列表显示脱敏手机号→编辑姓名并保存→列表显示新姓名→删除确认→恢复空列表。仅本次新增的测试记录被状态归档,数据库保留幂等记录;未拨打电话、未发短信、未授予设备权限。卡片密度和权限提示布局仍与原图不同,保持部分实现状态。 + +后续视觉修正:恢复蓝底白字编号和关系标签;“权限 · 暂未开放”统一显示在权限行上方,每项仍可点击查看说明。电话、编辑与权限按钮按原图并排,320宽或放大文字时分行。320/390/430宽度、1.3倍字体及长姓名/关系描述测试通过,新增实际点击远程控制后未开放提示的断言。仍未判整页视觉通过。 + +新版Web在内置浏览器390宽用独立“视觉测试”联系人核对,编号、标签及五个操作入口同排可见,远程控制弹出未开放说明。补充后端新增请求重放测试,验证启用记录重试不重复插入、归档记录不能被重试复活,测试通过。 + +错误反馈修正:新增稳定业务码2501(五人上限)、2502(本人列表号码重复)、2503(原请求已处理且资料不一致或已归档)。前端只对明确业务拒绝显示原因,未知网络错误保留“结果未确认”,删除失败使用删除文案。后端联系人测试及七项前端联系人测试通过;新版API已重启,数据库结构未改动。此轮错误分支主要由自动测试验证,不代表所有异常均已完成浏览器联调。 diff --git a/docs/开发日志_订单中心视觉与气瓶操作_20260911.md b/docs/开发日志_订单中心视觉与气瓶操作_20260911.md new file mode 100644 index 0000000..bceb1bb --- /dev/null +++ b/docs/开发日志_订单中心视觉与气瓶操作_20260911.md @@ -0,0 +1,51 @@ +# 订单中心视觉与气瓶操作开发日志 + +操作时间:2026-09-11 +操作类型:修改、扩展 +影响模块:用户端订单中心、用户订单接口、商城订单成交快照、视觉验收脚本 + +## 操作前状态 + +订单中心使用搜索框和稀疏卡片,和图20的页头搜索图标、横向状态标签、商品信息及底部操作差异明显。气瓶订单列表没有气站名称,已分配但未付款状态显示“待处理”,气瓶取消接口已存在但列表未发布取消动作。商城成交快照没有封面地址,新订单详情无法稳定展示成交时图片。 + +## 具体操作 + +- 用户端改为“我的订单”居中页头和搜索图标,保留气瓶订单、商城订单、报修工单三标签。 +- 增加订单号及商品名称搜索,三类列表共同响应搜索条件;状态区保留设计稿固定状态并合并服务端实际状态。 +- 订单卡展示订单号、下单时间、状态、商品、气站、规格、金额及服务端允许动作;点击卡片仍可进入详情。 +- 气瓶取消调用专用接口并要求二次确认;待付款状态同时显示取消订单和去支付。 +- 再次购买、申请售后、电子发票属于后续阶段,入口保留并统一展示“该功能将在后续版本开放,敬请期待。” +- 气瓶订单接口批量读取气站名称,状态16统一为“待付款”,创建态和已分配态发布取消动作。 +- 商城新订单读取后台商品封面并写入 `product_snapshot.image_url`,保证后台后续改图不会改变历史成交快照。历史订单的空快照保持真实,不进行猜测补图。 +- 视觉脚本补入商城订单分支,气瓶与商城均生成320、360、390、430宽和1.0/1.3文本比例截图。 + +## 操作后状态 + +首期已有订单列表、搜索、状态筛选、详情跳转、气瓶取消、商城取消/收货和既有支付/退款动作均有真实接口或页面连接。远程气瓶完成订单可显示气站和15kg规格;商城历史订单可正常读取,之后新建的商城订单能够展示成交时封面。 + +当前气瓶商品类型及订单明细没有图片字段,远程账号也只有1笔完成气瓶订单,因此没有伪造设计稿中的钢瓶图和3笔不同状态数据。图20仍为部分实现,不能标记严格1:1通过。 + +## 代码变更 + +- `apps/user_app/lib/ui/features/orders/orders_page.dart`:三标签控制、页头搜索和列表查询传递。 +- `apps/user_app/lib/ui/features/orders/order_list.dart`:状态筛选、订单卡字段、紧凑操作及后续功能提示。 +- `apps/user_app/lib/domain/models/order_summary.dart`:订单号、气站、规格和图片快照解析。 +- `apps/user_app/lib/data/repositories/client_repository.dart`、`order_action_handler.dart`:气瓶取消接口和动作分流。 +- `backend/api/internal/logic/client/user/gasorder.go`、`gas_detail.go`:气站名称、状态名称及允许动作。 +- `backend/api/internal/logic/client/user/shop.go`、`cart.go`:商品封面读取和成交快照固化。 +- `apps/user_app/test/ui/orders_page_test.dart`、`backend/api/internal/logic/client/user/gas_order_list_test.go`、`checkout_test.go`:真实动作路由、搜索、后续提示及图片快照回归。 + +## 验证结果 + +- Flutter静态分析通过;随后加入个人中心认证解析回归后,全量166项测试通过。 +- Flutter Web release构建通过,API地址为本地12426端口。 +- Go全量测试、`go vet ./...` 和API可执行文件构建通过。 +- API已在12426重启并通过健康检查;继续使用 `etc/heqi_dev.yaml` 中既有远程PostgreSQL和Redis,没有启动本地数据库容器。 +- 160项视觉fixture生成通过;已查看图20气瓶与商城390×844并排对照。 +- 内置浏览器以远程账号验证气瓶订单真实字段、搜索空态、后续版本弹层、商城订单列表及详情;未执行支付、取消或其他写操作。 + +## 风险评估 + +- 历史商城订单没有 `image_url` 的成交快照,无法无依据补回原商品图;新订单已修复。 +- 气瓶商品图片尚无正式数据模型和后台维护入口,继续显示真实的无图状态。 +- 取消和支付属于写操作,本轮浏览器只验证入口与自动测试,没有改变远程业务订单。 diff --git a/docs/开发日志_订单支付确认_20260911.md b/docs/开发日志_订单支付确认_20260911.md new file mode 100644 index 0000000..fc1f633 --- /dev/null +++ b/docs/开发日志_订单支付确认_20260911.md @@ -0,0 +1,45 @@ +# 订单支付确认开发日志 + +操作时间:2026-09-11 20:55:00 +操作类型:新增/扩展 +影响模块:用户端订单、钱包支付、气瓶订单、视觉验收 + +## 操作前状态 + +订单动作使用底部菜单直接选择渠道,没有图19独立支付确认页。气瓶订单支付只处理外部渠道,商城余额支付没有写入统一支付单,退款链路无法找到该支付事实。 + +## 具体操作 + +- 新增`/payment/:business/:identity`,同时读取本人订单详情和钱包,展示应付金额、商品快照、订单号、支付方式、安全验证和金额明细。 +- 余额支付使用六位脱敏圆点输入,扣款前再次确认订单号、渠道和金额;未知结果保留原幂等请求号。 +- 气瓶和商城余额支付均在数据库事务内锁定订单与钱包,校验支付密码,写入订单状态、支付单和钱包记录。 +- 订单不在可支付状态时禁用底部按钮。优惠券无服务端接口,保留位置并显示“暂未开放”及原因弹层。 +- 支付页复用服务端充值渠道就绪配置:当前Web环境未配置商户证书时,微信和支付宝直接显示“暂未开放”并禁用,不再允许用户提交后才得到配置错误。 +- 气瓶订单使用真实钢瓶位图;服务端订单快照有图片时优先显示快照,普通商城商品缺图时保持缺图图标,不伪造商品照片。 +- 按最新图19重校卡片高度、支付方式图标/单选位置、密码圆点、订单明细和固定底部按钮。 + +## 代码变更 + +- `apps/user_app/lib/ui/features/orders/payment_confirmation_page.dart`:支付页与交互。 +- `apps/user_app/lib/ui/core/payment_pin_field.dart`:共用六位支付密码组件。 +- `apps/user_app/lib/app/router.dart`、`apps/user_app/lib/ui/features/orders/order_action_handler.dart`:支付路由与订单入口。 +- `apps/user_app/lib/data/repositories/client_repository.dart`:支付密码、商城/气瓶参数校验与余额结果校验。 +- `backend/api/internal/logic/client/user/gasorder.go`、`shop.go`:原子余额支付、幂等重试和支付事实。 +- `apps/user_app/test/ui/payment_confirmation_page_test.dart`、`backend/api/internal/logic/client/user/wallet_order_payment_test.go`:页面和重试测试。 +- `apps/user_app/test/support/a1_fixture.dart`:视觉夹具提供明确的渠道就绪状态,不访问真实支付渠道。 +- `apps/user_app/tool/a1_visual_test.dart`、`scripts/compare-user-app-a1.ps1`:图19多尺寸截图和并排对照。 + +## 验证结果 + +- Flutter analyze通过,Flutter全量171项通过。 +- 图19在320/360/390/430宽、1.0/1.3文字缩放共8项通过;当前视觉集合共184项通过。 +- Go全量`go test ./...`、`go vet ./...`和API构建通过。 +- Flutter Web Release构建通过,API使用仓库现有远程PostgreSQL/Redis配置重启成功。 +- 内置浏览器展示远程真实订单金额、钱包余额、全部分组和优惠券未开放弹层。 +- 2026-09-11 23:55再次在内置浏览器打开真实已取消订单`EC1788842040900189900`:金额与余额来自远程数据,微信/支付宝显示“暂未开放”,优惠券弹层可见,订单不可支付按钮禁用;未产生订单或资金写入。 + +## 风险评估 + +- 当前测试账号无待付款订单,浏览器展示使用真实已取消订单,没有执行远程扣款。待付款成功路径由组件测试和服务端事务测试覆盖。 +- 微信、支付宝仍依赖环境的真实商户配置与回调;页面按服务端配置状态判定是否可选,尚未做真实渠道回调验收。 +- 优惠券缺少接口和后台配置,当前只显示首期缺失状态,不计为完成。 diff --git a/docs/开发日志_设备分类与厂商映射_20260911.md b/docs/开发日志_设备分类与厂商映射_20260911.md new file mode 100644 index 0000000..c9f2817 --- /dev/null +++ b/docs/开发日志_设备分类与厂商映射_20260911.md @@ -0,0 +1,26 @@ +# 设备分类与厂商映射 + +## 问题与变更 + +现有产品档案既包含钢瓶,也包含瓶阀;原有厂商上行消息使用独立16位设备编号,不能靠名称推测归属关系。 +新增 `product_info.device_kind`(未分类、智能阀、报警器、钢瓶)和 `vendor_device_id`(可为空的厂商编号)。历史记录默认未分类且未关联,不改写现有用户归属。 + +后台产品新建、编辑与详情加入两项配置。后端写入白名单和校验同时扩展:只有智能阀/报警器允许厂商编号,编号必须为16位数字且非全零;非空编号由数据库全局唯一索引约束。空编号表示尚未关联,不代表设备离线。 + +## 文件与验证 + +- 模型:`backend/api/internal/models/product_info.go`。 +- 写入校验:`backend/api/internal/logic/platform/product/product.go`、`device_mapping.go`。 +- 测试:`device_mapping_test.go`。 +- 独立迁移:`backend/api/cmd/cli/device_mapping_migration.go`,命令 `migrate-device-mapping`。 +- 后台字段:`frontend/platform_admin/src/api/resources.ts`。 + +产品逻辑、路由、CLI测试通过,后台类型与48项资源契约检查通过。 +迁移已在原远程配置执行成功,只新增两列、注释和唯一索引;API已切换到 `heqi-device-mapping-api.exe`。 +浏览器首次检查发现通用字段函数要求在中文名称字典注册,即便调用处已有label也不能省略,已补齐并验证列表恢复显示“未分类/未关联”。 + +内置浏览器实际进入新建表单,展开分类选项并选择“智能阀”,确认四项分类及厂商编号输入区可见。此次只检查表单,没有提交新产品或修改已有产品。 + +## 尚未完成 + +此变更只建立明确映射配置,用户“我的设备”页面和遥测读取还未接入,不开放远程控制。未将现有示例产品自动映射到真实厂商编号。配置保存、重复编号及实际设备联调还需继续验证,不能据此宣称设备闭环完成。 diff --git a/docs/开发日志_设备分组_20260911.md b/docs/开发日志_设备分组_20260911.md new file mode 100644 index 0000000..9732d00 --- /dev/null +++ b/docs/开发日志_设备分组_20260911.md @@ -0,0 +1,47 @@ +# 设备分组开发日志 + +操作时间:2026-09-11 16:00—17:00 +操作类型:新增、扩展 +影响模块:用户端设备、平台产品归属、远程 PostgreSQL + +## 操作前状态 + +图08没有独立页面,图34的“设备分组”仅弹出暂未开放提示。产品档案已有本人归属和设备分类,但没有用户分组资料或归组字段。在线状态、安全检查、扫码添加和远程控制没有可用物联网接口。 + +## 具体操作 + +- 新增 `user_device_group`,保存本人分组名称、顺序和新增幂等号;为每个字段及表补充中文数据库注释。 +- 为 `product_info` 增加 `device_group_id`。平台转移产品归属时自动清空旧用户分组,避免新用户继承旧分组。 +- 新增本人分组列表、新建、改名、删除和设备归组接口。所有查询都从登录令牌确定用户,客户端传入其他用户标识无效。 +- 删除分组时在同一事务内把组内设备移回未分组;分组新增限制20个,同名和幂等重放返回稳定错误码。 +- 新增图08页面,完成新建、编辑、删除、查看组内设备及设备归组。群控、安全检查明确提示“暂未开放”。 +- 图34改为真实分组卡片,并按设计稿补齐统计图标、分隔线和427像素Web手机画布;最终轮继续收紧搜索框、筛选控件和统计卡高度。 + +## 代码变更 + +- `backend/api/internal/models/user_device_group.go`:设备分组模型。 +- `backend/api/internal/models/product_info.go`:产品归组字段。 +- `backend/api/internal/logic/client/user/device_groups.go`:分组与设备归组业务。 +- `backend/api/internal/routers/client.go`:五条分组相关路由。 +- `backend/api/cmd/cli/device_group_migration.go`:独立可重复远程迁移。 +- `apps/user_app/lib/ui/features/profile/device_groups_page.dart`:图08页面。 +- `apps/user_app/lib/ui/features/profile/devices_page.dart`:图34分组布局。 +- `apps/user_app/lib/domain/models/device_group.dart`、`data/repositories/client_repository.dart`:分组模型与接口适配。 +- `apps/user_app/lib/app/app.dart`、`app/router.dart`:427像素Web预览画布和页面路由。 + +## 操作后状态 + +用户可创建、改名和删除本人分组,并把本人启用的智能阀或报警器移入或移出分组。删除分组后设备自动回到未分组。页面不展示虚构在线、离线、安全或控制结果。 + +## 验证结果 + +- 远程迁移 `migrate-device-groups` 成功,未启动本机 PostgreSQL、Redis 或 Docker。 +- `go test ./...`、`go vet ./...` 和API构建全量通过;新增输入边界、本人隔离、响应白名单和路由检查。 +- Flutter `analyze` 无问题;设备与分组9项定向测试通过,320/390/430宽度及1.3倍文字无溢出;全量159项Flutter测试通过。 +- Web Release 使用浏览器API地址构建成功。 +- 内置浏览器真实完成:创建“厨房”→测试设备归组→改名“厨房组”→删除→设备回到未分组。测试分组已删除,测试设备已恢复停用,最终为0设备、0分组。 +- 视觉证据:`视觉验收/08-设备分组_并排对照_20260911.jpg`、`视觉验收/34-我的设备_并排对照_20260911.jpg`。 + +## 风险评估 + +遥测、安全检查、扫码添加和远程控制仍缺服务端及厂商能力,页面明确显示暂未开放。源图是5台设备和3个分组,当前远程测试结束后为空状态,无法进行同内容像素验收,因此图08、34仍为视觉验收阻塞状态。 diff --git a/docs/开发日志_设置未开放提示_20260911.md b/docs/开发日志_设置未开放提示_20260911.md new file mode 100644 index 0000000..51ba0cb --- /dev/null +++ b/docs/开发日志_设置未开放提示_20260911.md @@ -0,0 +1,9 @@ +# 设置页未开放提示 + +修改 `apps/user_app/lib/ui/features/settings/settings_page.dart` 中账号认证、登录设备、通知、权限、隐私与注销入口的状态说明,统一为“暂未开放”,点击继续使用现有统一提示弹窗。 + +此前安全告警通知显示“安全告警不可关闭”,但通知通道尚未接入,会让用户误以为已在接收告警。本次改为明确的未开放状态,未增加虚假的开关或通知成功状态。 + +现有两项设置回归测试通过,覆盖缓存清除确认、取消与退出,以及登录密码校验与失败处理。此修改不代表通知、认证或设备功能已完成,也未将它们改归二期。页面整体仍待设计与功能完整验收。 + +Web 构建通过。内置浏览器使用已授权模拟账号完成密码登录,回跳设置页;390×844截图核对未开放状态可见,并点击“安全告警通知”,实际出现“该功能暂未开放,目前无法使用。”。对照图31,分组基本对应,但行高、图标和通知控件仍有差异,未判整页通过。 diff --git a/docs/开发日志_购物车视觉与首期状态_20260911.md b/docs/开发日志_购物车视觉与首期状态_20260911.md new file mode 100644 index 0000000..492e6e0 --- /dev/null +++ b/docs/开发日志_购物车视觉与首期状态_20260911.md @@ -0,0 +1,27 @@ +# 购物车视觉与首期状态开发日志 + +操作时间:2026-09-11 21:50 +操作类型:扩展 +影响模块:用户端购物车、商品图片空状态、购物车模型 + +## 操作前状态 + +图13已有真实购物车增删改查、推荐和结算,但缺气站配送、服务保障、规格与押金状态区,页面层级与产品设计差异明显。 + +## 具体操作 + +- `cart_page.dart` 增加真实服务归属配送卡、服务保障区、商品规格兼容展示和固定栏押金状态;配送、保障和押金缺少首期服务端能力时统一显示“暂未开放”。 +- `cart_item.dart` 兼容解析后续接口可返回的分类与规格字段,旧接口不受影响。 +- `shop_page.dart` 将缺图和加载失败改为标准图片状态图标,不再把图片地址或错误文本当作商品图。 +- 保留原购物车条件版本、服务端确认数量、管理删除、推荐和多商品结算行为。 + +## 验证结果 + +- Flutter analyze 通过;购物车3项交互测试通过;图13在320/360/390/430宽度及1.0/1.3文本缩放的8项视觉测试通过。 +- 已生成 `docs/视觉验收/A1/13_并排对照.png` 并逐项查看。 +- API继续连接既有远程数据库与Redis,本地API重建后在12426监听;Web使用本地API配置重建。 +- 内置浏览器真实账号显示“和气示例气站”、原购物车商品、后台推荐图片、真实金额和状态提示;点击预计送达显示“该功能暂未开放”。未提交订单或修改购物车。 + +## 风险评估 + +原购物车商品未配置封面与规格,真实页面按事实显示缺图图标和“规格暂未配置”。后台已有图片的推荐商品能够显示,但当前上传素材是联调截图,不是正式商品素材。押金、配送时效和服务保障规则仍缺服务端模型,不能伪造设计稿金额或承诺,因此严格1:1仍未通过。 diff --git a/docs/开发日志_退瓶退押金首期闭环_20260911.md b/docs/开发日志_退瓶退押金首期闭环_20260911.md new file mode 100644 index 0000000..dfa6bac --- /dev/null +++ b/docs/开发日志_退瓶退押金首期闭环_20260911.md @@ -0,0 +1,27 @@ +# 开发日志:退瓶退押金首期闭环 + +操作时间:2026-09-11 22:50 + +操作类型:新增/扩展 + +影响模块:用户端 App、Client API、平台总后台、远程 PostgreSQL 增量表 + +## 具体操作 + +- App 新增 `/deposits/return`,只列出服务端允许退瓶的本人押金记录。 +- 地址只能选本人有效地址;预约须晚于30分钟后、时段不超过4小时且不超过31天。 +- 用户需逐项确认瓶体、阀门、停止使用和规则,并通过最终确认对话框后才提交。 +- Client API 新增幂等创建、本人查询和未上门取消;取消后可用新请求号重新申请。 +- 总后台新增“退瓶处理”,状态严格按待上门、待验收、待退款、已完成流转。 +- 验收扣减不得超过原押金;退款与钱包余额、押金状态、退瓶状态及 `deposit_refund` 流水同事务提交。 + +## 验证结果 + +- Go 预约边界、路由、客户逻辑和平台逻辑测试通过,`go vet` 及构建通过。 +- Flutter analyze、押金页和退瓶页定向测试通过;320/360/390/430 与 1.0/1.3 文字缩放共8组视觉变体通过。 +- 总后台资源合同、类型检查和生产构建通过。 +- 远程账号当前无押金记录,因此内置浏览器只能验证真实空态,没有写入伪造押金或退款记录。 + +## 风险评估 + +最近安检合格日期尚无用户端权威查询接口,页面保留位置并标明“暂未开放”。真实资金闭环已实现,但上线前仍需用业务押金数据完成实际端到端演练。 diff --git a/docs/开发日志_配送详情与隐私化轨迹_20260912.md b/docs/开发日志_配送详情与隐私化轨迹_20260912.md new file mode 100644 index 0000000..45bb434 --- /dev/null +++ b/docs/开发日志_配送详情与隐私化轨迹_20260912.md @@ -0,0 +1,40 @@ +# 开发日志:配送详情与隐私化轨迹 + +操作时间:2026-09-12 00:05(Asia/Shanghai) +操作类型:新增、扩展 +影响模块:用户端 App、Client API、视觉验收 + +## 操作前状态 + +图22和图23没有独立用户页面。订单详情的“查看配送”只能进入未开放提示,用户端也没有本人订单的配送人员、有效资质或简化轨迹接口。 + +## 具体操作 + +- 新增本人供气订单配送详情接口,读取订单、气站、配送点、当前配送员、有效资质、最新轨迹摘要和商品;不下发员工内部账号或明文手机号。 +- 新增本人供气订单配送轨迹接口,复用配送端已经上报的不可变轨迹点和订单状态历史。经纬度约化到三位小数后下发,最多返回最近一次配送尝试的200个顺序点。 +- 新增图22配送详情页和图23配送轨迹页,并将订单详情、配送详情、配送轨迹三页串联。轨迹页支持真实刷新,地图背景为项目本地位图,蓝色路线只根据服务端隐私化坐标绘制。 +- 车辆后台资料、受控电话、订单消息和配送问题上报目前没有完整服务,入口保留并明确显示“暂未开放”。这些是首期缺项,没有标为二期。 +- 增加图22、图23的390×844对照图及320、360、390、430四种宽度和1.0、1.3两种文字倍率截图。 + +## 代码变更 + +- `backend/api/internal/logic/client/user/delivery_detail.go`:本人配送详情聚合与能力状态。 +- `backend/api/internal/logic/client/user/delivery_track.go`:本人轨迹、坐标约化和履约节点。 +- `backend/api/internal/routers/client.go`:新增配送详情及轨迹只读路由。 +- `apps/user_app/lib/domain/models/delivery_detail.dart`、`delivery_track.dart`:严格解析服务端事实。 +- `apps/user_app/lib/ui/features/orders/delivery_detail_page.dart`、`delivery_track_page.dart`:图22和图23页面。 +- `apps/user_app/lib/app/router.dart`、`client_repository.dart`:路由和仓储接入。 +- `apps/user_app/assets/staff/delivery-worker.png`、`assets/design/delivery-route-map.png`:视觉夹具人员图和无文字地图底图。 + +## 验证结果 + +- Go:`go test ./internal/logic/client/user ./internal/routers`、`go vet ./...`、`go build`通过。 +- Flutter:`flutter analyze`、配送详情/轨迹组件测试、图23八组视觉测试和Web Release构建通过。 +- 真实只读联调:远程样例订单返回“已完成”、王师傅、脱敏电话、1个隐私化位置点和1个履约节点;未创建或修改订单、资金、轨迹及人员资料。 +- 内置浏览器:从图22页面点击“查看实时轨迹”进入图23,地图、状态、时间线、人员和底部按钮均可见;刷新可重新读取数据;电话入口显示“电话联系暂未开放”。 + +## 风险评估 + +- 远程样例订单只有一个轨迹点,真实页不能形成完整道路折线;Fixture仅用于同状态视觉对照,不能替代真实多点联调。 +- 当前地图是无地名的本地背景,路线表达为隐私化坐标的相对进度,不声称提供精确导航。 +- 配送车辆、头像资源代理、受控呼叫/消息和配送异常上报仍是首期待办,因此图22和图23均记为“部分实现”。 diff --git a/docs/开发日志_钱包提现与银行卡_20260911.md b/docs/开发日志_钱包提现与银行卡_20260911.md new file mode 100644 index 0000000..db036e9 --- /dev/null +++ b/docs/开发日志_钱包提现与银行卡_20260911.md @@ -0,0 +1,40 @@ +# 钱包提现与银行卡开发日志 + +操作时间:2026-09-11 19:40 + +操作类型:扩展 + +影响模块:用户端钱包、银行卡、提现申请、Client API、钱包数据模型 + +## 操作前状态 + +图28已有余额、账单和充值入口,但提现仍弹“暂未开放”,银行卡入口没有页面。服务端已有本人银行卡绑定、解绑、提现申请和提现记录接口,Flutter未接入;银行卡没有默认到账卡字段和切换接口。 + +## 具体操作 + +- 新增`WalletBank`、`WalletWithdrawal`强类型模型,金额继续使用整数分,银行卡只读取脱敏卡号。 +- 新增`/wallet/withdraw`和`/wallet/banks`页面,接入真实余额、到账卡、提现记录、支付密码、二次确认、绑定和安全解绑。 +- 图28的提现和银行卡入口改为真实导航;押金、优惠券、待退款仍保留“暂未开放”入口,不填写设计样例金额。 +- 新增默认到账卡服务端字段与切换接口;首张新卡自动设为默认,解绑默认卡后选择下一张,待处理提现仍禁止解绑。 +- 提现提交只显示服务端返回的待审核或实际状态,不把申请成功写成已到账。 + +## 代码变更 + +- `apps/user_app/lib/domain/models/wallet_account.dart`:银行卡、提现记录及金额解析。 +- `apps/user_app/lib/data/repositories/client_repository.dart`:银行卡、默认卡和提现接口适配。 +- `apps/user_app/lib/ui/features/wallet/wallet_page.dart`:图28真实入口和视觉文案。 +- `apps/user_app/lib/ui/features/wallet/withdrawal_page.dart`:图39完整首期页面。 +- `apps/user_app/lib/ui/features/wallet/bank_cards_page.dart`:图40列表、添加、默认卡和解绑。 +- `backend/api/internal/models/wallet_bank.go`、`internal/logic/common/client_wallet.go`、`internal/routers/client.go`:默认卡字段、接口和事务规则。 +- `apps/user_app/test/data/wallet_account_test.dart`、`test/ui/withdrawal_page_test.dart`、`tool/a1_visual_test.dart`:契约、交互和多尺寸视觉测试。 + +## 验证结果 + +- `flutter analyze`通过;Flutter全量170项测试通过;Web Release构建通过。 +- 图39修复窄屏银行卡下拉和到账时间溢出;图39、40纳入320/360/390/430宽及1.0/1.3倍文字截图。 +- `go test ./...`、`go vet ./...`和API构建通过;新API已在12426重启,继续使用既有远程数据库配置。 +- 内置浏览器确认真实余额、真实可提现余额、脱敏银行卡、添加表单和押金未开放提示;没有提交提现、绑定、解绑或切换现有银行卡。 + +## 风险评估 + +银行卡渠道验证回执、短信验证码供应商和提现到账渠道仍依赖外部服务配置;当前首期支持支付密码校验和平台审核状态。图28的押金、优惠券、待退款仍无真实业务接口,严格1:1与全功能验收继续不通过。 diff --git a/docs/开发日志_首页视觉与入口_20260911.md b/docs/开发日志_首页视觉与入口_20260911.md new file mode 100644 index 0000000..5365971 --- /dev/null +++ b/docs/开发日志_首页视觉与入口_20260911.md @@ -0,0 +1,49 @@ +# 首页视觉与入口开发日志 + +操作时间:2026-09-11 + +操作类型:修改、扩展 + +影响模块:用户端首页、公告领域模型、共享底部导航 + +## 操作前状态 + +首页能读取服务归属和公告,但设备卡、快捷入口、安全记录、公告列表与产品设计图的高度和信息层级差异明显。“查看详情”仍弹未开放提示,已完成的设备列表和设备分组无法从首页进入;公告只显示版本号,没有服务端已有的摘要和日期。游客服务归属没有使用设计卡片。 + +## 具体操作 + +1. 将服务归属卡内边距和图标尺寸收敛到设计比例,并让游客状态复用同一张卡片。 +2. 重排设备未接入状态,保留四项指标结构且不展示虚构遥测。 +3. 将设备详情、设备分组和一键报修接入现有真实路由;受保护入口保留登录回跳。 +4. 快捷入口和安全信息使用设计稿相同的信息层级;未实现能力通过点击弹层说明“暂未开放”。 +5. 公告领域模型读取服务端`created_at`,首页展示前两条真实标题、摘要和日期并进入公告正文。 +6. 用户端底部导航高度调整为64像素,并恢复选中项浅蓝背景。 + +## 代码变更 + +- `apps/user_app/lib/ui/features/home/home_page.dart`:首页布局、真实入口、公告行和未开放入口。 +- `apps/user_app/lib/ui/features/home/service_relation_card.dart`:紧凑布局及游客可点击状态。 +- `apps/user_app/lib/ui/features/home/device_status_placeholder.dart`:诚实设备缺项结构。 +- `apps/user_app/lib/ui/core/feature_entry.dart`:新增紧凑和状态文字显示选项,默认行为保持兼容。 +- `apps/user_app/lib/domain/models/primary_models.dart`、`apps/user_app/lib/data/repositories/primary_repository.dart`:公告日期映射。 +- `apps/heqi_design_system/lib/src/theme.dart`:用户端底部导航视觉。 +- `apps/user_app/test/support/a1_fixture.dart`、`apps/user_app/test/ui/primary_pages_test.dart`:公告双行数据及入口测试。 + +## 行为变化 + +修改前:首页“查看详情”无法打开已完成设备页,分组入口仍显示未开放,公告显示版本号。 + +修改后:首页能进入设备、设备分组、报修及公告正文;公告显示真实摘要和日期;没有接口的设备值仍显示“暂不可查询”,其余未接入入口点击后明确说明当前不可用。 + +## 验证结果 + +- `flutter analyze`:通过。 +- `flutter test --no-pub`:161项通过。 +- 图03视觉用例:320、360、390、430像素宽,1.0与1.3倍文字共8项通过。 +- `flutter build web --release --dart-define=API_BASE_URL=http://127.0.0.1:12426`:通过。 +- 内置浏览器:远程公告标题、摘要、日期显示成功;扫码添加弹出“暂未开放”;设备详情游客路径进入登录页。 +- 并排检查:设计图保持宽高比,主要卡片的顺序、宽度、圆角和密度已收敛。 + +## 风险评估 + +设备遥测、扫码、蓝牙、安全告警与气瓶详情尚无首期接口,首页不能显示设计稿中的示例状态。公告日期使用现有`created_at`,服务端后续若增加独立发布时间,应改为优先读取发布时间。共享导航仅调整用户端品牌,服务端App保持原高度。 diff --git a/docs/开发进度_用户端APP全量功能开发.md b/docs/开发进度_用户端APP全量功能开发.md new file mode 100644 index 0000000..ca5fcaa --- /dev/null +++ b/docs/开发进度_用户端APP全量功能开发.md @@ -0,0 +1,183 @@ +# 用户端 App 全量功能开发进度 + +更新时间:2026-09-13。58张设计中,36张已有页面实现(其中34张部分实现、2张首期功能闭环),22张尚无对应独立完整页面,0张通过严格1:1全功能验收。一级入口存在或弹出“暂未开放”均不计为功能完成。最新浏览器实查见[58页清单](验收清单_用户端58页功能与视觉_20260911.md)。 + +## 已确认范围 + +- 2026-09-12图26“我的记录”:新增`/records`独立页,并行聚合本人供气订单和报修工单,显示本月真实数量、六类入口、最近记录与类型筛选。设备操作、告警记录缺首期接口,点击明确“暂未开放”;发票记录明确属于二期,点击显示“即将开放”。内置浏览器从个人中心实际点入,读取远程数据并完成筛选和两类提示验证。图26仍为部分实现,不判严格1:1验收通过。 + +- 2026-09-11图19支付确认:新增`/payment/:business/:identity`,商城和气瓶订单的应付金额、订单号、商品快照、钱包余额和可支付动作均重读服务端。余额支付接入六位支付密码、二次确认和幂等号;气瓶余额支付与商城支付记录补齐原子交易事实。优惠券缺接口,页面显示“暂未开放”并可查看原因;微信/支付宝读取服务端商户配置就绪状态,未配置时明确显示“暂未开放”并禁用。钢瓶场景使用真实位图,费用明细区分气费、可退押金和配送费。定向功能测试、8组多尺寸视觉、Flutter analyze/Web构建通过。内置浏览器使用远程真实已取消订单展示真实金额、禁用状态和优惠券弹层;当前账号无待付款订单,未提交远程扣款。见[支付确认日志](开发日志_订单支付确认_20260911.md)。 + +- 2026-09-11图28、39、40钱包批次:图28提现和银行卡从占位改为真实路由;图39接入服务端可提现余额、到账卡、提现记录、支付密码、幂等申请和二次确认;图40接入脱敏银行卡列表、绑定、默认到账卡和安全解绑。API新增`POST wallet/banks/:identity/default`并为银行卡增加默认标记。Flutter全量170项、analyze、Web构建及Go全量测试/vet/build通过;内置浏览器只读检查真实远程余额和银行卡,未提交资金或银行卡写操作。押金、优惠券、待退款和外部渠道回执仍缺,三页均未通过严格1:1。见[钱包提现与银行卡日志](开发日志_钱包提现与银行卡_20260911.md)。 + +- 2026-09-11图03第二轮:按最新原稿重新压缩首页服务卡、设备卡、四宫格、安全记录和公告列表;公告展示服务端真实摘要与创建日期,最多显示两条并进入全文。查看设备、分组控制、一键报修已接现有真实路由;扫码、蓝牙、安全告警和气瓶详情点击后明确提示首期“暂未开放”。游客服务归属保持设计卡片并登录回跳。8组多宽度/文字缩放、161项Flutter测试、analyze和Web Release构建通过;内置浏览器读取远程公告并验证未开放弹层。设备遥测接口缺失,图03仍为部分实现,不判严格1:1通过。 +- 2026-09-11图05第二轮:按原稿重校顶部更多菜单、故障图标、描述框、三格照片、联系地址、橙色燃气泄漏提示和固定底部操作。更多菜单可查看本人报修工单或打开报修说明;紧急电话只在用户明确点击后调用系统拨号器,失败提示手动拨打119。视觉夹具保持真实空照片状态0/3,不伪造原稿中的阀门照片。8组多宽度/文字缩放截图、12项报修定向测试及162项Flutter全量测试通过,analyze与Web Release构建通过;内置浏览器验证必填提示、报修说明和本人报修工单跳转。原生语音、拍照时间与定位仍需真机验收,因此图05保持部分实现。 + +- 2026-09-11本轮全量Flutter回归160项通过。设备分组和登录短信状态改动后同时完成 `flutter analyze`、Web构建、Go全量测试、`go vet ./...` 和API构建。内置浏览器确认设备空态、分组闭环、登录成功和未开放提示;不以测试全绿替代58页功能与视觉验收。 + +- 2026-09-11图01再次按保持事实源宽高比的390×844官方并排图复核:共同区域已对齐品牌、标签、输入、按钮、记住状态、忘记密码和协议;修复视觉夹具按钮中文方框。后端返回Mock短信`not_sent`时不再显示虚假倒计时,改为“短信验证码暂未开放”。内置浏览器真实密码登录成功。注册入口按明确要求保留,真实短信未配置,图01保持blocked。 + +- 2026-09-11图03恢复未开放设备区域结构:新增device_status_placeholder.dart,首页保留四个指标名称并展示不可查询状态;调整说明与安全/气瓶入口可见状态。四种宽度×1.3文字测试通过,Flutter analyze通过。不填写原稿模拟遥测,不把占位状态计作设备能力完成。原始批次A1仍未验收通过,后续继续补一级页面差异。 + +- 本轮浏览器验证:新版Web首页的扫码添加、蓝牙连接、分组控制显示“暂未开放”;点击扫码添加显示“该功能暂未开放,目前无法使用。”。一键报修不显示未开放标记。此为状态提示验证,不改变这些功能尚未实现的结论。 + +- 2026-09-11未开放提示规则接入:feature_entry.dart新增显式UnavailableStage,默认首期“暂未开放”,只有传入明确二期状态才显示“即将开放”和后续版本说明;无处理回调的图标入口直接显示状态,个人中心_pending列表显示“暂未开放”。不因缺接口推定二期。两项widget测试覆盖默认状态、二期说明和已开放回调,均通过;这不代表缺失业务功能已完成。其余自定义入口和设置文案仍需继续统一。 + +- 2026-09-11商城图11继续视觉调整:shop_page.dart的商品卡片改为图片/名称入口、16号价格、右下角收藏,去掉稿外购买按钮(购买仍经详情进入结算),明确卡片边框和间距。Flutter analyze和Web构建通过,内置浏览器390×844核对;原稿服务栏、真实素材、规格和销量等未完成,仍为部分实现。上传图片跨域缓存问题已修复,商城/详情已验证可显示,详见商品图片日志;购物车显示仍待本轮完整验收。 +- 2026-09-11商品图片最终复核:内置浏览器中商城卡片和商品详情均已显示“示例配送商品 10”的远程上传图片。公开接口确认该商品有非空 `image_url`,其余当前商品的 `image_url` 为空,所以显示“暂无商品图片”;数据库和App读取链路正常,剩余差异是后台尚未为每个商品配置正式素材。 + +- 按首期补齐要求新增平台后台商品图片上传预览控件及专用上传/公开读取接口,Go测试和后台类型检查通过;真实绑定与App显示尚待内置浏览器联调,见[商品图片开发日志](开发日志_商城商品图片上传_20260911.md)。 + +- 2026-09-11充值页面接入协议版本确认,确认失败不创建充值,契约测试通过;真实记录联调仍待完成,见[充值协议接入](操作日志_用户端APP_充值协议接入_20260911.md)。 + +- 2026-09-11阅读确认接口新增客户端版本匹配及幂等内容核对,用户逻辑测试通过;充值留痕尚待接入,见[协议版本确认](操作日志_用户端APP_协议版本确认_20260911.md)。 + +- 2026-09-11补充充值配置后端边界测试,覆盖未配置渠道、协议缺失、配置路径与鉴权失败,见[充值配置边界测试](操作日志_用户端APP_充值配置边界测试_20260911.md)。 + +- 2026-09-11补齐充值重试1112、查询异常及账号切换分支验证:仅明确不存在允许原请求重发,其余不发单,流程测试通过。 + +- 2026-09-11充值原订单重试增加入账前置查询,已到账不再次拉起支付;见[充值重试入账检查](操作日志_用户端APP_充值重试入账检查_20260911.md)。 + +- 2026-09-11充值页320/390窄屏及1.3倍字体无溢出测试通过,全量Flutter135项通过;视觉1:1仍未通过,见[充值窄屏回归](操作日志_用户端APP_充值窄屏回归_20260911.md)。 + +- 2026-09-11对照图38补充自定义选项、清空金额及确认按钮金额,交互测试通过;浏览器旧构建路由问题已复现,完整验收未完成,见[充值金额控件](操作日志_用户端APP_充值金额控件_20260911.md)。 + +- 2026-09-11修复充值已到账后余额刷新失败被误报未确认,回归测试通过,见[充值到账提示修复](操作日志_用户端APP_充值到账提示修复_20260911.md)。 + +- 2026-09-11充值表单已接入路由及钱包入口,缺协议禁用支付、待确认订单查询测试通过;尚未部署和视觉核对,图38仍不计验收,见[充值表单接入](操作日志_用户端APP_充值表单接入_20260911.md)。 + +- 2026-09-11补充充值待确认请求安全存储和恢复流程,3项测试通过;尚待页面接入,见[充值请求恢复](操作日志_用户端APP_充值请求恢复_20260911.md)。 + +- 2026-09-11接入钱包充值记录页,支持分页、去重、刷新和断网恢复,交互测试及静态检查通过;充值表单与图38视觉验收仍待完成,见[充值记录页面](操作日志_用户端APP_充值记录页面_20260911.md)。 + +- 2026-09-11补充充值渠道配置查询及客户端严格金额、协议和到账状态校验,新增3项测试通过;图38界面仍待接入,见[充值配置校验](操作日志_用户端APP_充值配置校验_20260911.md)。 + +- 2026-09-11补充充值结果、记录及请求号恢复查询,修复同请求号变更金额或渠道的冲突;远程独立事务验证一次入账并整体回滚。图38页面和渠道交互仍未完成,见[充值接口记录](操作日志_用户端APP_充值查询与幂等_20260911.md)。 + +- 2026-09-11修复登录输入连接切换及重复点击清空密码;新构建浏览器已验证遮蔽输入直接登录回到钱包,未新增此前的Web输入空值异常。详见[登录输入记录](操作日志_用户端APP_登录输入连接_20260911.md)。 + +- 设置支付密码已接通首次设置、旧密码修改与验证码找回,增加并发更新保护、一次性验证码和五次输错锁定;远程独立夹具回滚验证通过。真实短信供应商仍未配置,首次设置和找回仅完成Mock联调,不计真实短信可用。见[支付密码日志](操作日志_用户端APP_支付密码_20260908.md)。 + +- 图21已接入商城及气瓶订单详情。气瓶分支读取真实状态历史、配送费、气站、配送员姓名、成功支付记录和本人合同正文;待签收支持本人确认、事务释放占用及幂等重试。常规宽度时间轴按原图横向排列;配送轨迹、押金、受控联系、完整合同及严格视觉仍待补,图21只计部分实现。见 [订单详情日志](操作日志_用户端APP_订单详情_20260908.md)、[供气详情日志](操作日志_用户端APP_供气订单详情_20260908.md)。 + +- 图15已新增受保护收藏列表、真实分类数量、取消收藏、管理批量取消及从收藏加购;商城和详情收藏按钮、个人中心入口均接通。下架商品保留但不可加购,服务端版本防止过期请求复活收藏。远程此前仅定向新增ec_favorite表及注释;原图中的月销和商品素材仍有差异,详见 [收藏日志](操作日志_用户端APP_商品收藏_20260908.md)。 + +- 图13购物车已有真实条目、数量、单选全选、失效提示、管理删除,以及图12加入购物车和多商品结算。条件版本更新避免重试累加;订单事务消费对应购物车行,失败整体回滚。仍缺优惠、押金、配送承诺、服务保障配置和严格视觉还原。见 [购物车日志](操作日志_用户端APP_购物车_20260908.md)。 +- 2026-09-11图13按最新原稿补齐服务归属配送卡、商品规格/押金状态、服务保障区和固定结算栏层级;真实归属、金额、数量、推荐及后台封面继续来自接口。押金、配送时效和保障规则缺服务端能力,入口显示“暂未开放”,严格1:1仍未通过。见 [视觉与状态日志](开发日志_购物车视觉与首期状态_20260911.md)。 + +- 图13推荐区、图15“猜你喜欢”入口已接入真实在售商品分页、换一换、详情和条件加购;排除本人购物车,收藏场景另排除本人收藏,同类优先。窄屏大字体购物车金额单独成行,详情返回重读购物车。此轮无数据库迁移,见 [推荐商品日志](操作日志_用户端APP_推荐商品_20260908.md)。 +- 2026-09-11图15按最新原稿去除多余顶部按钮,压缩筛选与商品卡,使推荐入口进入首屏;规格缺失和月销量缺口分别显示“规格暂未配置”“月售暂未开放”。内置浏览器验证真实收藏、分类筛选、后台封面及推荐弹层。正式商品素材和销量接口未补齐,仍为部分实现。见 [收藏视觉日志](开发日志_我的收藏视觉与真实推荐_20260911.md)。 + +- 2026-09-08图05更新编号步骤、故障表单、三格照片和可操作的第一步联系地址预览;当时64项用户端测试及8组该页视觉检查通过。其后语音输入已接入,真实设备识别、真实采集信息、紧急电话和精细样式仍待验收,详见 [视觉对齐日志](操作日志_用户端APP_报修视觉对齐_20260908.md)、[语音输入日志](操作日志_用户端APP_报修语音输入_20260908.md)。 + +- 图05已有三步故障/联系地址/确认提交表单,故障类型与联系人快照落库,预约选填,重复提交返回首次工单。照片选择、预览、上传和工单关联已接入;报修草稿已按账户及API环境持久化,恢复保留原请求号,成功提交后清理。语音已接入并有自动回归,真实设备识别及采集定位/拍摄时间未验收。远程此前仅新增cs_ticket三个字段,照片复用既有证据表,草稿无数据库变更。见 [报修提交日志](操作日志_用户端APP_报修提交_20260907.md)、[照片日志](操作日志_用户端APP_报修照片_20260907.md)、[草稿日志](操作日志_用户端APP_报修草稿_20260907.md)。 + +- 工单列表可打开独立详情,读取真实地址、预约、处理结果及本人提交照片;取消与确认完成均需用户确认,服务端校验所有权和状态并支持重复操作。图42尚缺工程师联系、完整进度、改期和补充资料,仍为部分实现。见 [工单操作日志](操作日志_用户端APP_工单操作_20260907.md)。 + +- 商城订单取消、确认收货已接入页面,后端新增动作发布及幂等边界保护;详情见 [订单操作日志](操作日志_用户端APP_订单操作_20260907.md)。图20仍为部分实现。 + +- 2026-09-11图20二次实现:订单中心改为设计稿三标签和紧凑卡片,补订单号/商品搜索、固定状态筛选、气站与规格、气瓶取消入口;后端列表补气站名称,气瓶待付款状态与可取消动作统一。商城新订单会把后台商品封面固化到成交快照,历史空快照不伪造补图。再次购买、申请售后和电子发票按既定后续阶段保留入口并提示“即将开放”。165项Flutter测试、Go全量测试/vet/build、160组布局图与内置浏览器真实远程数据走查通过;气瓶商品图片模型及原稿三笔同状态数据仍缺,图20继续记为部分实现。见 [订单中心日志](开发日志_订单中心视觉与气瓶操作_20260911.md)。 + +- 2026-09-11图25二次实现:个人中心读取真实实名认证和紧急联系人数量,压缩资产、家庭及常用功能布局,使全部设计入口在390×844首屏可见;重复维修/退出按钮移除,原功能保留在报修入口和设置页。当前远程账号头像为空、联系人0人,页面如实显示首字头像和0人;头像拍照/相册上传入口已在内置浏览器打开。未接入功能保留“暂未开放”标签与弹层。Flutter全量166项测试、160项视觉fixture及Web release构建通过,图25仍因资产和消息等业务缺失保持部分实现。见 [个人中心日志](开发日志_个人中心视觉与真实资料_20260911.md)。 + +- 后续功能入口不隐藏,点击解释当前未开放;设备控制与遥测仍归 B,不模拟在线、关阀或电量。 +- 37 邀请注册补入 A2。现有 /register、ClientRecord 和旧 API 均保留。 +- 头像与昵称编辑已在浏览器通过实际选图、上传、保存、受保护图片读取验证;测试资料已恢复。2026-09-08 Android Debug APK构建通过,之前Maven下载与缓存阻塞已解除;原生相机、语音和iOS设备仍未验收。详情见 [纠偏日志](操作日志_用户端APP_头像与视觉纠偏_20260907.md)、[语音输入日志](操作日志_用户端APP_报修语音输入_20260908.md)。 +- 使用 backend/api/etc/heqi_dev.yaml 中既有远程 PostgreSQL、Redis。本地仅运行 API 与 Flutter Web;地址管理已执行三个字段的定向新增迁移,未执行全库迁移、清库或 mock-data。 +- 远程只读检查已确认基础角色、管理员、指定测试账号、上架商品存在,Redis PING 通过。 + +## 逐页状态 + +状态针对最新设计的独立页面;“未开始”不表示旧仓库没有同类基础接口。所有接口相对于 /heqi/client/v1/user;测试位于 apps/user_app/test,视觉测试统一为 tool/a1_visual_test.dart。未落地的路由和接口不编造为已存在。 + +| 设计图 | 批次 | 状态 | 已有路由/入口 | 对应接口 | 测试 | 视觉截图 | +| --- | --- | --- | --- | --- | --- | --- | +| 01-登录页.png | A1 | 部分实现 | /login;密码登录、协议、忘记密码、注册入口及安全回跳;短信未发送明确暂未开放 | POST auth/login、auth/verification-code、auth/reset-password;GET public/contents | login_page_test.dart 9项、8组多尺寸视觉、内置浏览器真实密码登录 | [390×844](视觉验收/A1/01_390_1.0.png)、[官方对照](视觉验收/A1/01_并排对照.png)、[浏览器截图](视觉验收/01-登录页_实现_20260911.jpg);注册入口和真实短信使整页保持blocked | +| 02-1-安全案例.png | D | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | +| 02-2-法律法规.png | D | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | +| 02-3-气价信息.png | D | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | +| 02-智能角阀功能介绍.png | D | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | +| 03-首页.png | A1 | 部分实现 | /home | GET public/contents、service-relation | primary_pages_test.dart、service_relation_card_test.dart | [390×844](视觉验收/A1/03_390_1.0.png)、[对照](视觉验收/A1/03_并排对照.png) | +| 04-智能角阀控制.png | B | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | +| 05-一键报修.png | B | 部分实现 | /repair | POST tickets;GET addresses | repair_page_test.dart、ticket_create_test.go | [390×844](视觉验收/A1/05_390_1.0.png),未通过严格1:1 | +| 06-1-扫码添加确认.png | B | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | +| 06-2-蓝牙连接设备.png | B | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | +| 06-3-手动输入设备码.png | B | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | +| 07-气瓶基本信息.png | B | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | +| 08-设备分组控制.png | B | 部分实现 | `/device-groups`;分组新增、改名、删除、设备归组真实可用;群控与安全检查暂未开放 | 内置浏览器427宽真实新建、归组、改名、删除闭环 | Go权限/幂等与Flutter交互、大字体测试通过;同状态视觉验收受真实数据限制 | [并排对照](视觉验收/08-设备分组_并排对照_20260911.jpg)、[日志](开发日志_设备分组_20260911.md) | +| 09-安全告警与报警器.png | B | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | +| 10-紧急联系人.png | B | 部分实现 | 本人联系人资料新增、编辑、归档及五人上限;授权和通知未开放 | 内置浏览器390宽真实资料操作,布局仍有差异 | API边界与前端重试测试通过,未完成全页验收 | 开发日志_紧急联系人_20260911.md | +| 11-燃气商城.png | A1 | 部分实现 | /shop | GET public/products;POST shop/orders | primary_pages_test.dart、primary_repository_test.dart | [390×844](视觉验收/A1/11_390_1.0.png)、[对照](视觉验收/A1/11_并排对照.png) | +| 12-商品详情.png | C | 部分实现:后台商品图、预览、价格库存、数量、分享、收藏、加购结算、服务归属及设计分组;规格/参数读取后台配置,配送预约与保障标准显示“暂未开放” | /products/:identity | GET public/products/:identity、GET service-relation;既有商品图片与属性后台资源 | product_detail_page_test.dart、product_detail_test.go、router_test.dart;Flutter全量171项、视觉8视口与内置浏览器真实商品核对 | [390×844](视觉验收/A1/12_390_1.0.png)、[并排图](视觉验收/A1/12_并排对照.png)、[日志](开发日志_商品详情视觉与后台数据_20260911.md) | +| 13-购物车.png | C | 部分实现 | /cart、/cart/checkout;详情加入购物车、推荐区 | GET shop/cart、GET/PUT shop/cart/items/:identity;GET shop/recommendations;POST shop/orders | cart_page_test.dart、cart_test.dart、cart_test.go、product_recommendations_test.dart、recommendation_test.go | [截图](视觉验收/A1/13_390_1.0.png)、[对照](视觉验收/A1/13_并排对照.png),未通过严格1:1 | +| 14-提交订单.png | A2 | 部分实现:地址、气站与商品、数量、费用、备注、钱包余额和下单后支付衔接;预约/优惠/押金显示“暂未开放”,二期发票显示“即将开放” | /checkout/:identity、/cart/checkout、/payment/shop/:identity | GET addresses、service-relation、wallet;POST shop/orders、cart/checkout(可选 expected_payable_amount) | checkout_test.go、shop_address_selection_test.dart、cart_page_test.dart;Flutter全量171项、视觉8视口及内置浏览器真实数据核对 | [390×844](视觉验收/A1/14_390_1.0.png)、[并排图](视觉验收/A1/14_并排对照.png)、[日志](开发日志_提交订单视觉与支付衔接_20260911.md) | +| 15-我的收藏.png | C | 部分实现 | /favorites;商城/详情收藏、个人中心入口、推荐弹层 | GET shop/favorites;GET/PUT shop/favorites/items/:identity;GET shop/recommendations | favorite_test.go、favorite_test.dart、favorites_page_test.dart、product_recommendations_test.dart | [截图](视觉验收/A1/15_390_1.0.png)、[对照](视觉验收/A1/15_并排对照.png),未通过严格1:1 | +| 16-押金管理.png | A2 | 部分实现:真实汇总、状态筛选、押金明细、规则查看和管理后台;远程当前为空数据 | /deposits | GET deposits;后台 deposit_policy/deposit_record | deposit_page_test.dart、8组视觉变体、Go路由/逻辑测试、内置浏览器真实空态 | [并排图](视觉验收/A1/16_并排对照.png)、[日志](开发日志_押金管理首期底座与真实空态_20260911.md);退瓶、验收、扣减、退款流水未完成 | +| 17-退瓶退押金.png | A2 | 已实现首期闭环:选择本人可退气瓶、上门地址、预约时段、瓶况确认、二次确认、取消、后台回收/验收/扣减/余额入账 | /deposits/return?deposit=:identity | POST/GET/cancel deposit-returns;后台 confirm-pickup/inspect/complete-refund | deposit_return_page_test.dart、预约边界与路由测试、8组视觉变体;真实账号无可退气瓶故未提交业务记录 | [并排图](视觉验收/A1/17_并排对照.png)、[日志](开发日志_退瓶退押金首期闭环_20260911.md);安检最新结论接口仍缺,页面标“暂未开放” | +| 18-气瓶下单.png | C | 已实现首期闭环 | /gas/order;首页“气瓶下单”入口 | GET /gas/order-options;POST /gas/orders;支付成功转正式押金 | gas_order_create_page_test.dart、预约边界、路由测试、8组视觉变体 | [并排图](视觉验收/A1/18_并排对照.png)、[日志](开发日志_气瓶下单首期闭环_20260911.md);远程账号押金规则未配置,真实页明确显示“押金规则暂未配置”并禁止下单 | +| 19-支付确认.png | A2 | 部分实现 | /payment/:business/:identity;余额/微信/支付宝、渠道就绪状态、六位支付密码、二次确认;优惠券暂未开放 | GET shop/gas orders/:identity、wallet、wallet/recharge-options;POST shop/gas orders/:identity/pay | payment_confirmation_page_test.dart、wallet_order_payment_test.go、8组视觉及内置浏览器真实取消订单只读走查 | [390×844](视觉验收/A1/19_390_1.0.png)、[对照](视觉验收/A1/19_并排对照.png);真实待付订单、外部支付页和回调仍待验收 | +| 20-订单中心.png | A1 | 部分实现 | /orders;三业务标签、搜索、状态筛选、真实字段、详情与服务端允许动作;后续功能明确提示 | GET shop/orders、gas/orders、refunds、tickets;POST gas/orders/:identity/cancel;既有支付/退款/商城取消与收货 | orders_page_test.dart 6项、gas_order_list_test.go、checkout_test.go;内置浏览器远程列表/搜索/详情/二期提示 | [气瓶390×844](视觉验收/A1/20_390_1.0.png)、[气瓶对照](视觉验收/A1/20_并排对照.png)、[商城对照](视觉验收/A1/20-shop_并排对照.png);气瓶图片模型和同状态数据仍缺,未通过严格1:1 | +| 21-订单详情.png | A2 | 部分实现:配送状态、地址、气站商品、费用、支付方式、合同、服务操作及本人签收;首期配送轨迹/受控联系/押金显示“暂未开放”,二期售后显示“即将开放” | /gas/orders/:identity、/shop/orders/:identity | GET gas/orders/:identity、GET gas/contracts/:identity、POST gas/orders/:identity/confirm-receipt | gas_detail_test.go、gas_order_detail_test.dart、shop_order_detail_test.dart;Flutter全量171项、视觉8视口与内置浏览器真实订单核对 | [商城对照](视觉验收/A1/21_并排对照.png)、[供气并排图](视觉验收/A1/21-gas_并排对照.png)、[日志](开发日志_供气订单详情视觉与功能状态_20260911.md) | +| 22-配送详情.png | A2 | 部分实现:本人订单、配送状态、预约、配送员、有效资质、气站/配送点、商品、地址、脱敏联系人和安全交付提示;车辆及受控联系明确“暂未开放” | /gas/orders/:identity/delivery | GET gas/orders/:identity/delivery | delivery_detail_test.go、delivery_detail_page_test.dart、8组视觉变体;内置浏览器真实订单核对 | [并排图](视觉验收/A1/22_并排对照.png)、[日志](开发日志_配送详情与隐私化轨迹_20260912.md);配送车辆后台配置、真实员工头像资源、受控电话/消息仍缺 | +| 23-配送轨迹.png | A2 | 部分实现:本人订单鉴权、约百米隐私化坐标、地图路线、履约节点、更新时间、刷新、配送员与未开放能力提示 | /gas/orders/:identity/delivery/track | GET gas/orders/:identity/delivery/track;复用配送端轨迹上报 | 坐标约化/状态节点Go测试、delivery_track_page_test.dart、8组视觉变体;内置浏览器从22页进入并刷新 | [390×844](视觉验收/A1/23_390_1.0.png)、[并排图](视觉验收/A1/23_并排对照.png)、[日志](开发日志_配送详情与隐私化轨迹_20260912.md);远程样例仅1个轨迹点,受控电话和配送问题上报仍缺 | +| 24-电子发票.png | C | 部分实现:已完成订单保留入口、读取本人真实订单、展示订单号/商品/商品金额/押金排除说明和完整二期表单结构;所有申请动作明确“即将开放”且不写入 | /invoice/:business/:identity;订单列表和详情入口 | 复用GET gas/shop orders/:identity;未新增伪开票接口 | invoice_preview_page_test.dart、订单入口回归、8组视觉变体;内置浏览器从真实已完成订单进入并点击提交提示 | [390×844](视觉验收/A1/24_390_1.0.png)、[并排图](视觉验收/A1/24_并排对照.png)、[日志](开发日志_电子发票二期入口_20260912.md);开票、抬头、税务回执、预览下载均属于二期 | +| 25-个人中心.png | A1 | 部分实现 | /me;真实头像入口、实名认证、联系人数量、资产/服务/家庭/常用入口及缺项提示 | GET auth/profile、auth/avatar、wallet、emergency-contacts;既有订单/设备/合同/地址/收藏/报修动作 | profile_edit_page_test.dart、client_models_test.dart、160项多尺寸视觉;内置浏览器真实资料/提示/头像菜单 | [390×844](视觉验收/A1/25_390_1.0.png)、[对照](视觉验收/A1/25_并排对照.png);远程为空头像和0联系人,押金/券/消息等仍缺 | +| 26-我的记录.png | D | 部分实现 | /records;供气和报修聚合、筛选及详情 | 本人订单与工单查询 | 页面测试、内置浏览器检查 | 设备与告警记录待补,严格1:1待验收 | +| 27-用气统计.png | D | 部分实现 | /usage;设备、周期、图表、明细及后台计量管理 | GET user/usage-statistics;平台dev_usage_stat | 内置浏览器后台列表/新建表单、App无设备空态;后台类型检查通过 | 历史日期、安全趋势、真实计量闭环及严格1:1待验收 | +| 28-我的钱包.png | A2 | 部分实现 | /wallet、/records/wallet、/wallet/recharge、/wallet/withdraw、/wallet/banks | GET wallet、wallet/bills;充值、本人银行卡和提现接口 | 金额、会话、分页、隐私、银行卡及提现二次确认测试;内置浏览器真实只读检查 | [对照](视觉验收/A1/28_并排对照.png)、[日志](开发日志_钱包提现与银行卡_20260911.md);押金/券/待退款及严格1:1待补 | +| 29-地址管理.png | A2 | 部分实现:增删改与默认切换可用,范围判定/定位待补 | /addresses、/addresses?select=1 | GET/POST addresses;PUT/DELETE addresses/:identity;POST addresses/:identity/default | addresses_page_test.dart、address_test.go、真实远程读写恢复 | [390×844](视觉验收/A1/29_390_1.0.png)、[日志](操作日志_用户端APP_地址管理_20260907.md) | +| 30-消息中心.png | D | 部分实现 | /messages;四类总览、筛选、已读及真实对象跳转 | GET messages、PUT messages/read;订单、工单、已发布公告事实聚合 | 页面2项、8组视觉;内置浏览器远程读取、单条已读、订单详情与筛选 | [对照](视觉验收/A1/30_并排对照.png);安全事件、通知偏好、不可变历史消息及严格1:1待补 | +| 31-设置.png | D | 部分实现 | /settings、/settings/password、/settings/payment-password | 登录及支付密码、已发布协议、真实版本、图片缓存及退出 | 改密版本保护、原子验证码、五次锁定、远程回滚及页面布局测试 | [对照](视觉验收/A1/31_并排对照.png)、[支付密码日志](操作日志_用户端APP_支付密码_20260908.md),真实短信、通知、权限、注销及严格1:1待补 | +| 32-供气合同.png | A2 | 部分实现 | /records/contracts;个人中心入口与深链 | 本人列表、正文、受控PDF、状态历史;搜索筛选;申请变更、气站答复、取消/确认 | 列表/附件/历史/申请权限及幂等测试;远程申请答复确认回滚;Web下载哈希一致;8组布局 | [对照](视觉验收/A1/32_并排对照.png)、[申请日志](操作日志_用户端APP_合同申请与个人中心复核_20260908.md),原生保存、签署、地址快照、续签提醒及严格1:1待补 | +| 33-家庭成员与设备共享.png | D | 部分实现:独立家庭页、手机号邀请、受邀账号接受/拒绝、待确认撤回、成员移除、按设备查看/告警/控制授权及不可变审计;紧急联系人不自动获权 | /family;个人中心入口 | GET /family、POST/DELETE /family-members、PUT device-permissions、GET/POST family-invitations | Go逻辑/路由/CLI测试,Flutter页面2项及8组宽度/字体视觉测试;远程邀请和撤回真实联调;内置浏览器可见操作 | [并排图](视觉验收/A1/33_并排对照.png)、[日志](开发日志_家庭成员与设备共享首期闭环_20260913.md);远程账号无设备且无第二账号,接受和真实设备权限执行未做双账号联调,成员头像资源也未接入,故暂不判完整闭环或严格1:1 | +| 34-我的设备.png | B | 部分实现 | 本人已分类设备列表、搜索、分类、分组和档案;遥测、扫码及控制暂未开放 | 内置浏览器427宽后台启用到App显示、归组、删组回退联调 | 查询隔离、分类、分组与大字体测试通过;源图为5设备状态,当前远程空状态未判1:1 | [并排对照](视觉验收/34-我的设备_并排对照_20260911.jpg)、[日志](开发日志_我的设备列表_20260911.md) | +| 35-安全记录详情.png | B | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | +| 36-申请售后.png | C | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | +| 37-邀请注册.png | A2 | 未开始 | /register(旧基础) | 待该批次核对 | 未建立该页验收 | 未生成 | +| 38-余额充值.png | A2 | 部分实现,真实支付不可用 | /wallet/recharge、/wallet/recharge-records | 充值配置、创建、记录及请求恢复;协议确认代码 | 金额、恢复、页面及配置测试 | 浏览器已实查,缺卡片/插图且渠道不可用,未通过1:1 | +| 39-余额提现.png | A2 | 部分实现 | /wallet/withdraw;余额、到账卡、金额、支付密码、记录和二次确认 | GET/POST wallet/withdrawals;GET wallet/banks | wallet_account_test.dart、withdrawal_page_test.dart、8组多尺寸视觉;浏览器只读检查 | [对照](视觉验收/A1/39_并排对照.png);外部到账回执及严格1:1待补 | +| 40-银行卡管理.png | A2 | 部分实现 | /wallet/banks;脱敏列表、绑定、默认卡、解绑 | GET/POST/DELETE wallet/banks;POST wallet/banks/:identity/default | 接口契约、路由、8组多尺寸视觉;浏览器打开添加表单 | [对照](视觉验收/A1/40_并排对照.png);银行验证回执和短信供应商待补 | +| 41-个人资料.png | A2 提前修复 | 部分实现 | /profile/edit;点击个人中心头像或资料行 | GET/PUT auth/profile、GET auth/avatar、POST /upload/avatar、GET service-relation/addresses | profile_edit_page_test.dart、avatar_upload_test.dart、profile_test.go、avatar_test.go | [390×844](视觉验收/A1/41_390_1.0.png)、[对照](视觉验收/A1/41_并排对照.png) | +| 42-报修工单详情.png | A2 | 部分实现 | /tickets/:identity;订单内报修标签 | GET tickets、POST tickets/:identity/cancel、confirm | ticket_detail_page_test.dart、ticket_actions_test.go | [390×844](视觉验收/A1/42_390_1.0.png),未通过严格1:1 | +| 43-1-安全宣传.png | D | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | +| 43-2-安全视频.png | D | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | +| 43-3-法律法规.png | D | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | +| 43-4-平台公告.png | D | 部分实现 | `/contents/:identity`;读取本人可见的已发布公告正文 | GET public/contents/:identity;按发布状态、类型和identity读取 | content_detail_test.go、safety_contents_page_test.dart、内置浏览器真实公告正文 | 尚缺摘要、已读、时间、图片与严格1:1;见开发日志_安全内容中心_20260911.md | +| 43-安全内容中心.png | D | 部分实现 | `/contents`;公告、安全宣传、法律法规筛选与标题搜索,视频暂未开放 | GET public/contents、public/contents/:identity | 内容权限、下架、搜索与页面测试 | 内置浏览器真实公告列表;尚缺分页、运营内容和严格1:1 | +| 44-自动关阀设置.png | B | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | +| 45-电子保修卡.png | D | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | +| 46-设备健康月报.png | D | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | +| 47-安全知识考试.png | D | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | +| 48-服务评价.png | C | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | +| 49-预约安全巡检.png | C | 未开始 | 尚未建立独立页 | 待该批次核对 | 未建立该页验收 | 未生成 | + +共58张:34张部分实现、22张尚无对应完整页面、2张已完成首期功能闭环、0张严格验收通过。历史A1五页记录仅代表当时批次,不代替当前逐页状态。 + +## 本批次验证 + +- 改动前:用户端 analyze 无问题、33 项测试通过;Go 全量测试通过。 +- 改动后:用户端 analyze 无问题、44 项测试通过;40 组页面截图和滚动溢出检查通过(320/360/390/430 × 844,文本 1.0/1.3)。Web Release 构建通过。 +- 共享包 analyze 与 3 项测试通过;服务端 App analyze 与 21 项测试通过。 +- Go test ./...、go vet ./...、go build 通过;新增协议重复确认测试定向通过。 +- 平台 contract:check 通过 48 个资源;未修改模型、注册路由、后台资源或权限菜单,无需生成契约变更。 +- 远程真实联调:密码登录,公开内容、分页商品、用户资料、钱包、地址、商城/供气订单、服务归属全部返回 code=0。 +- Mock 商品下单两次复用同一 request_no,只生成同一订单且只扣库存一次;取消后库存恢复。保留已取消订单 identity:01a07bc0-6237-7ec2-8cbc-1c05f16ad10b。未调用支付、退款或资金删除。 +- 浏览器实测:游客首页与未开放弹窗、受保护订单登录回跳、本人订单、脱敏个人资料、钱包摘要、商品搜索、未记住会话刷新失效及重复进入报修通过。 + +## 视觉结论与差异 + +见 [A1 视觉验收记录](视觉验收/A1/视觉验收记录_A1.md)。截图生成和不溢出通过不等于设计一致性通过。 + +设计图尺寸比例并不完全相同,对照左侧保持原图等比缩放,右侧固定 390×844;不是强行拉伸后的像素差分。Fixture 仅在 test/tool 中,未进入运行代码。远程商品缺图、无已发布 agreement,不能用设计稿的人像、金额、宣传图或协议正文充当真实数据。 + +## 已知限制及下一步 + +- 已按并排核对将验证码发送改为独立按钮、订单改为三类主 Tab 并保留退款入口、个人中心资产区收敛为三列。商家图片、原品牌资产和宣传位当前没有对应可用素材,原图完整设备/配送状态也没有同状态数据,因此严格视觉一致性仍未标“已验收”。 +- 商城/订单虽有分页 HTTP 契约,兼容仓储暂时顺序读完各页再筛选,大数据量下需在 A2 改为逐页加载与服务端筛选。 +- 页面内幂等号在结果未知时保留,跨页面销毁/应用重启的持久化重试归 A2;当前旧地址接口没有 request_no,不把新增地址声称为完整幂等闭环。 +- allowed_actions 由服务端生成,实际写入仍重新校验状态、归属、库存、支付与退款规则;完整详情与细粒度错误码归 A2。 +- 远程没有已发布协议,真实协议版本留痕用 SQL Mock 验证,未在远程伪造运营内容。正式投用前由平台发布真实用户协议与隐私政策。 +- 无 Android 设备/模拟器、无 macOS,未进行 Android/iOS 实机验收;本次没有新增移动插件。 +- 本次曾误建 heqi-a1-postgres、heqi-a1-redis,未建表或导入数据。Docker daemon 后来不可用,删除未能确认;不要启动 Docker 或动现有其他容器来绕过用户的远程环境要求。 + +下一起点:先完成 A1 剩余视觉收敛,再进入 A2,包含 37 邀请注册。每批保留测试、真实接口与视觉证据,不创建空白页面占位。 diff --git a/docs/操作日志_用户端APP_A1_20260907.md b/docs/操作日志_用户端APP_A1_20260907.md new file mode 100644 index 0000000..f3b324c --- /dev/null +++ b/docs/操作日志_用户端APP_A1_20260907.md @@ -0,0 +1,102 @@ +# 用户端 App A1 操作日志 + +操作时间:2026-09-07,收尾核对 20:17(Asia/Shanghai)。 + +操作类型:扩展、缺陷修复。影响模块:Flutter 用户端、共享设计系统、Go Client API;平台资源定义不变。 + +## 操作前状态 + +git 初始无既有业务改动;先读仓库规范、主开发文档、产品说明与 5 张最新 PNG。用户端已有登录、首页、商城、订单、我的及部分二级能力。基线 analyze 无问题、Flutter 33 项通过、Go 全量通过。尚无 58 页进度文件。 + +确认:未开放功能入口不隐藏;37 邀请注册归 A2;持久化环境使用仓库既有远程 PostgreSQL 与 Redis。 + +## 关键变更 + +以下路径相对 D:/5k/platforms;增删行为 20:17 的 git diff --numstat 快照,后续视觉收敛补记见表后,不将格式化行数等同业务功能数。 + +| 文件 | 核心函数/类 | 行数(新增/删除,或新文件总行) | 变化与理由 | +| --- | --- | --- | --- | +| apps/heqi_design_system/lib/src/theme.dart | HeqiTheme._theme | +23/-3 | 明确蓝白容器与对比色、一级标题位置、筛选标签可读性 | +| apps/heqi_design_system/lib/src/components.dart | HeqiSurfaceSection.build | +6/-4 | Material 承载 ListTile 点击墨水层 | +| apps/heqi_design_system/pubspec.yaml | version | +1/-1 | 0.1.1 补丁版本,无公共 API 破坏 | +| apps/user_app/lib/app/dependencies.dart | UserSession.login/resetPassword | +44/-1 | 显式记住登录、实际同意版本和展示时间,复用密码重置接口 | +| apps/user_app/lib/app/router.dart | createRouter | +25/-6 | 首页/商城游客可见,受保护页安全回跳、服务入口定位 | +| apps/user_app/lib/data/repositories/client_repository.dart | _readPages/products/_records | +20/-2 | 可选分页兼容旧数组,不将异常降级为空 | +| apps/user_app/lib/data/repositories/primary_repository.dart | PrimaryRepository | 新 53 | 公开内容/商品/归属强类型适配;非法金额失败而非变零 | +| apps/user_app/lib/domain/models/primary_models.dart | PublishedContent/ProductSummary/ServiceRelation | 新 35 | 明确首页和商城字段 | +| apps/user_app/lib/domain/models/order_summary.dart | OrderSummary.fromRecord/OrderItemSummary.fromJson | 新 65 | 隔离状态、金额、动作和成交快照,缺少权限默认不显示操作 | +| apps/user_app/lib/ui/core/async_content.dart | AsyncContentState.refresh/build | 新 79 | 首次、刷新、空、错误、重试;迟到请求不覆盖最新结果 | +| apps/user_app/lib/ui/core/feature_entry.dart | showUnavailableFeature/FeatureEntry | 新 36 | 保留功能入口并明确不可办理 | +| apps/user_app/lib/ui/core/text_entry_dialog.dart | TextEntryDialog | 新 59 | 弹窗自行释放控制器,避免关闭动画报错 | +| apps/user_app/lib/ui/features/auth/login_page.dart | _login/_sendCode/_modeTab/build | +221/-45 | 验证码/密码登录、冷却、协议确认、可见标签与横排品牌 | +| apps/user_app/lib/ui/features/auth/login_support.dart | showPublishedAgreements/ResetPasswordDialog | 新 174 | 读取后台协议、现有验证码重置闭环,不写死政策正文 | +| apps/user_app/lib/ui/features/home/home_page.dart | _load/build | +147/-86 | 服务归属和公开内容,设备保留入口,真实维修跳转 | +| apps/user_app/lib/ui/features/home/service_relation_card.dart | ServiceRelationCard.typed | +8/-0 | 保留旧 Map 构造方式并新增强类型入口 | +| apps/user_app/lib/ui/features/shop/shop_page.dart | _buy/build | +195/-134 | 后台分类/搜索/图片/库存、显式下单和页面内幂等重试 | +| apps/user_app/lib/ui/features/orders/orders_page.dart | _openActions/_performActions | +38/-26 | 服务端动作控制、提交锁、跨 Tab 刷新、原请求号重试 | +| apps/user_app/lib/ui/features/orders/order_list.dart | OrderList | 新 124 | 订单搜索/状态筛选/快照/时间与金额 | +| apps/user_app/lib/ui/features/profile/profile_page.dart | _load/_addAddress/_createTicket/didUpdateWidget | +202/-99 | 并行读取、手机脱敏、账户与服务入口、表单生命周期和点击锁 | +| backend/api/internal/logic/client/user/auth.go | Login/loginRequest | +10/-5 | 可选同意版本写入,旧登录兼容 | +| backend/api/internal/logic/client/user/shop.go | PublicProducts/ListShopOrders | +49/-9 | 修复公开商品误查私有订单明细;分类/图片/分页/订单动作 | +| backend/api/internal/logic/client/user/gasorder.go | ListGasOrders | +26/-2 | 本人订单项、状态名、允许动作、分页 | +| backend/api/internal/logic/client/user/list_response.go | paginateClientList/respondClientPage/clientOrderState | 新 50 | 分页上下限、旧数组响应、状态标签 | +| backend/api/internal/logic/client/user/login_consent.go | recordLoginConsents | 新 48 | 发布版本锁定,保留实际展示时间,阅读唯一键幂等 | + +测试改动:login_page_test.dart、orders_page_test.dart;新增 primary_pages_test.dart、primary_repository_test.dart、test/support/a1_fixture.dart、tool/a1_visual_test.dart、primary_api_test.go。 + +视觉收敛补记:login_page.dart 使用独立验证码按钮;orders_page.dart 的 _openRefunds 将退款列表保留在筛选入口,主 Tab 收敛为三类,旧 initialTab 映射保留;profile_page.dart 的 _assetSummary 改为三列资产摘要,_openRequestedRepair 消费一次性 query,修复重复从首页报修无法再开表单的问题。新增 scripts/compare-user-app-a1.ps1 复现并排图;末次增删行统计以 git diff 为准。 + +文档改动:58 页开发进度、新 v1.1 项目文档(v1.0 原件保留)、本日志、A1 视觉记录、docs/13 与文档导航。生成 40 张实现截图和 5 张并排对照。未新增生产依赖、表或路由;开发配置和凭据未改写。 + +## 代码行为示例 + +公开商品原先用商品 ID 查询 ec_order_item,可能读到同 ID 的无关订单项;现在只查询关联的商品图片、分类,响应继续剔除内部主键。 + +```go +// backend/api/internal/logic/client/user/shop.go,PublicProducts +impl.DBService.Where("ec_product_id = ? AND status = ?", product.ID, common.StatusEnable) +``` + +```dart +// apps/user_app/lib/domain/models/order_summary.dart,权限缺失时不自行推导 +actions: Set.unmodifiable((raw['allowed_actions'] as List? ?? []).whereType()), +``` + +## 验证结果 + +| 验证 | 结果 | +| --- | --- | +| 用户端 flutter analyze | 无问题 | +| 用户端 flutter test --no-pub | 44 项通过,比基线增加 11 项 | +| flutter test --no-pub --update-goldens tool/a1_visual_test.dart | 40 项尺寸/缩放/滚动检查通过,视觉差异另列 | +| flutter build web --release --no-pub --dart-define=API_BASE_URL=http://127.0.0.1:12426 | 通过 | +| 共享包 analyze/test | 无问题,3 项通过 | +| 服务端 App analyze/test | 无问题,21 项通过 | +| Go go test ./... / go vet ./... / go build | 均通过 | +| Go 协议重复确认、旧版本拒绝测试 | 通过;重复请求使用内容版本唯一键 | +| 平台 pnpm contract:check | 48 个资源通过 | + +中间测试曾发现:订单新增搜索框使旧退款测试选择器多匹配;登录 hint 与 error 文案相同使旧测试多匹配;新 HTTP Fixture 默认 Latin-1 不支持中文。已分别限定弹窗输入、直接验证 errorText、使用 UTF-8;负向测试验证具体 1714 码以防错误通过。不是忽略失败或降低断言。 + +共享包分析曾引起本机包源及 SDK 间接锁版本自动变化,已恢复本次产生的无关 pubspec.lock 改动,保留有意义的包版本与代码变更。 + +## 远程联调事实 + +1. 只读事务核对既有角色、管理员、指定测试用户、商品,Redis 仅 PING。 +2. 启动 API 前确认不自动迁移表;基础初始化所需记录已存在。未执行 migrate/mock-data。 +3. 使用任务指定账号登录;公开内容、商品分页、资料、钱包、地址、订单、服务归属返回 code=0。令牌、密码没有写入仓库、日志说明或截图。 +4. 对现有 MOCK 商品创建未支付订单,同 request_no 重试返回同一 identity,价格按整数分由服务器计算,库存只扣一次。 +5. 使用现有取消接口恢复库存,保留已取消订单 01a07bc0-6237-7ec2-8cbc-1c05f16ad10b,没有删除订单/支付/资金事实。 +6. 浏览器验证游客入口提示、受保护订单跳转登录并回跳、订单记录、个人中心实际余额与脱敏手机号、商品关键词筛选。 +7. 未勾选“记住登录状态”后刷新浏览器,访问订单重新进入登录页,确认没有恢复旧持久会话。 +8. 最终 Web 构建中再次登录后,三类订单主标签正常;连续两次从首页进入报修均打开表单并消费查询参数,均取消而未新建工单。390×844 个人中心截图无溢出,预览保留供试用。 + +本机仅运行 API(12426)与 Web 预览(18571);数据库与缓存保持原远程地址。曾因误解创建两个本任务容器,未初始化或使用;Docker 后来停止,删除结果未确认,未启动 Docker 强行处理,更未删除既有 workbuddy 容器。 + +## 风险与当前状态 + +业务实现和测试已落地,A1 严格视觉一致性仍未验收;详见视觉记录。没有进入 A2 或创建 53 个空页面。设计素材/完整设备与交易规则按后续批次推进。 + +分页门面暂读完所有页,数据多时延迟和查询量增加;A2 应改为显式逐页加载。商品关联查询目前逐项执行,不将本次小样本验证声称为压力测试。页面内幂等号不能保证跨重启恢复,旧地址接口幂等扩展仍待 A2。 + +现有退款/支付的允许动作是候选能力,执行时仍可能因状态并发或业务限制拒绝;最终结果以服务端为准。未验证正式商户、短信供应商、Android/iOS 实机。远程缺已发布协议与商品图,未造运营数据填空。 diff --git a/docs/操作日志_用户端APP_供气合同列表_20260908.md b/docs/操作日志_用户端APP_供气合同列表_20260908.md new file mode 100644 index 0000000..359aa7b --- /dev/null +++ b/docs/操作日志_用户端APP_供气合同列表_20260908.md @@ -0,0 +1,30 @@ +# 供气合同列表开发记录 + +操作时间:2026-09-08。操作类型:扩展及缺陷修复。影响模块:图32、本人合同接口。 + +操作前:个人中心合同入口仅为通用记录列表。ListGasContracts错误地用合同ID查询订单气瓶,数值碰撞时可能返回无关订单明细。 + +操作后:独立合同页面支持搜索编号/标题/气站、筛选真实业务状态、下拉刷新、失败重试、查看合同正文。保留原路由和旧contracts()仓储接口。 + +## 代码与接口 + +- backend/api/internal/logic/client/user/gasorder.go:ListGasContracts改查gasorder_contract_product并按合同归组,气站批量读取,避免逐合同查询。本人过滤保留,file_uri兼容字段置空,不再发送私有存储地址,新增station_name、has_attachment。 +- apps/user_app/lib/domain/models/gas_order_detail.dart:GasContractSummary保留业务状态、供气单位与日期,不将草稿推断成待签署。 +- apps/user_app/lib/data/repositories/client_repository.dart:gasContracts增加强类型适配和会话切换保护。 +- apps/user_app/lib/ui/features/orders/gas_contracts_page.dart:列表、搜索、筛选、卡片、有效期和正文查看;app/router.dart替换原通用页面。 +- 后端gas_contract_list_test.go新增归属与绑定查询回归、空列表不查关联;Flutter新增gas_contracts_page_test.dart及gas_contracts_test.dart,Fixture与tool/a1_visual_test.dart新增图32。 +- scripts/compare-user-app-a1.ps1加入图32,现16组对照对应15张设计。 + +## 验证 + +Go全量测试、vet及构建通过。Flutter analyze通过,原99项全量测试通过,随后新增合同类型/跨会话测试1项通过。UI测试覆盖首读失败重试、到期筛选空态、编号搜索和正文打开。图32在320/360/390/430与1.0/1.3字体的8组截图检查通过,已查看390与320大字体,并将筛选样式修正为原图蓝色圆角按钮。 + +Web Release和Android Debug构建通过。新API读取远程MOCK-CONTRACT-001返回和气示例气站、状态11、1条合同绑定且原始附件地址为空;本轮只读远程业务数据,没有迁移或修改现有合同。浏览器已验证合同深链登录回跳、已到期筛选空态、编号搜索、查看真实合同正文,控制台无错误或警告。API进程42232提供新接口,18571提供更新后的Web。 + +新增页面gas_contracts_page.dart共191行;后端ListGasContracts修改约40行,新增两项SQL查询回归。其余兼容文件已有前序改动,不能将工作树累计差异算成本轮修改行数。 + +## 未完成及维护边界 + +图32仍为部分实现:无签署工作流、家庭共享协议、用气地址快照、PDF下载、合同服务与完整签署历史。保留真实草稿/生效/到期/终止状态,不用设计演示合同、地址或法律效力声明补空。15张设计部分实现、43张无完整对应页、0张严格全功能1:1通过。 + +后续附件下载必须先校验本人合同并复用受控文件读取,不能将存储路径拼成公开URL;签署须衔接实名与审计契约,不可本地改变状态。修改绑定关联时应运行新增SQL作用域测试,防止合同ID与订单ID再次混用。 diff --git a/docs/操作日志_用户端APP_供气订单详情_20260908.md b/docs/操作日志_用户端APP_供气订单详情_20260908.md new file mode 100644 index 0000000..069fa8f --- /dev/null +++ b/docs/操作日志_用户端APP_供气订单详情_20260908.md @@ -0,0 +1,50 @@ +# 用户端供气订单详情开发与验证 + +操作时间:2026-09-08。操作类型:扩展。影响模块:用户端订单、本人合同读取、供气签收。 + +## 行为变化 + +此前气瓶订单只有列表与通用操作。本次增加独立详情:历史气瓶明细、供气单位、配送员姓名、配送费、实际支付记录、状态发生时间及本人合同正文。订单列表点击详情进入 `/gas/orders/:identity`。只对待签收订单发布确认收货动作,用户确认后服务端事务记录本人签收、完成订单并释放气瓶占用;重复确认不重复写证据。 + +## 文件与职责 + +| 文件(相对仓库) | 函数及变更 | +| --- | --- | +| backend/api/internal/logic/client/user/gas_detail.go | GetGasOrder、GetGasContract;所有权过滤、显式公开字段与统一状态动作 | +| backend/api/internal/logic/client/user/gas_confirm.go | ConfirmGasReceipt;行锁、状态校验、签收证据、占用释放和历史记录 | +| backend/api/internal/logic/client/user/gasorder.go | ListGasOrders复用状态及动作定义 | +| backend/api/internal/models/gasorder_confirm.go | 中文注释补充user_app签收方式;无表结构变更 | +| backend/api/internal/routers/client.go | 增加受保护详情、合同和签收路由,原接口兼容保留 | +| apps/user_app/lib/domain/models/gas_order_detail.dart | 强类型详情、整数分支付、合同正文模型 | +| apps/user_app/lib/data/repositories/client_repository.dart | gasOrderDetail、gasContractDetail、confirmGasReceipt;会话与响应归属校验 | +| apps/user_app/lib/ui/features/orders/gas_order_sections.dart | 真实状态时间轴、服务资料、合同读取及失败重试 | +| apps/user_app/lib/ui/features/orders/shop_order_detail_page.dart | 兼容商城页面并增加供气分支 | +| apps/user_app/lib/ui/features/orders/order_action_handler.dart、orders_page.dart | 签收确认、稳定请求号重试、详情返回刷新 | +| apps/user_app/lib/app/router.dart | 本人供气订单深链与登录回跳 | +| scripts/compare-user-app-a1.ps1 | 图21气瓶与商城分支分别配对原图,十五组对照对应十四张设计 | + +新增后端测试gas_detail_test.go、gas_confirm_test.go、gas_receipt_remote_test.go,前端test/data及test/ui/gas_order_detail_test.dart;视觉Fixture明确与真实数据分离。 + +## 接口与边界 + +- `GET /heqi/client/v1/user/gas/orders/:identity`、`GET gas/contracts/:identity`:JWT本人范围,缺失或非本人1112,匿名HTTP401。详情不返回内部主键、员工手机号、操作人身份或内部处理备注。 +- `POST gas/orders/:identity/confirm-receipt`:只接受request_no,长度1—128;仅状态34可签收,已完成23幂等返回,其他状态1704。收货人取当前账户,不能由请求冒用;写入user_app证据,不伪造手写签名或图片。 +- 合同仅阅读既有正文,附件地址不外泄;完整合同列表筛选、签署和附件下载仍未完成,不能把正文弹层计为图32完整页面。 +- 支付记录只读本订单、本付款人的成功支付;不能从履约状态推断实付。订单无押金、ETA及受控联系方式契约时不填设计演示数值。 + +## 验证证据 + +- 本轮Go全量测试、vet和API构建通过;Flutter全量98项及analyze通过。时间轴调整后analyze与5项详情定向测试再次通过。 +- 气瓶/商城订单列表与详情32组布局检查通过;时间轴调整后图21-gas的320/360/390/430宽度、1.0/1.3字号8组再次通过。已实际查看390常规、320大字体及最新原图,常规宽度改为横向时间轴,窄屏/大字体保留竖向完整历史。 +- 一次不带`--update-goldens`的全视觉检查出现22项旧截图差异失败,不能当成全视觉回归通过;此次仅更新并核对气瓶详情8组。生成截图不等于1:1断言。 +- 远程数据库显式回滚事务测试通过:临时订单、气瓶、合同绑定、占用明细均在同一事务,验证签收、重复确认、证据与占用释放,再回滚并确认临时记录数为0。无迁移、种子或本机数据库/缓存操作。 +- 真实HTTP读取MOCK-GASORDER-001:1条气瓶、1条历史、1条支付,配送费500分;合同MOCK-CONTRACT-001正文读取成功。缺失订单1112、匿名401。没有对既有真实订单执行签收。 +- 浏览器验证登录回跳、气瓶列表进入详情、读取合同正文及关闭。示例数据的完成时间早于建单时间,保留原值并记录为数据异常,不篡改历史。Web Release及Android Debug构建通过;平台契约同步与检查通过48个资源。 + +## 视觉结论与维护 + +**图21仍未通过严格1:1。** 时间轴已按真实数据横向排列;气站与商品分组、原始商品图片、押金、预计送达、地图配送、受控联系、完整售后和部分密度仍有差异。图32仍无完整对应页面。58图仍为14张部分实现、44张无完整页面、0张严格全功能验收通过。 + +证据:[气瓶详情对照](视觉验收/A1/21-gas_并排对照.png)、[390截图](视觉验收/A1/21-gas_390_1.0.png)、[320大字体](视觉验收/A1/21-gas_320_1.3.png)。 + +签收规则变更须同时回归原配送签收、订单占用释放与幂等。远程回滚测试默认跳过,仅明确设置HEQI_REMOTE_RECEIPT_TEST=1时执行;不得去掉外层回滚或运行全库初始化。新字段必须继续使用白名单和本人过滤。 diff --git a/docs/操作日志_用户端APP_充值到账提示修复_20260911.md b/docs/操作日志_用户端APP_充值到账提示修复_20260911.md new file mode 100644 index 0000000..9452863 --- /dev/null +++ b/docs/操作日志_用户端APP_充值到账提示修复_20260911.md @@ -0,0 +1,11 @@ +# 充值到账提示修复 + +操作时间:2026-09-11。操作类型:修复。 + +`apps/user_app/lib/ui/features/wallet/recharge_page.dart` 的到账查询已确认状态23后会再次读取余额。原实现将余额刷新异常落入充值查询失败提示,错误否认已经确认的到账事实。现将余额刷新单独处理,保留已到账结论并提示重新进入页面刷新余额。 + +`test/ui/recharge_page_test.dart` 注入首次余额成功、到账后余额读取失败,验证请求仍清除、金额解锁且不显示未确认提示;测试通过。改动不涉及支付调用、数据库或缓存写入。 + +充值表单仍未通过设计图视觉验收,协议版本留痕及完整真实支付测试待完成。 + +Go后端构建通过,已将12426服务更新为临时目录的 `heqi-recharge-options-api.exe`,进程1060,继续使用原dev远程配置。Web构建通过(88.9秒),18572预览服务返回HTTP200。这里只证明构建与服务响应,不代表已完成浏览器充值交互或渠道联调。 diff --git a/docs/操作日志_用户端APP_充值协议接入_20260911.md b/docs/操作日志_用户端APP_充值协议接入_20260911.md new file mode 100644 index 0000000..8494e69 --- /dev/null +++ b/docs/操作日志_用户端APP_充值协议接入_20260911.md @@ -0,0 +1,11 @@ +# 充值协议接入 + +操作时间:2026-09-11。操作类型:扩展充值前置确认。 + +`client_repository.dart` 新增confirmRechargeAgreement,向既有阅读确认接口提交内容标识、实际版本和账号版本稳定幂等号;响应必须包含有效阅读记录标识及相同版本,并检查会话未变化。 + +`recharge_page.dart` 在创建充值前调用确认接口;确认失败中止创建,不新增待确认订单状态。版本错误不能以成功处理。 + +新增 `test/data/recharge_agreement_test.dart` 验证POST路径、版本和稳定请求号,错误版本响应拒绝,测试通过。页面既有3项测试通过,静态检查通过。没有真实协议确认或支付请求。 + +尚未部署新前后端,真实协议记录联调、并发版本保护及跨入口幂等情况仍需验证;因此不将协议留痕认定为最终完成。图38视觉及真实渠道等验收仍未完成。 diff --git a/docs/操作日志_用户端APP_充值查询与幂等_20260911.md b/docs/操作日志_用户端APP_充值查询与幂等_20260911.md new file mode 100644 index 0000000..3010463 --- /dev/null +++ b/docs/操作日志_用户端APP_充值查询与幂等_20260911.md @@ -0,0 +1,43 @@ +# 充值查询与幂等接口开发记录 + +操作时间:2026-09-11 +操作类型:扩展、修复 +影响模块:共用客户端钱包充值接口。 + +## 操作前后 + +原充值接口只有创建和Mock确认,客户端无法查询本人充值记录或恢复未知结果。创建时遇到重复请求号直接返回旧记录,没有核对本次金额与渠道;重复提交不同金额可能得到旧金额支付参数。 + +现在以请求号唯一约束配合`ON CONFLICT DO NOTHING`,重复时核对主体类型、主体标识、金额和渠道。同请求同参数返回同充值单;金额、渠道或归属不一致返回2411“充值请求已存在,请保持原金额和支付方式”。不通过异常插入破坏外层事务,不创建重复充值记录。原返回字段保持兼容,真实渠道响应增加`recharge_identity`、`recharge_no`、`recharge_status`。 + +新增本人充值记录、单条结果及按原请求号恢复查询。钱包入账状态与支付单状态分别返回,缺少支付单时不推测已经支付。所有读取限定主体类型及身份,不返回钱包内部主键、操作者、密钥或支付调起参数。 + +## 接口契约 + +相对路径基于`/heqi/client/v1/user`,现有服务端客户端也复用同一能力。 + +| 方法与路径 | 参数 | 返回与边界 | +| --- | --- | --- | +| POST wallet/recharges | 既有amount、channel、request_no、pay_type等 | 同请求号重试必须保持金额和渠道,冲突2411 | +| GET wallet/recharges | 可选cursor,服务端返回的正整数游标 | items最多50条,next_cursor为空表示无后续页 | +| GET wallet/recharges/:identity | 公开充值标识 | 本人充值字段,可选payment_status、expires_at | +| GET wallet/recharge-requests/:request | 原始request_no | 网络结果未知时恢复本人充值记录,不修改状态 | + +充值字段:identity、recharge_no、recharge_status、amount(整数分)、channel、created_at、completed_at。`recharge_status=23`才是已入账,不能仅凭支付客户端返回成功宣称到账。原图10至5000元不是已确认的服务端业务规则,本轮没有改写现有限额配置或凭空发布充值协议。 + +## 核心文件 + +- `backend/api/internal/logic/common/client_wallet.go`:CreateRecharge冲突处理及响应补充;ConfirmMockRecharge补齐主体类型过滤。 +- `backend/api/internal/logic/common/recharge_query.go`:GetRecharge、ListRecharges及白名单投影rechargePublic。 +- `backend/api/internal/routers/client.go`:增加三个读取路由,旧接口保留。 +- `backend/api/internal/logic/common/recharge_remote_test.go`:显式开启的远程事务回滚测试。 + +## 验证和风险 + +`HEQI_REMOTE_RECHARGE_TEST=1`远程测试通过(16.17秒):独立用户与零余额钱包,创建同参重试、修改金额/渠道冲突、按请求号恢复、跨主体类型拒绝、Mock重复确认仅入账一次、只生成一笔流水。完成后整体回滚,确认测试充值单和钱包均不存在;没有实际调用微信/支付宝,也没有修改已有账户资金。普通测试默认跳过远程用例。 + +本轮无数据库结构变更,不启动本机PostgreSQL或Redis。真实渠道返回可能未知,客户端应保留原请求号查询;本轮不修改支付SDK的渠道重试逻辑,不宣称渠道侧恰好一次请求已得到完整验证。 + +图38充值页面及完整渠道交互仍未完成,本轮提供其必需的结果查询和幂等保证,不能据此把图38计为完成或改变全量17张部分实现、41张无对应完整页、0张严格通过的状态。 + +后端全量go test、go vet和API构建通过。新API进程42452监听12426;实际授权账户读取充值记录返回成功及1条既有记录,未知请求号返回业务码1112。SDK错误响应仍使用HTTP200包装业务码,客户端必须读取code,不能单靠HTTP状态判断存在或成功。本轮实测只读,没有创建真实充值单或发送真实支付请求。 diff --git a/docs/操作日志_用户端APP_充值窄屏回归_20260911.md b/docs/操作日志_用户端APP_充值窄屏回归_20260911.md new file mode 100644 index 0000000..483bce7 --- /dev/null +++ b/docs/操作日志_用户端APP_充值窄屏回归_20260911.md @@ -0,0 +1,11 @@ +# 充值窄屏与全量回归 + +操作时间:2026-09-11。操作类型:验证扩展。 + +`apps/user_app/test/ui/recharge_page_test.dart` 增加320及390逻辑像素宽度、844高度、1.3倍字体测试,检查首次加载及滚动至充值按钮无Flutter布局异常。两项均通过;同文件原有异常恢复和金额控件测试通过。 + +全量 `flutter test` 共135项通过(32秒);`git diff --check`通过。运行中的12426后端进程1060和18572预览进程34588已核对。此次没有数据库、缓存或支付写入。 + +布局异常检查不证明与设计图1:1一致,也不替代真机支付、浏览器操作或图片资产核对。图38整体样式、协议留痕、真实渠道等仍未验收,统计不调整为通过。 + +Web构建通过(61.4秒),最新金额控件已输出至18572服务使用的build/web目录;打开旧标签页需整页重新加载才能取得新代码。浏览器登录后的交互尚未验证。 diff --git a/docs/操作日志_用户端APP_充值表单接入_20260911.md b/docs/操作日志_用户端APP_充值表单接入_20260911.md new file mode 100644 index 0000000..5769ce9 --- /dev/null +++ b/docs/操作日志_用户端APP_充值表单接入_20260911.md @@ -0,0 +1,18 @@ +# 充值表单接入 + +操作时间:2026-09-11。操作类型:新增页面、扩展钱包入口。 + +## 代码和行为 + +- `apps/user_app/lib/ui/features/wallet/recharge_page.dart`:新增余额显示与隐藏、100/300/500金额选择、自定义金额、渠道就绪展示、协议查看与勾选、原请求继续支付及到账查询。 +- `apps/user_app/lib/app/router.dart`:增加 `/wallet/recharge` 路由。 +- `apps/user_app/lib/ui/features/wallet/wallet_page.dart`:两个充值入口转入表单。 +- 创建与恢复使用现有 `RechargeFlow`;仅实际充值完成状态清除待确认请求并刷新余额。协议缺失禁止创建,但不阻止已有请求查询。 + +## 验证与不足 + +新增页面测试通过,覆盖缺少协议时禁用支付、恢复订单时锁定金额、查询到账后删除待确认请求并解锁金额。首次检查发现新增回调位于const组件内,已去掉const;测试补充显式列表滚动定位后通过。 + +最终Flutter静态检查返回No issues found(93秒),进程退出码0;`git diff --check`通过。 + +本批尚未构建部署预览、未进行浏览器与设计图视觉核对,不计图38完整验收。协议版本尚未提交服务端留痕;关闭订单的后续处理、跨窗口重复提交和实际原生支付仍待完成。充值配置接口需要部署后端新构建。没有执行真实支付或改动远程资金。 diff --git a/docs/操作日志_用户端APP_充值记录页面_20260911.md b/docs/操作日志_用户端APP_充值记录页面_20260911.md new file mode 100644 index 0000000..ade5a33 --- /dev/null +++ b/docs/操作日志_用户端APP_充值记录页面_20260911.md @@ -0,0 +1,19 @@ +# 充值记录页面 + +操作时间:2026-09-11。操作类型:新增。 + +## 变更 + +- 新增 `apps/user_app/lib/ui/features/wallet/recharge_records_page.dart`,通过现有充值查询接口读取当前账号记录,支持游标分页、标识去重、刷新和失败重试。 +- `apps/user_app/lib/app/router.dart` 增加 `/wallet/recharge-records`;钱包标题栏增加充值记录入口。 +- 失败保留已显示记录;刷新使用新请求世代,忽略较早请求的迟到响应。充值完成状态才显示已到账,测试渠道历史记录明确标注测试充值。 + +## 验证 + +`test/ui/recharge_records_page_test.dart` 验证分页参数、重复记录去重、刷新失败保留数据及重试使用首页参数,1项通过。Flutter静态检查通过。 + +钱包原有交互和充值金额测试另5项通过;Web构建通过(78.8秒),预览服务18572仍在运行并指向构建目录。尚未用浏览器验证新页面,不能据构建结果认定视觉通过。 + +## 未完成 + +余额充值表单、渠道拉起及请求持久化恢复尚未完成;本页不代表图38完整实现,也未完成浏览器操作和设计图视觉验收。本次未请求支付或写入远程数据库、缓存。 diff --git a/docs/操作日志_用户端APP_充值请求恢复_20260911.md b/docs/操作日志_用户端APP_充值请求恢复_20260911.md new file mode 100644 index 0000000..2b92776 --- /dev/null +++ b/docs/操作日志_用户端APP_充值请求恢复_20260911.md @@ -0,0 +1,10 @@ +# 充值请求恢复 + +操作时间:2026-09-11。操作类型:扩展客户端充值数据层。 + +- 新增 `recharge_draft_store.dart`,使用独立安全存储命名空间保存请求号、整数分金额、渠道和支付类型,按API地址及服务端账号标识隔离。损坏数据报错,不静默丢弃。 +- 新增 `recharge_flow.dart`,先保存再创建充值;已有不同待确认请求时拒绝另发订单。查询未到账或响应异常时保留原请求,仅核对金额、渠道且确认入账后删除。 +- `client_repository.dart` 新增 `rechargeDraftOwner`,检查请求前后会话状态。 +- 新增存储和流程测试3项,通过;静态检查通过。测试覆盖存储失败不发单、响应丢失保留、不同请求阻止、未到账保留和已到账清除,以及账号环境隔离。 + +本批未调用真实支付或改写远程资金。页面尚未接入该流程;多窗口并发、支付关闭后的新订单处理、协议版本留痕和真实渠道仍待补齐,不能认定完整充值可用。单实例忙碌保护不等于跨窗口互斥。 diff --git a/docs/操作日志_用户端APP_充值配置校验_20260911.md b/docs/操作日志_用户端APP_充值配置校验_20260911.md new file mode 100644 index 0000000..5914f58 --- /dev/null +++ b/docs/操作日志_用户端APP_充值配置校验_20260911.md @@ -0,0 +1,18 @@ +# 充值配置和客户端校验 + +操作时间:2026-09-11 +操作类型:扩展 +影响模块:充值配置接口、客户端充值数据层。 + +## 行为与文件 + +- `backend/api/internal/logic/common/recharge_options.go`:新增渠道配置就绪状态、实际充值限额及已发布充值协议查询;不返回密钥,不提供Mock支付入口。文件存在仅表示配置条件满足,不证明支付渠道连接成功。 +- `apps/user_app/lib/domain/models/recharge.dart`:十进制金额直接转分,拒绝超精度、负数和指数输入;只有充值状态23表示已到账。协议缺少标识、正文或有效版本时拒绝使用。 +- `apps/user_app/lib/data/repositories/client_repository.dart`:扩展配置、记录、详情、请求恢复和创建接口,保留会话校验与创建结果核对。 +- `apps/user_app/test/domain/recharge_test.dart`:增加3个测试,覆盖金额边界、支付状态与入账状态区别、协议及限额异常。 + +## 验证与限制 + +后端 `go test ./internal/logic/common` 通过;新增Flutter测试3项通过。静态检查发现两个缺少花括号的提示,已修复并重新检查。 + +本次未发起真实充值、未变更数据库或缓存。新增配置接口尚未部署到预览后端。图38页面、支付拉起、持久化请求恢复与视觉验收仍待完成,不计完整功能,不调整17张部分实现、41张待实现、0张严格验收的统计。 diff --git a/docs/操作日志_用户端APP_充值配置边界测试_20260911.md b/docs/操作日志_用户端APP_充值配置边界测试_20260911.md new file mode 100644 index 0000000..a38a79b --- /dev/null +++ b/docs/操作日志_用户端APP_充值配置边界测试_20260911.md @@ -0,0 +1,11 @@ +# 充值配置边界测试 + +操作时间:2026-09-11。操作类型:增加后端回归验证。 + +新增 `backend/api/internal/logic/common/recharge_options_test.go`:使用SQLMock确认账户鉴权、已发布充值协议查询条件与版本排序;未配置渠道返回不可用,协议不存在不伪造协议内容,不提供Mock渠道。账户不存在时不返回渠道信息,也不继续访问协议查询。 + +另验证空路径、缺失路径和目录不能被视作可用密钥文件。测试不会连接远程数据库,不读取或打印真实密钥。定向测试通过;公共模块完整测试结果以本次执行输出为准。 + +这些测试只证明配置接口边界,不能证明真实支付供应商可用。页面样式、协议留痕、渠道联调等未完工作仍保留。 + +最终 `go test ./internal/logic/common` 通过(8.578秒),包含上述鉴权失败分支。 diff --git a/docs/操作日志_用户端APP_充值重试入账检查_20260911.md b/docs/操作日志_用户端APP_充值重试入账检查_20260911.md new file mode 100644 index 0000000..817c39e --- /dev/null +++ b/docs/操作日志_用户端APP_充值重试入账检查_20260911.md @@ -0,0 +1,11 @@ +# 充值重试入账检查 + +操作时间:2026-09-11。操作类型:修复充值重试。 + +`recharge_flow.dart` 在已有待确认请求重试前查询服务端充值事实,核对金额、渠道和账号;已到账返回状态23,不调用创建接口。明确业务码1112表示订单不存在时允许原请求号重发,其他查询异常保留请求且不发起支付。 + +`recharge_page.dart` 接收已到账状态后走到账查询及余额刷新,不调用支付SDK。`recharge_flow_test.dart` 增加到账后重试不增加创建次数、仍由恢复确认清理请求的断言。 + +相关流程和页面4项测试通过,静态检查通过;加入1112分支后流程测试再次通过。此次未请求真实支付,没有远程数据库或缓存写入。尚未重新构建预览,协议留痕及视觉等剩余验收不变。 + +后续补充异常分支测试:业务码1112允许同请求创建一次;500查询失败不创建;查询期间账号改变不创建,三种情况均保留待确认记录。测试首次使用Exception匹配StateError不准确,改为按分支匹配具体类型后,两项流程测试通过。验证使用内存夹具,没有远程请求。 diff --git a/docs/操作日志_用户端APP_充值金额控件_20260911.md b/docs/操作日志_用户端APP_充值金额控件_20260911.md new file mode 100644 index 0000000..b414ee7 --- /dev/null +++ b/docs/操作日志_用户端APP_充值金额控件_20260911.md @@ -0,0 +1,11 @@ +# 充值金额控件补齐 + +操作时间:2026-09-11。操作类型:扩展。 + +对照原图 `38-余额充值.png`,补充独立自定义选项、金额清空按钮和确认按钮实时金额。输入变化同步刷新选中态和按钮金额,待确认订单期间禁止修改。涉及 `recharge_page.dart`,增加并释放金额输入FocusNode。 + +页面测试增加清空、输入200.29及确认按钮精确显示金额的验证,通过;Flutter静态检查通过。未修改真实资金。 + +浏览器检查发现原标签页仍运行旧构建,直接跳充值路由显示Page Not Found;带新查询参数整页加载后进入登录页。尚未完成登录后的充值交互验证,不能将此记录作为浏览器验收通过。此次金额控件修改尚未重新构建部署。 + +整体卡片布局、图标及其他设计细节仍不一致,图38未通过严格视觉验收。后续继续核对完整页面及渠道流程。 diff --git a/docs/操作日志_用户端APP_协议版本确认_20260911.md b/docs/操作日志_用户端APP_协议版本确认_20260911.md new file mode 100644 index 0000000..60d4b51 --- /dev/null +++ b/docs/操作日志_用户端APP_协议版本确认_20260911.md @@ -0,0 +1,9 @@ +# 协议版本确认 + +操作时间:2026-09-11。操作类型:兼容扩展。 + +`backend/api/internal/logic/client/user/basic.go` 的ConfirmContentRead新增可选 `content_version`。提供时必须大于0并精确匹配发布且启用的内容版本;不提供时保留旧客户端行为。同一请求号重试还须匹配原内容ID和版本,不允许拿其他阅读记录冒充成功。 + +新增 `content_read_test.go` 验证过期版本与无效版本不能确认,不触发写入。`go test ./internal/logic/client/user`通过(0.514秒)。验证使用SQLMock,没有远程数据库、缓存或支付写入。 + +此为协议留痕前置修复,充值页面尚未提交阅读确认,接口尚未部署新构建;协议版本并发更新的事务保护和完整充值接入仍需继续。不能认定充值协议留痕已完成。 diff --git a/docs/操作日志_用户端APP_合同变更记录_20260908.md b/docs/操作日志_用户端APP_合同变更记录_20260908.md new file mode 100644 index 0000000..fde9733 --- /dev/null +++ b/docs/操作日志_用户端APP_合同变更记录_20260908.md @@ -0,0 +1,28 @@ +# 合同变更记录开发日志 + +操作时间:2026-09-08。操作类型:扩展。影响模块:本人供气合同。 + +## 行为变化 + +原合同页面无法查看历史状态与有效期变更。现在每份合同提供“变更记录”,独立读取不可变快照,支持失败重试、下拉刷新与空态。现有正文、PDF下载、搜索、筛选和旧路由保留。 + +## 关键文件 + +- backend/api/internal/logic/client/user/gas_contract_history.go:GetGasContractHistory先按公开identity、JWT用户及未归档状态限定合同,再按合同ID读取变更记录,发生时间与identity倒序。响应白名单仅包含identity、action、contract_status、occurred_at、effective_at、expired_at,不发送内部主键、员工身份或内部原因。 +- backend/api/internal/routers/client.go、client_test.go:增加受保护GET `/heqi/client/v1/user/gas/contracts/:identity/history`及路由回归。 +- apps/user_app/lib/domain/models/gas_order_detail.dart:GasContractEvent强类型适配,区分activate/renew/terminate等真实动作,未知动作仍显示通用变更而非成功签署。 +- apps/user_app/lib/data/repositories/client_repository.dart:gasContractHistory读取本人记录并丢弃跨会话迟到响应。 +- apps/user_app/lib/ui/features/orders/gas_contract_history.dart:历史弹层、状态及有效期快照、错误重试、空态和关闭;gas_contracts_page.dart增加入口。 +- 后端gas_contract_history_test.go覆盖归属SQL、顺序和内部字段不外泄;前端gas_contract_history_test.dart覆盖重试、真实变更含义及关闭。 + +## 验证 + +Go全量测试、vet与API构建通过。Flutter analyze、全量103项测试通过。图32在320/360/390/430宽度与1.0/1.3字体的8组截图检查通过,已查看390截图,更新并排证据。平台资源契约同步和48项检查通过。 + +真实API读取MOCK-CONTRACT-001,返回activate记录:发生时间2026-06-30 09:00,状态11,有效期至2027-07-01 09:00;匿名HTTP401。无数据库迁移或远程业务写入。 + +浏览器已完成登录回跳、列表“变更记录”入口、真实合同生效记录及关闭;桌面和390手机宽度均实际查看,控制台无错误/警告。Web Release和Android Debug构建通过。新API进程43912,Web端口18571。 + +## 边界 + +变更记录不等于电子签署证据,也不代表已实现合同变更申请、续签提醒或签署工作流。历史内部原因未区分公开/私有口径,因此不下发;不能把内部操作员姓名当作用户签名。图32仍为部分实现,严格视觉与完整合同服务仍待补。 diff --git a/docs/操作日志_用户端APP_合同申请与个人中心复核_20260908.md b/docs/操作日志_用户端APP_合同申请与个人中心复核_20260908.md new file mode 100644 index 0000000..cbab483 --- /dev/null +++ b/docs/操作日志_用户端APP_合同申请与个人中心复核_20260908.md @@ -0,0 +1,37 @@ +# 合同申请与个人中心复核 + +操作时间:2026-09-08。操作类型:扩展、修复。影响模块:用户合同、气站工单、平台工单、个人资料。 + +## 实际差异 + +已打开图25、图32原稿,与390宽运行页面及截图逐项核对。个人中心仍缺认证、押金、优惠券、设置、消息、紧急联系人、家庭共享等完整能力,分组密度和图标素材未达到1:1。设计中的示例头像不能替代登录用户的真实头像;无头像账户使用姓名占位。图32仍缺共享授权协议签署、地址快照、续签提醒及原稿合同服务布局。 + +头像可从个人中心资料区域进入“更换头像”,真实浏览器已核对拍照、相册和取消入口。既有上传成功证据保留在此前日志,本次没有重复改写远程用户头像。新增修复:服务端清空头像后下拉刷新,编辑页清除旧图片缓存。个人中心消息、联系人、家庭和收藏图标按原稿语义调整。 + +## 合同申请行为 + +原来只有合同正文、PDF和状态历史。现在每份本人合同可填写变更内容,提交至所属气站工单队列,读取状态、取消申请、查看答复并确认处理完成。申请不会直接变更合同条款或产生电子签名。 + +同账户锁、稳定请求号和同合同进行中申请检查防止重复待办。转出用户不能继续向原气站新申请条款变更。气站只能答复本站关联合同申请,待受理/处理中可答复,答复后待用户确认;相同答复可幂等重试。平台和气站普通工单编辑不得改换关联申请的用户或业务分类。普通维修工单原接口保持兼容,App报修列表不混入合同申请。 + +## 文件与接口 + +- `backend/api/internal/models/cs_ticket.go`:可选内部合同关联字段,0表示普通工单。 +- `backend/api/cmd/cli/address_migration.go`、`main.go`:`migrate-ticket-contract`只增加远程`cs_ticket.gasorder_contract_id`、索引及中文注释,已执行;未运行全库迁移或本机数据库。 +- `backend/api/internal/logic/client/user/gas_contract_request.go`:CreateGasContractRequest、ListGasContractRequests;GET/POST `/heqi/client/v1/user/gas/contracts/:identity/requests`。 +- `backend/api/internal/logic/gas/contract_request.go`、`ticket.go`:气站答复、原气站权限范围、处理按钮状态投影;POST `cs_ticket/:identity/resolve-contract-request`。 +- `backend/api/internal/logic/platform/contract_ticket_guard.go`、`routers/platform.go`:保留普通CRUD行为,保护关联申请归属。 +- `frontend/gas_admin/src/api/resources.ts`、资源契约:合同变更分类及“答复合同申请”详情操作。 +- `apps/user_app/lib/ui/features/orders/contract_change_requests.dart`:申请表单、状态、答复、确认/取消及错误重试;`gas_contracts_page.dart`增加入口。 +- `apps/user_app/lib/domain/models/gas_order_detail.dart`、`data/repositories/client_repository.dart`:类型适配、会话校验及请求方法。 +- `apps/user_app/lib/ui/features/profile/profile_edit_page.dart`:头像刷新清理;`profile_page.dart`:图标调整。 + +## 验证与风险 + +Go全量测试、vet、API构建通过。Flutter全量105项测试及analyze通过,包含头像清空刷新、申请失败重试保持请求号、取消二次确认。图25/32共16组宽度及文字缩放截图检查通过,已查看390图片并更新并排对照,截图生成不等于视觉验收。 + +气站管理端contract同步/检查及构建通过,平台48资源契约检查通过。远程数据库回滚测试验证提交、气站两次幂等答复、用户确认,原合同未变化,测试申请整体回滚无残留。正常测试运行默认跳过该远程用例,显式HEQI_REMOTE_CONTRACT_REQUEST_TEST=1才启用。 + +新Web及Android调试包构建通过,API已切换至本次构建并继续连接远程数据库/缓存。浏览器390×844实际完成登录→合同→申请提交→待受理→取消二次确认→已取消,测试单`TK1788877123489796400`保留已取消审计记录,无待办残留。气站答复本次为真实远程回滚事务验证,未声称气站浏览器人工处理已经验收。浏览器预览地址为 http://127.0.0.1:18571/。 + +新增列旧记录默认0,普通工单继续按既有服务关系授权;关联申请保留原气站归属。数据库结构与新API需一同发布。Android/iOS真实相机、相册和文件保存对话框仍待真机验证。全量状态仍为15张部分实现、43张未完成对应页面、0张严格全功能与1:1验收通过。 diff --git a/docs/操作日志_用户端APP_合同附件下载_20260908.md b/docs/操作日志_用户端APP_合同附件下载_20260908.md new file mode 100644 index 0000000..53a3ea4 --- /dev/null +++ b/docs/操作日志_用户端APP_合同附件下载_20260908.md @@ -0,0 +1,42 @@ +# 合同附件下载开发记录 + +## 2026-09-08浏览器成功路径补验 + +新增`backend/api/internal/logic/client/user/gas_contract_download_remote_test.go`:默认跳过,显式设置HEQI_CONTRACT_DOWNLOAD_FIXTURE=1和当前API文件根目录时,为授权测试账号创建一条独立的已终止测试合同及测试PDF,不修改任何既有合同。最长等待5分钟,收到完成标记或超时均清理独立记录与文件;普通单元测试不连接远程。强制终止进程无法保证defer执行,禁止用强杀结束该夹具。 + +实际验证已通过:浏览器从个人中心进入供气合同,点击“PDF下载验证文件(非业务合同)”的下载按钮,真实鉴权接口返回200,浏览器在Downloads生成619字节PDF。下载文件与服务端原文件SHA-256均为`D5B86BEB045675F863B06D9B75A60FBCEC4E09E8E9CA7543E599672A40AC3820`。PDF经Poppler识别为单页A4、PDF1.4,无JavaScript,已渲染并目视确认“DOWNLOAD TEST ONLY - NOT A BUSINESS CONTRACT”。Poppler报告本机Symbol/ArialUnicode显示字体不可用,但验证页使用Helvetica,文字完整;文件长度以Get-Item和哈希校验为准。 + +测试夹具运行84.42秒后PASS;再次读取真实接口仅剩原MOCK-CONTRACT-001,测试合同数0,服务端测试PDF不存在。Downloads中的测试副本已转存到验收证据并清理原下载位置。相关Go用户逻辑测试与vet通过,无生产代码改动,无需重建既有App产物。 + +证据:[实际下载PDF](视觉验收/A1/合同下载_浏览器测试.pdf)、[下载PDF渲染](视觉验收/A1/合同下载_浏览器测试.png)。**Web下载成功路径已验证;Android/iOS系统保存对话框仍待真机验收。** 原业务合同仍缺附件,不能将本测试文件作为其签署附件。以下保留上一轮实施记录。 + +操作时间:2026-09-08。操作类型:扩展。影响模块:本人合同、受控PDF文件输出及本地保存。 + +## 行为变化与关键代码 + +- backend/api/internal/logic/client/user/gas_contract_attachment.go:新增DownloadGasContractAttachment,GET gas/contracts/:identity/attachment限定JWT本人且未归档合同,不接受路径或外部URL。 +- backend/api/internal/logic/platform/gasorder/contract_attachment.go:抽取ServeAuthorizedContractPDF供已经完成鉴权的调用方复用,保留平台原入口。继续验证合同存储前缀、根目录边界、文件类型与10MiB限制,响应private/no-store、nosniff、sandbox;不公开存储路径。 +- backend/api/internal/routers/client.go及client_test.go:新增受保护附件路由与存在性回归。 +- apps/user_app/lib/data/services/api_client.dart:getBytes新增可选Accept参数,旧图片调用兼容。 +- apps/user_app/lib/data/repositories/client_repository.dart:gasContractPdf校验会话、缺失、大小及%PDF-文件头,错误JSON不能当PDF保存。 +- apps/user_app/lib/ui/features/orders/contract_download_button.dart:读取完成后交给系统保存;防重复点击、文件名清理、取消不报成功、失败可重试。错误原因持续显示在按钮下方并通过liveRegion播报,重试时清除;Web只提示已交给浏览器下载,不声称浏览器已完成落盘。 +- gas_contracts_page.dart、gas_order_sections.dart:合同列表与正文均可下载,常规宽度与查看按钮并排,窄屏大字体分行。 +- pubspec.yaml/lock:新增file_saver 0.4.0(带dio、dio_web_adapter);保留原依赖约束,使用字节保存,不把鉴权令牌或服务端私有路径传给插件。参考[插件官方文档](https://pub.dev/packages/file_saver)。 + +## 测试与当前证据 + +后端gas_contract_attachment_test.go覆盖本人实际文件字节、外部URL拒绝、越界拒绝、文件缺失、非本人合同以及响应头。平台原附件测试、用户逻辑与路由回归通过;Go全量测试、vet及API构建通过。Flutter新增gas_contract_pdf_test.dart与contract_download_button_test.dart,覆盖缺失、错误JSON、迟到会话、失败重试和系统取消。analyze与全量102项测试通过,正文入口增加按钮后两项详情回归通过。 + +图32的8组布局截图已更新,实际查看390普通截图确认查看与下载按钮并排;窄屏大字体按宽度自动分行。平台资源契约同步及检查48项通过。 + +Web Release与Android Debug构建通过,新增插件已参与构建;Android仍有既有Kotlin迁移警告,未阻断产物。合同卡片以identity作为稳定Key,筛选或刷新移除卡片时销毁该下载状态,避免迟到请求在另一份合同卡片上继续保存。 + +远程既有MOCK-CONTRACT-001没有可读取附件,真实GET返回404,匿名请求401。本轮没有修改远程合同、没有生成冒充业务合同的PDF,也没有更改数据库或缓存配置。真实业务PDF成功保存和Android/iOS系统保存对话框仍需有附件的受控测试数据及设备验收,不能用单元测试替代这项结论。 + +最终浏览器版本已点击真实合同的下载按钮,显示持续错误“合同附件暂不可用,请联系供气单位补充”,按钮恢复可重试。最终Web和Android再次构建通过;持续错误反馈的UI回归通过。当前API进程40192,Web端口18571。 + +## 维护与风险 + +服务端导出的ServeAuthorizedContractPDF要求调用者先完成权限校验,禁止直接注册路由。保留10MiB文件上限,避免无界客户端内存下载。系统保存依赖平台支持,取消应保持页面并允许重试;PDF缺失应由供气单位补充,不能从合同正文生成替代签署件。 + +图32继续部分实现:下载代码已接通,但真实文件保存尚未完成设备验收;签署、家庭共享协议、合同服务和完整视觉仍待补。 diff --git a/docs/操作日志_用户端APP_商品收藏_20260908.md b/docs/操作日志_用户端APP_商品收藏_20260908.md new file mode 100644 index 0000000..277f1a3 --- /dev/null +++ b/docs/操作日志_用户端APP_商品收藏_20260908.md @@ -0,0 +1,66 @@ +# 商品收藏开发操作日志 + +操作时间:2026-09-08。操作类型:新增和扩展。影响模块:Go用户Client API、Flutter商城/详情/个人中心、远程收藏表及设计验收记录。 + +## 操作前状态 + +已打开最新15-我的收藏原图,核对分类计数、收藏卡片、取消收藏、下架保留和加购;已阅读03需求及当前商城、购物车、迁移实现。仓库无收藏模型或用户接口,商城、详情和个人中心的收藏均是未开放入口。保留已有工作区变更,没有重置、提交或执行mock-data。 + +## 代码变更 + +| 文件(仓库相对路径) | 职责/位置 | 行数 | +| --- | --- | --- | +| backend/api/internal/models/ec_favorite.go | EcFavorite、唯一账户商品关系、revision | 新增17 | +| backend/api/cmd/cli/favorite_migration.go | migrateEcFavorite,单表事务迁移及9字段/表中文注释 | 新增30 | +| backend/api/cmd/cli/main.go | 新增migrate-ec-favorite命令与帮助 | 扩展原CLI | +| backend/api/internal/logic/client/user/favorite.go | 30行FavoriteState、52行SetFavorite、120行ListFavorites | 新增140 | +| backend/api/internal/logic/client/user/favorite_list.go | favoriteRows,按页批量关联商品、图片、分类及参数 | 新增81 | +| backend/api/internal/routers/client.go、client_test.go | 3条受保护路由及存在断言 | 扩展用户JWT组 | +| backend/api/internal/logic/client/user/favorite_test.go | 幂等、归档版本、所有权过滤、下架列表白名单及批量关联 | 新增138 | +| apps/user_app/lib/domain/models/product_favorite.dart | ProductFavoriteState和ProductFavorite,整数金额/协议校验 | 新增62 | +| apps/user_app/lib/data/repositories/client_repository.dart | 22行favorites、29行favoriteState、setFavorite和确认事件流 | 保留原接口 | +| apps/user_app/lib/data/services/api_client.dart | 116行captureSessionGuard,不暴露令牌的会话一致性检查 | 扩展共享HTTP工具 | +| apps/user_app/lib/ui/features/shop/favorite_button.dart | 初始化读取、条件收藏/取消、跨页面状态同步 | 新增152 | +| apps/user_app/lib/ui/features/shop/favorites_page.dart | 分类数量、管理取消、下架条目、加购重试 | 新增338 | +| apps/user_app/lib/ui/features/shop/shop_page.dart、product_detail_page.dart | 替换收藏占位按钮 | 接入共用FavoriteButton | +| apps/user_app/lib/ui/features/profile/profile_page.dart、lib/app/router.dart | 我的收藏入口及/favorites登录守卫 | 保留一级导航 | +| apps/user_app/test/ui/favorites_page_test.dart、test/data/favorite_test.dart | 分类/取消/加购、同步、账号迟到响应、分页契约 | 新增136/126 | +| apps/user_app/test/support/a1_fixture.dart、tool/a1_visual_test.dart | 测试收藏及图15多视口截图 | 测试数据不进入运行入口 | +| scripts/compare-user-app-a1.ps1 | 增加图15并排对照,现13组设计图 | 仅差异检查 | + +## 服务端行为与迁移 + +- `GET shop/favorites`沿用page/page_size兼容分页;`GET shop/favorites/items/:identity`读取单品收藏状态;`PUT`提交favorite布尔值和读取时的revision。路径相对于`/heqi/client/v1/user`,均要求用户JWT。 +- 每个账户和商品只有一条关系,取消设置status=3,收藏status=1;下架商品仍在收藏列表,不允许新增收藏或加购,但可以取消。已收藏的重复true/已取消的重复false无写入。 +- revision由收藏公开UUID和正整数版本组成;同目标重试不递增,真实状态变化才递增。过期条件返回2404,不能通过旧请求复活已取消收藏。账户行锁串行写入,最多1000条有效收藏。 +- 列表只返回商品公开标识、价格、图片、分类及实际参数,不暴露内部主键或其他账户数据。取消收藏不影响购物车和订单。 +- 列表资料按当前页批量读取:商品、图片、分类、参数共四次查询,不随页面商品数量增加数据库往返。测试验证两商品主图/规格隔离、原列表顺序和每商品最多两条规格,避免N+1远程查询。 +- 使用原有远程PostgreSQL配置执行`go run ./cmd/cli migrate-ec-favorite`两次均通过,验证定向迁移可重复执行。只新增ec_favorite及约束/注释;没有全库迁移、清库、初始化示例数据或本机数据库/Redis容器。 +- ec_favorite是用户私有关系,不开放平台通用任意改写入口。资源契约同步与检查通过,平台公开资源仍为48个。 + +## 前端行为 + +- 游客点击收藏进入登录并保留站内回跳;登录后由用户再次明确点击执行写入。加载失败显示重试图标,未收到成功响应不伪造已收藏。 +- 商城和详情共用FavoriteButton,仓储广播服务端已确认状态;迟到读取不能覆盖已确认的新状态,账号切换后旧响应不广播。 +- 收藏页按后台真实分类展示数量,下架/缺货显示暂不可售。管理模式支持按当前分类全选后批量取消,失败后重读实际列表,不宣称全部成功。 +- 收藏加购沿用购物车条件版本,结果未知时保留原绝对目标,当前页面重试不会再次累加。跨页面重建后的未知请求恢复仍是后续待完善项。 + +## 自动和远程验证 + +- Go全量`go test ./...`、`go vet ./...`、构建通过。定向测试覆盖8种收藏状态写入情形、缺少必填布尔参数和下架商品保留/公开字段。 +- 批量读取改动后,收藏定向测试、相关包vet和重新构建通过;新增双商品四次资料查询的回归测试通过。运行API已切换到包含批量读取的新构建。 +- Flutter analyze通过;完整84项用户端测试通过;布局调整后2项收藏交互测试再次通过。 +- 图11/12/15/25在320、360、390、430宽度和1.0、1.3文字缩放下共32项布局检查通过。查看图15的390与320大字体截图后修正分类按钮及价格/加购排列,图15的8项复查通过,已重新生成并排图。 +- Web Release和Android Debug构建通过。本机API12426运行新后端,18571提供新Web产物;Android测试包API地址为127.0.0.1,真机需adb reverse或使用可访问的API地址构建,不能仅凭构建通过声称已验收真机。 +- 远程HTTP实测商品10收藏版本从`01a07f64-bbc6-7672-b49b-21ef882647b7:1`取消为`:2`;重复收藏保留原版本,旧请求返回2404,分页列表只出现一条该商品。匿名访问HTTP401。接口联调新增收藏已取消归档,没有更改商品价格、库存或上下架状态。 +- 浏览器390×844实测游客点收藏后跳登录并回商品详情;再次点击后显示已收藏。个人中心“我的收藏”打开真实列表,显示全部1及对应分类1,价格10500分;从收藏加购后购物车新增该商品1件,合计20800分(含原有10300分商品)。随后浏览器取消测试收藏,HTTP复核收藏为空;测试加购条目已归档,原购物车仍为原有商品1件且已勾选。本轮没有创建或支付订单。 + +## 视觉核对与风险 + +原稿中的蓝色分类按钮、爱心、商品列表和价格/加购结构已按图调整。分类、金额和库存展示真实数据,原图的示例月销量没有后端口径,未编造。商品图片缺失仍显示空态,推荐区和精确素材/间距仍未完成,因此图15和全量验收继续failed。当前逐页状态为13张部分实现、45张尚无完整页面、0张通过严格1:1全功能验收。 + +本轮新增数据库表有外键与唯一约束;后续数据维护必须保留账户隔离和归档事实。批量取消为逐项条件操作,存在局部成功时通过重新加载展示。原生相机/语音及iOS设备仍未在本轮验收;Android构建仍提示speech_to_text/tobias未来Kotlin插件兼容警告,当前不影响构建。 + +## 维护指南 + +后续销量、推荐或优惠应由所属业务服务提供可信数据,不能直接复制设计稿数字。收藏字段、版本和路由变化须更新本日志、项目文档及契约,继续验证重复/过期请求、下架保留、账号切换和分页。数据库部署仅运行定向迁移,不用全库迁移代替。 diff --git a/docs/操作日志_用户端APP_商品详情_20260908.md b/docs/操作日志_用户端APP_商品详情_20260908.md new file mode 100644 index 0000000..f8fb7d9 --- /dev/null +++ b/docs/操作日志_用户端APP_商品详情_20260908.md @@ -0,0 +1,37 @@ +# 商品详情开发日志 + +操作时间:2026-09-08。操作类型:新增及兼容扩展。影响模块:图11商城、图12详情、图14结算及游客登录回跳。 + +## 操作前后 + +原商城只能直接购买,没有独立商品详情。现在点击商品图片或名称进入 `/products/:identity`;游客可直接查看,购买时先登录,登录后保留商品和数量进入结算。原商品购买按钮继续直接进入结算,已有调用不删除。 + +新增 `GET /heqi/client/v1/user/public/products/:identity`,查询启用且未删除商品,允许查看零库存商品;图片、属性只读取该商品启用记录并按后台顺序排列。白名单返回identity/name/price_amount/stock_quantity/category_name/images/attributes,不返回内部主键,不查询订单。不存在或下架返回1112,客户端显示“商品已下架或不存在”;首次联调发现通用500后已修正并实测。 + +详情支持后台图片翻页、缩放预览、参数、数量1—min(库存,999)、售罄禁购及加载重试。金额使用整数分。结算仍重新查询商品价格与库存,提交由服务端校验,不信任详情页数据。直接打开详情或结算也有可用返回入口。 + +## 代码职责 + +- `backend/api/internal/logic/client/user/product_detail.go`:`PublicProduct` 公开详情;新增文件,无模型迁移。 +- `backend/api/internal/routers/client.go`:注册匿名详情路由。 +- `apps/user_app/lib/domain/models/product_detail.dart`:公开字段、图片/属性集合、金额与库存校验。 +- `apps/user_app/lib/data/repositories/client_repository.dart`:`productDetail` 匿名读取、图片地址解析、中文错误映射。 +- `apps/user_app/lib/ui/features/shop/product_detail_page.dart`:详情图片、参数、数量、售罄、购买入口。 +- `shop_page.dart`:图片/名称打开详情;游客购买使用根级登录跳转,解决页面内push后登录刷新停留问题。 +- `checkout_page.dart`、`app/router.dart`:向后兼容的initialQuantity默认1,解析站内数量参数,结算重新读价。 +- `test/ui/product_detail_page_test.dart`、`test/data/product_detail_test.dart`、`test/app/router_test.dart`、后端`product_detail_test.go`:页面、数据、鉴权回跳及公开数据边界。 +- `tool/a1_visual_test.dart` 与 `scripts/compare-user-app-a1.ps1`:加入图12的8组截图及并排图。 + +## 验证 + +Go全量测试、vet、build通过,修改不存在错误码后两项详情专项复测通过。Flutter全量72项通过,新增数据解析/错误映射2项定向测试通过;analyze无问题,Web Release构建通过。平台contract:sync/check通过48个资源。图12的320/360/390/430宽、文本1.0/1.3共8组截图及溢出检查通过;已实际查看390截图和设计源图,严格1:1未通过。 + +远程只读接口:商品 `00000000-0000-7000-8000-000000002015` 返回“示例配送商品10”、10500分、库存30;该商品没有图片和参数,按空状态展示,没有把设计稿气瓶图冒充其真实商品图。不存在商品实测code=1112。 + +浏览器:新构建以游客打开真实详情,选2件,点击立即购买跳登录,测试账户登录后进入同一商品结算,数量2、应付¥210.00。没有提交订单、修改库存、支付或修改数据库。API仍使用原远程PostgreSQL与Redis,本机仅API/Web进程。 + +## 差异与风险 + +当前商品模型没有可选规格组合、月销量、押金、配送承诺和售后保障字段,因此只展示后台实际参数。收藏、客服和购物车入口继续明确说明未开放;尚未实现加购及分享。图片列表和参数接口已存在,但真实商品内容需要平台维护,不能填入与商品无关的样例。 + +整体更新为11张部分实现、47张尚无完整对应页,0张严格全功能与1:1通过。新详情不等同于完整商城闭环。 diff --git a/docs/操作日志_用户端APP_地址管理_20260907.md b/docs/操作日志_用户端APP_地址管理_20260907.md new file mode 100644 index 0000000..31e07f0 --- /dev/null +++ b/docs/操作日志_用户端APP_地址管理_20260907.md @@ -0,0 +1,50 @@ +# 用户端地址管理开发与验证 + +操作时间:2026-09-07。操作类型:扩展。影响模块:用户地址、个人中心、商城下单。 + +## 变更前后 + +此前地址入口只弹出详细地址输入框,缺少列表、联系人、编辑、删除和默认切换;商城直接使用第一条地址和账户联系人。本次增加 `/addresses` 管理页及编辑页,下单显式选择地址,收货联系人随地址保存。 + +图 29 的顶部添加、地址卡片、默认标签、编辑区、服务说明和底部新增按钮均有对应组件;服务范围尚无可用的配置/判定契约,当前明确显示待气站确认,不能计作该项完成,也不把服务关系存在当作可配送证明。 + +## 代码与接口 + +- `apps/user_app/lib/domain/models/shipping_address.dart`:地址领域字段与列表手机号脱敏。 +- `apps/user_app/lib/data/repositories/client_repository.dart`:新增 `shippingAddresses`、`saveShippingAddress`、`setDefaultAddress`、`deleteAddress`;保留旧 `addresses` 和 `addAddress`。 +- `apps/user_app/lib/ui/features/address/addresses_page.dart`:列表、失败重试、默认切换、编辑及下单选择。 +- `apps/user_app/lib/ui/features/address/address_edit_page.dart`:联系人、电话、地址、可选坐标、表单校验、稳定新增请求号、删除确认、未保存退出确认。 +- `profile_page.dart`、`profile_edit_page.dart`、`shop_page.dart`、`app/router.dart`:接入管理和选择流程。 +- `backend/api/internal/logic/client/user/address.go`:新增、更新、归档、设置默认;先锁当前账户行,再查地址归属及写入,事务失败回滚。列表函数仍在 `address_ticket.go`,旧工单逻辑保留。 +- `backend/api/internal/models/user_address.go`:新增 `contact_name`、`contact_phone`、`request_no`;请求号不进入响应。 +- `backend/api/cmd/cli/address_migration.go`:`migrate-user-address` 只执行上述三个新增字段及中文列注释,不运行全库 AutoMigrate、初始化或 seed。 + +| 方法 | 相对于 `/heqi/client/v1/user` 的路径 | 行为 | +| --- | --- | --- | +| GET | `/addresses` | 本账户未归档地址;兼容原数组结构 | +| POST | `/addresses` | 新增;可选 request_no 在本账户范围内幂等 | +| PUT | `/addresses/:identity` | 更新本人有效地址及独立联系人 | +| POST | `/addresses/:identity/default` | 原默认与新默认在同一事务切换 | +| DELETE | `/addresses/:identity` | 归档并取消默认;重复删除成功,订单快照保留 | + +新增/更新字段:address、contact_name、contact_phone、longitude、latitude、is_default。新增还接受 request_no。省略联系人时兼容旧客户端,采用地址原值或账户值;历史空联系人在新页面要求补齐。坐标允许同时为空;有值时必须成对、有限且经度在 ±180、纬度在 ±90 内。新增重试返回原地址,不把后续已变更的默认状态重置。 + +## 环境与验证 + +- 本机 Go API 使用既有 `heqi_dev.yaml` 的远程 PostgreSQL、Redis;未使用 Docker 或本机数据库。新增三列的定向迁移已执行成功,旧行保留。 +- 远程测试账户完成新增、同请求号重试、联系人持久化、编辑、设置默认、重复删除;测试地址已归档,原默认地址及列表数量已验证恢复。 +- 用户端完整回归 52 项通过;地址专项覆盖空表单、失败草稿、同请求号重试、默认设置失败不假更新、删除确认。后续样式修改再跑地址专项通过。 +- 后端专项验证坐标边界、他人地址拒绝、默认变更失败回滚、创建重试不重复写入。全库测试发现新字段缺少源代码中文注释,补齐后模型与用户逻辑测试通过。 +- 资源契约同步与检查通过,48 个资源;新增 Client 路由有注册测试。 +- 图 29 已生成 320、360、390、430 宽度及 1.0/1.3 文字缩放共 8 张截图,检查滚动后无溢出;截图是独立 Fixture,不是运行时数据。 +- 最终 Go 全量测试、vet 和 Flutter 静态检查通过,Web 正式构建成功。浏览器已验证鉴权回跳、新增、编辑保存及列表真实刷新,并在 390×844 下查看实际页面。旧预览进程已停止后重新启动本机 API 与 Web;远程连接配置保持一致。 +- 浏览器随后验证默认切换、恢复原默认及删除确认,最终只剩原地址;控制台无 error/warn。商城专项 `shop_address_selection_test.dart` 通过,确认前不写订单,提交选中地址的联系人,而非账号姓名/手机号。累计现有用户端 52 项加此专项 1 项通过。 +- 视觉证据:[图 29 并排对照](视觉验收/A1/29_并排对照.png)。原稿左侧、实现右侧,仍有地图图标、字号及服务范围显示差异,当前不通过严格 1:1。 + +## 风险与剩余事项 + +本次核心文件行数:地址后端逻辑 173 行,地址模型 21 行,定向迁移 42 行,地址列表页 240 行,编辑页 232 行,地址领域模型 40 行。现有 CLI 扩展命令、Client 路由新增 3 条,资源契约生成物已同步;未添加新的运行依赖。 + +删除采用归档而非物理删除,不修改旧订单的地址快照;删除默认地址后不会自行选择另一地址。旧联系人为空的地址可以保留和设为默认,但下单选择时需先补齐联系人。坐标当前为手动录入,尚未接入地图定位;配送范围判定、原生端完整验证仍未完成。页面局部图标、字体与原稿仍需继续对齐,不能以接口或截图检查通过声称严格 1:1。 + +维护时使用现有远程开发环境配置运行 `go run ./cmd/cli migrate-user-address`,切勿为这一功能运行全库迁移。测试地址只使用指定开发测试账号,验证后归档测试记录并恢复原默认。新增接口的所有权校验必须保持在默认状态写入之前。 diff --git a/docs/操作日志_用户端APP_头像与视觉纠偏_20260907.md b/docs/操作日志_用户端APP_头像与视觉纠偏_20260907.md new file mode 100644 index 0000000..910902d --- /dev/null +++ b/docs/操作日志_用户端APP_头像与视觉纠偏_20260907.md @@ -0,0 +1,61 @@ +# 用户端 App 头像与视觉纠偏 + +操作时间:2026-09-07。 +操作类型:扩展及缺陷修复。 +影响模块:用户端个人中心、个人资料、登录品牌、商城图片、头像上传及资料更新。 + +## 核对结论 + +用户指出的问题成立。此前没有图 41 的个人资料页和头像上传入口,多个一级入口只是未开放提示;布局、品牌图标、宣传图片和商品图片也没有达到 1:1。此前“已实现”的口径过宽,现按实际业务能力记录,不能将构建成功或截图生成视为验收。 + +| 设计范围 | 实际能力 | 仍未完成的设计功能 | +| --- | --- | --- | +| 01 登录 | 密码/验证码接口、密码显隐、发送冷却、忘记密码、会话、真实协议读取;品牌改为原稿像素 | 真实短信渠道及协议同版本同状态验收;表单尺寸仍有差异 | +| 03 首页 | 真实服务归属和已发布内容、导航及维修申请入口 | 设备遥测、扫码绑定、蓝牙、分组、阀门控制及各安全详情 | +| 11 商城 | 公开商品、后台分类、搜索、价格库存、单品确认下单;宣传图复用原稿 | 购物车、收藏、完整商品详情和多商品结算;远程多条商品未配置图像,不能冒用参考样例 | +| 20 订单 | 气瓶/商城/工单列表、退款记录、服务端允许的支付/退款操作 | 设计中的完整详情、配送轨迹、发票和售后闭环 | +| 25 个人中心 | 钱包、资料/头像编辑、订单、合同、地址新增、维修和退出 | 设置、消息、押金、优惠券、家庭共享、紧急联系人、用气统计、收藏、发票、客服/关于独立页;地址管理目前仍只是新增弹窗 | +| 41 个人资料 | 选图、预览、上传、昵称保存、错误重试、取消草稿、返回刷新、真实服务归属和默认地址 | 手机换绑、性别、生日、实名认证状态及证件脱敏字段;不能用图中演示认证冒充用户事实 | + +## 代码与行为变化 + +- `apps/user_app/lib/ui/features/profile/profile_edit_page.dart`:新增资料页、`_chooseAvatar`、`_save`、`_leave`。支持相机/相册;2MB、JPG/PNG 检查;保存失败保留草稿,重试复用已上传 URI;退出确认。 +- `apps/user_app/lib/app/router.dart`:新增受登录守卫保护的 `/profile/edit`。 +- `apps/user_app/lib/ui/features/profile/profile_page.dart`:头像资料行可点击;保存返回后刷新服务端头像;分组标题、行顺序、分隔线和图标调整。 +- `apps/user_app/lib/ui/features/profile/profile_avatar.dart`:头像增加可配置尺寸,保留加载错误回退。 +- `apps/user_app/lib/data/services/api_client.dart`:增加鉴权 multipart 上传,统一处理会话失效和后端错误。 +- `apps/user_app/lib/data/repositories/client_repository.dart`:新增上传/保存资料;同 API 域名的图片绝对路径可正确解析。昵称保存省略头像时不覆盖旧图。 +- `backend/api/internal/logic/upload/avatar.go`:头像写入登录客户端和账户摘要对应的目录;`OwnsAvatar` 拒绝跨账户、跨客户端、伪造及穿越路径。旧 URI 仍可读取。 +- `backend/api/internal/logic/client/user/auth.go`:资料更新保留省略的头像;新头像只能引用本人受控上传文件;纯空白昵称被拒绝。旧头像原值和显式清空兼容。 +- `apps/user_app/lib/ui/core/reference_image.dart`、`assets/design/`:保留用户提供的两张原 PNG,只在品牌和宣传位显示指定图像区域。未用整张截图替代页面;表单、价格、账户和交互均为真实 Flutter 组件。 +- 新增 `image_picker` 及平台实现依赖;iOS 加入相机/相册用途说明。数据库模型和 API 路由未变,无迁移。 + +## 验证证据 + +关键代码定位:`profile_edit_page.dart` 新增 399 行,选图 `_chooseAvatar` 第 64 行、保存 `_save` 第 136 行;`profile_page.dart` 的 `_editProfile` 第 44 行;`api_client.dart` 的上传方法第 148 行(新增 13 行);`avatar.go` 的 `OwnsAvatar` 第 109 行(新增 28 行、替换 1 行);`auth.go` 的 `UpdateProfile` 第 148 行。行数是当前快照,后续格式化后以符号名定位。 + +资料更新请求仅提交实际编辑内容: + +```dart +await widget.repository.updateProfile(_name.text, avatar: _uploadedUri); +``` + +- Flutter 全量 50 项测试通过。新增测试覆盖 multipart 鉴权、过期、超限、相对图片路径、头像选取取消、保存失败重试、昵称校验及草稿退出。 +- Go 全量测试、vet、构建通过。资料 SQL Mock 验证省略头像不覆盖旧值、跨账户拒绝、旧 URI 保留和空昵称拒绝。 +- 平台契约检查 48 个资源通过。 +- 六页、四种宽度(320/360/390/430)、两种字体缩放(1.0/1.3)共 48 项截图及溢出检查通过;已打开实际截图与原稿查看,严格视觉验收仍不通过。 +- 浏览器实测发现 Web 构建缓存中的插件注册文件未更新,首次选图失败。已定位到生成文件缺少 `ImagePickerPlugin.registerWith`,通过失效对应的 `web_entrypoint.stamp` 重新生成,不手改生成源码。 +- 修复缓存后,浏览器系统文件选择器实际选入测试 PNG;预览变更,点击保存后返回个人中心并显示新图。再次调用真实 API 确认资料 URI 属于 owners 目录、受保护图片 GET 返回 HTTP 200、88 字节。随后恢复测试账户原昵称与头像,未留下演示头像替代用户资料。 +- 共享组件 3 项、工作人员 App 21 项回归通过;用户端蓝色描边按钮、10dp 圆角和无胶囊选中背景的底栏样式仅对 consumer 品牌调整。列表触控高度保留 48dp,部分设计图等比缩放后的行距低于这个尺寸,因此不能以压缩点击区域换取截图一致。 +- 远程数据库与 Redis 沿用既有 dev 配置。本次没有启动本地数据库,没有执行迁移、种子或清库。 +- 最终 Web Release 构建通过、用户端 analyze 无问题。Android 构建未通过:在线解析 Kotlin `kotlin-build-tools-compat:2.3.20` 出现 Maven TLS 握手失败,正常 HTTPS 补下载也失败;离线构建确认 `androidx.profileinstaller:profileinstaller:1.3.1`、`androidx.core:core-ktx:1.13.1` 等依赖未缓存。没有降低 TLS 校验,不能宣称 APK 或原生相机验收通过。NDK 28.2、Build-Tools 36、CMake 3.22.1 已由标准 SDK 构建流程安装;未改数据库环境。 + +## 风险与维护 + +- 所有尚未开放项继续保留可见入口,按照用户此前决定展示明确未开放提示;不计为已完成。 +- 原图的固定宣传内容作为随包静态素材;真实商品图片仍由后台提供,不根据名称猜测图片、不覆盖远程商品记录。 +- 实测远程 10 条商品中,9 条没有图片 URI,另 1 条 `/mock/products/lpg-15kg.png` 返回 HTTP 404;前端路径解析修复不能补造不存在的后台文件。 +- 上传成功但资料保存失败会保留文件以便重试;文件清理需要后续按引用关系和保留期实现,不能直接删历史资源。 +- 增加插件后需要确认 Web 生成注册文件包含插件,避免仅凭 Flutter 构建返回成功判定功能有效。 +- 个人资料页下拉刷新时,若存在草稿、正在选图或正在保存,保留当前草稿,防止昵称或选图被旧服务端值覆盖;保存失败重试测试同时覆盖该行为。 +- 原头像可读兼容;出于权限修复,新 URI 不再允许任意跨账户引用。 diff --git a/docs/操作日志_用户端APP_工单操作_20260907.md b/docs/操作日志_用户端APP_工单操作_20260907.md new file mode 100644 index 0000000..e74a7b3 --- /dev/null +++ b/docs/操作日志_用户端APP_工单操作_20260907.md @@ -0,0 +1,45 @@ +# 用户端 App 工单详情与操作日志 + +操作时间:2026-09-07。操作类型:扩展、修复。影响模块:Flutter 用户端、Go 用户工单 API。 + +## 操作前后 + +原报修标签只有只读记录,确认和取消虽已有接口但未接入页面,重复提交动作返回错误,归档记录没有被动作查询排除。 + +新增 `/tickets/:identity` 独立页面,由订单的报修标签进入。详情重新查询本人列表,展示编号、中文状态、服务类型、描述、地址快照、预约时间、开始处理时间、结果和完成时间。只展示已存在的时间,不生成受理或工程师到达记录。返回后列表刷新。 + +用户先确认,再调用取消或完成接口。失败保留详情并允许重试;成功重新读取状态和许可动作。后端事务锁定本人工单;取消沿用既有32/18/11/21/34范围,22重复成功;确认仅允许34,23重复成功并保留首次完成时间。归档、越权及其他状态拒绝。列表增加 `status_name` 和 `allowed_actions`,兼容原字段并移除内部主键。 + +## 核心文件 + +| 文件 | 关键位置及职责 | +| --- | --- | +| backend/api/internal/logic/client/user/address_ticket.go | 第80行 ListTickets 增加展示契约;第100行 ticketState 集中已有许可;第128行 changeTicketState 处理事务、所有权、状态和幂等 | +| backend/api/internal/logic/client/user/ticket_actions_test.go | 新增10种动作边界、终态许可、列表归属和内部ID剥离测试 | +| apps/user_app/lib/domain/models/service_ticket.dart | 第5行 ServiceTicket 适配真实字段与本地时间,未知状态不自行授权 | +| apps/user_app/lib/data/repositories/client_repository.dart | 第115行 ticket 兼容列表读取详情;第124/128行取消与确认请求 | +| apps/user_app/lib/ui/features/tickets/ticket_detail_page.dart | 新增详情;第24行 _act 控制确认、失败和刷新;第185行 _field 对齐原稿左右字段布局 | +| apps/user_app/lib/app/router.dart | 增加受已有登录守卫保护的工单路由 | +| apps/user_app/lib/ui/features/orders/orders_page.dart | 报修记录可点击,详情返回刷新 | +| apps/user_app/lib/ui/core/widgets.dart | RecordCard 优先使用服务端中文状态,保留旧状态回退 | +| apps/user_app/test/ui/ticket_detail_page_test.dart | 取消确认不写、失败不丢详情、重试成功刷新、终态动作消失 | +| apps/user_app/test/support/a1_fixture.dart、tool/a1_visual_test.dart | 独立测试工单;图42八组截图,不作为运行时数据 | +| scripts/compare-user-app-a1.ps1 | 纳入图42并排对照 | + +没有新增运行依赖、后端路由或数据库字段,本阶段无需迁移。后续照片、人员与预约能力仍应沿用既有工单模型和服务端权限边界,不将前端演示数据写入业务表。 + +## 验证结果 + +- `go test ./...`、`go vet ./...`、API构建通过;追加列表契约测试后,`go test ./internal/logic/client/user -run Ticket -count=1` 通过。 +- Flutter 全部57项测试通过;布局调整后两个工单动作测试再次通过。`flutter analyze --no-pub` 无问题,Web release构建通过。 +- 图42在320/360/390/430宽度和1.0/1.3文字缩放共8项截图与滚动溢出检查通过。已实际打开原图和390截图,不能解释为1:1通过。 +- 本地 API 连接原配置的远程 PostgreSQL、Redis;未启动或使用本机数据库/缓存,未执行迁移、清库、种子或容器操作。 +- 远程测试账号创建临时工单 `TK1788792440299487600`,初态待受理、许可cancel。浏览器从报修列表进入,查看真实描述,确认取消,详情变为已取消、按钮消失;返回列表也为已取消。只操作本次临时工单,未改原MOCK-TICKET-001。临时记录保留为取消状态,不物理删除。 +- 浏览器 error/warn 控制台为空。真实完成确认没有通过篡改工作人员工单状态来制造测试数据,当前证据为SQLMock与Widget测试。 +- 测试期间修复确认弹窗背后的多余加载动画;发送请求时才显示进度。调整初版字段上下布局为左右布局,长地址自然换行。 + +## 视觉与功能差距 + +图42仍缺现场照片、工程师资料与受控电话、完整历史进度、预约改期、补充资料、紧急联系链路。图05仍是旧文字报修弹窗,未实现三步表单、照片及采集时间/定位、语音和草稿;创建接口的完整幂等与表单快照也待该阶段处理。此次只是详情和已有动作闭环,不能算完整报修功能或严格还原。 + +当前58图统计更新为9张部分实现、49张尚无对应完整页面、0张严格全功能与1:1通过。维护时先核对05/42原图和跨端工单字段,扩展上传必须保护证据所有权和访问权限;不能把设计中的样例照片当作用户提交证据。 diff --git a/docs/操作日志_用户端APP_报修提交_20260907.md b/docs/操作日志_用户端APP_报修提交_20260907.md new file mode 100644 index 0000000..ec1c362 --- /dev/null +++ b/docs/操作日志_用户端APP_报修提交_20260907.md @@ -0,0 +1,45 @@ +# 用户端 App 报修提交日志 + +操作时间:2026-09-07。操作类型:扩展、修复。影响模块:Flutter 用户端报修、Go 工单创建、cs_ticket三个字段。 + +## 本次变化 + +个人中心和首页报修入口原来打开只有描述的弹窗,现在进入 `/repair`,按故障信息、联系与地址、确认提交分三步。故障可选阀门、报警器、泄漏、其他;描述必填,地址必须有完整联系人;预约时间选填且必须在将来。确认前不写工单,上一步和地址页返回保留输入。发送期间禁止重复点击,未知结果冻结原请求并使用同一UUID重试;明确校验错误后可返回修改。 + +后端保存fault_type、contact_name、contact_phone。联系人由当前用户地址生成快照,兼容历史地址时回退本人账户;不接受其他账户地址。创建在账户行锁事务内执行,同一用户相同request_no返回首次工单,地址归档或预约时间已过不影响重复请求。首次创建拒绝空白描述、空白/超过128字符请求号、非法故障枚举、过去预约时间;服务归属数据库错误不再被忽略。旧category/description请求兼容。 + +## 文件与职责 + +| 文件 | 主要改动 | +| --- | --- | +| apps/user_app/lib/ui/features/tickets/repair_page.dart | 新增三步表单、地址/时间选择、确认、失败保持、离开提示 | +| apps/user_app/lib/ui/features/profile/profile_page.dart | _createTicket由文字弹窗改为导航 | +| apps/user_app/lib/app/router.dart | 新增/repair,沿用登录守卫 | +| apps/user_app/lib/data/repositories/client_repository.dart | submitRepair返回工单UUID,保留旧createTicket | +| apps/user_app/lib/domain/models/service_ticket.dart | 故障枚举中文映射 | +| apps/user_app/lib/ui/features/tickets/ticket_detail_page.dart | 显示故障类型与提交时联系人快照 | +| backend/api/internal/models/cs_ticket.go | 新增fault_type、contact_name、contact_phone,默认空字符串,旧记录不回填 | +| backend/api/internal/logic/client/user/address_ticket.go | CreateTicket事务、幂等、校验与快照 | +| backend/api/cmd/cli/address_migration.go、main.go | 通用定向字段迁移执行器与migrate-ticket-contact命令,无全表初始化 | +| backend/api/internal/logic/client/user/ticket_create_test.go | 重试、空值、非法枚举、地址越权回归 | +| apps/user_app/test/ui/repair_page_test.dart | 分步校验、地址返回、确认前零写入、未知结果同UUID重试 | +| apps/user_app/tool/a1_visual_test.dart | 新增05,多尺寸截图仍是独立Fixture | +| frontend/platform_admin/src/contracts/platform-resources.json | 通过同步脚本更新新增工单字段 | + +## 实际验证 + +- Go全包测试、vet、CLI和API构建通过;新增创建测试后Ticket相关测试通过。 +- Flutter58项测试通过,analyze无问题,Web release构建通过。 +- 05与42分别在320/360/390/430宽度及1.0/1.3文本缩放检查,共16项截图/溢出检查通过,已打开05原图与实现截图,差距仍明显,未通过严格1:1。 +- 已在既有远程数据库执行migrate-ticket-contact,只增加三个字段及中文COMMENT;事务锁超时5秒。未修改旧工单内容、未运行全库迁移或种子,未使用本机PostgreSQL/Redis容器。 +- contract:sync和contract:check通过,48个资源。 +- 远程临时地址与工单 `TK1788793274954000000` 验证:故障、联系人、地址、预约快照一致;地址归档后重复提交返回同一UUID,列表仅一笔;工单取消及重复取消均成功。临时地址已归档,工单保留取消状态,原地址及原工单未修改。 +- 浏览器验证登录回跳/repair、空描述拦截、输入后进入联系地址、打开真实地址列表、返回及上一步保留输入;控制台error/warn为空。当前原地址缺联系人,未为了浏览器测试改写用户原地址。成功提交路径证据为Widget测试加真实API验证,并非浏览器整条提交验收。 + +## 未完成与风险 + +现场照片仍是可见的未开放入口,未声称上传可用。语音、采集定位、原始照片时间、持久化草稿和跨重启请求恢复尚未实现。预约目前是用户请求时间,不代表气站已承诺时段;照片与工程师完整链路也未完成。下一阶段需专用受控上传和工单证据授权,不能使用头像接口或把原图示例照片写成真实证据。 + +字段迁移是向后兼容的加法,回退旧API时可保留列;不要删除列损失已保存快照。新增联系人会包含个人信息,沿用本人Client访问与后台脱敏保护。维护时保留首次请求号语义,结果未知时不能重生成号自动重发。 + +当前10图部分实现、48图尚无对应完整页面、0图严格全功能1:1通过。操作日志记录的是本阶段实际事实,不缩小58图总目标。 diff --git a/docs/操作日志_用户端APP_报修照片_20260907.md b/docs/操作日志_用户端APP_报修照片_20260907.md new file mode 100644 index 0000000..23964e5 --- /dev/null +++ b/docs/操作日志_用户端APP_报修照片_20260907.md @@ -0,0 +1,45 @@ +# 用户端 App 报修照片日志 + +操作时间:2026-09-07。操作类型:扩展。影响模块:用户App、Client照片接口、受控文件存储、既有工单证据表。 + +## 操作前后 + +现场照片原为未开放按钮,现支持相册/相机来源选择、1—3张照片、预览、删除、去重、上传和工单关联。客户端要求至少一张,旧API客户端仍可省略photos。每张JPG/PNG不超过2MiB;服务端再次验证扩展名、MIME、完整解码和4096像素边界。 + +上传使用独立账户与内容摘要目录,不复用头像目录或权限。完整临时文件写入后原子移动,相同内容重复上传复用URI。工单与reported照片证据在同一事务中,越权或无效照片导致整张工单回滚。客户端缓存上传结果,提交失败重试不重复上传成功照片。 + +详情批量查询证据,只返回UUID、添加时间、来源和完整性标记。读取同时验证用户、工单归属和证据关联,返回private/no-store及nosniff,并记录访问日志。页面支持读取失败重试与放大预览。 + +## 代码变更 + +| 文件 | 关键函数与位置 | +| --- | --- | +| backend/api/internal/logic/upload/ticket_photo.go | 新增存储;UploadTicketPhoto第43行、ServeTicketPhoto第95行;摘要路径和账户归属 | +| backend/api/internal/logic/client/user/ticket_photo.go | saveTicketPhotos第34行事务关联;TicketPhoto第74行双重授权;ticketPhotosForList批量元数据 | +| backend/api/internal/logic/client/user/address_ticket.go | 创建可选photos及事务;列表照片元数据 | +| backend/api/internal/routers/client.go、client_test.go | POST ticket-photos、GET tickets/:identity/photos/:photoIdentity与路由测试 | +| apps/user_app/lib/ui/features/tickets/repair_photos.dart | 第27行RepairPhotos选择、删除、预览;RepairPhoto缓存上传URI | +| apps/user_app/lib/ui/features/tickets/repair_page.dart | 图片必填、上传期间保护、重试复用、确认摘要 | +| apps/user_app/lib/ui/features/tickets/ticket_photos.dart、ticket_detail_page.dart | 第8行TicketPhotos鉴权缩略图、重试、预览和添加时间 | +| apps/user_app/lib/data/services/api_client.dart | 第153行uploadImage共用鉴权;保持旧头像方法和错误提示兼容 | +| apps/user_app/lib/data/repositories/client_repository.dart、domain/models/service_ticket.dart | 上传/读取照片、submitRepair可选photos、安全解析元数据 | +| backend/api/internal/logic/upload/ticket_photo_test.go、client/user/ticket_photo_test.go | 格式、大小、尺寸、路径、归属、事务回滚与读取缓存测试 | +| apps/user_app/test/ui/repair_page_test.dart | 选图注入,失败重试UUID相同且上传次数为1 | + +没有新增数据库字段或表、迁移、种子或本机数据库/缓存。复用cs_ticket_evidence;文件沿用API工作目录runtime/uploads,可用HEQI_UPLOAD_DIR指定受控存储,不能公开映射该目录。 + +## 验证结果 + +- Go全包测试、vet、API构建通过;格式伪造、2MiB限制、4096像素边界、目录穿越、头像URI误用、跨账户/工单读取、关联失败整体回滚均有测试。 +- Flutter58项测试通过,analyze无问题;05/42的16组宽度/文本缩放截图通过,Web release构建通过。旧头像提示回归发现后已保持兼容。 +- contract:sync和contract:check通过48个资源,新路由纳入测试。 +- 浏览器从系统文件选择器选取64×64蓝白验收图案,描述注明验收无需派单;选择临时完整地址后确认提交,自动进入详情,照片能重新读取并放大。 +- 工单TK1788794635076747500,UUID为01a07c78-0344-7b67-acba-b3e3f289974d。随后浏览器确认取消,已取消且按钮消失,证据保留。 +- 鉴权GET=200、image/png、Cache-Control含no-store/private;匿名GET=401。读取图片与原验收图尺寸相同,64×64全部像素差异为0;SHA256不同,不能声称原文件字节保真。 +- 临时地址01a07c74-5c76-7805-873a-4d82b8c48977已归档,列表恢复仅原默认地址。没有修改原地址、原工单或其他账户。浏览器error/warn为空。 + +## 剩余范围与维护 + +来源user_camera/user_gallery,完整性capture_time_unknown;captured_at暂存客户端添加时间,只以added_at输出。页面明确提示拍摄时间/位置未核实,不伪造现场取证。原始拍摄时间、真实定位、原生相机验收、语音、持久化草稿、补充照片、工作人员查看用户照片的完整入口、联系工程师与改期仍待完成。 + +当前10图部分实现、48图尚无完整对应页面、0图严格全功能1:1通过。远程数据库保存证据记录不代表多实例共享文件存储已部署;后续迁移必须保留资源归属与受保护读取。 diff --git a/docs/操作日志_用户端APP_报修草稿_20260907.md b/docs/操作日志_用户端APP_报修草稿_20260907.md new file mode 100644 index 0000000..eeac7db --- /dev/null +++ b/docs/操作日志_用户端APP_报修草稿_20260907.md @@ -0,0 +1,38 @@ +# 用户端 App 报修草稿操作日志 + +操作时间:2026-09-07(开发环境时区)。操作类型:扩展。影响模块:图05报修表单、本人照片读取。 + +## 操作前后 + +原先页面销毁会丢失表单和请求号。现在生产依赖注入 `SecureRepairDraftStore`,按 API 地址与认证账户隔离保存;照片先上传,草稿只保存 URI。点击暂存退出才离开,失败保留表单。提交前持久化请求号与待确认标记,未知结果恢复后复用原请求号;恢复不自动提交,成功才尝试清理草稿。 + +未发出的草稿恢复时重新查询本人地址,已删除地址回到选择步骤;未知提交保留首次地址快照。照片或草稿损坏不会自动删除,用户明确清除前提示先检查已有工单。 + +## 关键代码 + +- `apps/user_app/lib/data/services/repair_draft_store.dart`:新建安全存储接口与实现,schema=1,只保留一个账户草稿。 +- `apps/user_app/lib/ui/features/tickets/repair_page.dart`:`_loadDraft`(49行)、`_persistDraft`(173行)、`_saveAndExit`(194行)、`_next`(291行),扩展恢复、保存、清理及发送顺序。 +- `apps/user_app/lib/app/dependencies.dart`、`router.dart`:注入存储;测试可显式不持久化。 +- `apps/user_app/lib/data/repositories/client_repository.dart`:认证草稿所属账户、受保护照片字节读取。 +- `backend/api/internal/logic/upload/ticket_photo.go`:`ServeOwnedTicketPhoto`(23行),只构造当前账户目录。 +- `backend/api/internal/logic/client/user/ticket_photo.go`:`UploadedTicketPhoto`(34行),先校验登录用户。 +- `backend/api/internal/routers/client.go`:新增第35行 GET `/ticket-photos/:name`。原有上传、工单照片和提交接口兼容。 +- `test/data/repair_draft_store_test.dart`、`test/ui/repair_draft_test.dart`:隔离、恢复重试、写入失败、清理和损坏恢复覆盖。 +- `tool/a1_visual_test.dart`:保留生产暂存入口的内存夹具。`scripts/compare-user-app-a1.ps1` 纳入图05并排核对。 + +仓库此前已有大量未提交变更,以上为本次职责范围;不把累积 Git 行数当作本次改动统计。没有新增依赖、数据库列、迁移或容器。 + +## 验证 + +- 用户端 analyze 无问题;全量63项测试通过后新增损坏草稿案例,草稿组件5项定向测试全部通过,另有安全存储隔离1项通过。 +- Go `test ./...`、`vet ./...` 通过;新草稿图片读取测试覆盖本人200、他人404、路径穿越404、无效文件名404。`go build ./cmd/main` 通过。 +- 平台契约同步与检查通过,48个资源。 +- 图05的8组宽度/文本缩放截图通过;已实际打开设计源图及390像素实现图。步骤条、表单分区、照片占位和联系地址预览仍不同,严格1:1未通过。 +- Web Release 构建使用 `--dart-define=API_BASE_URL=http://127.0.0.1:12426`。首次遗漏该参数导致浏览器连接模拟器默认地址失败,已重新构建修正。 +- 浏览器真实验证:授权测试账户填写“草稿恢复浏览器验收:只暂存,不提交工单。”并选择既有蓝白验收图,暂存返回个人中心。完整刷新后重新登录同一账户,出现恢复提示;选择恢复后文字与照片均正确显示。API运行日志记录一次草稿照片读取、零次工单创建请求。验收结束通过“不保存”删除本次草稿。 + +## 风险和维护 + +草稿保存在当前设备及浏览器存储,不跨设备同步;暂存照片需要网络。删除本地草稿不会删除可能已关联工单的照片,未关联文件的过期清理仍需独立实现。提交已成功但本地清理失败时保留原请求号,下次重试由后端返回原工单,避免重复创建。 + +继续使用原远程 PostgreSQL、Redis,本机只运行 API 与 Web。语音、真实采集位置/时间、原生设备和完整设计还原仍未验收,不能将本次草稿完成等同于图05或全部58图完成。 diff --git a/docs/操作日志_用户端APP_报修视觉对齐_20260908.md b/docs/操作日志_用户端APP_报修视觉对齐_20260908.md new file mode 100644 index 0000000..894928e --- /dev/null +++ b/docs/操作日志_用户端APP_报修视觉对齐_20260908.md @@ -0,0 +1,23 @@ +# 报修页面视觉对齐 + +操作时间:2026-09-08。操作类型:修改及扩展。影响模块:图05一键报修。 + +操作前:步骤为普通文字,故障类型和描述各自分散,照片只有一个添加按钮,第一步没有联系人预览。操作后:编号圆形步骤、统一白色故障分区、外置必填标签、三格照片区域;第一步可以打开本人地址选择,显示选中联系人和真实地址。没有虚构示例手机号、定位成功或故障照片。 + +## 文件与职责 + +- `apps/user_app/lib/ui/features/tickets/repair_layout.dart`:新增 `RepairStepHeader`、`RepairSection`、`RequiredRepairLabel`、`RepairContactPreview`。展示层无网络、上传或提交状态。 +- `repair_page.dart`:引用上述组件,故障下拉、描述、照片置于同一分区;第一步地址入口复用 `_chooseAddress`。减少顶部与计数器占位,保持2000字限制。 +- `repair_photos.dart`:每张实际照片占一格,其余格均可选图,总数仍限制3张。保留删除、预览、重复照片判断和选图忙碌保护。 +- `test/ui/repair_page_test.dart`:选择入口改按可读标签定位,并验证初始三格;提交按钮按按钮类型定位,避免与步骤标题混淆。 +- `docs/视觉验收/A1/05_*.png`:更新8组实际截图及设计并排图。 + +## 验证与限制 + +用户端64项全量测试通过,报修及草稿6项定向回归通过,analyze无问题。320/360/390/430像素宽、文字1.0/1.3共8组截图和溢出检查通过。已实际查看390普通文字及320放大文字截图,390视图中可见暂存按钮。窄屏放大文字时允许自然换行和滚动,不缩小用户文字偏好。 + +中途发现白色容器遮挡ListTile点击反馈,已为分区内部添加透明Material并复测。Web Release使用本机API地址构建;后端、远程数据库、缓存、公共接口均未修改,无迁移。 + +浏览器在390×844实际打开最终构建,三格照片和底部两个按钮均可见。点击第一步报修地址进入本人地址列表,看到现有真实地址及“待补充联系人”,随后返回报修页。本次没有修改地址、提交工单或写入数据库;完整地址选择后的提交由原报修组件回归测试覆盖。 + +语音、真实拍摄定位/时间、紧急电话入口、照片虚线和其他精细间距仍未完成。设计图中的故障实拍属于用户内容,不能作为生产默认照片。严格1:1状态继续为未通过;整体10张部分实现、48张无完整对应页的口径不变。 diff --git a/docs/操作日志_用户端APP_报修语音输入_20260908.md b/docs/操作日志_用户端APP_报修语音输入_20260908.md new file mode 100644 index 0000000..04c2bd1 --- /dev/null +++ b/docs/操作日志_用户端APP_报修语音输入_20260908.md @@ -0,0 +1,31 @@ +# 报修语音输入操作日志 + +操作时间:2026-09-08。操作类型:扩展。影响模块:图05故障描述、系统权限。 + +## 实现 + +`speech_to_text 7.4.0` 调用设备系统识别服务;只在点击语音输入时初始化和申请权限。应用不保存录音文件,不向业务后端发送音频,系统识别服务可能使用网络。识别结果进入原故障描述,可在结束后校对编辑,再沿用既有草稿与报修提交接口。 + +`RepairSpeech` 接口隔离平台和组件测试;`SystemRepairSpeech.instance` 复用唯一系统实例,优先匹配设备提供的 zh-CN 语种。`RepairSpeechButton` 保存启动时的文本与选区,将本轮完整识别结果替换同一区域,避免中间结果重复追加。保留前后文字,按字符簇限制2000字,避免截断表情。拒绝权限、不可用、网络错误、无识别结果均有中文反馈,已有文字保留。 + +识别期间禁止下一步、暂存、地址切换及照片操作;用户可结束识别。初始化时的系统授权窗口可能触发 inactive,不能误判为切后台;paused、离页和50秒兜底超时会取消识别,迟到回调不再修改输入。 + +## 变更文件 + +- `apps/user_app/lib/data/services/repair_speech.dart`:系统单例适配、识别回调、停止与取消。 +- `apps/user_app/lib/ui/features/tickets/repair_speech_button.dart`:文字选区保护、状态、权限错误与生命周期。 +- `apps/user_app/lib/ui/features/tickets/repair_page.dart`:描述框内语音入口、识别中操作约束。 +- `apps/user_app/android/app/src/main/AndroidManifest.xml`:麦克风权限及 RecognitionService 查询;显式禁用蓝牙识别支持,未额外申请蓝牙权限。 +- `apps/user_app/ios/Runner/Info.plist`:麦克风和语音识别用途;相机/相册用途补充实际报修照片功能。 +- `apps/user_app/pubspec.yaml`、`pubspec.lock`:新增speech_to_text及3项传递依赖,未升级其他既有依赖。 +- `apps/user_app/test/ui/repair_speech_test.dart`:5项语音组件测试。 + +## 验证 + +analyze无问题,全量68项测试通过;随后增加权限弹窗/切后台边界,语音5项定向测试全部通过。8组图05视觉与滚动溢出检查通过,已打开390×844实际截图,语音按钮位于故障描述框右下。Web Release构建通过,API地址为127.0.0.1:12426。 + +Android Debug构建通过(174.4秒),产物 `apps/user_app/build/app/outputs/flutter-apk/app-debug.apk`,159209325字节。本次APK编译的API地址为127.0.0.1:12426,设备运行需通过adb reverse转发12426端口或重新构建指定设备可访问的API地址,不能直接把手机环回地址当开发电脑。该构建仍提示speech_to_text/tobias的旧Kotlin Gradle插件兼容性警告,未阻止当前构建;未来升级Flutter需复核。 + +原API/Web进程已退出且旧临时API文件不存在,已重新编译启动API与Web预览,仍读取原远程配置。浏览器重新登录测试账户后实际看到描述框中的语音输入入口,控制台无警告/错误;本轮未开启麦克风,没有采集现场声音、修改地址或提交工单。 + +识别平台能力以[插件官方说明](https://pub.dev/packages/speech_to_text/versions/7.4.0)为准。自动化测试注入可控识别结果,不等同于真实麦克风语音成功;Android/iOS设备和实际语音采集仍未验收。没有新增数据库字段、后端接口或迁移;PostgreSQL和Redis保持原远程配置。 diff --git a/docs/操作日志_用户端APP_推荐商品_20260908.md b/docs/操作日志_用户端APP_推荐商品_20260908.md new file mode 100644 index 0000000..de5b02e --- /dev/null +++ b/docs/操作日志_用户端APP_推荐商品_20260908.md @@ -0,0 +1,52 @@ +# 推荐商品开发与视觉检查记录 + +操作时间:2026-09-08。操作类型:新增、扩展和布局修复。影响模块:用户推荐API、购物车、收藏、商品详情返回刷新。沿用远程PostgreSQL与Redis,本轮没有数据库迁移、容器操作、全库初始化或商品资料写入。 + +## 操作前后 + +已打开并检查最新图13购物车和图15我的收藏。操作前购物车缺少“猜你喜欢”和换一换,收藏缺少底部推荐入口。操作后购物车显示真实推荐商品,收藏推荐入口打开可滚动弹层,均可查看详情、换一批及加购。实际价格、库存、图片来自既有后台,不用原图示例金额代替业务数据。 + +截图检查还发现320宽度、1.3倍字体时购物车金额会逐字换行,已改为金额独立一行。从推荐或已有购物车商品进入详情后返回会重读购物车,避免详情加购后合计仍显示旧值。 + +## 文件与职责 + +| 文件(仓库相对路径) | 关键函数与变更 | +| --- | --- | +| backend/api/internal/logic/client/user/recommendation.go | 新增90行,Recommendations、recommendationValues,归属过滤、分类排序、分页、批量图片与分类 | +| backend/api/internal/logic/client/user/recommendation_test.go | 新增96行,双入口归属/排序、分页、公开字段与非法参数回归 | +| backend/api/internal/routers/client.go、client_test.go | 新增受JWT保护的GET shop/recommendations和路由回归 | +| apps/user_app/lib/domain/models/product_recommendations.dart | 新增49行,ProductRecommendations校验分页与整数分金额 | +| apps/user_app/lib/data/repositories/client_repository.dart | recommendations;cart/cartItem/setCartItem补充会话一致性检查 | +| apps/user_app/lib/ui/features/shop/product_recommendations.dart | 新增283行,ProductRecommendationSection、showFavoriteRecommendations,读取、换页、条件加购和错误恢复 | +| apps/user_app/lib/ui/features/shop/cart_page.dart | build/_busy,内嵌推荐、加购时阻止结算、金额响应式布局、详情返回刷新 | +| apps/user_app/lib/ui/features/shop/favorites_page.dart | build增加推荐入口,关闭弹层后刷新收藏 | +| apps/user_app/test/ui/product_recommendations_test.dart | 新增195行,分页、未知加购重试、库存、结算阻止、详情返回与收藏弹层 | +| apps/user_app/test/data/product_recommendations_test.dart、cart_test.dart | 新增推荐契约与购物车旧账号迟到响应回归 | +| apps/user_app/test/support/a1_fixture.dart | 仅自动测试用推荐数据,不进入运行入口 | + +## 接口行为 + +`GET /heqi/client/v1/user/shop/recommendations?source=cart&page=1&page_size=2`:source只接受cart、favorites;page范围1至10000,page_size范围1至20。未登录HTTP401。服务端只选择status=1、未软删除且stock_quantity>0商品,始终排除本人有效购物车,收藏场景再排除本人有效收藏。 + +同入口关联分类优先,其余按商品更新时间倒序、公开标识排序。通过GORM的clause.OrderBy.Expression绑定账户参数,避免Order(gorm.Expr)被忽略;表名只取固定枚举,禁止拼接用户输入。每页商品元数据只需一次图片和一次分类查询,返回字段白名单,不暴露内部ID。 + +客户端换一换读取下一页,末页回到第一页。加购读取购物车快照并提交绝对目标数量和原revision;未知响应在当前组件保留原目标,重试不重复加量,成功后重读购物车及推荐。加购期间禁用购物车结算与批量操作。401交由会话路由处理,旧账号迟到响应不更新当前界面。 + +## 验证与实际数据 + +- Go全量测试、vet、API构建通过;新增测试验证双入口SQL归属、分类排序实际生效、分页多读一条、主图与分类隔离、公开字段及空页/非法参数。 +- Flutter analyze、全量90项测试通过;补充详情返回刷新后,购物车及推荐的7项定向测试再次通过。所有测试使用生产主题,Fixture不修改远程数据。 +- 图13/15在320、360、390、430宽度及1.0/1.3文字缩放共16项布局检查通过;修复金额换行后图13的8项复查通过,并实际查看390普通、320大字体截图。并排对照已更新。 +- Web Release与Android Debug构建通过;原生设备操作未据此声明已验收。Android仍有speech_to_text/tobias未来Kotlin插件兼容提示。 +- 远程接口首批实际返回后台现有“示例配送商品10/9”,下一批8/7;原购物车1件商品被排除。临时收藏商品10后,favorites推荐排除它,测试收藏已取消恢复;未登录HTTP401。 +- 浏览器390×844验证换一换、查看商品8详情、返回、推荐商品10加购:购物车由1件变2件,合计从10300分变20800分,推荐列表移除商品10。通过管理仅删除本轮新增条目,保留原商品1件;本轮未创建订单、支付或改变库存。 +- 收藏页实际打开推荐弹层,显示真实价格与可操作的换一换、详情和加购入口;关闭正常,浏览器错误/警告日志为空。最终HTTP复核收藏为空、购物车仅原商品1件且已勾选,基线恢复通过。 +- 最终详情返回修复已重新构建Web与Android Debug;API进程32148运行本轮后端,Web继续由18571提供。构建通过不代表原生设备验收完成。 + +## 视觉结果与剩余风险 + +严格验收仍为failed。58图仍是13张部分实现、45张无完整页、0张通过严格全功能1:1。商品图片为空或404、月销量无口径、押金与配送服务承诺尚未实现,卡片密度和精细样式仍有差异。图13推荐双列及图15推荐入口已补,不能据此声称整个购物车/收藏页面还原完成。 + +证据:[图13并排](视觉验收/A1/13_并排对照.png)、[图15并排](视觉验收/A1/15_并排对照.png)、[320大字体修复](视觉验收/A1/13_320_1.3.png)。图片缺失保留明确提示,不把设计截图当成真实商品照片。未知加购结果只在当前组件保留;跨页面重建恢复、全商品图片运营配置、其余45页仍需继续实现。 + +维护时扩展推荐条件应更新双入口归属测试,继续使用字段白名单和参数绑定;不可把推荐服务改成客户端上传完整私有购物车。测试单独执行`flutter test test/ui/product_recommendations_test.dart test/data/product_recommendations_test.dart`,Go使用`go test ./internal/logic/client/user ./internal/routers`。 diff --git a/docs/操作日志_用户端APP_支付密码_20260908.md b/docs/操作日志_用户端APP_支付密码_20260908.md new file mode 100644 index 0000000..583e1f2 --- /dev/null +++ b/docs/操作日志_用户端APP_支付密码_20260908.md @@ -0,0 +1,38 @@ +# 支付密码开发与验证记录 + +操作时间:2026-09-08 +操作类型:扩展、修复 +影响模块:用户设置、钱包密码、各业务端共用验证码与支付密码校验。 + +## 操作前后 + +设置中的支付密码原为未开放入口。现接通`/settings/payment-password`,读取当前钱包的`payment_password_set`,区分首次设置、旧密码修改、验证码找回;状态缺失进入错误重试,不能推断为未设置。密码限定六位数字、二次确认、请求中禁止重复提交,错误保留表单,仅收到`changed=true`才提示成功。 + +后端更新增加旧散列条件,防止并发覆盖;密码散列不进入SQL日志。输错计数与十五分钟锁定改为Redis原子脚本,五次失败后拒绝正确密码;缓存不可用也拒绝验证。验证码绑定客户端、手机号及用途,并在同一原子操作中校验和消费,防止并发重复使用。成功重置后清除本人锁定;若清锁失败,响应独立返回`lock_cleared=false`,页面明确区分密码已更新与锁仍可能生效。 + +## 短信限制 + +仓库仅有Mock验证码写入Redis,没有真实短信供应商。用户确认不知道现成配置路径;不重复索要密钥,也未擅自接入付费服务。接口保留原字段并新增`delivery_mode=mock`、`delivery_status=not_sent`及`retry_after`,关闭Mock时返回2410“短信验证服务尚未配置”。支付密码和注册页面不再把请求建立描述为短信已发送。首次设置与找回已通过隔离Mock联调,但真实手机收码仍未完成,不能计为生产可用短信流程。 + +## 代码与接口 + +- `apps/user_app/lib/ui/features/settings/payment_password_page.dart`:新增页面;`_load`状态读取、`_sendCode`用途及冷却、`_save`明确响应确认。 +- `apps/user_app/lib/data/repositories/client_repository.dart`:`wallet`、`requestPaymentPasswordCode`、`setPaymentPassword`增加会话保护,验证码手机号来自当前资料;不会接受页面传入钱包归属。 +- `apps/user_app/lib/domain/models/client_models.dart`:可空密码状态及验证码请求结果模型,保持旧构造调用兼容。 +- `apps/user_app/lib/app/router.dart`、`ui/features/settings/settings_page.dart`:受保护路由及设置入口;`register_page.dart`修正Mock提示。 +- `backend/api/internal/logic/common/client_wallet.go`:`SetPaymentPassword`严格数字、锁感知验证、条件更新及清锁结果;原接口字段兼容。 +- `backend/api/internal/logic/common/payment_password_guard.go`:抽出原共用校验并增加原子计数。 +- `backend/api/internal/logic/common/client_auth.go`:短信未配置拒绝、交付元数据及验证码原子消费。 +- 新增`payment_password_test.go`、`payment_password_remote_test.go`、`verification_availability_test.go`;Flutter新增`test/data/payment_password_test.dart`与`test/ui/payment_password_page_test.dart`。视觉工具增加`31-payment`,沿用明确测试数据,不从真实账户生成密码截图。 + +## 验证与风险 + +Flutter analyze及全量117项测试通过;页面8组320/360/390/430宽度、1.0/1.3文本缩放检查通过,已查看320放大截图无溢出。后端全量go test、go vet、API构建通过;新增短信不可用拒绝测试通过。 + +Web使用`--pwa-strategy=none`构建成功,Android调试包构建成功。浏览器加载新包后,支付密码深链跳转登录并在登录后回到原页面,读取真实已设置状态;空表单出现三处六位数字提示,找回模式正确切换为手机验证码,控制台无error/warn。未在真实用户钱包提交改密或发送验证码,成功变更由上述独立远程回滚测试覆盖。API进程39900监听12426,健康检查200;18571继续提供新Web产物。Android构建仍有既有插件Kotlin迁移提示,本轮未升级依赖,真机键盘及输入尚待验证。 + +显式开启`HEQI_REMOTE_PAYMENT_PASSWORD_TEST=1`的远程测试通过:独立临时用户与零余额钱包、首次设置、旧密码修改、验证码重置、错误格式、用途不匹配、16并发验证码仅一次成功、8并发错误密码触发五次锁定、重置解锁。数据库整体回滚后确认账户和钱包均不存在,仅清理本轮独立Redis键。没有修改真实账户支付密码、余额、数据库结构或远程缓存配置。 + +原子校验为共用逻辑,会影响所有调用支付密码或验证码的业务端,因此运行后端全量回归。Redis故障时相关验证不可用属于预期拒绝行为。网络中断后密码更新结果仍可能不确定,现有接口没有操作号查询;页面不把未知结果表述为密码未变更。 + +该子页没有独立一比一原稿,不把通用表单截图算作图31全量视觉验收。图31及全量App仍未完成严格1:1与全部功能验收,当前计数仍为16张部分实现、42张未完成对应完整页面、0张严格验收通过。 diff --git a/docs/操作日志_用户端APP_登录输入连接_20260911.md b/docs/操作日志_用户端APP_登录输入连接_20260911.md new file mode 100644 index 0000000..f89da9b --- /dev/null +++ b/docs/操作日志_用户端APP_登录输入连接_20260911.md @@ -0,0 +1,25 @@ +# 登录输入连接修复记录 + +操作时间:2026-09-11 +操作类型:修复 +影响模块:用户端登录页验证码、密码输入及显隐切换。 + +## 问题与范围 + +钱包浏览器联调中,验证码/密码共用输入框曾出现键入未生效,控制台捕获Flutter Web输入配置操作空DOM的异常。编译栈与本地引擎`text_editing.dart`的`applyConfiguration`相符,但仅凭栈不能断定引擎是唯一根因。 + +代码同时存在可稳定复现的问题:点击已选中的“密码登录”也会清空用户刚输入的密码。本轮处理登录页输入连接切换,不改Flutter SDK、账户密码、数据库或缓存配置。 + +## 代码变化 + +- `apps/user_app/lib/ui/features/auth/login_page.dart`:密码字段按登录方式和显隐状态设置独立键;`_configureSecretInput`先失焦结束旧连接,再更新状态,必要时下一帧恢复焦点。显隐保留控制器文本,方式切换清除上个方式凭据;当前已选方式点击不再清空输入。 +- 自动填充提示区分一次性验证码和密码,避免验证码框使用密码自动填充语义。 +- `apps/user_app/test/ui/login_page_test.dart`:新增显隐、焦点、重复选择及有焦点时切换方式的回归测试。 + +## 验证 + +登录页8项测试通过,Flutter analyze无问题,Web构建完成。浏览器在12:06:23之后的新构建中实测:聚焦验证码框、切换密码模式、直接在遮蔽输入框键入授权测试密码、重复点击当前方式、填写手机号、登录成功回到钱包。该路径没有要求先显示密码才能输入。 + +控制台最后的输入空值异常时间为12:05:54,早于新构建;新登录路径后没有新增error/warn。因此本轮确认修复后的路径未复现,不把历史日志仍存在误判为新异常,也不推断所有浏览器和原生输入法已全部验收。 + +最终Flutter全量125项测试通过,Android Debug APK构建成功;没有后端代码、接口或资金变更,远程数据库与Redis继续沿用原配置。全量设计验收状态仍为17张部分实现、41张未完成对应完整页面、0张严格通过。 diff --git a/docs/操作日志_用户端APP_结算流程_20260907.md b/docs/操作日志_用户端APP_结算流程_20260907.md new file mode 100644 index 0000000..2bc4efc --- /dev/null +++ b/docs/操作日志_用户端APP_结算流程_20260907.md @@ -0,0 +1,36 @@ +# 用户端结算流程扩展 + +操作时间:2026-09-07。操作类型:扩展。影响模块:商城结算、库存事务与幂等恢复。 + +## 操作前后 + +原商城只能单件购买,以弹窗确认地址和金额。本次接入 `/checkout/:identity` 独立提交订单页面,选择地址、调整数量、填写配送备注、查看商品金额及合计,提交成功进入商城订单列表。游客登录回跳保留商品公开标识。图 12、14 原稿均已检查;本轮实现图 14 的基础结算链路,图 12 仍未实现,不能将商品摘要称作商品详情页。 + +## 关键代码 + +- `apps/user_app/lib/ui/features/shop/checkout_page.dart`:按标识加载商品和默认地址,数量范围 1 至 min(库存,999),提交前不写订单;结果未知时冻结原数量、地址、备注,重试沿用原请求号。价格或库存明确变化时允许刷新后再确认。 +- `shop_page.dart`、`app/router.dart`:购买进入独立结算页,路由继续受登录保护。 +- `data/repositories/client_repository.dart`:新增 `submitShopOrder`,保留旧 `createShopOrder` 方法兼容调用。 +- `backend/api/internal/logic/client/user/shop.go`:可选 expected_payable_amount;依据数据库价格计算,报价不符回滚库存。校验单项数量 ≤999、最多100项及备注长度。乘加之前检查整数溢出。优先查找本人同请求号订单,地址随后归档或库存归零不影响成功请求的恢复。 +- `backend/api/internal/logic/client/user/checkout_test.go`:金额按数量结算、报价变化回滚、溢出不扣库存、已创建订单不再依赖地址。 +- `apps/user_app/test/ui/shop_address_selection_test.dart`:确认前不下单、选择地址联系人、数量两件与金额一致、失败冻结输入、同请求号重试。 + +## 接口兼容与环境 + +原 `POST /heqi/client/v1/user/shop/orders` 接受新增可选 `expected_payable_amount`(整数分);旧客户端可省略。客户端传入金额只用于确认,不代替服务端计算。错误 2401 表示价格变化,2402 表示库存不足或商品下架。错误对象在包初始化注册,避免并发请求修改 SDK 全局错误表。 + +本轮不修改数据库表,不运行迁移或 seed。本机 API 继续使用远程 PostgreSQL、Redis;没有启动本机数据库/缓存容器。 + +## 验证 + +- Go 结算专项通过,SQL Mock 验证失败时回滚,不连接真实数据库。 +- 远程指定开发账户对 `MOCK-EC-PRODUCT-010` 验证错误报价拒绝、两件金额、备注持久化、重复提交恢复同一订单及库存只扣一次。临时订单已取消,库存恢复,未支付或配送。 +- Widget 结算流程通过;图 14 已生成四种手机宽度与两种文字缩放的 8 张截图,检查滚动后无溢出。 +- 用户端全量 53 项测试、Flutter 静态检查、Go 全量测试及 vet 通过;Web 最新构建成功。[图 14 对照](视觉验收/A1/14_并排对照.png)继续用于记录缺项,不是通过证明。 +- 浏览器验证登录回到指定商品结算、数量由1变2时金额由105元变210元、打开地址选择后返回仍保留数量;390×844实际页面已核对。真实创建/取消验证由 API 完成,浏览器未再次创建订单。结算页285行、后端结算专项68行;无新增依赖。 + +## 未完成项与风险 + +图 14 中的预约配送、押金、优惠券、发票、结算前支付方式选择和多商品合并仍未接入。本页没有把设计示例金额当作真实收费规则。商品原图缺失仍需后台素材补齐;当前截图不通过严格 1:1。订单创建与付款分开,成功后进入既有订单页付款,不代表付款或配送已经成功。 + +单项数量/条目上限是本次输入保护,可能拒绝旧客户端超过上限的超大请求;正常用户端购买不受影响。后续购物车接入必须继续服务端计算总额,保留事务回滚与请求幂等测试。 diff --git a/docs/操作日志_用户端APP_订单操作_20260907.md b/docs/操作日志_用户端APP_订单操作_20260907.md new file mode 100644 index 0000000..6d0bad6 --- /dev/null +++ b/docs/操作日志_用户端APP_订单操作_20260907.md @@ -0,0 +1,35 @@ +# 商城订单取消与收货 + +操作时间:2026-09-07。类型:扩展与状态边界修复。影响:用户商城订单列表、取消、确认收货。 + +## 变更前后 + +此前用户端仅显示支付和退款操作,即使已有取消/确认收货接口也没有对应入口。本次服务端通过 allowed_actions 发布 cancel、confirm_receipt,列表显示直接操作按钮,同时保留操作菜单;用户明确确认后调用接口,成功重新读取订单列表。 + +原确认收货只检查物流状态20,未校验订单业务状态;已取消订单残留物流状态时也可能被确认。现在在事务内锁定本人订单,仅业务状态18且物流状态20允许首次收货;物流30重试不改写时间。取消仅允许状态16,状态22重试返回成功且不再次返还库存。 + +## 文件与方法 + +- `backend/api/internal/logic/client/user/shop.go`:ListShopOrders、CancelShopOrder、ConfirmShopReceipt。已支付发货订单显示待收货,收货后显示已收货;仍保持原订单/物流双状态模型。 +- `apps/user_app/lib/data/repositories/client_repository.dart`:cancelShopOrder、confirmShopReceipt,使用公开订单标识。 +- `apps/user_app/lib/ui/features/orders/order_list.dart`:新增 onAction,显示取消/收货及去支付入口,保留菜单。 +- `apps/user_app/lib/ui/features/orders/orders_page.dart`:复用原提交锁,二次确认、失败保留状态、成功刷新商城列表。 +- `backend/api/internal/logic/client/user/order_actions_test.go`:八项状态/所有权/幂等场景,SQL Mock 验证不发生额外库存写入。 +- `apps/user_app/test/ui/orders_page_test.dart`:增加取消与收货确认/返回/刷新测试,原退款跨标签刷新测试保留。 + +## 契约与兼容 + +继续使用原 POST shop/orders/:identity/cancel 和 POST shop/orders/:identity/confirm-receipt,未新增接口或表字段。allowed_actions 为增量字段值,旧客户端继续可读。客户端不按状态数字自行推断权限,服务端再次校验归属与实时状态。气瓶订单取消未在本轮开放;其分派、支付和履约模型需单独核对,不套用商城逻辑。 + +## 验证记录 + +- Go 全量测试与 vet 通过,八项新边界测试覆盖取消重试、已支付拒绝取消、越权拒绝、已取消拒绝收货、未发货拒绝、首次收货及重试时间保护。 +- Widget 订单专项三项通过,包含原退款功能;Flutter 静态检查及 Web 构建通过。 +- 远程开发环境创建一笔指定示例商品临时订单,列表已返回 pay/cancel;浏览器取消与库存恢复结果在后续条目记录。 +- 浏览器已取消本轮临时订单,刷新后为“已取消”且取消/支付入口消失。公开商品接口确认指定示例商品库存为30,与此前已验证恢复的基线一致;未付款、未发货。 +- 用户端全量55项测试通过;商城订单标签新增四宽度、两种文字缩放共8张截图。初次截图发现全宽取消按钮导致换行,已为订单操作指定紧凑宽度,继续保留48px触控高度。 +- 修正后8项截图检查、Flutter静态检查、最终Web构建通过;[390px截图](视觉验收/A1/20-shop_390_1.0.png)中操作按钮已并排。订单列表159行、订单页338行、后端新专项55行;未新增运行依赖或数据库迁移。 + +## 剩余项 + +图20仍不是严格1:1:配送轨迹、联系配送员、发票、再次购买、订单图片等尚未齐全。确认收货未改动远程已有真实履约订单,只通过隔离 SQL Mock 验证;不得为浏览器演示把未付款测试订单改成已发货。接口新增事务锁仅作用于本次操作的本人订单,不修改支付或退款规则。 diff --git a/docs/操作日志_用户端APP_订单详情_20260908.md b/docs/操作日志_用户端APP_订单详情_20260908.md new file mode 100644 index 0000000..e28bc21 --- /dev/null +++ b/docs/操作日志_用户端APP_订单详情_20260908.md @@ -0,0 +1,55 @@ +# 商城订单详情开发与验证记录 + +操作时间:2026-09-08。操作类型:新增详情、提取共用操作、修复底栏。影响模块:用户商城订单接口、订单列表、详情路由。数据库与缓存继续连接既有远程服务;本轮无迁移、无本机数据库容器、无订单或库存写入。 + +## 操作前后 + +已打开最新21-订单详情原图,检查状态、配送进度、联系人、商品、费用、订单信息及操作入口。原订单列表只能打开操作弹层,没有独立详情。新增`/shop/orders/:identity`,点击商城订单或“查看详情”进入;直接深链需登录,登录后返回原订单。气瓶订单仍沿用旧入口,本轮未把商城详情冒充气瓶完整详情。 + +详情独立读取本人订单。商品名称、数量、单价与收货信息来自成交快照;资料缺失明确显示空态,不读取当前商品/地址覆盖历史。仅展示真实记录的下单、支付、发货及收货时间,电话按原图脱敏,应付金额不标为已实付。 + +## 代码变更 + +| 文件(仓库相对路径) | 职责/函数及行数 | +| --- | --- | +| backend/api/internal/logic/client/user/shop_detail.go | 新增73行;GetShopOrder本人详情,shopOrderState共用动作与状态 | +| backend/api/internal/logic/client/user/shop.go | ListShopOrders复用shopOrderState,原响应及操作规则保持兼容 | +| backend/api/internal/logic/client/user/shop_detail_test.go | 新增73行;归属条件、成交快照、白名单、非法订单和状态动作 | +| backend/api/internal/routers/client.go、client_test.go | 新增受JWT保护的GET shop/orders/:identity及路由检查 | +| apps/user_app/lib/domain/models/shop_order_detail.dart | 新增89行;ShopOrderDetail、ShopOrderLine,整数分金额、成交数量、时间与OrderSummary适配 | +| apps/user_app/lib/data/repositories/client_repository.dart | shopOrderDetail独立读取,校验返回identity及会话一致性 | +| apps/user_app/lib/ui/features/orders/order_action_handler.dart | 新增217行;从原订单页提取确认、支付、退款及退款原因对话框,不重复实现交易规则 | +| apps/user_app/lib/ui/features/orders/orders_page.dart、order_list.dart | 列表点击进入详情;保留“查看操作”,详情返回刷新订单和退款列表 | +| apps/user_app/lib/ui/features/orders/shop_order_detail_page.dart | 新增302行;_load、_act、_copy、_footer,独立加载、操作前刷新、复制反馈、底栏布局 | +| apps/user_app/lib/app/router.dart | 新增受会话守卫保护的/shop/orders/:identity | +| apps/user_app/test/ui/shop_order_detail_test.dart、test/data/shop_order_detail_test.dart | 新增137/70行;失败重试、复制、确认取消、过期动作、金额、响应归属 | +| apps/user_app/test/support/a1_fixture.dart、tool/a1_visual_test.dart | 明确独立测试Fixture及图21多尺寸截图 | +| scripts/compare-user-app-a1.ps1 | 加入图21,现14组并排设计证据 | + +## 接口及交互边界 + +- `GET /heqi/client/v1/user/shop/orders/:identity`在SQL中同时限定公开identity和JWT对应user_account_id,未命中返回1112,未登录HTTP401。 +- 响应显式列出订单信息和商品快照字段,不返回内部主键、用户外键或完整任意JSON快照。商品已改价/删除不会改变成交明细。暂无历史图片的订单继续显示缺失状态。 +- 商城状态16发布pay/cancel,18发布refund,18且物流20发布confirm_receipt,取消订单不发布动作;详情与列表共用定义。服务端动作接口仍负责最终状态及所有权校验。 +- 详情操作前重新读取订单,原动作已失效时不提交。取消/收货必须确认,未知错误显示失败并重读,不伪造成功。支付与退款复用原请求号策略;本轮没有执行真实支付或退款。 +- 操作栏放在Scaffold底栏,避免SnackBar遮挡按钮;按容器宽度约束两个按钮,修复原主题最小宽度导致申请退款独占整行的问题。 + +## 验证 + +- Go全量`go test ./...`、`go vet ./...`与API构建通过;路由与业务测试覆盖本人过滤、历史名称/金额、字段白名单、缺失订单及五组状态动作。 +- Flutter analyze通过,全量95项测试通过。原订单取消、确认收货、退款刷新回归保留;新增详情测试覆盖深链、首读失败重试、复制内容、确认取消、失败重试、已失效动作不提交、整数分及跨账号迟到响应。 +- 图20(两种标签)和21的320/360/390/430宽度与1.0/1.3字号共24项布局检查通过。查看图21的390普通和320大字体截图后修复按钮布局,图21再次8项通过,并实际查看修复截图。 +- Web Release与Android Debug构建通过;Android仍提示既有插件Kotlin和SDK XML版本兼容警告,未影响当前产物。构建通过不代表真机相机/语音或支付验收完成。 +- 本机API进程6884运行新后端,端口12426;18571提供Web。读取此前已取消测试订单`EC1788842040900189900`:商品9/10各2件,单价10400/10500分,总金额41800分,动作列表为空。不存在订单返回1112,匿名访问HTTP401。 +- 浏览器390×844实际验证深链跳登录并回原订单,显示已取消、历史联系人与地址、两项商品、418元费用和订单备注;点击复制显示“已复制订单号”。订单及库存未改变。 +- 浏览器返回商城订单列表后再点击“查看详情”,重新进入同一订单并独立读取;没有回退到旧操作弹层。浏览器错误/警告日志为空。 + +## 视觉结果与待办 + +整页验收仍failed。原图是气瓶配送中的示例,商城分支尚不能提供押金、配送员、预计送达、供气合同、服务联系与地图轨迹;历史商品图缺失,时间轴结构和页面密度仍有差异。未填入设计示例时间、照片、合同或实付数据。58图中现14张部分实现、44张无完整页面、0张严格全功能1:1通过。 + +证据:[图21并排](视觉验收/A1/21_并排对照.png)、[390截图](视觉验收/A1/21_390_1.0.png)、[320大字体](视觉验收/A1/21_320_1.3.png)。后续需继续补气瓶订单详情与配送业务,不能仅将路由存在视为完成。 + +## 维护方式 + +订单列表与详情的允许动作只能修改shopOrderState并补状态测试,写操作继续保留服务端锁和幂等边界。公共字段扩展使用白名单,不直接透传模型或JSON快照;详情显示历史资料的含义不得变成当前商品信息。运行`flutter test test/ui/orders_page_test.dart test/ui/shop_order_detail_test.dart test/data/shop_order_detail_test.dart`与Go用户逻辑/路由测试后,使用图21多视口截图核对布局。 diff --git a/docs/操作日志_用户端APP_设置与登录密码_20260908.md b/docs/操作日志_用户端APP_设置与登录密码_20260908.md new file mode 100644 index 0000000..81fce0d --- /dev/null +++ b/docs/操作日志_用户端APP_设置与登录密码_20260908.md @@ -0,0 +1,38 @@ +# 设置与登录密码开发日志 + +操作时间:2026-09-08。类型:扩展与修复。影响模块:图31设置、登录密码、个人中心。 + +## 行为与实现 + +个人中心设置从不可用提示改为`/settings`,支持深链登录回跳。按最新图31组织账号、消息、设备、通用、协议分组,增加`/settings/password`,当前密码、新密码和再次确认必须校验通过才提交;失败保留输入,只有服务端明确确认changed=true后清空表单并退出本机会话。 + +版本读取当前安装包/Web构建元数据,不使用设计稿的1.6.0。缓存大小来自Flutter可恢复图片缓存,清理二次确认后刷新数值,不清空令牌、报修草稿或文件。协议沿用后台已发布内容和真实版本,资料读取失败不阻断清缓存和退出。图31的手机号与提示在窄屏大字体上下排列,避免末位数字断行;所有深链均提供返回。 + +后端保留`PUT /heqi/client/v1/user/auth/password`字段契约,更新条件增加旧密码散列版本,避免并发改密覆盖新值;该更新关闭SQL参数日志以保护密码散列。错误密码、并发旧版本不会产生成功响应。 + +## 核心文件 + +- `apps/user_app/lib/ui/features/settings/settings_page.dart`:SettingsPage分组、独立附属加载、缓存与退出确认。 +- `apps/user_app/lib/ui/features/settings/login_password_page.dart`:LoginPasswordPage密码内存草稿、UTF-8长度边界、二次确认、错误与成功退出。 +- `apps/user_app/lib/data/services/app_settings_service.dart`:版本与图片缓存平台适配;新增`package_info_plus 10.2.1`依赖。 +- `apps/user_app/lib/data/repositories/client_repository.dart`:changeLoginPassword校验响应和会话世代;不接受缺少成功标记的响应。 +- `apps/user_app/lib/app/router.dart`、`ui/features/profile/profile_page.dart`:设置及改密路由和入口。 +- `apps/user_app/lib/ui/features/auth/login_support.dart`:协议阅读支持注入当前仓储,保持登录页调用兼容。 +- `backend/api/internal/logic/client/user/auth.go:179`:ChangePassword,200行增加条件更新,随后确认影响行数;接口参数保持兼容。 +- 前端新增`settings_page_test.dart`、`change_login_password_test.dart`,扩展`router_test.dart`和`tool/a1_visual_test.dart`;后端新增`change_password_test.go`及显式开启的`change_password_remote_test.go`。 + +## 验证 + +Go全量测试、vet、API构建通过。Flutter全量110项测试及analyze通过;图31共8组320/360/390/430宽度及1.0/1.3字号布局检查通过,实际查看390及320放大截图后修复手机号断行和返回入口。 + +远程回滚测试显式设置`HEQI_REMOTE_PASSWORD_TEST=1`执行;仅在持锁事务内临时设置授权测试账户密码夹具,验证错误旧密码、正确改密和旧密码失效,整体回滚后比对原散列一致。未发送验证码、未改写远程真实密码、无数据结构迁移;普通测试默认跳过远程用例。 + +浏览器390宽实测错误旧密码被拒绝且保留表单和会话,图片缓存6.0MB→0.0MB,退出确认后回到登录页。协议接口无已发布协议,确认显示真实空态。运行时曾出现版本读取失败,改为Web显式页面目录并用`--pwa-strategy=none`构建本地预览、消除旧Service Worker缓存影响后,浏览器已真实读取`1.0.0 (1)`,与构建version.json一致。不能把第一轮对插件路径的推断单独当作已证实根因;版本成功以最后实际页面为证据。Web和Android调试包构建通过,原生版本读取仍待真机确认。 + +## 未完成与维护 + +图31仍未全量完成:实名认证、支付密码设置/重置、换手机号、设备登录撤销、通知投递偏好、原生权限入口、隐私偏好和注销审核尚待接入。未绘制假生效开关。协议与隐私当前共用已发布协议列表,后台缺少明确的细分类契约;未发布时显示真实空态。安全用气指南尚缺独立内容类型与后台承接。 + +当前改密只退出本机会话,不能宣称其他设备令牌已撤销;设备会话管理需后续统一补齐。网络中断可能无法确认改密是否生效,现有接口不提供请求号查询;不把重试失败声称为密码未变更。缓存统计仅为图片内存缓存,不代表系统或浏览器全部占用。 + +运行前端使用现有18571预览,API12426连接已有远程PostgreSQL/Redis。全量计数现为16张部分实现、42张未完成对应页面、0张严格全功能与1:1验收通过。 diff --git a/docs/操作日志_用户端APP_购物车_20260908.md b/docs/操作日志_用户端APP_购物车_20260908.md new file mode 100644 index 0000000..3345d4e --- /dev/null +++ b/docs/操作日志_用户端APP_购物车_20260908.md @@ -0,0 +1,58 @@ +# 用户端 App 购物车操作日志 + +操作时间:2026-09-08。操作类型:扩展。影响模块:Flutter用户端、Go商城Client API、接口契约与视觉记录。 + +## 操作前状态 + +已阅读最新13-购物车原图及03需求。既有ec_cart表没有用户端接口;商城/详情购物车按钮弹出未开放提示,详情没有加入购物车;结算页面只接收单一商品。工作区已有大量前序变更,本轮未重置、清理或提交。数据库和缓存继续使用原配置的远程服务,本机只运行API和Web。 + +## 本次变更 + +| 文件(仓库相对路径) | 位置与职责 | 行数说明 | +| --- | --- | --- | +| backend/api/internal/logic/client/user/cart.go | 51行ListCart、80行GetCartItem、104行SetCartItem;本人条目和条件数量写入 | 新增186行 | +| backend/api/internal/logic/client/user/shop.go | 65行CreateShopOrder;items校验、固定锁顺序、购物车版本消费 | 扩展既有函数,不替换支付和退款 | +| backend/api/internal/routers/client.go、client_test.go | 用户鉴权组新增GET shop/cart、GET/PUT shop/cart/items/:identity | 3条路由和路由注册断言 | +| apps/user_app/lib/domain/models/cart_item.dart | 强类型条目、整数金额、库存和版本 | 新增49行 | +| apps/user_app/lib/data/repositories/client_repository.dart | 18行cart及条件写入;40行submitCartOrder | 追加接口,保留submitShopOrder签名 | +| apps/user_app/lib/ui/features/shop/cart_page.dart | 购物车、失效提示、单选全选、管理删除、真实金额 | 新增350行 | +| apps/user_app/lib/ui/features/shop/product_detail_page.dart | 37行_add;未知结果保留目标数量,重试不重新累加 | 扩展底部动作 | +| apps/user_app/lib/ui/features/shop/checkout_page.dart | fromCart模式和103行_submit;多商品快照、地址和金额确认 | 文件现355行,单商品流程保留 | +| apps/user_app/lib/ui/features/shop/shop_page.dart、lib/app/router.dart | 商城购物车按钮;/cart和/cart/checkout及登录守卫 | 扩展现有导航 | +| backend/api/internal/logic/client/user/cart_test.go | 幂等、账户过滤、版本、数量、归档及多商品事务 | 新增178行 | +| apps/user_app/test/ui/cart_page_test.dart、test/data/cart_test.dart | 交互、未知结果、选中地址、整数金额、HTTP鉴权 | 新增182和64行 | +| apps/user_app/test/support/a1_fixture.dart、tool/a1_visual_test.dart | 独立购物车测试数据及图13视觉截图 | 不进入运行中的商品数据 | +| scripts/compare-user-app-a1.ps1 | 图13并排图,现12组原图对照 | 输出仅用于检查差异 | + +## 行为与数据边界 + +- 所有购物车读写按JWT账户过滤,商品使用公开identity。账户行锁串行保护创建、改量和归档;不新增远程表、字段或容器。 +- PUT提交绝对quantity、selected和revision;相同目标无副作用,过期版本返回2403。版本采用购物车公开UUID和微秒精度时间,已通过真实PostgreSQL写后重读验证。 +- quantity=0归档。数据库仍保留正数量以兼容旧CHECK,API把归档行解释为零并保留版本,避免旧请求复活已删除商品。新增/增加受库存和999上限限制;最多100条有效商品。 +- 全选和批量删除逐项写入,部分失败后重新加载服务端状态;不能把局部成功描述为整体成功。 +- 订单可提交多个不同商品,按identity排序锁库存,拒绝重复商品及非法数量。cart_revision必须与本人的有效勾选条目及数量一致;移除购物车、扣库存、创建订单在同一事务,任一步失败整体回滚。 +- 原订单request_no优先恢复已有结果,原单商品客户端不传cart_revision仍兼容。用户点击提交前不创建订单,不支付、不触发真实配送。 + +## 验证结果 + +- `go test ./...`、`go vet ./...`、`go build -o %TEMP%/heqi-cart-api.exe ./cmd/main/main.go`通过;之后补充首次加购false勾选和非法订单items两项测试,定向测试通过。 +- `flutter analyze --no-pub`通过;用户端完整79项测试通过。视觉发现生产主题的FilledButton在Row内无限宽约束,修复为受约束宽度,并让新增交互测试使用真实AppTheme,3项交互回归再次通过。 +- 图12、13各在320/360/390/430宽度、1.0/1.3文字缩放下共16项视觉检查通过,已打开390图13及320大字体图12核对。原图和截图并排生成成功;这仅证明无布局溢出,不代表原稿一致。 +- Flutter Web Release重新构建通过,本地18571使用新产物;Go API本地12426连接既有远程PostgreSQL/Redis。`pnpm contract:sync`与`pnpm contract:check`通过,48个资源。 +- 匿名GET shop/cart实测HTTP401。授权测试账户原有一条15kg配送服务购物车记录,数量1/勾选true,本次保留。 +- 远程HTTP联调给商品9和10各加入2件,同一数量请求重试不累加。创建订单 **EC1788842040900189900**(identity `01a07f4b-5e44-72e5-893d-6b194089cf13`),两商品应付41800分;重复提交返回同一订单,已结算购物车条目被原子移除。 +- 上述联调订单已取消,临时地址已归档,两种商品库存恢复29和30;没有支付或实际履约。 +- 浏览器390×844实测游客加购跳登录并回商品详情,登录后加购成功;购物车将测试商品从1改为2,合计20800分变为31300分,多商品结算显示原有商品10300分×1和新增商品10500分×2。未在浏览器提交订单;完整创建/重试/取消已由上述HTTP真实联调覆盖。 +- 重新打开最新Web构建并重新登录后,新增商品仍为2件,验证了远程持久化;手机截图已核对最新分组与底部按钮。随后通过管理、单选和删除确认归档本次测试商品,HTTP复核仅剩原有商品数量1/勾选true。 + +## 视觉差异与未完成项 + +整体仍为failed/部分实现,不能写“图13已1:1完成”。原图有配送承诺、规格、押金提示、保障区和推荐区,当前后台模型及接口没有完整支持;本次未编造承诺、押金或优惠。原图128+169+100与展示合计425不一致,实际结算必须服从服务端整数金额。 + +远程示例商品9/10没有图片,原有15kg商品图片URL返回404,仍显示明确空/失败状态,没有把设计稿里的其他商品图当成真实商品照片。基础白色商品分组、分隔线、圆形勾选和底部金额已调整;图片、上下分区、规格及精确间距仍不一致。 + +详情收藏、客服、分享及购物车优惠/推荐/押金配置未完成;全量状态更新为12张部分实现、46张尚无完整页面、0张通过严格全功能1:1验收。订单未知结果在当前页面复用请求号,跨页面重建持久化恢复仍须继续完善;本轮没有将原生Android/iOS购物车交互标为验收通过。 + +## 维护说明 + +后续增加SKU、促销、押金与推荐应扩展对应领域服务,金额和库存继续服务端确认。修改购物车必须复用条件版本契约,并覆盖失效条目、并发版本、所有权和事务回滚测试。批量动作若改为原子接口需同步契约和失败提示。继续沿用当前技术栈和环境;禁止通过全库迁移或重置数据修复展示差异。 diff --git a/docs/操作日志_用户端APP_钱包与账单_20260911.md b/docs/操作日志_用户端APP_钱包与账单_20260911.md new file mode 100644 index 0000000..bf9dba6 --- /dev/null +++ b/docs/操作日志_用户端APP_钱包与账单_20260911.md @@ -0,0 +1,41 @@ +# 钱包与账单开发记录 + +操作时间:2026-09-11 +操作类型:扩展、修复 +影响模块:用户钱包主页、钱包账单、本人流水读取。 + +## 功能变化 + +个人中心的钱包原先直接打开通用流水列表。现新增`/wallet`,按图28组织账户余额、资产、快捷服务、最近账单和资金安全入口;可隐藏余额与最近账单金额,支付密码接入已完成页面。`/records/wallet`地址保持兼容,改为专用账单页,支持全部、收入、支出筛选、游标续页、下拉刷新和账单详情。刷新失败保留原账单并允许重试,账户切换丢弃迟到响应。 + +资产数据没有已确认接口时显示“暂未提供”,不写入原图的200元押金、两张券和100元待退款。充值、提现、银行卡等入口保留,但尚未完成办理流程,图28只能计部分实现,不能宣称钱包全部可用或严格1:1通过。 + +## 核心变更 + +- `backend/api/internal/logic/common/wallet_bills.go`:新增`ListWalletBills`,本人钱包范围、每页50条、按序号倒序及显式下一页游标,白名单输出,不返回内部请求号和操作者信息。 +- `backend/api/internal/routers/client.go`:增加`GET /wallet/bills`,既有`/wallet/records`接口不删除。 +- `apps/user_app/lib/domain/models/wallet_bill.dart`:账单金额必须为整数分,收支及时间不完整时拒绝解析,不伪装成空账单或零金额。 +- `apps/user_app/lib/data/repositories/client_repository.dart`:新增`walletBills`,会话保护与严格响应检查;`wallet`拒绝小数、负数、缺失金额及可提现额超过余额。 +- `apps/user_app/lib/ui/features/wallet/wallet_page.dart`:钱包主页、隐私开关和独立账单重试;`wallet_bills_page.dart`:筛选、分页、详情。 +- `apps/user_app/lib/app/router.dart`、`ui/features/profile/profile_page.dart`:受保护钱包路由与个人中心入口。 +- 新增后端`wallet_bills_test.go`、前端`wallet_bill_test.dart`和`wallet_page_test.dart`;视觉夹具和并排脚本增加图28。 + +## 关键纠偏 + +远程只读检查发现旧种子充值流水使用`direction=in`,当前业务使用`income`。新接口在收入筛选和响应中兼容这一已确认旧值,不重写任何历史资金事实。首次测试还发现账单重试的`setState`箭头回调返回Future,已改为同步块并保留回归测试。 + +没有数据库迁移,没有充值、提现或余额改写。测试环境仍为原远程PostgreSQL及Redis;本机仅运行API和Web预览。 + +## 验证记录 + +上次执行因会话中断未保留最终日志,2026-09-11重新运行当前代码的Go全量测试、vet、Flutter全量测试、图28八组布局检查。以本轮最终工具结果更新结论,旧执行意图不算通过证据。 + +本轮结果:Go全量测试、vet和API构建通过;Flutter全量123项测试通过。资产区压缩及金额右对齐修正后,重新执行2项页面测试及8组320/360/390/430宽度、1.0/1.3文本布局检查,10项均通过,analyze无问题。已查看最终并排图,仍有卡片高度、图标、资产数据和缺失办理流程差异,严格视觉仍为failed。 + +Web与Android Debug APK构建成功。Android工具链仍提示既有插件Kotlin迁移及SDK XML版本差异,未升级依赖或修改原生业务。浏览器在18571仍加载到旧路由,新产物中确认存在钱包路由后,使用独立端口18572验证新包:登录回跳钱包,真实余额500元、可提现余额300元、历史充值500元;金额隐藏同步覆盖最近账单与详情。接口实测全部1条、收入1条、支出0条,旧`in`读取兼容已生效。本轮未创建或改写资金流水。 + +运行入口:http://127.0.0.1:18572/#/wallet;API12426进程24552,继续读取既有远程配置。所有截图夹具金额均与真实运行事实分开记录。 + +浏览器追加验证:收入筛选显示历史500元充值,支出筛选显示“暂无账单”。控制台在登录输入期间捕获`Null check operator used on a null value`,编译栈对应Flutter Web输入配置的DOM元素操作;自动输入曾多次未生效,切换显示模式并逐键输入后登录成功。根因尚未确认,不能宣称运行时完全无错误;需要继续排查登录输入与可访问模式的组合。该问题不由账单回归测试覆盖。 + +后续登录输入连接已单独修复并完成浏览器复核,遮蔽密码输入可直接登录,未新增同类错误;详见[登录输入记录](操作日志_用户端APP_登录输入连接_20260911.md),保留上段作为发现过程,不代表当前最新状态。 diff --git a/docs/视觉验收/01-登录页_实现_20260911.jpg b/docs/视觉验收/01-登录页_实现_20260911.jpg new file mode 100644 index 0000000..638b9a6 Binary files /dev/null and b/docs/视觉验收/01-登录页_实现_20260911.jpg differ diff --git a/docs/视觉验收/08-设备分组_实现_20260911.jpg b/docs/视觉验收/08-设备分组_实现_20260911.jpg new file mode 100644 index 0000000..a4caa98 Binary files /dev/null and b/docs/视觉验收/08-设备分组_实现_20260911.jpg differ diff --git a/docs/视觉验收/08-设备分组_并排对照_20260911.jpg b/docs/视觉验收/08-设备分组_并排对照_20260911.jpg new file mode 100644 index 0000000..ea8412e Binary files /dev/null and b/docs/视觉验收/08-设备分组_并排对照_20260911.jpg differ diff --git a/docs/视觉验收/34-我的设备_实现_20260911.jpg b/docs/视觉验收/34-我的设备_实现_20260911.jpg new file mode 100644 index 0000000..5e18934 Binary files /dev/null and b/docs/视觉验收/34-我的设备_实现_20260911.jpg differ diff --git a/docs/视觉验收/34-我的设备_并排对照_20260911.jpg b/docs/视觉验收/34-我的设备_并排对照_20260911.jpg new file mode 100644 index 0000000..df4007f Binary files /dev/null and b/docs/视觉验收/34-我的设备_并排对照_20260911.jpg differ diff --git a/docs/视觉验收/A1/01_320_1.0.png b/docs/视觉验收/A1/01_320_1.0.png new file mode 100644 index 0000000..65ea25e Binary files /dev/null and b/docs/视觉验收/A1/01_320_1.0.png differ diff --git a/docs/视觉验收/A1/01_320_1.3.png b/docs/视觉验收/A1/01_320_1.3.png new file mode 100644 index 0000000..0083cbd Binary files /dev/null and b/docs/视觉验收/A1/01_320_1.3.png differ diff --git a/docs/视觉验收/A1/01_360_1.0.png b/docs/视觉验收/A1/01_360_1.0.png new file mode 100644 index 0000000..7291360 Binary files /dev/null and b/docs/视觉验收/A1/01_360_1.0.png differ diff --git a/docs/视觉验收/A1/01_360_1.3.png b/docs/视觉验收/A1/01_360_1.3.png new file mode 100644 index 0000000..1323f04 Binary files /dev/null and b/docs/视觉验收/A1/01_360_1.3.png differ diff --git a/docs/视觉验收/A1/01_390_1.0.png b/docs/视觉验收/A1/01_390_1.0.png new file mode 100644 index 0000000..f141d3d Binary files /dev/null and b/docs/视觉验收/A1/01_390_1.0.png differ diff --git a/docs/视觉验收/A1/01_390_1.3.png b/docs/视觉验收/A1/01_390_1.3.png new file mode 100644 index 0000000..eb506b0 Binary files /dev/null and b/docs/视觉验收/A1/01_390_1.3.png differ diff --git a/docs/视觉验收/A1/01_430_1.0.png b/docs/视觉验收/A1/01_430_1.0.png new file mode 100644 index 0000000..41912b4 Binary files /dev/null and b/docs/视觉验收/A1/01_430_1.0.png differ diff --git a/docs/视觉验收/A1/01_430_1.3.png b/docs/视觉验收/A1/01_430_1.3.png new file mode 100644 index 0000000..cb9752b Binary files /dev/null and b/docs/视觉验收/A1/01_430_1.3.png differ diff --git a/docs/视觉验收/A1/01_并排对照.png b/docs/视觉验收/A1/01_并排对照.png new file mode 100644 index 0000000..77233db Binary files /dev/null and b/docs/视觉验收/A1/01_并排对照.png differ diff --git a/docs/视觉验收/A1/03_320_1.0.png b/docs/视觉验收/A1/03_320_1.0.png new file mode 100644 index 0000000..a49c5bd Binary files /dev/null and b/docs/视觉验收/A1/03_320_1.0.png differ diff --git a/docs/视觉验收/A1/03_320_1.3.png b/docs/视觉验收/A1/03_320_1.3.png new file mode 100644 index 0000000..10480c4 Binary files /dev/null and b/docs/视觉验收/A1/03_320_1.3.png differ diff --git a/docs/视觉验收/A1/03_360_1.0.png b/docs/视觉验收/A1/03_360_1.0.png new file mode 100644 index 0000000..75e347d Binary files /dev/null and b/docs/视觉验收/A1/03_360_1.0.png differ diff --git a/docs/视觉验收/A1/03_360_1.3.png b/docs/视觉验收/A1/03_360_1.3.png new file mode 100644 index 0000000..168a891 Binary files /dev/null and b/docs/视觉验收/A1/03_360_1.3.png differ diff --git a/docs/视觉验收/A1/03_390_1.0.png b/docs/视觉验收/A1/03_390_1.0.png new file mode 100644 index 0000000..e75149d Binary files /dev/null and b/docs/视觉验收/A1/03_390_1.0.png differ diff --git a/docs/视觉验收/A1/03_390_1.3.png b/docs/视觉验收/A1/03_390_1.3.png new file mode 100644 index 0000000..7ec9329 Binary files /dev/null and b/docs/视觉验收/A1/03_390_1.3.png differ diff --git a/docs/视觉验收/A1/03_430_1.0.png b/docs/视觉验收/A1/03_430_1.0.png new file mode 100644 index 0000000..43c2a36 Binary files /dev/null and b/docs/视觉验收/A1/03_430_1.0.png differ diff --git a/docs/视觉验收/A1/03_430_1.3.png b/docs/视觉验收/A1/03_430_1.3.png new file mode 100644 index 0000000..1c895ac Binary files /dev/null and b/docs/视觉验收/A1/03_430_1.3.png differ diff --git a/docs/视觉验收/A1/03_并排对照.png b/docs/视觉验收/A1/03_并排对照.png new file mode 100644 index 0000000..ad7a59c Binary files /dev/null and b/docs/视觉验收/A1/03_并排对照.png differ diff --git a/docs/视觉验收/A1/05_320_1.0.png b/docs/视觉验收/A1/05_320_1.0.png new file mode 100644 index 0000000..645d86c Binary files /dev/null and b/docs/视觉验收/A1/05_320_1.0.png differ diff --git a/docs/视觉验收/A1/05_320_1.3.png b/docs/视觉验收/A1/05_320_1.3.png new file mode 100644 index 0000000..8737945 Binary files /dev/null and b/docs/视觉验收/A1/05_320_1.3.png differ diff --git a/docs/视觉验收/A1/05_360_1.0.png b/docs/视觉验收/A1/05_360_1.0.png new file mode 100644 index 0000000..0fa81a4 Binary files /dev/null and b/docs/视觉验收/A1/05_360_1.0.png differ diff --git a/docs/视觉验收/A1/05_360_1.3.png b/docs/视觉验收/A1/05_360_1.3.png new file mode 100644 index 0000000..3987e2f Binary files /dev/null and b/docs/视觉验收/A1/05_360_1.3.png differ diff --git a/docs/视觉验收/A1/05_390_1.0.png b/docs/视觉验收/A1/05_390_1.0.png new file mode 100644 index 0000000..28325b8 Binary files /dev/null and b/docs/视觉验收/A1/05_390_1.0.png differ diff --git a/docs/视觉验收/A1/05_390_1.3.png b/docs/视觉验收/A1/05_390_1.3.png new file mode 100644 index 0000000..807b55c Binary files /dev/null and b/docs/视觉验收/A1/05_390_1.3.png differ diff --git a/docs/视觉验收/A1/05_430_1.0.png b/docs/视觉验收/A1/05_430_1.0.png new file mode 100644 index 0000000..1e8eaff Binary files /dev/null and b/docs/视觉验收/A1/05_430_1.0.png differ diff --git a/docs/视觉验收/A1/05_430_1.3.png b/docs/视觉验收/A1/05_430_1.3.png new file mode 100644 index 0000000..1b131b9 Binary files /dev/null and b/docs/视觉验收/A1/05_430_1.3.png differ diff --git a/docs/视觉验收/A1/05_并排对照.png b/docs/视觉验收/A1/05_并排对照.png new file mode 100644 index 0000000..dda23e2 Binary files /dev/null and b/docs/视觉验收/A1/05_并排对照.png differ diff --git a/docs/视觉验收/A1/11_320_1.0.png b/docs/视觉验收/A1/11_320_1.0.png new file mode 100644 index 0000000..7854632 Binary files /dev/null and b/docs/视觉验收/A1/11_320_1.0.png differ diff --git a/docs/视觉验收/A1/11_320_1.3.png b/docs/视觉验收/A1/11_320_1.3.png new file mode 100644 index 0000000..f254c30 Binary files /dev/null and b/docs/视觉验收/A1/11_320_1.3.png differ diff --git a/docs/视觉验收/A1/11_360_1.0.png b/docs/视觉验收/A1/11_360_1.0.png new file mode 100644 index 0000000..d7b8fc2 Binary files /dev/null and b/docs/视觉验收/A1/11_360_1.0.png differ diff --git a/docs/视觉验收/A1/11_360_1.3.png b/docs/视觉验收/A1/11_360_1.3.png new file mode 100644 index 0000000..de07689 Binary files /dev/null and b/docs/视觉验收/A1/11_360_1.3.png differ diff --git a/docs/视觉验收/A1/11_390_1.0.png b/docs/视觉验收/A1/11_390_1.0.png new file mode 100644 index 0000000..8d04900 Binary files /dev/null and b/docs/视觉验收/A1/11_390_1.0.png differ diff --git a/docs/视觉验收/A1/11_390_1.3.png b/docs/视觉验收/A1/11_390_1.3.png new file mode 100644 index 0000000..cd1d7f4 Binary files /dev/null and b/docs/视觉验收/A1/11_390_1.3.png differ diff --git a/docs/视觉验收/A1/11_430_1.0.png b/docs/视觉验收/A1/11_430_1.0.png new file mode 100644 index 0000000..1776c2c Binary files /dev/null and b/docs/视觉验收/A1/11_430_1.0.png differ diff --git a/docs/视觉验收/A1/11_430_1.3.png b/docs/视觉验收/A1/11_430_1.3.png new file mode 100644 index 0000000..6b3555a Binary files /dev/null and b/docs/视觉验收/A1/11_430_1.3.png differ diff --git a/docs/视觉验收/A1/11_并排对照.png b/docs/视觉验收/A1/11_并排对照.png new file mode 100644 index 0000000..6d87c0f Binary files /dev/null and b/docs/视觉验收/A1/11_并排对照.png differ diff --git a/docs/视觉验收/A1/12_320_1.0.png b/docs/视觉验收/A1/12_320_1.0.png new file mode 100644 index 0000000..2b6ec87 Binary files /dev/null and b/docs/视觉验收/A1/12_320_1.0.png differ diff --git a/docs/视觉验收/A1/12_320_1.3.png b/docs/视觉验收/A1/12_320_1.3.png new file mode 100644 index 0000000..acc1fe7 Binary files /dev/null and b/docs/视觉验收/A1/12_320_1.3.png differ diff --git a/docs/视觉验收/A1/12_360_1.0.png b/docs/视觉验收/A1/12_360_1.0.png new file mode 100644 index 0000000..7ed44ae Binary files /dev/null and b/docs/视觉验收/A1/12_360_1.0.png differ diff --git a/docs/视觉验收/A1/12_360_1.3.png b/docs/视觉验收/A1/12_360_1.3.png new file mode 100644 index 0000000..dd4a80f Binary files /dev/null and b/docs/视觉验收/A1/12_360_1.3.png differ diff --git a/docs/视觉验收/A1/12_390_1.0.png b/docs/视觉验收/A1/12_390_1.0.png new file mode 100644 index 0000000..d4bf5e9 Binary files /dev/null and b/docs/视觉验收/A1/12_390_1.0.png differ diff --git a/docs/视觉验收/A1/12_390_1.3.png b/docs/视觉验收/A1/12_390_1.3.png new file mode 100644 index 0000000..967678c Binary files /dev/null and b/docs/视觉验收/A1/12_390_1.3.png differ diff --git a/docs/视觉验收/A1/12_430_1.0.png b/docs/视觉验收/A1/12_430_1.0.png new file mode 100644 index 0000000..7be60a8 Binary files /dev/null and b/docs/视觉验收/A1/12_430_1.0.png differ diff --git a/docs/视觉验收/A1/12_430_1.3.png b/docs/视觉验收/A1/12_430_1.3.png new file mode 100644 index 0000000..dbcbf5b Binary files /dev/null and b/docs/视觉验收/A1/12_430_1.3.png differ diff --git a/docs/视觉验收/A1/12_并排对照.png b/docs/视觉验收/A1/12_并排对照.png new file mode 100644 index 0000000..d1fa037 Binary files /dev/null and b/docs/视觉验收/A1/12_并排对照.png differ diff --git a/docs/视觉验收/A1/13_320_1.0.png b/docs/视觉验收/A1/13_320_1.0.png new file mode 100644 index 0000000..bdc2757 Binary files /dev/null and b/docs/视觉验收/A1/13_320_1.0.png differ diff --git a/docs/视觉验收/A1/13_320_1.3.png b/docs/视觉验收/A1/13_320_1.3.png new file mode 100644 index 0000000..7df6981 Binary files /dev/null and b/docs/视觉验收/A1/13_320_1.3.png differ diff --git a/docs/视觉验收/A1/13_360_1.0.png b/docs/视觉验收/A1/13_360_1.0.png new file mode 100644 index 0000000..d093734 Binary files /dev/null and b/docs/视觉验收/A1/13_360_1.0.png differ diff --git a/docs/视觉验收/A1/13_360_1.3.png b/docs/视觉验收/A1/13_360_1.3.png new file mode 100644 index 0000000..c73455e Binary files /dev/null and b/docs/视觉验收/A1/13_360_1.3.png differ diff --git a/docs/视觉验收/A1/13_390_1.0.png b/docs/视觉验收/A1/13_390_1.0.png new file mode 100644 index 0000000..3f6549d Binary files /dev/null and b/docs/视觉验收/A1/13_390_1.0.png differ diff --git a/docs/视觉验收/A1/13_390_1.3.png b/docs/视觉验收/A1/13_390_1.3.png new file mode 100644 index 0000000..98b9901 Binary files /dev/null and b/docs/视觉验收/A1/13_390_1.3.png differ diff --git a/docs/视觉验收/A1/13_430_1.0.png b/docs/视觉验收/A1/13_430_1.0.png new file mode 100644 index 0000000..d8b6731 Binary files /dev/null and b/docs/视觉验收/A1/13_430_1.0.png differ diff --git a/docs/视觉验收/A1/13_430_1.3.png b/docs/视觉验收/A1/13_430_1.3.png new file mode 100644 index 0000000..33389ab Binary files /dev/null and b/docs/视觉验收/A1/13_430_1.3.png differ diff --git a/docs/视觉验收/A1/13_并排对照.png b/docs/视觉验收/A1/13_并排对照.png new file mode 100644 index 0000000..567bcb9 Binary files /dev/null and b/docs/视觉验收/A1/13_并排对照.png differ diff --git a/docs/视觉验收/A1/14_320_1.0.png b/docs/视觉验收/A1/14_320_1.0.png new file mode 100644 index 0000000..05c43b5 Binary files /dev/null and b/docs/视觉验收/A1/14_320_1.0.png differ diff --git a/docs/视觉验收/A1/14_320_1.3.png b/docs/视觉验收/A1/14_320_1.3.png new file mode 100644 index 0000000..b003539 Binary files /dev/null and b/docs/视觉验收/A1/14_320_1.3.png differ diff --git a/docs/视觉验收/A1/14_360_1.0.png b/docs/视觉验收/A1/14_360_1.0.png new file mode 100644 index 0000000..1269e5c Binary files /dev/null and b/docs/视觉验收/A1/14_360_1.0.png differ diff --git a/docs/视觉验收/A1/14_360_1.3.png b/docs/视觉验收/A1/14_360_1.3.png new file mode 100644 index 0000000..3e834df Binary files /dev/null and b/docs/视觉验收/A1/14_360_1.3.png differ diff --git a/docs/视觉验收/A1/14_390_1.0.png b/docs/视觉验收/A1/14_390_1.0.png new file mode 100644 index 0000000..af1ec3a Binary files /dev/null and b/docs/视觉验收/A1/14_390_1.0.png differ diff --git a/docs/视觉验收/A1/14_390_1.3.png b/docs/视觉验收/A1/14_390_1.3.png new file mode 100644 index 0000000..a1ec5ac Binary files /dev/null and b/docs/视觉验收/A1/14_390_1.3.png differ diff --git a/docs/视觉验收/A1/14_430_1.0.png b/docs/视觉验收/A1/14_430_1.0.png new file mode 100644 index 0000000..dfbc21e Binary files /dev/null and b/docs/视觉验收/A1/14_430_1.0.png differ diff --git a/docs/视觉验收/A1/14_430_1.3.png b/docs/视觉验收/A1/14_430_1.3.png new file mode 100644 index 0000000..b05ec46 Binary files /dev/null and b/docs/视觉验收/A1/14_430_1.3.png differ diff --git a/docs/视觉验收/A1/14_并排对照.png b/docs/视觉验收/A1/14_并排对照.png new file mode 100644 index 0000000..f55f83b Binary files /dev/null and b/docs/视觉验收/A1/14_并排对照.png differ diff --git a/docs/视觉验收/A1/15_320_1.0.png b/docs/视觉验收/A1/15_320_1.0.png new file mode 100644 index 0000000..ff7be0f Binary files /dev/null and b/docs/视觉验收/A1/15_320_1.0.png differ diff --git a/docs/视觉验收/A1/15_320_1.3.png b/docs/视觉验收/A1/15_320_1.3.png new file mode 100644 index 0000000..3b355ab Binary files /dev/null and b/docs/视觉验收/A1/15_320_1.3.png differ diff --git a/docs/视觉验收/A1/15_360_1.0.png b/docs/视觉验收/A1/15_360_1.0.png new file mode 100644 index 0000000..9c91bfe Binary files /dev/null and b/docs/视觉验收/A1/15_360_1.0.png differ diff --git a/docs/视觉验收/A1/15_360_1.3.png b/docs/视觉验收/A1/15_360_1.3.png new file mode 100644 index 0000000..f8ed8b4 Binary files /dev/null and b/docs/视觉验收/A1/15_360_1.3.png differ diff --git a/docs/视觉验收/A1/15_390_1.0.png b/docs/视觉验收/A1/15_390_1.0.png new file mode 100644 index 0000000..7a22e8f Binary files /dev/null and b/docs/视觉验收/A1/15_390_1.0.png differ diff --git a/docs/视觉验收/A1/15_390_1.3.png b/docs/视觉验收/A1/15_390_1.3.png new file mode 100644 index 0000000..4f182e2 Binary files /dev/null and b/docs/视觉验收/A1/15_390_1.3.png differ diff --git a/docs/视觉验收/A1/15_430_1.0.png b/docs/视觉验收/A1/15_430_1.0.png new file mode 100644 index 0000000..8eaba00 Binary files /dev/null and b/docs/视觉验收/A1/15_430_1.0.png differ diff --git a/docs/视觉验收/A1/15_430_1.3.png b/docs/视觉验收/A1/15_430_1.3.png new file mode 100644 index 0000000..d1c5f9a Binary files /dev/null and b/docs/视觉验收/A1/15_430_1.3.png differ diff --git a/docs/视觉验收/A1/15_并排对照.png b/docs/视觉验收/A1/15_并排对照.png new file mode 100644 index 0000000..4565924 Binary files /dev/null and b/docs/视觉验收/A1/15_并排对照.png differ diff --git a/docs/视觉验收/A1/16_320_1.0.png b/docs/视觉验收/A1/16_320_1.0.png new file mode 100644 index 0000000..43684a5 Binary files /dev/null and b/docs/视觉验收/A1/16_320_1.0.png differ diff --git a/docs/视觉验收/A1/16_320_1.3.png b/docs/视觉验收/A1/16_320_1.3.png new file mode 100644 index 0000000..8671d22 Binary files /dev/null and b/docs/视觉验收/A1/16_320_1.3.png differ diff --git a/docs/视觉验收/A1/16_360_1.0.png b/docs/视觉验收/A1/16_360_1.0.png new file mode 100644 index 0000000..0234b34 Binary files /dev/null and b/docs/视觉验收/A1/16_360_1.0.png differ diff --git a/docs/视觉验收/A1/16_360_1.3.png b/docs/视觉验收/A1/16_360_1.3.png new file mode 100644 index 0000000..458bc92 Binary files /dev/null and b/docs/视觉验收/A1/16_360_1.3.png differ diff --git a/docs/视觉验收/A1/16_390_1.0.png b/docs/视觉验收/A1/16_390_1.0.png new file mode 100644 index 0000000..683346c Binary files /dev/null and b/docs/视觉验收/A1/16_390_1.0.png differ diff --git a/docs/视觉验收/A1/16_390_1.3.png b/docs/视觉验收/A1/16_390_1.3.png new file mode 100644 index 0000000..8864a26 Binary files /dev/null and b/docs/视觉验收/A1/16_390_1.3.png differ diff --git a/docs/视觉验收/A1/16_430_1.0.png b/docs/视觉验收/A1/16_430_1.0.png new file mode 100644 index 0000000..a35258c Binary files /dev/null and b/docs/视觉验收/A1/16_430_1.0.png differ diff --git a/docs/视觉验收/A1/16_430_1.3.png b/docs/视觉验收/A1/16_430_1.3.png new file mode 100644 index 0000000..b761ec3 Binary files /dev/null and b/docs/视觉验收/A1/16_430_1.3.png differ diff --git a/docs/视觉验收/A1/16_并排对照.png b/docs/视觉验收/A1/16_并排对照.png new file mode 100644 index 0000000..6f6c341 Binary files /dev/null and b/docs/视觉验收/A1/16_并排对照.png differ diff --git a/docs/视觉验收/A1/17_320_1.0.png b/docs/视觉验收/A1/17_320_1.0.png new file mode 100644 index 0000000..35929bf Binary files /dev/null and b/docs/视觉验收/A1/17_320_1.0.png differ diff --git a/docs/视觉验收/A1/17_320_1.3.png b/docs/视觉验收/A1/17_320_1.3.png new file mode 100644 index 0000000..0c39d24 Binary files /dev/null and b/docs/视觉验收/A1/17_320_1.3.png differ diff --git a/docs/视觉验收/A1/17_360_1.0.png b/docs/视觉验收/A1/17_360_1.0.png new file mode 100644 index 0000000..94d37d8 Binary files /dev/null and b/docs/视觉验收/A1/17_360_1.0.png differ diff --git a/docs/视觉验收/A1/17_360_1.3.png b/docs/视觉验收/A1/17_360_1.3.png new file mode 100644 index 0000000..375244d Binary files /dev/null and b/docs/视觉验收/A1/17_360_1.3.png differ diff --git a/docs/视觉验收/A1/17_390_1.0.png b/docs/视觉验收/A1/17_390_1.0.png new file mode 100644 index 0000000..6a7140d Binary files /dev/null and b/docs/视觉验收/A1/17_390_1.0.png differ diff --git a/docs/视觉验收/A1/17_390_1.3.png b/docs/视觉验收/A1/17_390_1.3.png new file mode 100644 index 0000000..5c3431d Binary files /dev/null and b/docs/视觉验收/A1/17_390_1.3.png differ diff --git a/docs/视觉验收/A1/17_430_1.0.png b/docs/视觉验收/A1/17_430_1.0.png new file mode 100644 index 0000000..b5024b5 Binary files /dev/null and b/docs/视觉验收/A1/17_430_1.0.png differ diff --git a/docs/视觉验收/A1/17_430_1.3.png b/docs/视觉验收/A1/17_430_1.3.png new file mode 100644 index 0000000..9fcbc23 Binary files /dev/null and b/docs/视觉验收/A1/17_430_1.3.png differ diff --git a/docs/视觉验收/A1/17_并排对照.png b/docs/视觉验收/A1/17_并排对照.png new file mode 100644 index 0000000..2ddce5a Binary files /dev/null and b/docs/视觉验收/A1/17_并排对照.png differ diff --git a/docs/视觉验收/A1/18_320_1.0.png b/docs/视觉验收/A1/18_320_1.0.png new file mode 100644 index 0000000..0bf8c1c Binary files /dev/null and b/docs/视觉验收/A1/18_320_1.0.png differ diff --git a/docs/视觉验收/A1/18_320_1.3.png b/docs/视觉验收/A1/18_320_1.3.png new file mode 100644 index 0000000..9b0acc5 Binary files /dev/null and b/docs/视觉验收/A1/18_320_1.3.png differ diff --git a/docs/视觉验收/A1/18_360_1.0.png b/docs/视觉验收/A1/18_360_1.0.png new file mode 100644 index 0000000..9985834 Binary files /dev/null and b/docs/视觉验收/A1/18_360_1.0.png differ diff --git a/docs/视觉验收/A1/18_360_1.3.png b/docs/视觉验收/A1/18_360_1.3.png new file mode 100644 index 0000000..5af0b42 Binary files /dev/null and b/docs/视觉验收/A1/18_360_1.3.png differ diff --git a/docs/视觉验收/A1/18_390_1.0.png b/docs/视觉验收/A1/18_390_1.0.png new file mode 100644 index 0000000..c1e0b73 Binary files /dev/null and b/docs/视觉验收/A1/18_390_1.0.png differ diff --git a/docs/视觉验收/A1/18_390_1.3.png b/docs/视觉验收/A1/18_390_1.3.png new file mode 100644 index 0000000..b444c8b Binary files /dev/null and b/docs/视觉验收/A1/18_390_1.3.png differ diff --git a/docs/视觉验收/A1/18_430_1.0.png b/docs/视觉验收/A1/18_430_1.0.png new file mode 100644 index 0000000..37f0942 Binary files /dev/null and b/docs/视觉验收/A1/18_430_1.0.png differ diff --git a/docs/视觉验收/A1/18_430_1.3.png b/docs/视觉验收/A1/18_430_1.3.png new file mode 100644 index 0000000..13d24a7 Binary files /dev/null and b/docs/视觉验收/A1/18_430_1.3.png differ diff --git a/docs/视觉验收/A1/18_并排对照.png b/docs/视觉验收/A1/18_并排对照.png new file mode 100644 index 0000000..9f143f2 Binary files /dev/null and b/docs/视觉验收/A1/18_并排对照.png differ diff --git a/docs/视觉验收/A1/19_320_1.0.png b/docs/视觉验收/A1/19_320_1.0.png new file mode 100644 index 0000000..cbabbed Binary files /dev/null and b/docs/视觉验收/A1/19_320_1.0.png differ diff --git a/docs/视觉验收/A1/19_320_1.3.png b/docs/视觉验收/A1/19_320_1.3.png new file mode 100644 index 0000000..930b307 Binary files /dev/null and b/docs/视觉验收/A1/19_320_1.3.png differ diff --git a/docs/视觉验收/A1/19_360_1.0.png b/docs/视觉验收/A1/19_360_1.0.png new file mode 100644 index 0000000..e328e16 Binary files /dev/null and b/docs/视觉验收/A1/19_360_1.0.png differ diff --git a/docs/视觉验收/A1/19_360_1.3.png b/docs/视觉验收/A1/19_360_1.3.png new file mode 100644 index 0000000..b21e034 Binary files /dev/null and b/docs/视觉验收/A1/19_360_1.3.png differ diff --git a/docs/视觉验收/A1/19_390_1.0.png b/docs/视觉验收/A1/19_390_1.0.png new file mode 100644 index 0000000..97bb1cc Binary files /dev/null and b/docs/视觉验收/A1/19_390_1.0.png differ diff --git a/docs/视觉验收/A1/19_390_1.3.png b/docs/视觉验收/A1/19_390_1.3.png new file mode 100644 index 0000000..648e8a3 Binary files /dev/null and b/docs/视觉验收/A1/19_390_1.3.png differ diff --git a/docs/视觉验收/A1/19_430_1.0.png b/docs/视觉验收/A1/19_430_1.0.png new file mode 100644 index 0000000..1b07d5e Binary files /dev/null and b/docs/视觉验收/A1/19_430_1.0.png differ diff --git a/docs/视觉验收/A1/19_430_1.3.png b/docs/视觉验收/A1/19_430_1.3.png new file mode 100644 index 0000000..64017e5 Binary files /dev/null and b/docs/视觉验收/A1/19_430_1.3.png differ diff --git a/docs/视觉验收/A1/19_并排对照.png b/docs/视觉验收/A1/19_并排对照.png new file mode 100644 index 0000000..96f5589 Binary files /dev/null and b/docs/视觉验收/A1/19_并排对照.png differ diff --git a/docs/视觉验收/A1/20-shop_320_1.0.png b/docs/视觉验收/A1/20-shop_320_1.0.png new file mode 100644 index 0000000..f1e6d87 Binary files /dev/null and b/docs/视觉验收/A1/20-shop_320_1.0.png differ diff --git a/docs/视觉验收/A1/20-shop_320_1.3.png b/docs/视觉验收/A1/20-shop_320_1.3.png new file mode 100644 index 0000000..4206de2 Binary files /dev/null and b/docs/视觉验收/A1/20-shop_320_1.3.png differ diff --git a/docs/视觉验收/A1/20-shop_360_1.0.png b/docs/视觉验收/A1/20-shop_360_1.0.png new file mode 100644 index 0000000..8d9715c Binary files /dev/null and b/docs/视觉验收/A1/20-shop_360_1.0.png differ diff --git a/docs/视觉验收/A1/20-shop_360_1.3.png b/docs/视觉验收/A1/20-shop_360_1.3.png new file mode 100644 index 0000000..f0c7ad0 Binary files /dev/null and b/docs/视觉验收/A1/20-shop_360_1.3.png differ diff --git a/docs/视觉验收/A1/20-shop_390_1.0.png b/docs/视觉验收/A1/20-shop_390_1.0.png new file mode 100644 index 0000000..fae9325 Binary files /dev/null and b/docs/视觉验收/A1/20-shop_390_1.0.png differ diff --git a/docs/视觉验收/A1/20-shop_390_1.3.png b/docs/视觉验收/A1/20-shop_390_1.3.png new file mode 100644 index 0000000..d441714 Binary files /dev/null and b/docs/视觉验收/A1/20-shop_390_1.3.png differ diff --git a/docs/视觉验收/A1/20-shop_430_1.0.png b/docs/视觉验收/A1/20-shop_430_1.0.png new file mode 100644 index 0000000..8e89406 Binary files /dev/null and b/docs/视觉验收/A1/20-shop_430_1.0.png differ diff --git a/docs/视觉验收/A1/20-shop_430_1.3.png b/docs/视觉验收/A1/20-shop_430_1.3.png new file mode 100644 index 0000000..e33b191 Binary files /dev/null and b/docs/视觉验收/A1/20-shop_430_1.3.png differ diff --git a/docs/视觉验收/A1/20-shop_并排对照.png b/docs/视觉验收/A1/20-shop_并排对照.png new file mode 100644 index 0000000..8aed57f Binary files /dev/null and b/docs/视觉验收/A1/20-shop_并排对照.png differ diff --git a/docs/视觉验收/A1/20_320_1.0.png b/docs/视觉验收/A1/20_320_1.0.png new file mode 100644 index 0000000..80a88a2 Binary files /dev/null and b/docs/视觉验收/A1/20_320_1.0.png differ diff --git a/docs/视觉验收/A1/20_320_1.3.png b/docs/视觉验收/A1/20_320_1.3.png new file mode 100644 index 0000000..d926e2c Binary files /dev/null and b/docs/视觉验收/A1/20_320_1.3.png differ diff --git a/docs/视觉验收/A1/20_360_1.0.png b/docs/视觉验收/A1/20_360_1.0.png new file mode 100644 index 0000000..837fdac Binary files /dev/null and b/docs/视觉验收/A1/20_360_1.0.png differ diff --git a/docs/视觉验收/A1/20_360_1.3.png b/docs/视觉验收/A1/20_360_1.3.png new file mode 100644 index 0000000..abb79f4 Binary files /dev/null and b/docs/视觉验收/A1/20_360_1.3.png differ diff --git a/docs/视觉验收/A1/20_390_1.0.png b/docs/视觉验收/A1/20_390_1.0.png new file mode 100644 index 0000000..0b98de5 Binary files /dev/null and b/docs/视觉验收/A1/20_390_1.0.png differ diff --git a/docs/视觉验收/A1/20_390_1.3.png b/docs/视觉验收/A1/20_390_1.3.png new file mode 100644 index 0000000..f80b01d Binary files /dev/null and b/docs/视觉验收/A1/20_390_1.3.png differ diff --git a/docs/视觉验收/A1/20_430_1.0.png b/docs/视觉验收/A1/20_430_1.0.png new file mode 100644 index 0000000..74701b9 Binary files /dev/null and b/docs/视觉验收/A1/20_430_1.0.png differ diff --git a/docs/视觉验收/A1/20_430_1.3.png b/docs/视觉验收/A1/20_430_1.3.png new file mode 100644 index 0000000..89e5f4c Binary files /dev/null and b/docs/视觉验收/A1/20_430_1.3.png differ diff --git a/docs/视觉验收/A1/20_并排对照.png b/docs/视觉验收/A1/20_并排对照.png new file mode 100644 index 0000000..3b56001 Binary files /dev/null and b/docs/视觉验收/A1/20_并排对照.png differ diff --git a/docs/视觉验收/A1/21-gas_320_1.0.png b/docs/视觉验收/A1/21-gas_320_1.0.png new file mode 100644 index 0000000..4e98005 Binary files /dev/null and b/docs/视觉验收/A1/21-gas_320_1.0.png differ diff --git a/docs/视觉验收/A1/21-gas_320_1.3.png b/docs/视觉验收/A1/21-gas_320_1.3.png new file mode 100644 index 0000000..34ba8d2 Binary files /dev/null and b/docs/视觉验收/A1/21-gas_320_1.3.png differ diff --git a/docs/视觉验收/A1/21-gas_360_1.0.png b/docs/视觉验收/A1/21-gas_360_1.0.png new file mode 100644 index 0000000..775c531 Binary files /dev/null and b/docs/视觉验收/A1/21-gas_360_1.0.png differ diff --git a/docs/视觉验收/A1/21-gas_360_1.3.png b/docs/视觉验收/A1/21-gas_360_1.3.png new file mode 100644 index 0000000..ed2c8f2 Binary files /dev/null and b/docs/视觉验收/A1/21-gas_360_1.3.png differ diff --git a/docs/视觉验收/A1/21-gas_390_1.0.png b/docs/视觉验收/A1/21-gas_390_1.0.png new file mode 100644 index 0000000..d25badb Binary files /dev/null and b/docs/视觉验收/A1/21-gas_390_1.0.png differ diff --git a/docs/视觉验收/A1/21-gas_390_1.3.png b/docs/视觉验收/A1/21-gas_390_1.3.png new file mode 100644 index 0000000..548f10f Binary files /dev/null and b/docs/视觉验收/A1/21-gas_390_1.3.png differ diff --git a/docs/视觉验收/A1/21-gas_430_1.0.png b/docs/视觉验收/A1/21-gas_430_1.0.png new file mode 100644 index 0000000..7c58577 Binary files /dev/null and b/docs/视觉验收/A1/21-gas_430_1.0.png differ diff --git a/docs/视觉验收/A1/21-gas_430_1.3.png b/docs/视觉验收/A1/21-gas_430_1.3.png new file mode 100644 index 0000000..889ccef Binary files /dev/null and b/docs/视觉验收/A1/21-gas_430_1.3.png differ diff --git a/docs/视觉验收/A1/21-gas_并排对照.png b/docs/视觉验收/A1/21-gas_并排对照.png new file mode 100644 index 0000000..ff598b7 Binary files /dev/null and b/docs/视觉验收/A1/21-gas_并排对照.png differ diff --git a/docs/视觉验收/A1/21_320_1.0.png b/docs/视觉验收/A1/21_320_1.0.png new file mode 100644 index 0000000..d34b0b4 Binary files /dev/null and b/docs/视觉验收/A1/21_320_1.0.png differ diff --git a/docs/视觉验收/A1/21_320_1.3.png b/docs/视觉验收/A1/21_320_1.3.png new file mode 100644 index 0000000..c0fe4bc Binary files /dev/null and b/docs/视觉验收/A1/21_320_1.3.png differ diff --git a/docs/视觉验收/A1/21_360_1.0.png b/docs/视觉验收/A1/21_360_1.0.png new file mode 100644 index 0000000..f25f92c Binary files /dev/null and b/docs/视觉验收/A1/21_360_1.0.png differ diff --git a/docs/视觉验收/A1/21_360_1.3.png b/docs/视觉验收/A1/21_360_1.3.png new file mode 100644 index 0000000..9cec15e Binary files /dev/null and b/docs/视觉验收/A1/21_360_1.3.png differ diff --git a/docs/视觉验收/A1/21_390_1.0.png b/docs/视觉验收/A1/21_390_1.0.png new file mode 100644 index 0000000..84745a0 Binary files /dev/null and b/docs/视觉验收/A1/21_390_1.0.png differ diff --git a/docs/视觉验收/A1/21_390_1.3.png b/docs/视觉验收/A1/21_390_1.3.png new file mode 100644 index 0000000..d616ce4 Binary files /dev/null and b/docs/视觉验收/A1/21_390_1.3.png differ diff --git a/docs/视觉验收/A1/21_430_1.0.png b/docs/视觉验收/A1/21_430_1.0.png new file mode 100644 index 0000000..76d2e2f Binary files /dev/null and b/docs/视觉验收/A1/21_430_1.0.png differ diff --git a/docs/视觉验收/A1/21_430_1.3.png b/docs/视觉验收/A1/21_430_1.3.png new file mode 100644 index 0000000..1b46c78 Binary files /dev/null and b/docs/视觉验收/A1/21_430_1.3.png differ diff --git a/docs/视觉验收/A1/21_并排对照.png b/docs/视觉验收/A1/21_并排对照.png new file mode 100644 index 0000000..ba6b7f0 Binary files /dev/null and b/docs/视觉验收/A1/21_并排对照.png differ diff --git a/docs/视觉验收/A1/22_320_1.0.png b/docs/视觉验收/A1/22_320_1.0.png new file mode 100644 index 0000000..2b71748 Binary files /dev/null and b/docs/视觉验收/A1/22_320_1.0.png differ diff --git a/docs/视觉验收/A1/22_320_1.3.png b/docs/视觉验收/A1/22_320_1.3.png new file mode 100644 index 0000000..1c1226c Binary files /dev/null and b/docs/视觉验收/A1/22_320_1.3.png differ diff --git a/docs/视觉验收/A1/22_360_1.0.png b/docs/视觉验收/A1/22_360_1.0.png new file mode 100644 index 0000000..c3f1575 Binary files /dev/null and b/docs/视觉验收/A1/22_360_1.0.png differ diff --git a/docs/视觉验收/A1/22_360_1.3.png b/docs/视觉验收/A1/22_360_1.3.png new file mode 100644 index 0000000..39657da Binary files /dev/null and b/docs/视觉验收/A1/22_360_1.3.png differ diff --git a/docs/视觉验收/A1/22_390_1.0.png b/docs/视觉验收/A1/22_390_1.0.png new file mode 100644 index 0000000..45e2487 Binary files /dev/null and b/docs/视觉验收/A1/22_390_1.0.png differ diff --git a/docs/视觉验收/A1/22_390_1.3.png b/docs/视觉验收/A1/22_390_1.3.png new file mode 100644 index 0000000..125df84 Binary files /dev/null and b/docs/视觉验收/A1/22_390_1.3.png differ diff --git a/docs/视觉验收/A1/22_430_1.0.png b/docs/视觉验收/A1/22_430_1.0.png new file mode 100644 index 0000000..18b3a72 Binary files /dev/null and b/docs/视觉验收/A1/22_430_1.0.png differ diff --git a/docs/视觉验收/A1/22_430_1.3.png b/docs/视觉验收/A1/22_430_1.3.png new file mode 100644 index 0000000..6aaa051 Binary files /dev/null and b/docs/视觉验收/A1/22_430_1.3.png differ diff --git a/docs/视觉验收/A1/22_并排对照.png b/docs/视觉验收/A1/22_并排对照.png new file mode 100644 index 0000000..88a432e Binary files /dev/null and b/docs/视觉验收/A1/22_并排对照.png differ diff --git a/docs/视觉验收/A1/23_320_1.0.png b/docs/视觉验收/A1/23_320_1.0.png new file mode 100644 index 0000000..943cea0 Binary files /dev/null and b/docs/视觉验收/A1/23_320_1.0.png differ diff --git a/docs/视觉验收/A1/23_320_1.3.png b/docs/视觉验收/A1/23_320_1.3.png new file mode 100644 index 0000000..85d787c Binary files /dev/null and b/docs/视觉验收/A1/23_320_1.3.png differ diff --git a/docs/视觉验收/A1/23_360_1.0.png b/docs/视觉验收/A1/23_360_1.0.png new file mode 100644 index 0000000..d8f8e0e Binary files /dev/null and b/docs/视觉验收/A1/23_360_1.0.png differ diff --git a/docs/视觉验收/A1/23_360_1.3.png b/docs/视觉验收/A1/23_360_1.3.png new file mode 100644 index 0000000..f013e1d Binary files /dev/null and b/docs/视觉验收/A1/23_360_1.3.png differ diff --git a/docs/视觉验收/A1/23_390_1.0.png b/docs/视觉验收/A1/23_390_1.0.png new file mode 100644 index 0000000..7460daa Binary files /dev/null and b/docs/视觉验收/A1/23_390_1.0.png differ diff --git a/docs/视觉验收/A1/23_390_1.3.png b/docs/视觉验收/A1/23_390_1.3.png new file mode 100644 index 0000000..d7fcfe7 Binary files /dev/null and b/docs/视觉验收/A1/23_390_1.3.png differ diff --git a/docs/视觉验收/A1/23_430_1.0.png b/docs/视觉验收/A1/23_430_1.0.png new file mode 100644 index 0000000..49df5cb Binary files /dev/null and b/docs/视觉验收/A1/23_430_1.0.png differ diff --git a/docs/视觉验收/A1/23_430_1.3.png b/docs/视觉验收/A1/23_430_1.3.png new file mode 100644 index 0000000..18d93d8 Binary files /dev/null and b/docs/视觉验收/A1/23_430_1.3.png differ diff --git a/docs/视觉验收/A1/23_并排对照.png b/docs/视觉验收/A1/23_并排对照.png new file mode 100644 index 0000000..1e9d949 Binary files /dev/null and b/docs/视觉验收/A1/23_并排对照.png differ diff --git a/docs/视觉验收/A1/24_320_1.0.png b/docs/视觉验收/A1/24_320_1.0.png new file mode 100644 index 0000000..a449cb1 Binary files /dev/null and b/docs/视觉验收/A1/24_320_1.0.png differ diff --git a/docs/视觉验收/A1/24_320_1.3.png b/docs/视觉验收/A1/24_320_1.3.png new file mode 100644 index 0000000..b748c4d Binary files /dev/null and b/docs/视觉验收/A1/24_320_1.3.png differ diff --git a/docs/视觉验收/A1/24_360_1.0.png b/docs/视觉验收/A1/24_360_1.0.png new file mode 100644 index 0000000..bb840dc Binary files /dev/null and b/docs/视觉验收/A1/24_360_1.0.png differ diff --git a/docs/视觉验收/A1/24_360_1.3.png b/docs/视觉验收/A1/24_360_1.3.png new file mode 100644 index 0000000..b586ee2 Binary files /dev/null and b/docs/视觉验收/A1/24_360_1.3.png differ diff --git a/docs/视觉验收/A1/24_390_1.0.png b/docs/视觉验收/A1/24_390_1.0.png new file mode 100644 index 0000000..0f11085 Binary files /dev/null and b/docs/视觉验收/A1/24_390_1.0.png differ diff --git a/docs/视觉验收/A1/24_390_1.3.png b/docs/视觉验收/A1/24_390_1.3.png new file mode 100644 index 0000000..4a27fec Binary files /dev/null and b/docs/视觉验收/A1/24_390_1.3.png differ diff --git a/docs/视觉验收/A1/24_430_1.0.png b/docs/视觉验收/A1/24_430_1.0.png new file mode 100644 index 0000000..5fd82e8 Binary files /dev/null and b/docs/视觉验收/A1/24_430_1.0.png differ diff --git a/docs/视觉验收/A1/24_430_1.3.png b/docs/视觉验收/A1/24_430_1.3.png new file mode 100644 index 0000000..44534ac Binary files /dev/null and b/docs/视觉验收/A1/24_430_1.3.png differ diff --git a/docs/视觉验收/A1/24_并排对照.png b/docs/视觉验收/A1/24_并排对照.png new file mode 100644 index 0000000..572f408 Binary files /dev/null and b/docs/视觉验收/A1/24_并排对照.png differ diff --git a/docs/视觉验收/A1/25_320_1.0.png b/docs/视觉验收/A1/25_320_1.0.png new file mode 100644 index 0000000..fda1908 Binary files /dev/null and b/docs/视觉验收/A1/25_320_1.0.png differ diff --git a/docs/视觉验收/A1/25_320_1.3.png b/docs/视觉验收/A1/25_320_1.3.png new file mode 100644 index 0000000..42f5aa5 Binary files /dev/null and b/docs/视觉验收/A1/25_320_1.3.png differ diff --git a/docs/视觉验收/A1/25_360_1.0.png b/docs/视觉验收/A1/25_360_1.0.png new file mode 100644 index 0000000..2a17e8d Binary files /dev/null and b/docs/视觉验收/A1/25_360_1.0.png differ diff --git a/docs/视觉验收/A1/25_360_1.3.png b/docs/视觉验收/A1/25_360_1.3.png new file mode 100644 index 0000000..e8075a4 Binary files /dev/null and b/docs/视觉验收/A1/25_360_1.3.png differ diff --git a/docs/视觉验收/A1/25_390_1.0.png b/docs/视觉验收/A1/25_390_1.0.png new file mode 100644 index 0000000..a963d35 Binary files /dev/null and b/docs/视觉验收/A1/25_390_1.0.png differ diff --git a/docs/视觉验收/A1/25_390_1.3.png b/docs/视觉验收/A1/25_390_1.3.png new file mode 100644 index 0000000..3ead595 Binary files /dev/null and b/docs/视觉验收/A1/25_390_1.3.png differ diff --git a/docs/视觉验收/A1/25_430_1.0.png b/docs/视觉验收/A1/25_430_1.0.png new file mode 100644 index 0000000..34323b4 Binary files /dev/null and b/docs/视觉验收/A1/25_430_1.0.png differ diff --git a/docs/视觉验收/A1/25_430_1.3.png b/docs/视觉验收/A1/25_430_1.3.png new file mode 100644 index 0000000..5bcae28 Binary files /dev/null and b/docs/视觉验收/A1/25_430_1.3.png differ diff --git a/docs/视觉验收/A1/25_并排对照.png b/docs/视觉验收/A1/25_并排对照.png new file mode 100644 index 0000000..acdbaec Binary files /dev/null and b/docs/视觉验收/A1/25_并排对照.png differ diff --git a/docs/视觉验收/A1/26_320_1.0.png b/docs/视觉验收/A1/26_320_1.0.png new file mode 100644 index 0000000..7d3a4f9 Binary files /dev/null and b/docs/视觉验收/A1/26_320_1.0.png differ diff --git a/docs/视觉验收/A1/26_320_1.3.png b/docs/视觉验收/A1/26_320_1.3.png new file mode 100644 index 0000000..f0ba843 Binary files /dev/null and b/docs/视觉验收/A1/26_320_1.3.png differ diff --git a/docs/视觉验收/A1/26_360_1.0.png b/docs/视觉验收/A1/26_360_1.0.png new file mode 100644 index 0000000..a3375f8 Binary files /dev/null and b/docs/视觉验收/A1/26_360_1.0.png differ diff --git a/docs/视觉验收/A1/26_360_1.3.png b/docs/视觉验收/A1/26_360_1.3.png new file mode 100644 index 0000000..52d4473 Binary files /dev/null and b/docs/视觉验收/A1/26_360_1.3.png differ diff --git a/docs/视觉验收/A1/26_390_1.0.png b/docs/视觉验收/A1/26_390_1.0.png new file mode 100644 index 0000000..1dd5b0f Binary files /dev/null and b/docs/视觉验收/A1/26_390_1.0.png differ diff --git a/docs/视觉验收/A1/26_390_1.3.png b/docs/视觉验收/A1/26_390_1.3.png new file mode 100644 index 0000000..91dc594 Binary files /dev/null and b/docs/视觉验收/A1/26_390_1.3.png differ diff --git a/docs/视觉验收/A1/26_430_1.0.png b/docs/视觉验收/A1/26_430_1.0.png new file mode 100644 index 0000000..4c3d1d2 Binary files /dev/null and b/docs/视觉验收/A1/26_430_1.0.png differ diff --git a/docs/视觉验收/A1/26_430_1.3.png b/docs/视觉验收/A1/26_430_1.3.png new file mode 100644 index 0000000..f1786af Binary files /dev/null and b/docs/视觉验收/A1/26_430_1.3.png differ diff --git a/docs/视觉验收/A1/26_并排对照.png b/docs/视觉验收/A1/26_并排对照.png new file mode 100644 index 0000000..8c68589 Binary files /dev/null and b/docs/视觉验收/A1/26_并排对照.png differ diff --git a/docs/视觉验收/A1/27_320_1.0.png b/docs/视觉验收/A1/27_320_1.0.png new file mode 100644 index 0000000..875cd89 Binary files /dev/null and b/docs/视觉验收/A1/27_320_1.0.png differ diff --git a/docs/视觉验收/A1/27_320_1.3.png b/docs/视觉验收/A1/27_320_1.3.png new file mode 100644 index 0000000..b3f7f0a Binary files /dev/null and b/docs/视觉验收/A1/27_320_1.3.png differ diff --git a/docs/视觉验收/A1/27_360_1.0.png b/docs/视觉验收/A1/27_360_1.0.png new file mode 100644 index 0000000..e5495e6 Binary files /dev/null and b/docs/视觉验收/A1/27_360_1.0.png differ diff --git a/docs/视觉验收/A1/27_360_1.3.png b/docs/视觉验收/A1/27_360_1.3.png new file mode 100644 index 0000000..8e1e94e Binary files /dev/null and b/docs/视觉验收/A1/27_360_1.3.png differ diff --git a/docs/视觉验收/A1/27_390_1.0.png b/docs/视觉验收/A1/27_390_1.0.png new file mode 100644 index 0000000..0c04458 Binary files /dev/null and b/docs/视觉验收/A1/27_390_1.0.png differ diff --git a/docs/视觉验收/A1/27_390_1.3.png b/docs/视觉验收/A1/27_390_1.3.png new file mode 100644 index 0000000..a340806 Binary files /dev/null and b/docs/视觉验收/A1/27_390_1.3.png differ diff --git a/docs/视觉验收/A1/27_430_1.0.png b/docs/视觉验收/A1/27_430_1.0.png new file mode 100644 index 0000000..a98c73b Binary files /dev/null and b/docs/视觉验收/A1/27_430_1.0.png differ diff --git a/docs/视觉验收/A1/27_430_1.3.png b/docs/视觉验收/A1/27_430_1.3.png new file mode 100644 index 0000000..ce807f8 Binary files /dev/null and b/docs/视觉验收/A1/27_430_1.3.png differ diff --git a/docs/视觉验收/A1/27_并排对照.png b/docs/视觉验收/A1/27_并排对照.png new file mode 100644 index 0000000..9f2cf21 Binary files /dev/null and b/docs/视觉验收/A1/27_并排对照.png differ diff --git a/docs/视觉验收/A1/28_320_1.0.png b/docs/视觉验收/A1/28_320_1.0.png new file mode 100644 index 0000000..5ae2e1e Binary files /dev/null and b/docs/视觉验收/A1/28_320_1.0.png differ diff --git a/docs/视觉验收/A1/28_320_1.3.png b/docs/视觉验收/A1/28_320_1.3.png new file mode 100644 index 0000000..508d272 Binary files /dev/null and b/docs/视觉验收/A1/28_320_1.3.png differ diff --git a/docs/视觉验收/A1/28_360_1.0.png b/docs/视觉验收/A1/28_360_1.0.png new file mode 100644 index 0000000..a2d6f77 Binary files /dev/null and b/docs/视觉验收/A1/28_360_1.0.png differ diff --git a/docs/视觉验收/A1/28_360_1.3.png b/docs/视觉验收/A1/28_360_1.3.png new file mode 100644 index 0000000..acd80b5 Binary files /dev/null and b/docs/视觉验收/A1/28_360_1.3.png differ diff --git a/docs/视觉验收/A1/28_390_1.0.png b/docs/视觉验收/A1/28_390_1.0.png new file mode 100644 index 0000000..7d213d1 Binary files /dev/null and b/docs/视觉验收/A1/28_390_1.0.png differ diff --git a/docs/视觉验收/A1/28_390_1.3.png b/docs/视觉验收/A1/28_390_1.3.png new file mode 100644 index 0000000..d98d58a Binary files /dev/null and b/docs/视觉验收/A1/28_390_1.3.png differ diff --git a/docs/视觉验收/A1/28_430_1.0.png b/docs/视觉验收/A1/28_430_1.0.png new file mode 100644 index 0000000..12e30dd Binary files /dev/null and b/docs/视觉验收/A1/28_430_1.0.png differ diff --git a/docs/视觉验收/A1/28_430_1.3.png b/docs/视觉验收/A1/28_430_1.3.png new file mode 100644 index 0000000..8ceb7fc Binary files /dev/null and b/docs/视觉验收/A1/28_430_1.3.png differ diff --git a/docs/视觉验收/A1/28_并排对照.png b/docs/视觉验收/A1/28_并排对照.png new file mode 100644 index 0000000..900c239 Binary files /dev/null and b/docs/视觉验收/A1/28_并排对照.png differ diff --git a/docs/视觉验收/A1/29_320_1.0.png b/docs/视觉验收/A1/29_320_1.0.png new file mode 100644 index 0000000..2c1c0a4 Binary files /dev/null and b/docs/视觉验收/A1/29_320_1.0.png differ diff --git a/docs/视觉验收/A1/29_320_1.3.png b/docs/视觉验收/A1/29_320_1.3.png new file mode 100644 index 0000000..f4f1334 Binary files /dev/null and b/docs/视觉验收/A1/29_320_1.3.png differ diff --git a/docs/视觉验收/A1/29_360_1.0.png b/docs/视觉验收/A1/29_360_1.0.png new file mode 100644 index 0000000..8345b61 Binary files /dev/null and b/docs/视觉验收/A1/29_360_1.0.png differ diff --git a/docs/视觉验收/A1/29_360_1.3.png b/docs/视觉验收/A1/29_360_1.3.png new file mode 100644 index 0000000..dd2a854 Binary files /dev/null and b/docs/视觉验收/A1/29_360_1.3.png differ diff --git a/docs/视觉验收/A1/29_390_1.0.png b/docs/视觉验收/A1/29_390_1.0.png new file mode 100644 index 0000000..1639bec Binary files /dev/null and b/docs/视觉验收/A1/29_390_1.0.png differ diff --git a/docs/视觉验收/A1/29_390_1.3.png b/docs/视觉验收/A1/29_390_1.3.png new file mode 100644 index 0000000..ac20ad3 Binary files /dev/null and b/docs/视觉验收/A1/29_390_1.3.png differ diff --git a/docs/视觉验收/A1/29_430_1.0.png b/docs/视觉验收/A1/29_430_1.0.png new file mode 100644 index 0000000..389a2c0 Binary files /dev/null and b/docs/视觉验收/A1/29_430_1.0.png differ diff --git a/docs/视觉验收/A1/29_430_1.3.png b/docs/视觉验收/A1/29_430_1.3.png new file mode 100644 index 0000000..b9b81e9 Binary files /dev/null and b/docs/视觉验收/A1/29_430_1.3.png differ diff --git a/docs/视觉验收/A1/29_并排对照.png b/docs/视觉验收/A1/29_并排对照.png new file mode 100644 index 0000000..427bc32 Binary files /dev/null and b/docs/视觉验收/A1/29_并排对照.png differ diff --git a/docs/视觉验收/A1/30_320_1.0.png b/docs/视觉验收/A1/30_320_1.0.png new file mode 100644 index 0000000..0135055 Binary files /dev/null and b/docs/视觉验收/A1/30_320_1.0.png differ diff --git a/docs/视觉验收/A1/30_320_1.3.png b/docs/视觉验收/A1/30_320_1.3.png new file mode 100644 index 0000000..c1a5be8 Binary files /dev/null and b/docs/视觉验收/A1/30_320_1.3.png differ diff --git a/docs/视觉验收/A1/30_360_1.0.png b/docs/视觉验收/A1/30_360_1.0.png new file mode 100644 index 0000000..dfe1f25 Binary files /dev/null and b/docs/视觉验收/A1/30_360_1.0.png differ diff --git a/docs/视觉验收/A1/30_360_1.3.png b/docs/视觉验收/A1/30_360_1.3.png new file mode 100644 index 0000000..267f9a9 Binary files /dev/null and b/docs/视觉验收/A1/30_360_1.3.png differ diff --git a/docs/视觉验收/A1/30_390_1.0.png b/docs/视觉验收/A1/30_390_1.0.png new file mode 100644 index 0000000..6ce1d7b Binary files /dev/null and b/docs/视觉验收/A1/30_390_1.0.png differ diff --git a/docs/视觉验收/A1/30_390_1.3.png b/docs/视觉验收/A1/30_390_1.3.png new file mode 100644 index 0000000..439f16d Binary files /dev/null and b/docs/视觉验收/A1/30_390_1.3.png differ diff --git a/docs/视觉验收/A1/30_430_1.0.png b/docs/视觉验收/A1/30_430_1.0.png new file mode 100644 index 0000000..c72beaa Binary files /dev/null and b/docs/视觉验收/A1/30_430_1.0.png differ diff --git a/docs/视觉验收/A1/30_430_1.3.png b/docs/视觉验收/A1/30_430_1.3.png new file mode 100644 index 0000000..25b16a0 Binary files /dev/null and b/docs/视觉验收/A1/30_430_1.3.png differ diff --git a/docs/视觉验收/A1/30_并排对照.png b/docs/视觉验收/A1/30_并排对照.png new file mode 100644 index 0000000..f4811f6 Binary files /dev/null and b/docs/视觉验收/A1/30_并排对照.png differ diff --git a/docs/视觉验收/A1/31-payment_320_1.0.png b/docs/视觉验收/A1/31-payment_320_1.0.png new file mode 100644 index 0000000..49ff074 Binary files /dev/null and b/docs/视觉验收/A1/31-payment_320_1.0.png differ diff --git a/docs/视觉验收/A1/31-payment_320_1.3.png b/docs/视觉验收/A1/31-payment_320_1.3.png new file mode 100644 index 0000000..f722acd Binary files /dev/null and b/docs/视觉验收/A1/31-payment_320_1.3.png differ diff --git a/docs/视觉验收/A1/31-payment_360_1.0.png b/docs/视觉验收/A1/31-payment_360_1.0.png new file mode 100644 index 0000000..1f0b1df Binary files /dev/null and b/docs/视觉验收/A1/31-payment_360_1.0.png differ diff --git a/docs/视觉验收/A1/31-payment_360_1.3.png b/docs/视觉验收/A1/31-payment_360_1.3.png new file mode 100644 index 0000000..1654ec2 Binary files /dev/null and b/docs/视觉验收/A1/31-payment_360_1.3.png differ diff --git a/docs/视觉验收/A1/31-payment_390_1.0.png b/docs/视觉验收/A1/31-payment_390_1.0.png new file mode 100644 index 0000000..fb97e19 Binary files /dev/null and b/docs/视觉验收/A1/31-payment_390_1.0.png differ diff --git a/docs/视觉验收/A1/31-payment_390_1.3.png b/docs/视觉验收/A1/31-payment_390_1.3.png new file mode 100644 index 0000000..3285b3c Binary files /dev/null and b/docs/视觉验收/A1/31-payment_390_1.3.png differ diff --git a/docs/视觉验收/A1/31-payment_430_1.0.png b/docs/视觉验收/A1/31-payment_430_1.0.png new file mode 100644 index 0000000..ddeac39 Binary files /dev/null and b/docs/视觉验收/A1/31-payment_430_1.0.png differ diff --git a/docs/视觉验收/A1/31-payment_430_1.3.png b/docs/视觉验收/A1/31-payment_430_1.3.png new file mode 100644 index 0000000..75a1360 Binary files /dev/null and b/docs/视觉验收/A1/31-payment_430_1.3.png differ diff --git a/docs/视觉验收/A1/31_320_1.0.png b/docs/视觉验收/A1/31_320_1.0.png new file mode 100644 index 0000000..9c6936f Binary files /dev/null and b/docs/视觉验收/A1/31_320_1.0.png differ diff --git a/docs/视觉验收/A1/31_320_1.3.png b/docs/视觉验收/A1/31_320_1.3.png new file mode 100644 index 0000000..9edb3b6 Binary files /dev/null and b/docs/视觉验收/A1/31_320_1.3.png differ diff --git a/docs/视觉验收/A1/31_360_1.0.png b/docs/视觉验收/A1/31_360_1.0.png new file mode 100644 index 0000000..7e37b87 Binary files /dev/null and b/docs/视觉验收/A1/31_360_1.0.png differ diff --git a/docs/视觉验收/A1/31_360_1.3.png b/docs/视觉验收/A1/31_360_1.3.png new file mode 100644 index 0000000..cdd3c16 Binary files /dev/null and b/docs/视觉验收/A1/31_360_1.3.png differ diff --git a/docs/视觉验收/A1/31_390_1.0.png b/docs/视觉验收/A1/31_390_1.0.png new file mode 100644 index 0000000..27ab5a9 Binary files /dev/null and b/docs/视觉验收/A1/31_390_1.0.png differ diff --git a/docs/视觉验收/A1/31_390_1.3.png b/docs/视觉验收/A1/31_390_1.3.png new file mode 100644 index 0000000..4583070 Binary files /dev/null and b/docs/视觉验收/A1/31_390_1.3.png differ diff --git a/docs/视觉验收/A1/31_430_1.0.png b/docs/视觉验收/A1/31_430_1.0.png new file mode 100644 index 0000000..1c12211 Binary files /dev/null and b/docs/视觉验收/A1/31_430_1.0.png differ diff --git a/docs/视觉验收/A1/31_430_1.3.png b/docs/视觉验收/A1/31_430_1.3.png new file mode 100644 index 0000000..1108e74 Binary files /dev/null and b/docs/视觉验收/A1/31_430_1.3.png differ diff --git a/docs/视觉验收/A1/31_并排对照.png b/docs/视觉验收/A1/31_并排对照.png new file mode 100644 index 0000000..d1bbcd2 Binary files /dev/null and b/docs/视觉验收/A1/31_并排对照.png differ diff --git a/docs/视觉验收/A1/32_320_1.0.png b/docs/视觉验收/A1/32_320_1.0.png new file mode 100644 index 0000000..5f8b9fb Binary files /dev/null and b/docs/视觉验收/A1/32_320_1.0.png differ diff --git a/docs/视觉验收/A1/32_320_1.3.png b/docs/视觉验收/A1/32_320_1.3.png new file mode 100644 index 0000000..a00a8a9 Binary files /dev/null and b/docs/视觉验收/A1/32_320_1.3.png differ diff --git a/docs/视觉验收/A1/32_360_1.0.png b/docs/视觉验收/A1/32_360_1.0.png new file mode 100644 index 0000000..ed4a1f0 Binary files /dev/null and b/docs/视觉验收/A1/32_360_1.0.png differ diff --git a/docs/视觉验收/A1/32_360_1.3.png b/docs/视觉验收/A1/32_360_1.3.png new file mode 100644 index 0000000..44f0cfa Binary files /dev/null and b/docs/视觉验收/A1/32_360_1.3.png differ diff --git a/docs/视觉验收/A1/32_390_1.0.png b/docs/视觉验收/A1/32_390_1.0.png new file mode 100644 index 0000000..9f7284f Binary files /dev/null and b/docs/视觉验收/A1/32_390_1.0.png differ diff --git a/docs/视觉验收/A1/32_390_1.3.png b/docs/视觉验收/A1/32_390_1.3.png new file mode 100644 index 0000000..b869777 Binary files /dev/null and b/docs/视觉验收/A1/32_390_1.3.png differ diff --git a/docs/视觉验收/A1/32_430_1.0.png b/docs/视觉验收/A1/32_430_1.0.png new file mode 100644 index 0000000..ecf0996 Binary files /dev/null and b/docs/视觉验收/A1/32_430_1.0.png differ diff --git a/docs/视觉验收/A1/32_430_1.3.png b/docs/视觉验收/A1/32_430_1.3.png new file mode 100644 index 0000000..f83a268 Binary files /dev/null and b/docs/视觉验收/A1/32_430_1.3.png differ diff --git a/docs/视觉验收/A1/32_并排对照.png b/docs/视觉验收/A1/32_并排对照.png new file mode 100644 index 0000000..e6d565c Binary files /dev/null and b/docs/视觉验收/A1/32_并排对照.png differ diff --git a/docs/视觉验收/A1/33_320_1.0.png b/docs/视觉验收/A1/33_320_1.0.png new file mode 100644 index 0000000..2ec23db Binary files /dev/null and b/docs/视觉验收/A1/33_320_1.0.png differ diff --git a/docs/视觉验收/A1/33_320_1.3.png b/docs/视觉验收/A1/33_320_1.3.png new file mode 100644 index 0000000..2586242 Binary files /dev/null and b/docs/视觉验收/A1/33_320_1.3.png differ diff --git a/docs/视觉验收/A1/33_360_1.0.png b/docs/视觉验收/A1/33_360_1.0.png new file mode 100644 index 0000000..ab1adad Binary files /dev/null and b/docs/视觉验收/A1/33_360_1.0.png differ diff --git a/docs/视觉验收/A1/33_360_1.3.png b/docs/视觉验收/A1/33_360_1.3.png new file mode 100644 index 0000000..e7c6e13 Binary files /dev/null and b/docs/视觉验收/A1/33_360_1.3.png differ diff --git a/docs/视觉验收/A1/33_390_1.0.png b/docs/视觉验收/A1/33_390_1.0.png new file mode 100644 index 0000000..8f59b05 Binary files /dev/null and b/docs/视觉验收/A1/33_390_1.0.png differ diff --git a/docs/视觉验收/A1/33_390_1.3.png b/docs/视觉验收/A1/33_390_1.3.png new file mode 100644 index 0000000..80cfb5e Binary files /dev/null and b/docs/视觉验收/A1/33_390_1.3.png differ diff --git a/docs/视觉验收/A1/33_430_1.0.png b/docs/视觉验收/A1/33_430_1.0.png new file mode 100644 index 0000000..7f0f664 Binary files /dev/null and b/docs/视觉验收/A1/33_430_1.0.png differ diff --git a/docs/视觉验收/A1/33_430_1.3.png b/docs/视觉验收/A1/33_430_1.3.png new file mode 100644 index 0000000..d0667bb Binary files /dev/null and b/docs/视觉验收/A1/33_430_1.3.png differ diff --git a/docs/视觉验收/A1/33_并排对照.png b/docs/视觉验收/A1/33_并排对照.png new file mode 100644 index 0000000..21774a7 Binary files /dev/null and b/docs/视觉验收/A1/33_并排对照.png differ diff --git a/docs/视觉验收/A1/39_320_1.0.png b/docs/视觉验收/A1/39_320_1.0.png new file mode 100644 index 0000000..a034bde Binary files /dev/null and b/docs/视觉验收/A1/39_320_1.0.png differ diff --git a/docs/视觉验收/A1/39_320_1.3.png b/docs/视觉验收/A1/39_320_1.3.png new file mode 100644 index 0000000..d3a0221 Binary files /dev/null and b/docs/视觉验收/A1/39_320_1.3.png differ diff --git a/docs/视觉验收/A1/39_360_1.0.png b/docs/视觉验收/A1/39_360_1.0.png new file mode 100644 index 0000000..4c5df2a Binary files /dev/null and b/docs/视觉验收/A1/39_360_1.0.png differ diff --git a/docs/视觉验收/A1/39_360_1.3.png b/docs/视觉验收/A1/39_360_1.3.png new file mode 100644 index 0000000..0753e55 Binary files /dev/null and b/docs/视觉验收/A1/39_360_1.3.png differ diff --git a/docs/视觉验收/A1/39_390_1.0.png b/docs/视觉验收/A1/39_390_1.0.png new file mode 100644 index 0000000..24e4fee Binary files /dev/null and b/docs/视觉验收/A1/39_390_1.0.png differ diff --git a/docs/视觉验收/A1/39_390_1.3.png b/docs/视觉验收/A1/39_390_1.3.png new file mode 100644 index 0000000..b80fa3d Binary files /dev/null and b/docs/视觉验收/A1/39_390_1.3.png differ diff --git a/docs/视觉验收/A1/39_430_1.0.png b/docs/视觉验收/A1/39_430_1.0.png new file mode 100644 index 0000000..7dcbea8 Binary files /dev/null and b/docs/视觉验收/A1/39_430_1.0.png differ diff --git a/docs/视觉验收/A1/39_430_1.3.png b/docs/视觉验收/A1/39_430_1.3.png new file mode 100644 index 0000000..80dd11f Binary files /dev/null and b/docs/视觉验收/A1/39_430_1.3.png differ diff --git a/docs/视觉验收/A1/39_并排对照.png b/docs/视觉验收/A1/39_并排对照.png new file mode 100644 index 0000000..84b8ba4 Binary files /dev/null and b/docs/视觉验收/A1/39_并排对照.png differ diff --git a/docs/视觉验收/A1/40_320_1.0.png b/docs/视觉验收/A1/40_320_1.0.png new file mode 100644 index 0000000..d37ca88 Binary files /dev/null and b/docs/视觉验收/A1/40_320_1.0.png differ diff --git a/docs/视觉验收/A1/40_320_1.3.png b/docs/视觉验收/A1/40_320_1.3.png new file mode 100644 index 0000000..ef4499a Binary files /dev/null and b/docs/视觉验收/A1/40_320_1.3.png differ diff --git a/docs/视觉验收/A1/40_360_1.0.png b/docs/视觉验收/A1/40_360_1.0.png new file mode 100644 index 0000000..ab32fad Binary files /dev/null and b/docs/视觉验收/A1/40_360_1.0.png differ diff --git a/docs/视觉验收/A1/40_360_1.3.png b/docs/视觉验收/A1/40_360_1.3.png new file mode 100644 index 0000000..94503d3 Binary files /dev/null and b/docs/视觉验收/A1/40_360_1.3.png differ diff --git a/docs/视觉验收/A1/40_390_1.0.png b/docs/视觉验收/A1/40_390_1.0.png new file mode 100644 index 0000000..e51b0f6 Binary files /dev/null and b/docs/视觉验收/A1/40_390_1.0.png differ diff --git a/docs/视觉验收/A1/40_390_1.3.png b/docs/视觉验收/A1/40_390_1.3.png new file mode 100644 index 0000000..2d8d492 Binary files /dev/null and b/docs/视觉验收/A1/40_390_1.3.png differ diff --git a/docs/视觉验收/A1/40_430_1.0.png b/docs/视觉验收/A1/40_430_1.0.png new file mode 100644 index 0000000..0a9e854 Binary files /dev/null and b/docs/视觉验收/A1/40_430_1.0.png differ diff --git a/docs/视觉验收/A1/40_430_1.3.png b/docs/视觉验收/A1/40_430_1.3.png new file mode 100644 index 0000000..18f50e1 Binary files /dev/null and b/docs/视觉验收/A1/40_430_1.3.png differ diff --git a/docs/视觉验收/A1/40_并排对照.png b/docs/视觉验收/A1/40_并排对照.png new file mode 100644 index 0000000..37ad9e1 Binary files /dev/null and b/docs/视觉验收/A1/40_并排对照.png differ diff --git a/docs/视觉验收/A1/41_320_1.0.png b/docs/视觉验收/A1/41_320_1.0.png new file mode 100644 index 0000000..9d7b8c8 Binary files /dev/null and b/docs/视觉验收/A1/41_320_1.0.png differ diff --git a/docs/视觉验收/A1/41_320_1.3.png b/docs/视觉验收/A1/41_320_1.3.png new file mode 100644 index 0000000..175c83e Binary files /dev/null and b/docs/视觉验收/A1/41_320_1.3.png differ diff --git a/docs/视觉验收/A1/41_360_1.0.png b/docs/视觉验收/A1/41_360_1.0.png new file mode 100644 index 0000000..d42984f Binary files /dev/null and b/docs/视觉验收/A1/41_360_1.0.png differ diff --git a/docs/视觉验收/A1/41_360_1.3.png b/docs/视觉验收/A1/41_360_1.3.png new file mode 100644 index 0000000..37d8808 Binary files /dev/null and b/docs/视觉验收/A1/41_360_1.3.png differ diff --git a/docs/视觉验收/A1/41_390_1.0.png b/docs/视觉验收/A1/41_390_1.0.png new file mode 100644 index 0000000..12acef1 Binary files /dev/null and b/docs/视觉验收/A1/41_390_1.0.png differ diff --git a/docs/视觉验收/A1/41_390_1.3.png b/docs/视觉验收/A1/41_390_1.3.png new file mode 100644 index 0000000..7ec4063 Binary files /dev/null and b/docs/视觉验收/A1/41_390_1.3.png differ diff --git a/docs/视觉验收/A1/41_430_1.0.png b/docs/视觉验收/A1/41_430_1.0.png new file mode 100644 index 0000000..3347472 Binary files /dev/null and b/docs/视觉验收/A1/41_430_1.0.png differ diff --git a/docs/视觉验收/A1/41_430_1.3.png b/docs/视觉验收/A1/41_430_1.3.png new file mode 100644 index 0000000..06a4fb3 Binary files /dev/null and b/docs/视觉验收/A1/41_430_1.3.png differ diff --git a/docs/视觉验收/A1/41_并排对照.png b/docs/视觉验收/A1/41_并排对照.png new file mode 100644 index 0000000..26e3861 Binary files /dev/null and b/docs/视觉验收/A1/41_并排对照.png differ diff --git a/docs/视觉验收/A1/42_320_1.0.png b/docs/视觉验收/A1/42_320_1.0.png new file mode 100644 index 0000000..d526802 Binary files /dev/null and b/docs/视觉验收/A1/42_320_1.0.png differ diff --git a/docs/视觉验收/A1/42_320_1.3.png b/docs/视觉验收/A1/42_320_1.3.png new file mode 100644 index 0000000..34dedf0 Binary files /dev/null and b/docs/视觉验收/A1/42_320_1.3.png differ diff --git a/docs/视觉验收/A1/42_360_1.0.png b/docs/视觉验收/A1/42_360_1.0.png new file mode 100644 index 0000000..c7d6fab Binary files /dev/null and b/docs/视觉验收/A1/42_360_1.0.png differ diff --git a/docs/视觉验收/A1/42_360_1.3.png b/docs/视觉验收/A1/42_360_1.3.png new file mode 100644 index 0000000..21caffd Binary files /dev/null and b/docs/视觉验收/A1/42_360_1.3.png differ diff --git a/docs/视觉验收/A1/42_390_1.0.png b/docs/视觉验收/A1/42_390_1.0.png new file mode 100644 index 0000000..ba09e98 Binary files /dev/null and b/docs/视觉验收/A1/42_390_1.0.png differ diff --git a/docs/视觉验收/A1/42_390_1.3.png b/docs/视觉验收/A1/42_390_1.3.png new file mode 100644 index 0000000..3ae95c3 Binary files /dev/null and b/docs/视觉验收/A1/42_390_1.3.png differ diff --git a/docs/视觉验收/A1/42_430_1.0.png b/docs/视觉验收/A1/42_430_1.0.png new file mode 100644 index 0000000..72e77c9 Binary files /dev/null and b/docs/视觉验收/A1/42_430_1.0.png differ diff --git a/docs/视觉验收/A1/42_430_1.3.png b/docs/视觉验收/A1/42_430_1.3.png new file mode 100644 index 0000000..6565629 Binary files /dev/null and b/docs/视觉验收/A1/42_430_1.3.png differ diff --git a/docs/视觉验收/A1/42_并排对照.png b/docs/视觉验收/A1/42_并排对照.png new file mode 100644 index 0000000..044b0c8 Binary files /dev/null and b/docs/视觉验收/A1/42_并排对照.png differ diff --git a/docs/视觉验收/A1/合同下载_浏览器测试.pdf b/docs/视觉验收/A1/合同下载_浏览器测试.pdf new file mode 100644 index 0000000..18413f4 --- /dev/null +++ b/docs/视觉验收/A1/合同下载_浏览器测试.pdf @@ -0,0 +1,32 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> +endobj +4 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +5 0 obj +<< /Length 75 >> +stream +BT /F1 18 Tf 50 780 Td (DOWNLOAD TEST ONLY - NOT A BUSINESS CONTRACT) Tj ET +endstream +endobj +xref +0 6 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000241 00000 n +0000000311 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +436 +%%EOF diff --git a/docs/视觉验收/A1/合同下载_浏览器测试.png b/docs/视觉验收/A1/合同下载_浏览器测试.png new file mode 100644 index 0000000..90abb4b Binary files /dev/null and b/docs/视觉验收/A1/合同下载_浏览器测试.png differ diff --git a/docs/视觉验收/A1/视觉验收记录_A1.md b/docs/视觉验收/A1/视觉验收记录_A1.md new file mode 100644 index 0000000..5846368 --- /dev/null +++ b/docs/视觉验收/A1/视觉验收记录_A1.md @@ -0,0 +1,34 @@ +# A1 视觉验收记录 + +日期:2026-09-07。结论:严格 1:1 视觉及完整功能验收不通过。已实现六页中的部分能力;当前 48 项尺寸与溢出验证通过,不能替代功能与视觉验收。 + +本次纠偏:图 41 增加真实头像上传和昵称保存;图 25 恢复分组与可编辑资料入口;图 01 品牌和图 11 宣传位复用原稿像素;商品相对路径显示缺陷已修复。商品原图、认证事实及完整深层页面仍有缺口。详见 [头像与视觉纠偏日志](../../操作日志_用户端APP_头像与视觉纠偏_20260907.md)。 + +## 首次 A1 检查记录(以下保留修复前历史) + +- 每页先打开 doc/用户端APP-最新参考产品设计 中对应 PNG。 +- 实现截图来自真实 Flutter 组件与 test/support/a1_fixture.dart,无整图背景、假成功或生产 Fixture 入口。 +- tool/a1_visual_test.dart 运行 5 页 × 4 宽度 × 2 字体缩放,共 40 项;视口高度固定 844,截图后滚动到底检查异常。 +- 文件命名为 编号_宽度_缩放.png,默认对照为 编号_390_1.0.png。并排图左侧原设计等比缩放,右侧实现 390×844;源图比例各异,不能把并排图当严格像素差分。 +- 浏览器使用真实 API 和远程测试数据确认游客入口、密码登录回跳、订单记录、个人信息脱敏、钱包余额、商品搜索。浏览器 390×844 视口已检查;浏览器数据与 Fixture 截图不混称同数据。 + +## 逐页核对 + +| 页面 | 已核对与修复 | 剩余差异 | 证据 | +| --- | --- | --- | --- | +| 01 登录 | 蓝白品牌、双登录方式、可见字段标签、密码显隐、发送冷却、记住状态、辅助入口;修正品牌横排、标签下划线和独立验证码按钮 | 安全图标并非原品牌资产;保留原注册入口及显式同意弹窗,未复制原图“登录即同意”的隐式确认 | [并排](01_并排对照.png) | +| 03 首页 | 服务归属、设备保留入口、扫码/蓝牙/分组/报修、安全/气瓶、公告、底栏;文字和点击层正常 | 按用户决策不绘制不存在的遥测;公告实际版本与原图样例不同 | [并排](03_并排对照.png) | +| 11 商城 | 搜索、后台分类、双列商品、整数分价格、库存、缺图态、收藏保留入口、确认下单;修复筛选标签低对比度 | 远程缺商品图;宣传图/服务承诺没有已发布配置,未虚构;不展示设计样例销量 | [并排](11_并排对照.png) | +| 20 订单 | 搜索、服务端状态、成交快照、金额、允许动作、错误重试;三类主 Tab 与退款/售后入口;修复裸分值重复展示及低对比度标签 | 配送图片、轨迹及完整详情动作归后续闭环,当前测试只有可操作的订单摘要,不冒充原图完整履约状态 | [并排](20_并排对照.png) | +| 25 我的 | 资料/头像兼容、手机脱敏、三列资产摘要、押金/优惠券未开放、服务和家庭入口、地址/合同/维修/退出保留 | 缺少已认证、未读数量、押金/优惠券事实;不能把原图样例人像及金额带入真实界面 | [并排](25_并排对照.png) | + +## 不作为通过依据的项目 + +- 生成 golden 文件只是记录当前输出,不代表已经匹配参考图。 +- 40 项无溢出验证覆盖正常内容和底部滚动,不等于所有支付、设备、深层表单、暗色或横屏均验收。 +- 无真实商品图片和相同设备状态的部分,不用补造图片、数值或成功文案消除视觉差异。 +- Android/iOS 实机、本批次深层页面、正式商户支付、真实短信供应商未验收。 + +## 后续修复入口 + +01、20、25 的可直接修正结构差异已处理;后续用平台提供的原品牌、商品素材及相同业务状态进行严格同数据对照,完成后再将开发进度中的状态改为“已验收”。重新生成并排图执行 scripts/compare-user-app-a1.ps1。 diff --git a/docs/项目文档_用户端APP全量功能开发_v1.1.md b/docs/项目文档_用户端APP全量功能开发_v1.1.md new file mode 100644 index 0000000..06079e6 --- /dev/null +++ b/docs/项目文档_用户端APP全量功能开发_v1.1.md @@ -0,0 +1,776 @@ +# 用户端 App 全量功能开发文档 v1.1 + +2026-09-11图19增补:新增商城/气瓶订单支付确认页,金额、商品快照、钱包余额和可用动作以服务端为准。余额扣款在同一事务内校验六位支付密码、更新订单与钱包、写入支付单和账单,重试使用幂等号返回首次结果。优惠券缺少接口,明确显示“暂未开放”;当前测试账号无待付款订单,未执行远程真实扣款。详见[支付确认日志](开发日志_订单支付确认_20260911.md)。 + +2026-09-11图28、39、40增补:钱包提现和银行卡入口已从占位接入真实Client API,新增脱敏银行卡列表、绑定、默认到账卡、安全解绑、提现记录、支付密码校验、幂等申请和二次确认。银行卡增加`is_default`字段及默认切换接口;外部银行回执、短信供应商、押金、优惠券和待退款仍待补齐,详见[钱包提现与银行卡日志](开发日志_钱包提现与银行卡_20260911.md)。 + +阅读确认接口增加可选content_version精确匹配及幂等内容核对,充值页面尚待接入;见[协议版本确认](操作日志_用户端APP_协议版本确认_20260911.md)。 + +充值重试已增加服务端入账查询,避免已到账订单再次拉起渠道,见[充值重试入账检查](操作日志_用户端APP_充值重试入账检查_20260911.md)。 + +2026-09-11最新Flutter全量回归135项通过,充值页新增320/390窄屏及1.3倍字体检查通过;这不代表1:1视觉或真实支付验收,见[充值窄屏回归](操作日志_用户端APP_充值窄屏回归_20260911.md)。 + +充值金额控件按图38补充自定义入口、清空和确认金额,测试通过;当前修改尚未重新部署,视觉仍未通过,见[充值金额控件](操作日志_用户端APP_充值金额控件_20260911.md)。 + +2026-09-11充值配置后端和表单Web构建已更新到本地预览12426/18572;修复到账后余额刷新失败的错误提示。构建及回归测试通过,浏览器视觉和真实渠道验证仍待完成,见[到账提示修复](操作日志_用户端APP_充值到账提示修复_20260911.md)。 + +充值表单 `/wallet/recharge` 已在代码中接入钱包,包含金额、渠道、协议和原请求恢复交互;部署、协议版本留痕和视觉核对尚未完成,见[充值表单接入](操作日志_用户端APP_充值表单接入_20260911.md)。 + +充值数据层新增按环境与账号隔离的待确认请求存储,以及先存后发、到账确认后清除的恢复流程;页面接入和支付关闭处理未完成,详见[充值请求恢复](操作日志_用户端APP_充值请求恢复_20260911.md)。 + +2026-09-11补充钱包充值记录页面 `/wallet/recharge-records`,支持分页、去重、刷新和失败保留数据;新增交互测试、钱包回归与金额测试共6项通过,静态检查和Web构建通过。充值表单及真实渠道流程未完成,详见[充值记录页面](操作日志_用户端APP_充值记录页面_20260911.md)。 + +充值后续接口已增加本人记录分页、按充值标识查询及按请求号恢复结果;创建接口拒绝同请求号更改金额或渠道,真实支付响应增加充值业务标识。远程回滚测试确认Mock重复回调只入账一次,尚不代表图38页面及真实渠道流程完成。详见[充值查询与幂等记录](操作日志_用户端APP_充值查询与幂等_20260911.md)。 + +2026-09-11当前状态:新增图28钱包主页与账单收支筛选、游标分页、详情和金额隐藏。共17张部分实现、41张尚无对应完整页面、0张严格全功能及1:1通过;下方9月8日记录为历史批次。新接口`GET /wallet/bills`限定本人钱包、兼容旧充值方向`in`,保留旧`/wallet/records`。没有资金写入或数据库迁移;充值、提现和其他资产口径仍待补齐。见[钱包与账单日志](操作日志_用户端APP_钱包与账单_20260911.md)。 + +2026-09-08 纠偏补充:一级页面按“部分实现”记录;新增资料编辑、真实头像上传、地址管理、商品详情、购物车、收藏、结算页、商城及供气订单详情、供气合同列表、设置与登录密码、报修表单和工单详情,复用品牌及宣传原图。16 张设计已有部分能力、42 张尚无完整页面,0 张通过严格全功能与 1:1 验收。详见 [设置日志](操作日志_用户端APP_设置与登录密码_20260908.md)、[订单详情日志](操作日志_用户端APP_订单详情_20260908.md)、[收藏日志](操作日志_用户端APP_商品收藏_20260908.md)、[购物车日志](操作日志_用户端APP_购物车_20260908.md)、[头像与视觉日志](操作日志_用户端APP_头像与视觉纠偏_20260907.md)、[地址日志](操作日志_用户端APP_地址管理_20260907.md)、[结算日志](操作日志_用户端APP_结算流程_20260907.md)、[工单日志](操作日志_用户端APP_工单操作_20260907.md)、[报修提交日志](操作日志_用户端APP_报修提交_20260907.md)。 + +图32已替换为独立合同列表,可搜索、筛选真实状态并读取正文;修复后端合同ID误查订单明细的问题。本人受控PDF下载已通过浏览器真实落盘、哈希及渲染验证,独立测试合同/文件已清理;原生保存对话框、签署及合同服务仍待补。见 [合同列表日志](操作日志_用户端APP_供气合同列表_20260908.md)、[附件日志](操作日志_用户端APP_合同附件下载_20260908.md)。 + +头像使用现有 `POST /upload/avatar` 上传本人 JPG/PNG(最大 2MB),再通过 `PUT /heqi/client/v1/user/auth/profile` 保存。省略 avatar 保留原图,变更 URI 必须归当前账户;读取继续使用受保护的 `GET auth/avatar`。无需数据库迁移;旧客户端保留旧 URI 或清空头像的行为兼容。 + +## 1. 项目概述 + +图32新增本人合同变更记录:读取服务端不可变的生效/续期/终止状态及有效期快照,支持重试和空态;内部人员及原因不公开。另已接入合同申请、气站答复、取消及用户确认,原合同条款不会被申请流程直接修改;仍不等于电子签署审计。见 [变更记录日志](操作日志_用户端APP_合同变更记录_20260908.md)、[合同申请日志](操作日志_用户端APP_合同申请与个人中心复核_20260908.md)。 + +图21供气订单分支:新增本人订单详情、状态历史、实际支付记录及合同正文读取;状态34由本人确认后事务记录签收、完成订单并释放占用,状态23重复确认无副作用。无数据库迁移,远程回滚测试确认无临时记录。常规时间轴已横向对齐,商品素材、押金、配送地图及受控联系仍未完成;图32正文弹层不算完整合同页面。详见 [供气详情日志](操作日志_用户端APP_供气订单详情_20260908.md)。 + +图21商城订单分支:新增`GET shop/orders/:identity`和`/shop/orders/:identity`页面,订单详情独立读取本人历史快照,不以当前商品或当前地址覆盖成交事实。`OrderActionHandler`从原订单页抽出,列表与详情共用确认、退款、支付和请求号重试规则;原列表“查看操作”继续保留,点击商品订单可进入详情。无数据库迁移和新依赖,气瓶订单详情及原图押金/合同等内容尚未完成。 + +图16押金管理:新增 `deposit_policy`、`deposit_record`、登录用户的 `GET /deposits` 和 App `/deposits` 页面,总后台同步增加押金规则与只读押金记录入口。远程当前没有规则或记录,所以真实 App 显示零金额空态,不使用产品稿示例值。退瓶闭环已在图17批次接入。 + +图17退瓶退押金:新增 `deposit_return_request`、`/deposits/return`、用户幂等提交/查询/取消接口和后台“退瓶处理”。后台确认回收、验收扣减和退款入账由严格状态机约束,退款、钱包余额、押金状态及资金流水同事务落库。当前真实账号无押金记录,未为演示伪造资金数据;最近安检结论继续标记“暂未开放”。 + +图18气瓶下单:新增 `/gas/order`、服务端报价接口和用户幂等创建接口。页面只展示本人有效合同中未占用的实体气瓶,金额由合同价、后台押金规则和合同配送费计算;支付成功后再将订单押金快照转为正式押金记录。远程账号缺少押金规则时保留规格并提示“押金规则暂未配置”,禁止继续提交。 + +图13与图15推荐已接通`GET shop/recommendations`,购物车内嵌推荐,收藏通过底部“猜你喜欢”打开弹层。只推荐有库存的有效商品,过滤本人购物车与收藏场景已收藏商品;按关联分类优先和更新时间排序,不编造销量或画像。客户端条件加购、分页失败重试、详情返回刷新购物车,复用原仓储接口;无新依赖和数据库变更。见 [推荐商品日志](操作日志_用户端APP_推荐商品_20260908.md)。 + +图15收藏已新增EcFavorite、受用户JWT保护的查询与条件状态接口,以及独立`/favorites`。每账户每商品唯一,取消归档而非删除,下架商品仍保留。商城/详情复用FavoriteButton,服务端确认后同步图标;跨账号迟到响应不广播。新增迁移命令`go run ./cmd/cli migrate-ec-favorite`只创建收藏表并写注释,不能以全库迁移替代。 + +图13购物车使用既有ec_cart表,不新增远程字段;GET shop/cart、GET/PUT shop/cart/items/:identity 已落地,数量零归档而非物理删除。服务端比较公开revision并以账户行锁串行处理,客户端提交绝对数量。`/cart/checkout` 复用结算页,多商品订单按固定顺序锁定商品、校验购物车快照,并在同一事务扣库存和移除已结算条目。原单商品接口保持兼容。 + +图12商品详情:新增匿名详情接口与 `/products/:identity`,图片和参数来自后台,数量随游客登录回跳进入结算。售罄可看不可买,下架明确提示;未补造规格、销量、押金或配送承诺。见 [商品详情日志](操作日志_用户端APP_商品详情_20260908.md)。 + +图05语音输入已接入系统识别服务及Android/iOS用途声明,识别结果写入可编辑故障描述,错误、权限和离页取消有回归覆盖。真实设备麦克风与识别效果仍待验收。见 [语音输入日志](操作日志_用户端APP_报修语音输入_20260908.md)。 + +图05视觉扩展:编号步骤、故障表单分区、三格照片和可操作联系地址预览已接入。实现与源图继续并排核对,尚未通过严格1:1。见 [视觉对齐日志](操作日志_用户端APP_报修视觉对齐_20260908.md)。 + +报修草稿扩展:`RepairDraftStore` 沿用安全存储保存表单与照片URI,按认证账户和API地址隔离。暂存失败保留当前输入;恢复不会自动提交,结果未知时沿用原请求号。`GET /heqi/client/v1/user/ticket-photos/:name` 仅读取本账户文件。未提交草稿恢复时重新检查本人地址,损坏草稿须明确清除。见 [草稿日志](操作日志_用户端APP_报修草稿_20260907.md)。 + +报修照片扩展:已实现选图、预览、删除、上传、工单关联及本人鉴权读取,浏览器真实提交链路通过;原始拍摄时间、定位和完整视觉验收未完成。见 [报修照片日志](操作日志_用户端APP_报修照片_20260907.md)。 + +订单操作扩展:商城取消与确认收货已接入可执行按钮,修复收货状态校验与重复操作边界。图20仍未完整实现,详见 [订单操作日志](操作日志_用户端APP_订单操作_20260907.md)。 + +### 1.1 项目名称 + +瓶安芯用户端 App 产品设计落地与全量功能开发。 + +### 1.2 文档目标 + +本文档以 `doc/用户端APP-最新参考产品设计` 中 49 个主编号页面组、58 张 PNG 设计图为视觉事实源,结合现有 Flutter 用户端、Go Client API、平台总后台及正式需求,指导研发完成以下工作: + +- 按设计图统一优化用户端 UI,不改变已经确认的蓝白视觉方向。 +- 保留现有真实登录、内容、商城、订单、合同、工单、钱包和地址能力。 +- 补齐设备控制、安全闭环、押金、消息、发票、收藏、家庭共享和扩展服务能力。 +- 完善用户端 Client API、平台后台配置、数据模型、状态机和异常处理。 +- 建立逐页面验收、接口测试、Widget 测试和关键业务集成测试。 + +### 1.3 事实源优先级 + +发生冲突时按以下顺序处理: + +1. 安全、支付、隐私、合同和设备控制的服务端规则。 +2. `docs/03-用户端App需求.md`、`docs/02-核心业务流程.md` 和 `docs/11-数据接口与安全.md`。 +3. 本文档定义的接口与实施约束。 +4. `doc/用户端APP-最新参考产品设计` 中对应页面设计图。 +5. 当前 Flutter 页面实现。 + +设计图决定页面布局、信息层级和交互入口,不得用设计图中的示例金额、状态、日期或成功结果替代服务端事实。 + +### 1.4 当前状态 + +当前用户端已经具备以下真实基础能力: + +- 手机号密码和验证码登录、注册、找回密码及会话失效处理。 +- 首页公开内容、当前服务归属和下拉刷新。 +- 公开商品列表、商城订单创建、订单列表、取消、支付、退款和确认收货。 +- 供气合同列表、燃气订单列表、取消、支付和退款。 +- 工单列表、创建、确认和取消。 +- 用户资料、头像读取、地址列表与新增。 +- 钱包余额、流水、充值、支付密码、银行卡和提现。 +- 已发布内容查询和阅读确认。 + +当前用户端主要使用通用 `ClientRecord` 承载多个业务对象,页面集中在登录、首页、商城、订单、我的和两个记录列表,尚未形成与 58 张设计图对应的完整路由、领域模型和页面模块。 + +### 1.5 技术栈与运行环境 + +| 层级 | 当前技术 | 开发要求 | +| --- | --- | --- | +| 用户端 | Flutter、Dart 3.12、Material 3 | Android、iOS、Web 共用业务层,平台能力通过适配器隔离 | +| 路由 | `go_router` | 保留 `StatefulShellRoute.indexedStack` 四栏导航,二级页使用独立路由 | +| UI 基础 | `heqi_design_system` | 统一复用颜色、间距、圆角、按钮、状态和消息组件 | +| 网络 | `http`、现有 `ApiClient` | 保留统一响应解析、错误码和会话失效逻辑 | +| 安全存储 | `flutter_secure_storage` | 仅保存令牌及必要敏感临时凭据 | +| 支付 | `fluwx`、`tobias`、Web JSAPI | 客户端回传不推进支付事实,必须查询服务端状态 | +| 后端 | Go、Gin、GORM、PostgreSQL、Redis | 新接口继续放在 `/heqi/client/v1/user`,保持 v1 向下兼容 | +| 管理后台 | Vue 3、TypeScript、Arco Design | 补齐内容、通知、价格、规则和扩展服务配置 | + +本地运行: + +```bash +cd apps/user_app +flutter pub get +flutter run --dart-define=API_BASE_URL=http://10.0.2.2:12426 +``` + +Release 环境必须通过 `--dart-define=API_BASE_URL=https://...` 注入 HTTPS API 地址。 + +## 2. 目录结构规划 + +在不破坏现有代码的前提下逐步扩展为按业务模块组织的结构: + +```text +platforms/ +├── apps/ +│ ├── heqi_design_system/ # 多端共用设计 Token 和基础组件 +│ └── user_app/ +│ ├── lib/ +│ │ ├── app/ # 启动、依赖、路由和根级守卫 +│ │ ├── data/ +│ │ │ ├── dto/ # Client API 请求与响应 DTO +│ │ │ ├── repositories/ # 领域仓储实现 +│ │ │ └── services/ # HTTP、扫码、蓝牙、定位、推送和支付适配 +│ │ ├── domain/ +│ │ │ ├── models/ # 用户、设备、安全、内容、订单和资金模型 +│ │ │ └── use_cases/ # 控阀、下单、退款、退押等关键业务编排 +│ │ └── ui/ +│ │ ├── core/ # 主题、公共状态页和可访问性组件 +│ │ └── features/ # 按页面域拆分的 UI 和 ViewModel +│ ├── test/ # 单元、Widget 和契约测试 +│ └── integration_test/ # 核心用户旅程测试 +├── backend/ +│ └── api/internal/ +│ ├── models/ # 数据模型与迁移 +│ ├── logic/client/user/ # 用户端 Client API 业务逻辑 +│ └── routers/client.go # 用户端路由注册 +├── frontend/platform_admin/ # 平台内容、规则和运营配置后台 +├── doc/用户端APP-最新参考产品设计/ # 58 张视觉事实源 +└── docs/ # 需求、技术、接口、验收和项目文档 +``` + +迁移原则:先新增强类型 DTO、领域模型和 Feature,不一次性删除 `ClientRecord`;旧页面完成迁移和回归后再清理无引用代码。 + +## 3. UI 优化实施规范 + +### 3.1 视觉基线 + +- 用户端主色使用 `HeqiColors.consumerPrimary`,值为 `#2563EB`。 +- 背景使用 `#F7F8FA`,成功、警告、危险色继续使用设计系统 Token。 +- 间距以 4dp 为基础网格,页面水平安全边距默认 16dp。 +- 触控区域不得小于 48dp,主按钮高度使用 52dp。 +- 一级页面保留“首页、商城、订单、我的”四栏底部导航;二级页面只保留返回导航。 +- 禁止新增紫色、渐变、AI 元素、玻璃拟态、发光、Emoji 和无业务意义装饰。 +- 业务图标优先使用 Material Icons;商品、宣传和视频封面必须使用真实后台资源。 + +### 3.2 公共组件 + +在 `heqi_design_system` 或用户端 `ui/core` 中扩展以下组件,已存在的组件不得重复实现: + +| 组件 | 用途 | +| --- | --- | +| `AppScaffold` | 统一 SafeArea、页面背景、标题栏和最大内容宽度 | +| `AsyncContent` | 统一 `initial/loading/content/empty/error/refreshing` 状态 | +| `StatusPill` | 订单、安全、设备、资金和阅读状态标签 | +| `SectionHeader` | 标题、说明和“查看全部”入口 | +| `ServiceRelationCard` | 所属气站与服务配送点展示 | +| `DeviceStatusCard` | 在线、阀门、电量、告警和更新时间 | +| `MoneyBreakdown` | 商品、优惠、运费、押金、退款和应付金额拆分 | +| `TimelineView` | 订单、配送、工单、安全事件和审核进度 | +| `SensitiveText` | 手机号、银行卡、证件和人员信息脱敏展示 | +| `EvidencePicker` | 图片、视频、定位和采集时间的受控取证 | +| `RiskConfirmationSheet` | 开阀、群控、解绑、退押和资金操作二次确认 | + +### 3.3 页面状态要求 + +每个异步页面必须覆盖: + +- 首次加载、骨架或进度状态。 +- 正常内容、下拉刷新和分页加载。 +- 空数据及明确的下一步入口。 +- 网络失败、权限失败、会话失效和服务不可用。 +- 写操作提交中、成功、失败、冲突、重复请求和结果待确认。 + +不得用 Toast 或静态成功页代替服务端最终状态。设备命令、支付、退款、提现和退押在无最终回执时只能显示“处理中”或“待确认”。 + +### 3.4 适配与无障碍 + +- 以 390×844 逻辑像素作为设计比对基准,同时验证 320、360、390、430 宽度。 +- 文本缩放 1.3 倍时不得遮挡按钮、价格、状态和安全提示。 +- 状态不能只依赖颜色,必须同时提供图标或文字。 +- Tab、筛选、底部操作区和软键盘出现时不得导致内容重叠。 +- Android 返回键、iOS 返回手势和 Web 浏览器前进后退必须保持路由一致。 + +## 4. 页面、路由与开发范围 + +状态说明:`保留优化` 表示已有主要 API 或页面;`接口扩展` 表示已有领域能力但不足以完成设计;`新增闭环` 表示需要新增用户端、Client API 和后台配置。 + +### 4.1 入口、首页与设备 + +| 编号 | 页面与设计图 | 建议路由 | 开发状态 | 主要工作 | +| --- | --- | --- | --- | --- | +| 01 | `01-登录页.png` | `/login` | 保留优化 | 对齐验证码/密码切换、协议、记住状态、忘记密码和错误定位 | +| 02 | `02-智能角阀功能介绍.png`、`02-1-安全案例.png`、`02-2-法律法规.png`、`02-3-气价信息.png` | `/onboarding/safety?tab=` | 接口扩展 | 内容版本、强制阅读、展示频率、气价信息和根级守卫 | +| 03 | `03-首页.png` | `/home` | 保留优化 | 合并服务归属、内容、设备摘要、快捷服务和公告入口 | +| 04 | `04-智能角阀控制.png` | `/devices/:identity` | 新增闭环 | 状态、遥测、安全检查、开关阀命令和最终回执 | +| 05 | `05-一键报修.png` | `/repairs/new` | 接口扩展 | 故障分类、证据、语音转写、地址、定位和紧急提示 | +| 06 | `06-1-扫码添加确认.png`、`06-2-蓝牙连接设备.png`、`06-3-手动输入设备码.png` | `/devices/add/:method` | 新增闭环 | 三种并存入口、设备校验、绑定确认和失败恢复 | +| 07 | `07-气瓶基本信息.png` | `/cylinders/:identity` | 新增闭环 | 气瓶规格、充装、制造、有效期、来源和异常提示 | +| 08 | `08-设备分组控制.png` | `/device-groups` | 部分闭环 | 分组 CRUD、设备归组已完成;逐设备群控和安全检查待物联网接口 | +| 09 | `09-安全告警与报警器.png` | `/safety/alarms` | 新增闭环 | 报警器状态、告警、测试、联动关阀和紧急电话 | +| 10 | `10-紧急联系人.png` | `/safety/contacts` | 新增闭环 | 联系顺序、通知渠道、设备查看/控制授权和审计 | + +### 4.2 商城、押金与购买 + +| 编号 | 页面与设计图 | 建议路由 | 开发状态 | 主要工作 | +| --- | --- | --- | --- | --- | +| 11 | `11-燃气商城.png` | `/shop` | 保留优化 | 分类、搜索、商品卡、库存、收藏和购物车数量 | +| 12 | `12-商品详情.png` | `/products/:identity` | 部分实现 | 真实图片/参数/库存、数量、分享、收藏、加购和结算已接入;规格组合依赖后台配置,配送预约与保障标准暂未开放 | +| 13 | `13-购物车.png` | `/cart` | 接口扩展 | 选择、数量、删除、失效商品、价格试算和结算 | +| 14 | `14-提交订单.png` | `/checkout` | 接口扩展 | 地址、预约、优惠、费用、发票、备注和支付方式 | +| 15 | `15-我的收藏.png` | `/favorites` | 新增闭环 | 收藏列表、取消、下架保留和加入购物车 | +| 16 | `16-押金管理.png` | `/deposits` | 新增闭环 | 押金汇总、明细、使用中、退款中和已退回 | +| 17 | `17-退瓶退押金.png` | `/deposits/return` | 首期闭环已完成 | 对象选择、上门回收、验收、扣减和退款去向;最近安检结论暂未开放 | +| 18 | `18-气瓶下单.png` | `/gas-orders/new` | 新增闭环 | 气站、规格、库存、配送时段、换气和押金试算 | +| 19 | `19-支付确认.png` | `/payment/:business/:identity` | 已建立,继续对齐 | 渠道选择、余额密码、二次确认、调起渠道和服务端支付结果 | + +### 4.3 订单、配送与售后 + +| 编号 | 页面与设计图 | 建议路由 | 开发状态 | 主要工作 | +| --- | --- | --- | --- | --- | +| 20 | `20-订单中心.png` | `/orders` | 保留优化 | 气瓶/商城/报修聚合、状态筛选和可用操作 | +| 21 | `21-订单详情.png` | `/orders/:business/:identity` | 接口扩展 | 商品、金额、支付、合同、履约、人员和售后入口 | +| 22 | `22-配送详情.png` | `/gas/orders/:identity/delivery` | 首期部分实现 | 配送员、有效资质、配送点、预约和交付状态已接入;车辆资质、受控联系和真实头像资源仍待补 | +| 23 | `23-配送轨迹.png` | `/gas/orders/:identity/delivery/track` | 首期部分实现 | 本人订单、约百米简化轨迹、更新时间、历史节点与刷新已接入;受控电话和配送问题上报仍待补 | +| 24 | `24-电子发票.png` | `/invoice/:business/:identity` | 二期入口已实现 | 本人订单事实和完整表单结构可见,统一标“即将开放”;抬头、申请、状态、预览和下载授权待二期 | +| 35 | `35-安全记录详情.png` | `/safety/events/:identity` | 新增闭环 | 风险等级、关阀结果、证据、时间线、整改和复检 | +| 36 | `36-申请售后.png` | `/after-sales/new` | 接口扩展 | 类型、原因、方案、证据、联系人和退款审核 | +| 42 | `42-报修工单详情.png` | `/repairs/:identity` | 接口扩展 | 工单详情、进度、证据、工程师、改期、取消和确认 | +| 48 | `48-服务评价.png` | `/reviews/new` | 新增闭环 | 总体/分项评分、标签、凭证和安全交付确认 | +| 49 | `49-预约安全巡检.png` | `/inspections/new` | 新增闭环 | 服务、设备、日期时段、联系人、周期规则和取消改期 | + +### 4.4 个人中心、账户与消息 + +| 编号 | 页面与设计图 | 建议路由 | 开发状态 | 主要工作 | +| --- | --- | --- | --- | --- | +| 25 | `25-个人中心.png` | `/me` | 保留优化 | 资料、钱包、订单、设备、安全家庭和常用入口 | +| 26 | `26-我的记录.png` | `/records` | 接口扩展 | 用气、设备、告警、报修、押金和发票聚合索引 | +| 27 | `27-用气统计.png` | `/usage` | 新增闭环 | 日周月年趋势、构成、安全趋势、明细和数据口径 | +| 28 | `28-我的钱包.png` | `/wallet` | 保留优化 | 余额、可提现余额、押金、充值、提现、银行卡和账单 | +| 29 | `29-地址管理.png` | `/addresses` | 接口扩展 | 列表、新增、编辑、删除、默认地址和服务范围校验 | +| 30 | `30-消息中心.png` | `/messages` | 新增闭环 | 安全、订单、服务、公告、已读和对象跳转 | +| 31 | `31-设置.png` | `/settings` | 接口扩展 | 账号、通知、权限、支付密码、协议、退出和注销 | +| 32 | `32-供气合同.png` | `/contracts` | 接口扩展 | 列表、详情、下载、签署、变更、到期和续签 | +| 33 | `33-家庭成员与设备共享.png` | `/family` | 新增闭环 | 成员邀请、设备范围、查看/控制授权和撤销 | +| 34 | `34-我的设备.png` | `/devices` | 部分闭环 | 搜索、分类、分组已完成;在线状态、快捷控制和添加待接口 | +| 37 | `37-邀请注册.png` | `/invite/:token` | 接口扩展 | 邀请解析、登录/注册、地址确认、归属建立和异常提示 | +| 38 | `38-余额充值.png` | `/wallet/recharge` | 保留优化 | 套餐、自定义金额、渠道、限额和结果轮询 | +| 39 | `39-余额提现.png` | `/wallet/withdraw` | 保留优化 | 到账卡、金额、手续费、支付密码、审核和受限余额 | +| 40 | `40-银行卡管理.png` | `/wallet/banks` | 保留优化 | 列表、绑定、默认到账卡、实名校验和解绑二次确认 | +| 41 | `41-个人资料.png` | `/me/profile` | 接口扩展 | 头像、昵称、手机号、认证、服务归属和默认地址 | + +### 4.5 内容、安全设置与扩展能力 + +| 编号 | 页面与设计图 | 建议路由 | 开发状态 | 主要工作 | +| --- | --- | --- | --- | --- | +| 43 | `43-安全内容中心.png`、`43-1-安全宣传.png`、`43-2-安全视频.png`、`43-3-法律法规.png`、`43-4-平台公告.png` | `/safety-content?tab=` | 接口扩展 | 宣传、视频、法规、公告、搜索、筛选、详情和阅读状态 | +| 44 | `44-自动关阀设置.png` | `/devices/:identity/close-schedules` | 新增闭环 | 规则、周期、提前提醒、启停、执行记录和告警优先 | +| 45 | `45-电子保修卡.png` | `/devices/:identity/warranty` | 新增闭环 | 保修期限、范围、服务商、维修记录和凭证 | +| 46 | `46-设备健康月报.png` | `/devices/:identity/reports/:period` | 新增闭环 | 健康评分、在线率、异常、趋势、来源和更新时间 | +| 47 | `47-安全知识考试.png` | `/safety/exams/:identity` | 新增闭环 | 题目版本、计时、进度、及格规则、提交和结果 | + +## 5. Flutter 实施方案 + +### 5.1 分层与状态 + +- UI 只负责渲染与收集输入,不直接拼接接口请求。 +- 每个 Feature 使用独立 ViewModel,保持现有 `ChangeNotifier` 模式,状态对象不可变。 +- Repository 返回强类型领域模型;新页面不得继续通过 `raw` Map 读取关键业务字段。 +- 开阀、群控、支付、退款、退押和提现放入 Use Case,统一处理幂等号、二次确认和状态轮询。 +- 相机、相册、扫码、蓝牙、定位、推送、文件下载和支付均定义抽象接口,并分别提供 Android、iOS、Web 适配。 + +### 5.2 路由与守卫 + +- 根级公开路由:登录、注册、邀请解析、公开商品、公开内容。 +- 登录保护路由:设备、订单、钱包、地址、合同、工单、消息、共享和个人资料。 +- 首次宣导守卫在登录成功且会话恢复后执行;未完成当前强制版本阅读时跳转 `/onboarding/safety`。 +- 登录回跳只接受站内绝对路径,禁止外部 URL、协议相对路径和登录循环。 +- 推送和深链只携带业务类型、对象 `identity` 和短期签名上下文,页面打开后重新读取服务端状态。 + +### 5.3 缓存策略 + +| 数据 | 缓存策略 | +| --- | --- | +| 内容、法规和公告 | 按内容版本和 ETag 缓存;后台上下架后允许失效 | +| 商品和分类 | 短时缓存;提交订单前必须重新询价和校验库存 | +| 服务归属 | 登录后缓存,切换地址、扫码邀请或服务关系变更后失效 | +| 设备遥测 | 仅保存最后展示快照,必须显示采集时间和数据延迟 | +| 订单、支付、资金、安全事件 | 本地只缓存展示数据,不得覆盖服务端事实 | +| 图片和视频 | 使用受控 URL、磁盘缓存上限和过期清理,不持久化敏感取证资源 | + +## 6. Client API 设计 + +### 6.1 通用约定 + +- 基础路径继续使用 `/heqi/client/v1/user`,现有接口不得改名或改变已有字段语义。 +- 列表统一支持 `page`、`page_size`,响应包含 `items`、`page`、`page_size`、`total`;现有裸数组接口在兼容期继续返回原结构,新页面通过新增分页接口或 `view=page` 使用分页结构。 +- 查询对象统一使用 UUID V7 `identity`,禁止向客户端暴露数据库自增主键。 +- 时间使用 RFC 3339,金额使用整数分,数量使用明确单位。 +- 写接口通过 `Idempotency-Key` 或请求体 `request_no` 保证幂等。 +- 错误响应使用稳定业务码、中文安全文案和可选 `details`;前端不得解析英文错误文案驱动流程。 +- 设备、资金、安全事件、合同和隐私数据全部执行服务端对象归属校验。 + +### 6.2 保留的现有接口 + +以下接口继续保持兼容,并按设计需要补充非破坏性字段: + +| 接口族 | 现有能力 | +| --- | --- | +| `/auth/*` | 验证码、注册、登录、找回密码、资料、头像和修改密码 | +| `/public/gas-stations`、`/public/delivery-points` | 注册及邀请流程的服务组织选择 | +| `/public/contents` | 已发布内容列表和 `content_type` 筛选 | +| `/contents/read-confirmations` | 内容版本阅读确认 | +| `/public/products` | 已发布商品列表 | +| `/service-relation` | 当前所属气站和服务配送点 | +| `/gas/contracts`、`/gas/orders/*` | 供气合同、燃气订单、取消、支付和退款 | +| `/shop/orders/*` | 商城订单创建、列表、取消、支付、退款和确认收货 | +| `/tickets/*` | 工单列表、创建、确认和取消 | +| `/refunds` | 用户退款列表 | +| `/wallet/*` | 钱包、流水、充值、支付密码、银行卡和提现 | + +### 6.3 内容与宣导接口 + +| 方法与路径 | 用途 | 实施类型 | +| --- | --- | --- | +| `GET /public/contents` | 增加关键词、分类、置顶、发布时间和服务范围筛选 | 扩展 | +| `GET /public/contents/:identity` | 内容详情,返回正文、媒体、版本和外链信息 | 新增 | +| `GET /contents/read-statuses` | 批量返回当前用户对内容版本的已读状态 | 新增 | +| `POST /contents/read-confirmations` | 保留现有阅读确认,增加展示场景字段 | 兼容扩展 | +| `GET /onboarding/current` | 返回当前宣导版本、标签、强制标志和展示频率 | 新增 | +| `GET /gas-prices/current` | 按服务归属返回气瓶价格、押金、配送费和更新时间 | 新增 | + +内容类型保留现有 `notice`、`agreement`,新增 `onboarding`、`safety_case`、`safety_article`、`safety_video`、`regulation` 和 `price_notice`。未知历史类型不得静默转换。 + +### 6.4 设备与安全接口 + +| 方法与路径 | 用途 | +| --- | --- | +| `GET /devices` | 本人及已授权设备列表、筛选和设备摘要 | +| `GET /devices/:identity` | 设备详情、在线状态、阀门状态和最后更新时间 | +| `GET /devices/:identity/telemetry` | 当前遥测和趋势数据 | +| `POST /devices/verify` | 扫码或手工设备码预校验 | +| `POST /devices/bindings` | 确认安装地址、昵称和服务关系后绑定 | +| `DELETE /devices/:identity/binding` | 受限解绑,返回阻断原因和冷静期 | +| `POST /devices/:identity/commands` | 创建开阀、关阀或测试命令 | +| `GET /device-commands/:identity` | 查询命令投递、设备回执、失败或超时状态 | +| `GET/POST /device-groups`、`PUT/DELETE /device-groups/:identity`、`PUT /devices/:identity/group` | 本人设备分组资料及设备归组 | +| `POST /device-groups/:identity/commands` | 群控并返回逐设备结果 | +| `GET /devices/:identity/cylinder` | 当前关联气瓶及充装、制造和有效期信息 | +| `GET /alarm-devices` | 报警器列表、状态和最近告警 | +| `POST /alarm-devices/:identity/tests` | 发起报警器测试并查询结果 | +| `GET /safety/events` | 当前用户安全事件列表 | +| `GET /safety/events/:identity` | 事件详情、证据、处置和复检状态 | +| `POST /safety/events/:identity/rechecks` | 申请复检 | +| `GET/POST/PUT/DELETE /emergency-contacts` | 紧急联系人管理 | +| `PUT /emergency-contacts/:identity/permissions` | 通知顺序和设备查看/控制授权 | +| `GET/POST/PUT/DELETE /devices/:identity/close-schedules` | 自动关阀规则管理 | +| `GET /devices/:identity/close-schedules/executions` | 定时规则执行历史 | + +设备命令响应必须包含 `command_identity`、`command_type`、`command_status`、`requested_at`、`sent_at`、`acknowledged_at`、`failure_code` 和 `failure_message`。`command_status` 至少支持 `pending`、`sent`、`acknowledged`、`failed`、`timeout`、`cancelled`。 + +### 6.5 商城、气瓶、押金和支付接口 + +| 方法与路径 | 用途 | +| --- | --- | +| `GET /public/categories` | 商城一级分类和排序 | +| `GET /public/products` | 扩展关键词、分类、库存和分页,不破坏现有列表 | +| `GET /public/products/:identity` | 商品图片、规格、属性、服务和售后详情 | +| `GET /shop/cart`、`GET/PUT /shop/cart/items/:identity` | 已实现购物车查询、按商品读取版本、设置绝对数量/勾选;数量0归档 | +| `POST /cart/quote` | 库存、优惠、运费、服务费和押金试算 | +| `GET /shop/favorites`、`GET/PUT /shop/favorites/items/:identity` | 已落地;分页列表、单品状态、收藏/取消的绝对状态及版本 | +| `POST /shop/orders/quote` | 商城结算前服务端询价 | +| `POST /gas/orders/quote` | 气站、气瓶规格、库存、换气和押金试算 | +| `POST /gas/orders` | 创建气瓶预约订单 | +| `GET /deposits` | 押金汇总和明细 | +| `POST /deposit-refunds/quote` | 按设备、气瓶和状态计算预计退款 | +| `POST /deposit-refunds` | 创建退瓶退押申请 | +| `GET /deposit-refunds/:identity` | 回收、验收、扣减和退款进度 | +| `GET /payments/:identity` | 查询统一支付尝试状态 | + +金额响应至少拆分 `goods_amount`、`discount_amount`、`delivery_fee`、`service_fee`、`deposit_amount`、`payable_amount`。押金不能计入普通商品可开票收入。 + +### 6.6 订单、配送、售后和服务接口 + +| 方法与路径 | 用途 | +| --- | --- | +| `GET /orders` | 统一聚合商城、气瓶和报修订单摘要 | +| `GET /orders/:business/:identity` | 统一订单详情和允许操作 | +| `GET /orders/:business/:identity/delivery` | 配送人员、车辆、预约和交付状态 | +| `GET /orders/:business/:identity/tracks` | 本人订单的简化轨迹和历史节点 | +| `GET /tickets/:identity` | 用户工单详情、进度和证据 | +| `POST /tickets/:identity/reschedule` | 在规则允许时申请改期 | +| `POST /after-sales` | 创建完整售后申请 | +| `GET /after-sales/:identity` | 售后审核、退货、退款和处理记录 | +| `GET /invoices/eligible-orders` | 查询可开票订单和金额 | +| `POST /invoices` | 创建发票申请 | +| `GET /invoices/:identity` | 开具、作废、红冲和文件状态 | +| `POST /invoices/:identity/download-ticket` | 获取短期预览或下载凭证 | +| `POST /reviews` | 创建订单或服务评价,保证一单一次有效评价 | +| `GET /inspection-services` | 可预约巡检服务、规则和费用 | +| `POST /inspection-appointments` | 创建巡检预约 | +| `PUT /inspection-appointments/:identity` | 在规则允许时改期 | +| `DELETE /inspection-appointments/:identity` | 在规则允许时取消 | + +轨迹响应只返回当前订单履约所需位置,不返回配送员非履约时间的个人轨迹。 + +### 6.7 用户、消息、共享与扩展接口 + +| 方法与路径 | 用途 | +| --- | --- | +| `PUT/DELETE /addresses/:identity` | 编辑、删除地址并校验默认地址约束 | +| `POST /addresses/:identity/default` | 设置默认地址 | +| `GET /messages` | 按安全、订单、服务和公告筛选消息 | +| `POST /messages/read` | 批量标记已读 | +| `GET/PUT /notification-preferences` | 通知偏好;安全通知不可关闭 | +| `GET /records/summary` | 我的记录各业务分类数量与最近记录 | +| `GET /usage-statistics` | 按日周月年返回用气量、单位、口径和更新时间 | +| `GET/POST/DELETE /family-members` | 家庭成员邀请、接受和移除 | +| `PUT /family-members/:identity/device-permissions` | 授予或撤销设备查看/控制权限 | +| `GET /devices/:identity/warranty` | 电子保修卡和维修历史 | +| `GET /devices/:identity/health-reports` | 健康月报列表和详情 | +| `GET /safety/exams/current` | 当前考试、题目版本和及格规则 | +| `POST /safety/exams/:identity/attempts` | 创建答题尝试 | +| `POST /safety/exam-attempts/:identity/submit` | 幂等提交并返回结果 | +| `POST /account/cancellation-requests` | 账号注销申请和阻断原因 | + +## 7. 数据模型规划 + +### 7.1 内容域 + +现有 `cms_content` 保留 `content_type`、`title`、`body`、`version_no` 和 `publish_status`,通过迁移增加: + +- `summary`:列表摘要。 +- `category_code`:内容二级分类。 +- `cover_uri`:受控封面资源。 +- `video_uri`、`video_duration_seconds`:视频资源和时长。 +- `external_url`、`external_domain`:法规或外部资料链接及展示域名。 +- `published_at`、`effective_at`:发布时间和法规生效时间。 +- `is_pinned`、`must_read`、`sort_no`:置顶、强制阅读和排序。 +- `source_name`:发布来源。 + +新增 `cms_content_scope` 保存内容适用的平台、气站、配送点和服务区域;新增 `cms_content_media` 保存多媒体资源、排序、类型和完整性信息。阅读记录继续使用 `cms_content_read`,唯一约束保持“用户 + 内容 + 版本”。 + +### 7.2 用户、设备与安全域 + +| 实体 | 职责与关键字段 | +| --- | --- | +| `usr_device_binding` | 用户、设备、地址、昵称、绑定状态、来源和时间 | +| `user_device_group` | 用户设备分组名称、幂等请求和排序;不承载控制权限 | +| `usr_device_group_member` | 分组和设备唯一关系 | +| `usr_emergency_contact` | 联系人、脱敏电话、通知顺序和启用状态 | +| `usr_device_share` | 所有者、成员、设备、查看/控制权限、有效期和撤销时间 | +| `dev_close_schedule` | 设备、执行时间、重复周期、提醒、时区和启停状态 | +| `dev_close_schedule_execution` | 每次调度、命令、回执和失败原因 | +| `saf_event` | 风险等级、来源设备、状态、自动关阀结果和 SLA | +| `saf_event_evidence` | 证据类型、受控 URI、采集时间、来源和哈希 | +| `dev_usage_stat` | 周期、用量、单位、数据来源、计算版本和更新时间 | +| `dev_health_report` | 报告周期、健康评分、在线率、异常和生成版本 | +| `dev_warranty` | 保修起止时间、范围、服务商和关联设备 | + +### 7.3 交易、资金与服务域 + +| 实体 | 职责与关键字段 | +| --- | --- | +| `ec_favorite` | 用户与商品唯一收藏关系,下架后保留历史 | +| `wal_deposit` | 押金对象、规格、数量、单价、原始金额和状态 | +| `wal_deposit_refund` | 退押申请、预计金额、验收、扣减、实际退款和去向 | +| `ord_invoice` | 购买方、订单范围、可开票金额、状态和第三方回执 | +| `ord_invoice_file` | 发票文件、哈希、短期授权和版本 | +| `ord_after_sale` | 售后类型、原因、方案、审核和退款关系 | +| `ord_service_review` | 订单、服务人员、评分、标签和安全确认 | +| `ord_inspection_appointment` | 服务、设备、地址、日期、时段、周期和状态 | +| `msg_notification` | 消息类型、接收用户、对象、标题、正文、优先级和发送状态 | +| `msg_notification_read` | 用户消息阅读时间和设备信息 | +| `msg_notification_preference` | 用户通知渠道与免打扰配置 | + +所有新增迁移必须为表和字段提供中文数据库 COMMENT;枚举字段 COMMENT 必须列出所有允许值,JSON 字段必须说明结构。 + +## 8. 平台总后台完善 + +平台后台不能只保留通用 `cms_content` 的“公告、协议”选择,需要扩展以下运营能力: + +- 内容管理:类型、摘要、正文、封面、视频、法规链接、版本、适用范围、排序、置顶、强制阅读、草稿、发布和下架。 +- 气价管理:气站、气瓶规格、商品价格、押金、配送费、有效期和变更记录。 +- 安全规则:告警等级、自动关阀、开阀限制、紧急电话、通知升级和 SLA。 +- 设备规则:绑定限制、分组上限、定时关阀、保修模板和健康报告口径。 +- 消息与推送:模板、渠道、对象范围、跳转目标、发送状态和失败重试。 +- 巡检服务:服务内容、可预约区域、时间段、周期、费用、改期和取消规则。 +- 考试管理:题库、版本、考试时长、及格线、危险题约束和发布状态。 + +后台修改上述配置必须记录操作前后值、操作者、原因和发布时间。已被订单、报告、考试或阅读确认引用的版本不得物理删除。 + +## 9. 关键状态机与业务约束 + +### 9.1 设备命令 + +```text +pending -> sent -> acknowledged + -> failed + -> timeout +pending/sent -> cancelled(仅服务端允许且设备尚未执行) +``` + +开阀前服务端必须校验账户、设备归属、共享权限、在线状态、未解除高风险事件、传感器状态和安装条件。前端按钮禁用不能替代服务端校验。 + +### 9.2 订单与支付 + +- 订单状态和支付状态分离;订单创建成功不代表支付成功。 +- 支付回调重复到达不得重复扣款、建单或推进履约。 +- 支付超时、客户端退出或渠道返回未知结果时,页面轮询服务端支付状态。 +- 商品、优惠、库存、押金和运费在提交前重新试算,客户端金额仅供展示。 + +### 9.3 押金退款 + +```text +draft -> submitted -> pickup_pending -> inspecting -> reviewing + -> refunded + -> rejected + -> cancelled +``` + +实际退款必须关联原始押金、回收对象、验收结果、扣减明细、审核记录和退款流水。 + +### 9.4 安全事件 + +```text +open -> acknowledged -> handling -> rectified -> recheck_pending -> closed + \-> escalated +``` + +安全事件不可由用户删除。高风险事件关闭前不得通过单设备、群控或定时规则重新开阀。 + +### 9.5 内容与考试 + +- 内容草稿不能进入用户端;发布新版本后按 `must_read` 和展示频率触发宣导。 +- 法规外链跳转前展示域名与风险提示,只允许 HTTPS 和后台白名单域名。 +- 考试尝试绑定题目版本;计时以服务端时间为准,提交操作幂等。 + +## 10. 安全、隐私与异常处理 + +- 用户只能读取本人、本人订单或明确授权家庭成员范围内的数据。 +- 手机号、联系人、配送员、银行卡和证件默认脱敏;拨号使用受控联系能力。 +- 定位只在用户主动选择地址、报修取证或查看本人配送订单时使用。 +- 图片和视频上传校验扩展名、MIME、文件头、大小、完整解码和恶意内容;下载使用短期授权。 +- 日志、埋点、崩溃信息和剪贴板不得记录令牌、支付密码、完整手机号、地址、银行卡和精确定位。 +- 401 及鉴权业务码继续由现有会话层统一清理,业务页面不得重复弹出英文错误。 +- 429 展示稍后重试和剩余等待时间;409 展示服务端当前状态和刷新入口;未知错误使用统一中文文案并保留 `request_id`。 +- 安全通知不可关闭;普通营销通知可按渠道关闭或进入免打扰时段。 + +## 11. 测试与验收 + +### 11.1 Flutter 测试 + +- Repository 测试:正常、空数据、错误码、分页、字段缺失和兼容旧响应。 +- ViewModel 测试:加载、刷新、提交、防重复点击、失败恢复和会话失效。 +- Widget 测试:Tab、筛选、表单校验、键盘、长文本、文本缩放和无障碍语义。 +- Golden 测试:以设计图归一化到 390×844,对首页、四栏主页面、设备详情、内容中心和订单详情做像素比对。 +- 集成测试:登录回跳、首次宣导、设备绑定、关阀/开阀、下单支付、订单配送、报修、退款、充值、提现和消息深链。 + +### 11.2 后端测试 + +- 路由测试覆盖匿名/登录、错误 Client claim、越权对象、归档对象和跨用户访问。 +- 状态机测试覆盖非法跳转、重复请求、并发更新和超时恢复。 +- 幂等测试覆盖订单、支付、退款、充值、提现、退押、报修、设备命令、内容确认和考试提交。 +- 数据测试覆盖金额守恒、押金扣减、钱包流水、库存冻结、命令 Outbox 和审计日志。 +- 上传测试覆盖伪造扩展名、超限文件、损坏媒体、病毒检测失败和过期授权。 + +### 11.3 UI 验收 + +每张设计图至少验证: + +- 页面路由、返回行为和底部导航符合设计层级。 +- 首屏结构、标题、间距、颜色、圆角、图标和主要信息层级与设计一致。 +- 加载、空数据、错误、离线、无权限、禁用和长数据状态均可使用。 +- 金额、单位、时间、更新时间、来源和状态来自真实接口。 +- 危险操作有二次确认、明确阻断原因和最终回执。 +- 320 至 430 宽度、Android、iOS 和 Web 不出现文字截断或控件重叠。 + +### 11.4 必跑命令 + +```bash +cd apps/user_app +flutter analyze +flutter test +flutter build apk --debug +flutter build ios --simulator --no-codesign +flutter build web --release --dart-define=API_BASE_URL=https://api.example.com + +cd backend/api +go test ./... +go vet ./... +go build ./cmd/main +``` + +涉及平台后台时同时执行其类型检查、静态契约检查、单元测试和生产构建。 + +## 12. 分阶段交付 + +### 阶段 A:基础架构与现有功能 UI 对齐 + +- 建立 Feature 目录、强类型 DTO、公共异步状态组件和完整路由骨架。 +- 优化 01、03、11、14、19、20、25、28、29、32、37 至 42 页面。 +- 保持现有 Client API 兼容,补齐详情、分页、地址编辑和支付状态查询。 + +完成标准:现有真实能力全部可用,Release 不出现静态成功功能,核心页面通过 Golden 与回归测试。 + +### 阶段 B:设备安全闭环 + +- 开发 04 至 10、34、35、44 页面。 +- 打通设备绑定、遥测、命令回执、告警、紧急联系人、分组和定时关阀。 +- 完成高风险开阀拦截、自动关阀和审计链路。 + +完成标准:通过 AC-01 至 AC-05、AC-12、AC-15、AC-22 和 AC-25。 + +### 阶段 C:交易、押金与服务履约 + +- 完善 12 至 18、21 至 24、36、48、49 页面。 +- 打通购物车、收藏、气瓶下单、押金、配送详情、轨迹、发票、售后、评价和巡检预约。 + +完成标准:金额、库存、支付、履约、退押、退款和发票均以服务端事实为准,通过 AC-07、AC-11、AC-13、AC-14 和 AC-18。 + +### 阶段 D:内容、消息与增值能力 + +- 完善 02、26、27、30、31、33、43、45、46、47 页面。 +- 打通内容分类、气价、消息、家庭共享、保修、月报和考试后台配置。 + +完成标准:内容版本、阅读确认、通知偏好、授权撤销、报告口径和考试版本可追溯。 + +## 13. 上线与回滚 + +- 新接口、表字段和页面入口使用功能开关按用户、气站或区域灰度。 +- 数据库迁移只新增表或可空字段;扩大枚举时先部署服务端兼容,再部署后台和 App。 +- 旧 API 在至少一个稳定 App 版本周期内保留,禁止先删除再升级客户端。 +- 设备控制、支付、押金和安全事件上线前完成故障演练、审计验证和人工回退流程。 +- 回滚只关闭新入口和新写入,已产生的订单、资金、安全、阅读及审计事实继续可查。 + +## 14. 待确认事项 + +以下事项必须在对应阶段开发前确认: + +1. 气价、押金和配送费的权威来源、适用区域、生效时间及历史版本。 +2. 安全视频是宣教视频还是现场取证视频;两者必须使用不同权限和留存策略。 +3. 法律法规外链白名单、地方适用范围和版本更新责任人。 +4. 智能瓶阀厂商协议、设备证书、离线行为、命令回执和超时语义。 +5. 开阀责任、自动关阀优先级、人工审批和安全事件关闭条件。 +6. 微信、支付宝、余额支付及退款渠道的正式商户配置。 +7. 退瓶验收、押金扣减、退款去向和争议处理规则。 +8. 地图与受控联系供应商、轨迹刷新频率和定位留存周期。 +9. 发票服务商、税务口径、文件授权和红冲流程。 +10. 巡检服务范围、费用、周期、改期和取消规则。 + +未确认事项不得通过前端默认值固化为业务规则。 + +## 15. 核心文件说明 + +| 文件 | 职责 | +| --- | --- | +| `apps/user_app/lib/app/router.dart` | 当前用户端路由、四栏导航和鉴权回跳 | +| `apps/user_app/lib/data/services/api_client.dart` | HTTP、统一响应、业务错误和会话失效 | +| `apps/user_app/lib/data/repositories/client_repository.dart` | 当前用户端 Client API 访问入口 | +| `apps/user_app/lib/domain/models/client_models.dart` | 当前通用记录、用户和钱包模型 | +| `apps/user_app/lib/ui/core/app_theme.dart` | 用户端设计系统主题适配 | +| `apps/heqi_design_system/lib/src/tokens.dart` | 色彩、间距、圆角、尺寸和动效 Token | +| `backend/api/internal/routers/client.go` | 用户端和工作人员端 Client API 路由 | +| `backend/api/internal/logic/client/user/` | 用户端认证、内容、商城、订单、工单和服务归属逻辑 | +| `backend/api/internal/models/` | 现有业务数据模型和迁移注册 | +| `frontend/platform_admin/src/api/resources.ts` | 平台资源字段和内容管理配置 | + +## 16. 维护指南 + +- 新页面先在本文件登记路由、业务对象、接口和验收,再进入开发。 +- 新增接口必须同步请求/响应示例、错误码、鉴权范围和幂等要求。 +- 新增状态值必须同时更新数据库 COMMENT、Go 常量、Flutter 枚举、后台中文映射和测试。 +- 设计修改后只更新受影响页面及关联组件,不无关重构其他模块。 +- 完成一个阶段后更新本文档版本、当前实现状态、测试结果和已知问题。 + +## 17. 变更记录 + +| 版本 | 日期 | 变更内容 | +| --- | --- | --- | +| v1.1 | 2026-09-07 | A1 实现、兼容接口、远程联调及未完成视觉项见第 18 节 | +| v1.0 | 2026-09-06 | 根据 58 张最新参考设计图建立全量开发范围、路由、UI 规范、Client API、数据模型、测试和分阶段交付计划 | + +## 18. A1 实施增补(2026-09-07) + +本节记录当前代码事实;第 2 至 12 节其余规划不表示已经实现。v1.0 原文件保留作为归档。本版本为 v1.1,逐页状态以 [开发进度](开发进度_用户端APP全量功能开发.md) 为准。 + +### 18.1 已落地结构 + +```text +apps/user_app/lib/ +├── data/repositories/primary_repository.dart # 公开内容、服务归属与商品强类型适配 +├── domain/models/primary_models.dart # 内容、商品、归属摘要 +├── domain/models/order_summary.dart # 订单金额、快照与服务端可用动作 +├── ui/core/async_content.dart # 首次加载、保留旧数据刷新、错误与重试 +├── ui/core/feature_entry.dart # 明确保留未开放业务入口 +├── ui/core/text_entry_dialog.dart # 弹窗独立管理控制器与空输入校验 +├── ui/features/auth/login_support.dart # 已发布协议查看、现有密码重置流程 +└── ui/features/orders/order_list.dart # 订单筛选与摘要展示 +backend/api/internal/logic/client/user/ +├── list_response.go # 可选分页与旧数组兼容 +└── login_consent.go # 同意版本校验与已有阅读表幂等留痕 +``` + +沿用 Flutter、Go/Gin/GORM、PostgreSQL、Redis 及已有平台资源。开发环境使用仓库配置的远程数据库和缓存;本机运行 API 与 Web 预览。没有新增数据库表、移动插件或第三方依赖。共享包版本 0.1.1。 + +### 18.2 A1 增量接口契约 + +根路径:/heqi/client/v1/user。响应继续使用 code、message、details、timeseq;公开资源剔除内部 id。 + +| 接口 | 增量 | 兼容与安全 | +| --- | --- | --- | +| GET public/products | category_identity、category_name、image_url;可选 page/page_size | 无分页参数仍返回数组;只关联商品图片及分类,修复原先误查商城订单明细的问题 | +| GET shop/orders、GET gas/orders | status_code、status_name、allowed_actions、items;可选分页 | 只查本人订单;金额为整数分;最终动作仍由写接口重新校验 | +| POST auth/login | 可选 consents 数组:identity、version、shown_at(RFC3339) | 旧客户端省略仍可登录;新客户端在显式确认后提交;只接受当前已发布 agreement 版本 | +| 原有验证码、密码重置、下单、退款、支付、地址与工单接口 | 路由及请求字段保持兼容 | A1 继续复用;完整详情及资金状态查询属于 A2 | + +分页范围:page 1–100000,page_size 1–100;传分页参数时 details 为 {items,page,page_size,has_more};多查一条判断后续页。不支持 total。非法参数返回既有 ErrInvalidArgument(1704)。 + +新客户端兼容层先读完各页再筛选;大数据量优化留给 A2。未提供 allowed_actions 的旧响应不自行推导可支付/退款。服务端当前动作仅表达候选能力,业务写入仍进行最终状态、支付和退款条件校验。 + +consents 在同一数据库事务内锁定已发布版本,并通过 cms_content_read 的(user_account_id,cms_content_id,version_no)唯一键去重。shown_at 保留客户端展示时间,confirmed_at 由服务端记录。版本失效、缺少展示时间或非法内容类型返回 1704。本次未新增法律条款或伪造已发布协议。 + +### 18.3 业务与视觉冲突记录 + +1. 未开放设备、安全、押金、消息等入口不隐藏,点击解释当前不可办理;不显示样例电量、押金、认证状态或通知数。 +2. 最新商城网格优先于旧 Design System 文档的商品 Row;共用主题 API 不变,服务端 App 回归验证。 +3. 订单按最新设计调整为气瓶订单、商城订单、报修工单三个主 Tab;原退款列表从订单筛选区的“退款/售后”打开。原订单/退款接口保留,旧构造参数映射兼容,退款后重新打开列表读取最新服务端状态。 +4. 商城单品与购物车已使用独立提交订单页,可选择地址、读取气站和钱包、编辑备注,并在创建成功后进入支付确认页。预约、优惠、押金缺服务端能力,界面显示“暂未开放”;电子发票属于二期,显示“即将开放”。 +5. 供气订单详情按最新设计重排配送状态、地址、商品、费用、订单信息和服务操作。合同正文及签收继续使用既有真实接口;配送轨迹、受控电话与押金属于首期缺口,申请售后属于二期,页面分别标识。 +6. 商品详情读取后台商品图片和属性,按最新设计补齐商品信息、规格、配送、保障、说明与底部操作分组。分享复制当前深链;后台未配置规格或参数时如实显示,配送预约和服务标准属于首期缺口。 +5. 37 邀请注册补入 A2,原 /register 不删除。 +6. 用户明确使用远程 PostgreSQL、Redis,覆盖原任务“本地 Mock”措辞造成的歧义。地址管理已定向增加三个字段;未执行全库迁移、清库或导入 seed。 + +### 18.4 验证与维护 + +查看 [操作日志](操作日志_用户端APP_A1_20260907.md) 与 [视觉记录](视觉验收/A1/视觉验收记录_A1.md)。A1 五页为部分实现,不等于 58 页全量完成或 A1 严格视觉验收通过。 + +新增页面继续在领域适配层解析兼容 raw,页面不可读 raw 金额、权限和状态。列表异常不得转为空成功;刷新失败保留上次内容并提示。提交结果未知时保留原请求号,完整跨重启恢复归 A2。 + +截图使用 test/support/a1_fixture.dart 的明确测试数据;运行入口 lib 不导入 Fixture。视觉脚本在 Windows 读取微软雅黑,字体缺失时需提供等价中文字体后重跑,不能用空方块截图验收。 + +本地预览:http://127.0.0.1:18571;API:http://127.0.0.1:12426。开发服务运行于本机,但持久事实仍在原远程数据库。凭据仅从既有开发配置读取,不复制到本文件。 + +2026-09-08支付密码扩展:设置页新增`/settings/payment-password`,复用本人钱包与验证码接口,支持六位数字密码首次设置、旧密码修改及验证码找回。后端采用散列条件更新、Redis原子验证码消费和五次输错锁定;远程独立夹具回滚验证通过,Flutter全量117项测试通过。真实短信尚缺供应商配置,不能将Mock收码流程算生产完成。接口字段、维护方式和验证边界见[支付密码操作日志](操作日志_用户端APP_支付密码_20260908.md)。 + +2026-09-12图26扩展:个人中心新增`/records`“我的记录”入口,聚合本人供气订单和报修工单,支持本月概览、类型筛选和详情跳转。设备操作与告警记录缺首期权威接口,保留入口并提示“暂未开放”;发票记录属二期,提示“即将开放”。详见[开发日志](开发日志_我的记录聚合页_20260912.md)。 diff --git a/docs/验收清单_用户端58页功能与视觉_20260911.md b/docs/验收清单_用户端58页功能与视觉_20260911.md new file mode 100644 index 0000000..4ebda82 --- /dev/null +++ b/docs/验收清单_用户端58页功能与视觉_20260911.md @@ -0,0 +1,85 @@ +# 用户端58页功能与视觉核对 + +本次按用户要求在可见内置浏览器操作,视口390×844,使用已授权测试账号。设计源为 `doc/用户端APP-最新参考产品设计`,核对当前路由、页面代码、既有并排图及实际运行画面。 + +## 结论与证据边界 + +58张设计,36张已有页面实现(其中34张部分实现、2张首期功能闭环),22张尚无对应独立完整页面,0张通过全功能及严格1:1验收。部分实现不等于完成;没有页面的22张无需伪造截图,直接判未实现、不能视觉验收。 + +本次浏览器实际操作:密码登录→充值→充值记录;首页→商城→商品详情→购物车→结算;个人中心→个人资料→头像来源菜单;设置、地址、合同、报修、订单中心→供气详情;支付确认页使用真实已取消商城订单做只读展示,并点击优惠券查看“暂未开放”弹层;收藏、钱包、提现、银行卡和报修工单详情。当前账号无待付款订单,没有提交订单、支付、提现、银行卡写入、修改资料或取消工单。头像本次只验证来源菜单,上传保存成功证据来自此前真实联调。 + +视觉结论结合本次浏览器390视口和既有并排图。既有并排图右侧是Fixture测试数据,不应冒充本次真实浏览器截图;本次并未为全部页面重新制作逐像素对照。下列“不同”表示已有明确差异,不表示剩余细节已全部穷尽。 + +## 已有页面实现的32页 + +| 设计页 | 已实现功能点 | 未完成或未验证 | 视觉核对 | +| --- | --- | --- | --- | +| 01 登录页 | 密码登录、模式切换、安全回跳、协议、忘记密码和注册入口;Mock短信未发送明确提示暂未开放 | 真实短信供应商及验证码完整交付 | 共同区域已按官方390×844等比并排图收敛;保留的注册入口不在原稿中,整页仍blocked | +| 03 首页 | 服务归属、公告标题/摘要/日期、查看全部、设备列表、设备分组、报修与登录回跳 | 设备在线/电量/遥测、扫码、蓝牙、告警与气瓶详情 | 共同结构已按官方等比并排图收敛;未接入设备区以真实“暂不可查询”替代设计示例值,因此整页仍blocked | +| 05 一键报修 | 三步表单、照片、地址、草稿、幂等提交、紧急拨号 | 原生语音与拍照定位真机验收 | 接近:结构和间距已重校;验收图保持真实0/3,未伪造原稿1/3照片状态 | +| 08 设备分组控制 | 分组新增、改名、删除、设备归组及删除后回到未分组 | 群控、安全检查、真实在线状态 | 不同:当前远程空态,无法与原稿3组同状态比较;共同区域已生成并排证据 | +| 10 紧急联系人 | 本人联系人新增、编辑、归档删除、五人上限及手机号脱敏 | 告警联动、设备授权、通知顺序和后台治理 | 不同:权限状态与设计示例不同,布局仍未严格通过 | +| 11 燃气商城 | 商品、分类、搜索、详情、收藏/加购入口、后台商品图上传后显示 | 其余商品正式素材、完整服务承诺 | 不同:仅已配置图片的商品显示远程图片,其余接口图片为空;分类仍为示例分类,卡片密度与图标不同 | +| 12 商品详情 | 后台商品图、图片预览、名称分类、价格库存、数量、规格配置状态、服务归属、分享、收藏、加购与结算 | 规格组合及商品参数需后台配置;预约送达、配送前联系和四项服务标准属于首期缺口并显示“暂未开放” | 接近:图片、商品信息、规格、配送、四项保障、说明和底部操作已按390×844原稿重排;真实商品10显示后台上传图片,但上传内容本身是商城截图而非正式商品素材,不能判严格1:1 | +| 13 购物车 | 数量、勾选、删除、真实气站归属、推荐、合计、多商品结算、配送与保障状态入口 | 押金、优惠、配送时效与保障规则仍显示“暂未开放”;原购物车商品缺后台封面和规格 | 接近:配送卡、商品卡、保障区、推荐和固定结算栏已按原稿重排;真实数据缺口使严格1:1未通过 | +| 14 提交订单 | 地址选择、气站与真实商品图、单品/购物车数量、费用、备注、钱包余额、幂等提交及成功后进入支付确认页 | 预约、优惠、押金仍缺服务端能力并显示“暂未开放”;电子发票属二期并显示“即将开放”;旧地址联系人需补全 | 接近:地址、配送、商品、费用、订单服务、支付和固定提交栏已按原稿重排;真实订单金额不伪造原稿押金和优惠,严格1:1仍未通过 | +| 15 我的收藏 | 真实收藏、分类计数与筛选、详情、取消收藏、下架保留、条件加购、真实推荐 | 月销量统计显示“暂未开放”;部分商品缺正式素材和规格 | 接近:顶部、筛选、商品卡及推荐入口已重排;真实数据与设计样例不同,严格1:1未通过 | +| 19 支付确认 | 商城/气瓶真实订单金额、钱包余额、余额密码支付、微信/支付宝配置就绪状态、二次确认、幂等扣款;优惠券明确暂未开放;气瓶位图及气费/押金明细 | 优惠券接口、外部通道真实商户配置与回调验收;当前账号无待付款订单 | 对照图的主要分组、图片、行高和底部操作已收敛;内置浏览器验证真实取消订单、渠道禁用和优惠券弹层,不能替代待付款资金全流程验收 | +| 20 订单中心 | 气瓶/商城/报修三标签、订单号与商品搜索、固定状态筛选、真实卡片字段和详情;气瓶/商城取消、商城确认收货及既有支付退款动作 | 配送联系与轨迹;再次购买、申请售后、电子发票属于后续版本并保留“即将开放”提示 | 接近:页头、标签、筛选、卡片和底部操作已二次对齐;远程仅1笔气瓶订单且气瓶商品模型无图片字段,不能伪造原稿3笔订单和钢瓶图,仍未通过严格1:1 | +| 21 订单详情 | 配送状态、地址、气站商品、费用、支付方式、合同编号与正文、四项服务操作、签收接口 | 押金、配送详情/轨迹、受控联系属于首期缺口并显示“暂未开放”;申请售后属二期并显示“即将开放” | 接近:主要卡片、时间轴、四宫格及底部操作已按390×844原稿压缩对齐;远程气瓶商品无图片字段,且示例完成时间早于下单时间,均未伪造修正 | +| 25 个人中心 | 真实资料/头像入口/实名认证、余额、紧急联系人数量、气瓶/商城/报修/设备、合同/地址/收藏入口;其余入口不隐藏并明确暂未开放 | 可退押金、优惠券、家庭共享、用气统计、发票、客服、关于、消息中心的真实业务 | 接近:标题、资料、资产、四宫格和两组列表密度已二次对齐并完整落入390×844;当前远程账号未上传头像且无联系人,按真实首字头像和0人显示,资产缺项不伪造设计示例值 | +| 26 我的记录 | 聚合本人供气订单和报修工单,本月数量、六类入口、最近记录、类型筛选及详情跳转可用 | 设备操作和告警记录缺首期接口,显示“暂未开放”;发票记录属二期,显示“即将开放” | 接近:页头、本月概览、六宫格、最近记录已按390×844原稿排列;远程真实数据与原稿示例数量不同,且缺安全、设备、押金和发票记录,不判严格1:1 | +| 28 我的钱包 | 余额、隐私开关、账单分页筛选、充值、提现、银行卡及支付密码入口 | 押金/优惠券/待退款资产 | 接近:结构、分组和真实入口已对齐;缺失资产显示暂未开放且不伪造金额 | +| 39 余额提现 | 真实可提余额、到账卡、金额校验、支付密码、提现记录、幂等申请和二次确认 | 银行外部到账回执 | 接近:全部设计分组均有真实组件;当前数据和图标与示例不同,严格1:1未通过 | +| 40 银行卡管理 | 脱敏列表、添加、默认到账卡、安全解绑 | 银行渠道验证回执、短信供应商 | 接近:卡片、资金安全和须知已实现;真实账户卡数与设计样例不同 | +| 29 地址管理 | 增删改、独立联系人、默认切换、下单选择 | 定位与配送范围判定 | 不同:实际旧地址缺联系人,显示待补充;间距与状态不同 | +| 31 设置 | 登录/支付密码、协议、版本、缓存、退出 | 真实短信、登录设备、通知偏好、权限、注销 | 不同:多处未开放/未接入,完整交互缺失 | +| 32 供气合同 | 列表、筛选搜索、正文、受控PDF、变更记录/申请 | 签署、地址快照、续签提醒、原生保存验收 | 不同:卡片密度与按钮排列,完整原稿字段未齐 | +| 34 我的设备 | 本人设备、名称/编号搜索、角阀/报警器筛选、分组和资料查看 | 遥测、扫码绑定、状态筛选和远程控制 | 当前远程空态;共同区域的页头、筛选、统计卡和底部操作已并排核对 | +| 38 余额充值 | 金额、自定义/清空、记录、请求恢复、协议确认代码 | 微信支付宝实查均不可用,协议未发布,最新留痕未部署联调 | 明显不同:无原稿分组卡片和钱包插图、渠道图标不同;限额实际0.01–1000000元,与稿10–5000元不同,不能擅改业务配置 | +| 41 个人资料 | 头像选择/上传保存、昵称、手机号与服务归属读取 | 性别、生日、实名认证等 | 不同:头像框、字段展示及分组;本次拍照/相册菜单可打开 | +| 42 报修工单详情 | 本人详情、照片、取消/完成接口 | 工程师联系、完整进度、改期、补充资料 | 不同:字段密度与进度结构;示例工单缺描述、地址和预约 | +| 43 安全内容中心 | 已发布公告、安全宣传、法律法规分类与标题搜索;视频入口提示暂未开放 | 服务端分页、正式内容、配图和完整分类元数据 | 不同:当前为通用列表,缺原稿摘要、已读、时间和图片 | +| 43-4 平台公告 | 本人可见的已发布公告列表和独立正文 | 置顶、摘要、已读状态、发布时间及配图 | 已在内置浏览器读取远程公告正文,尚未严格1:1 | + +## 尚无对应独立完整页面的25张 + +以下每行均为未完成,无法判为与设计稿一致;旧接口或通用列表存在不计独立页完成。 + +| 设计页 | 状态 | +| --- | --- | +| 02-1 安全案例 | 未完成 | +| 02-2 法律法规 | 未完成 | +| 02-3 气价信息 | 未完成 | +| 02 智能角阀功能介绍 | 未完成 | +| 04 智能角阀控制 | 未完成 | +| 06-1 扫码添加确认 | 未完成 | +| 06-2 蓝牙连接设备 | 未完成 | +| 06-3 手动输入设备码 | 未完成 | +| 07 气瓶基本信息 | 未完成 | +| 09 安全告警与报警器 | 未完成 | +| 16 押金管理 | 已接真实押金汇总、状态分类、明细、规则及后台规则/记录入口;图17已补齐退瓶闭环 | 远程账号当前无押金和规则,按真实空态显示 | 接近:汇总、标签、记录卡和规则区已按390×844原稿对齐;真实空数据无法复制原稿示例金额,不判严格1:1 | +| 17 退瓶退押金 | 选择本人可退气瓶、上门地址、预约、瓶况确认、规则确认、幂等提交与取消;后台确认回收、验收扣减、退入余额和资金流水 | 最近安检结论尚无独立权威接口,当前明确显示“暂未开放” | 接近:三步条、气瓶、回收、预约、确认、退款和底部提交层级已按原稿压缩对齐;气瓶图为图标且真实空数据无法进入表单,不判严格1:1 | +| 18 气瓶下单 | 已实现:读取本人有效供气合同、气站、未占用合同气瓶、服务端单价、押金规则、地址和预约时段;数量受实体气瓶数量限制,二次确认后幂等创建待支付订单;取消释放占用,支付成功生成正式押金事实 | 远程账号对应规格尚未配置押金规则,真实页面显示“押金规则暂未配置”且禁用提交,不伪造金额 | 接近:气站卡、三规格列表、真实钢瓶图、数量器、配送、押金、费用和底部按钮均按原稿对齐;Fixture 390×844首屏完整显示,8组宽度/字体倍率无溢出 | +| 22 配送详情 | 部分实现:本人订单的配送状态、预约、配送人员、有效资质、配送点、商品、地址和安全提示已接真实接口;车辆管理、员工头像受控资源、电话和消息服务仍标“暂未开放” | 远程样例订单可读取,未改写远程数据 | 接近:页面分组、信息层级、真实钢瓶图和底部操作已对照;车辆与人员资料不足,不能判严格1:1 | +| 23 配送轨迹 | 部分实现:只读本人订单,服务端将定位点约化到三位小数;地图路线、履约时间线、配送员、刷新和缺项提示可用 | 远程样例只有1个位置点,因此真实页只显示单点和完成节点;未伪造路线 | 接近:地图、状态卡、纵向时间线、人员、安全提示和底部按钮已对照;真实单点状态与设计稿配送中多节点状态不同,不判严格1:1 | +| 24 电子发票 | 二期入口已实现:从本人已完成订单进入,展示真实订单号、商品和订单商品金额;抬头、税号、接收方式和提交均标“即将开放”,不生成开票记录 | 远程已完成气瓶订单只读验证,点击提交仅显示二期说明 | 接近:订单卡、类型、抬头表单、金额、常用抬头、说明和底部按钮已按原稿排列;二期数据为空,不伪造企业抬头,不能判严格1:1 | +| 27 用气统计 | 部分实现:统计页面、周期切换、设备选择、明细及后台计量记录管理已实现。2026-09-12内置浏览器验证后台列表和新建表单、App无设备空态;历史日期和安全趋势暂未开放,真实计量闭环及严格1:1待验收。见开发日志_用气统计联调恢复_20260912.md | +| 30 消息中心 | 部分实现:真实订单、工单和公告聚合,四类总览、筛选、单条/全部已读及对象跳转已完成;远程浏览器验证单条已读后订单未读6→5。安全事件、通知偏好、不可变历史消息及严格1:1待补。见开发日志_消息中心首期闭环_20260912.md | +| 33 家庭成员与设备共享 | 已建立独立页面与真实接口:手机号邀请、受邀账号接受/拒绝、撤回、移除、按设备查看/告警/控制授权和操作审计;远程账号无设备及第二登录账号,本次在内置浏览器完成真实邀请与撤回,接受和设备授权执行仍待双账号及真实设备联调;Fixture与原稿同状态并排图已生成,成员头像未接受控资源,严格1:1未通过 | +| 35 安全记录详情 | 未完成 | +| 36 申请售后 | 未完成 | +| 37 邀请注册 | 旧注册页存在,设计中的邀请流程未完成 | +| 43-1 安全宣传 | 未完成 | +| 43-2 安全视频 | 未完成 | +| 43-3 法律法规 | 未完成 | +| 44 自动关阀设置 | 未完成 | +| 45 电子保修卡 | 未完成 | +| 46 设备健康月报 | 未完成 | +| 47 安全知识考试 | 未完成 | +| 48 服务评价 | 未完成 | +| 49 预约安全巡检 | 未完成 | + +## 修复顺序 + +先补现有26页的缺失业务与真实素材,同时按原图统一卡片、间距、图标、字体和底栏;充值需完成真实配置与协议联调,不能把禁用按钮算可用。随后按支付资金、配送售后、设备与安全、账户内容模块补齐32页。每页都需浏览器操作与相同尺寸对照,测试通过只作为功能证据之一。 diff --git a/frontend/delivery_admin/src/api/resources.ts b/frontend/delivery_admin/src/api/resources.ts index 57336c5..500206f 100644 --- a/frontend/delivery_admin/src/api/resources.ts +++ b/frontend/delivery_admin/src/api/resources.ts @@ -264,6 +264,7 @@ const fieldLabels: Record = { subject_identity: '结算主体唯一标识', user_account_identity: '用户唯一标识', user_address_identity: '用户地址唯一标识', + deposit_amount: '气瓶押金(分)', wallet_bank_identity: '银行卡唯一标识', wallet_basic_identity: '钱包唯一标识', warehouse_identity: '库房唯一标识', diff --git a/frontend/gas_admin/src/api/resources.ts b/frontend/gas_admin/src/api/resources.ts index 3260d5d..8393898 100644 --- a/frontend/gas_admin/src/api/resources.ts +++ b/frontend/gas_admin/src/api/resources.ts @@ -163,6 +163,7 @@ const fieldLabels: Record = { repair_no: '检修单号', repair_type: '检修类型', appointment_at: '预约时间', + deposit_amount: '气瓶押金(分)', started_at: '开始时间', completed_at: '完成时间', result: '检修结果', @@ -413,6 +414,7 @@ const ticketCategoryField = f('category', { { label: '安检', value: 'inspection' }, { label: '复检', value: 'reinspection' }, { label: '客服咨询', value: 'customer_service' }, + { label: '合同变更', value: 'contract_change' }, ], }); @@ -624,7 +626,9 @@ const platformResources: ResourceUiDefinition[] = [ define('fin_settlement', '财务结算', 'writable', [f('settlement_no', { required: true }), f('subject_type', { required: true }), f('subject_identity', { required: true }), f('period_start', { required: true }), f('period_end', { required: true })]), define('fin_reconciliation', '财务对账', 'readonly', []), define('cms_content', '内容', 'writable', [f('content_type', { required: true }), f('title', { required: true }), f('body', { required: true }), f('version_no'), f('publish_status')]), - define('cs_ticket', '客服工单', 'writable', ticketFields), + define('cs_ticket', '客服工单', 'writable', ticketFields, 'list', [ + { name: '答复合同申请', resource: '/cs_ticket/:identity/resolve-contract-request', fields: [f('result', { label: '处理结果', required: true })], visibleFor: { field: 'contract_request_open', values: [1] } }, + ]), define('platform_account', '平台账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), f('avatar'), f('platform_role_code', { required: true }), f('phone')]), define('platform_role', '平台角色', 'writable', [f('role_code', { required: true }), f('name', { required: true }), f('location_scope', { required: true, type: 'select', options: [{ label: '脱敏坐标', value: 'standard' }, { label: '精确坐标', value: 'precise' }] })], 'list', [ { name: '分配菜单', resource: '/platform_role/:identity/menu', method: 'PUT', fields: [f('menu_identities', { type: 'identity-list', relation: '/platform_menu' })] }, @@ -671,7 +675,9 @@ const gasOverrides: ResourceUiDefinition[] = [ define('wallet_apply_cash', '提现申请', 'append_only', [relation('wallet_bank_identity', '/wallet_bank'), f('request_no', { required: true }), f('amount', { required: true }), withdrawalChannelField, f('remark')]), define('fin_settlement', '财务结算', 'readonly', []), define('fin_reconciliation', '财务对账', 'readonly', []), - define('cs_ticket', '客服工单', 'writable', ticketFields), + define('cs_ticket', '客服工单', 'writable', ticketFields, 'list', [ + { name: '答复合同申请', resource: '/cs_ticket/:identity/resolve-contract-request', fields: [f('result', { label: '处理结果', required: true })], visibleFor: { field: 'contract_request_open', values: [1] } }, + ]), ]; const gasResourceNames = new Set(gasOverrides.map((item) => item.name)); diff --git a/frontend/gas_admin/src/contracts/gas-resources.json b/frontend/gas_admin/src/contracts/gas-resources.json index 69dd186..fdd7afa 100644 --- a/frontend/gas_admin/src/contracts/gas-resources.json +++ b/frontend/gas_admin/src/contracts/gas-resources.json @@ -1 +1 @@ -{"resources":[{"domain":"delivery","name":"delivery_basic","path":"/delivery_basic","pageKind":"list","mode":"writable","searchFields":[{"key":"delivery_code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"delivery","name":"delivery_account","path":"/delivery_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"},{"key":"display_name","kind":"text"},{"key":"role_code","kind":"enum","values":[{"value":"admin","label":"配送点管理员"}]}]},{"domain":"staff","name":"staff_account","path":"/staff_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"},{"key":"role_code","kind":"enum","values":[{"value":"installer","label":"安装人员"},{"value":"delivery","label":"配送人员"},{"value":"operations","label":"运维人员"}]}]},{"domain":"staff","name":"staff_credential","path":"/staff_credential","pageKind":"list","mode":"writable","searchFields":[{"key":"credential_type","kind":"text"}]},{"domain":"user","name":"user_account","path":"/user_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"}]},{"domain":"user","name":"user_address","path":"/user_address","pageKind":"list","mode":"writable"},{"domain":"contract","name":"gasorder_contract","path":"/gasorder_contract","pageKind":"list","mode":"managed","searchFields":[{"key":"contract_no","kind":"text"},{"key":"title","kind":"text"}]},{"domain":"contract","name":"gasorder_contract_product","path":"/gasorder_contract_product","pageKind":"list","mode":"append_only"},{"domain":"contract","name":"gasorder_contract_revision","path":"/gasorder_contract_revision","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_basic","path":"/gasorder_basic","pageKind":"list","mode":"append_only","searchFields":[{"key":"request_no","kind":"text"},{"key":"creator_type","kind":"enum","values":[{"value":"user","label":"用户"},{"value":"staff","label":"工作人员"},{"value":"delivery","label":"配送站"},{"value":"gas","label":"气站"}]}]},{"domain":"contract","name":"product_info","path":"/product_info","pageKind":"list","mode":"readonly","searchFields":[{"key":"code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"finance","name":"wallet_basic","path":"/wallet_basic","pageKind":"list","mode":"readonly","searchFields":[{"key":"owner_type","kind":"text"}]},{"domain":"finance","name":"wallet_bank","path":"/wallet_bank","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"payment_order","path":"/payment_order","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_record","path":"/wallet_record","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"payment_refund","path":"/payment_refund","pageKind":"list","mode":"readonly","searchFields":[{"key":"refund_no","kind":"text"}]},{"domain":"finance","name":"wallet_apply_cash","path":"/wallet_apply_cash","pageKind":"list","mode":"append_only","searchFields":[{"key":"cash_no","kind":"text"}]},{"domain":"finance","name":"fin_settlement","path":"/fin_settlement","pageKind":"list","mode":"readonly","searchFields":[{"key":"settlement_no","kind":"text"},{"key":"subject_type","kind":"text"}]},{"domain":"finance","name":"fin_reconciliation","path":"/fin_reconciliation","pageKind":"list","mode":"readonly"},{"domain":"ticket","name":"cs_ticket","path":"/cs_ticket","pageKind":"list","mode":"writable","searchFields":[{"key":"ticket_no","kind":"text"},{"key":"category","kind":"text"},{"key":"priority","kind":"text"}]}],"routes":[{"method":"POST","path":"/gasorder_contract"},{"method":"POST","path":"/gasorder_contract/attachment/upload"},{"method":"POST","path":"/gasorder_contract/attachment/cleanup"},{"method":"POST","path":"/gasorder_contract/:identity/activate"},{"method":"POST","path":"/gasorder_contract/:identity/renew"},{"method":"POST","path":"/gasorder_contract/:identity/terminate"},{"method":"POST","path":"/gasorder_contract_product"},{"method":"POST","path":"/gasorder_contract_product/:identity/unbind"},{"method":"POST","path":"/gasorder_basic"},{"method":"POST","path":"/gasorder_basic/:identity/ready"},{"method":"POST","path":"/gasorder_basic/:identity/recover"},{"method":"POST","path":"/gasorder_basic/:identity/assign"},{"method":"POST","path":"/gasorder_basic/:identity/filling"},{"method":"POST","path":"/gasorder_basic/:identity/exception"},{"method":"POST","path":"/gasorder_basic/:identity/cancel"},{"method":"POST","path":"/delivery_basic"},{"method":"POST","path":"/delivery_account"},{"method":"POST","path":"/staff_account"},{"method":"POST","path":"/staff_credential"},{"method":"POST","path":"/user_account"},{"method":"POST","path":"/user_address"},{"method":"POST","path":"/auth/login"},{"method":"POST","path":"/wallet_apply_cash"},{"method":"POST","path":"/cs_ticket"},{"method":"GET","path":"/gasorder_contract"},{"method":"GET","path":"/gasorder_contract_product"},{"method":"GET","path":"/gasorder_contract_product/:identity"},{"method":"GET","path":"/gasorder_contract_revision"},{"method":"GET","path":"/gasorder_contract_revision/:identity"},{"method":"GET","path":"/gasorder_contract/:identity"},{"method":"GET","path":"/gasorder_contract/:identity/attachment"},{"method":"GET","path":"/gasorder_basic"},{"method":"GET","path":"/gasorder_basic/:identity"},{"method":"GET","path":"/gas_menu"},{"method":"GET","path":"/wallet_basic"},{"method":"GET","path":"/wallet_basic/:identity"},{"method":"GET","path":"/wallet_bank"},{"method":"GET","path":"/wallet_bank/:identity"},{"method":"GET","path":"/wallet_record"},{"method":"GET","path":"/wallet_record/:identity"},{"method":"GET","path":"/wallet_apply_cash"},{"method":"GET","path":"/wallet_apply_cash/:identity"},{"method":"GET","path":"/delivery_basic"},{"method":"GET","path":"/delivery_basic/:identity"},{"method":"GET","path":"/delivery_account"},{"method":"GET","path":"/delivery_account/:identity"},{"method":"GET","path":"/dashboard/overview"},{"method":"GET","path":"/dashboard/reports"},{"method":"GET","path":"/payment_order"},{"method":"GET","path":"/payment_order/:identity"},{"method":"GET","path":"/payment_refund"},{"method":"GET","path":"/payment_refund/:identity"},{"method":"GET","path":"/product_info"},{"method":"GET","path":"/product_info/:identity"},{"method":"GET","path":"/staff_account"},{"method":"GET","path":"/staff_account/:identity"},{"method":"GET","path":"/staff_account/:identity/avatar"},{"method":"GET","path":"/staff_credential"},{"method":"GET","path":"/staff_credential/:identity"},{"method":"GET","path":"/user_account"},{"method":"GET","path":"/user_account/:identity"},{"method":"GET","path":"/user_account/:identity/avatar"},{"method":"GET","path":"/user_address"},{"method":"GET","path":"/user_address/:identity"},{"method":"GET","path":"/fin_settlement"},{"method":"GET","path":"/fin_settlement/:identity"},{"method":"GET","path":"/fin_reconciliation"},{"method":"GET","path":"/fin_reconciliation/:identity"},{"method":"GET","path":"/cs_ticket"},{"method":"GET","path":"/cs_ticket/:identity"},{"method":"GET","path":"/auth/profile"},{"method":"GET","path":"/invitation/qrcode"},{"method":"PUT","path":"/delivery_account/:identity"},{"method":"PUT","path":"/delivery_account/:identity/password"},{"method":"PUT","path":"/delivery_basic/:identity"},{"method":"PUT","path":"/staff_account/:identity"},{"method":"PUT","path":"/staff_account/:identity/password"},{"method":"PUT","path":"/staff_credential/:identity"},{"method":"PUT","path":"/user_account/:identity"},{"method":"PUT","path":"/user_account/:identity/password"},{"method":"PUT","path":"/user_address/:identity"},{"method":"PUT","path":"/auth/password"},{"method":"PUT","path":"/gasorder_contract/:identity"},{"method":"PUT","path":"/cs_ticket/:identity"},{"method":"PATCH","path":"/delivery_basic/:identity/status"},{"method":"PATCH","path":"/delivery_account/:identity/status"},{"method":"PATCH","path":"/staff_account/:identity/status"},{"method":"PATCH","path":"/staff_credential/:identity/status"},{"method":"PATCH","path":"/user_account/:identity/status"},{"method":"PATCH","path":"/user_address/:identity/status"},{"method":"PATCH","path":"/cs_ticket/:identity/status"},{"method":"DELETE","path":"/delivery_basic/:identity"},{"method":"DELETE","path":"/delivery_account/:identity"},{"method":"DELETE","path":"/staff_account/:identity"},{"method":"DELETE","path":"/staff_credential/:identity"},{"method":"DELETE","path":"/user_account/:identity"},{"method":"DELETE","path":"/user_address/:identity"},{"method":"DELETE","path":"/cs_ticket/:identity"}]} +{"resources":[{"domain":"delivery","name":"delivery_basic","path":"/delivery_basic","pageKind":"list","mode":"writable","searchFields":[{"key":"delivery_code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"delivery","name":"delivery_account","path":"/delivery_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"},{"key":"display_name","kind":"text"},{"key":"role_code","kind":"enum","values":[{"value":"admin","label":"配送点管理员"}]}]},{"domain":"staff","name":"staff_account","path":"/staff_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"},{"key":"role_code","kind":"enum","values":[{"value":"installer","label":"安装人员"},{"value":"delivery","label":"配送人员"},{"value":"operations","label":"运维人员"}]}]},{"domain":"staff","name":"staff_credential","path":"/staff_credential","pageKind":"list","mode":"writable","searchFields":[{"key":"credential_type","kind":"text"}]},{"domain":"user","name":"user_account","path":"/user_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"}]},{"domain":"user","name":"user_address","path":"/user_address","pageKind":"list","mode":"writable"},{"domain":"contract","name":"gasorder_contract","path":"/gasorder_contract","pageKind":"list","mode":"managed","searchFields":[{"key":"contract_no","kind":"text"},{"key":"title","kind":"text"}]},{"domain":"contract","name":"gasorder_contract_product","path":"/gasorder_contract_product","pageKind":"list","mode":"append_only"},{"domain":"contract","name":"gasorder_contract_revision","path":"/gasorder_contract_revision","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_basic","path":"/gasorder_basic","pageKind":"list","mode":"append_only","searchFields":[{"key":"request_no","kind":"text"},{"key":"creator_type","kind":"enum","values":[{"value":"user","label":"用户"},{"value":"staff","label":"工作人员"},{"value":"delivery","label":"配送站"},{"value":"gas","label":"气站"}]}]},{"domain":"contract","name":"product_info","path":"/product_info","pageKind":"list","mode":"readonly","searchFields":[{"key":"code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"finance","name":"wallet_basic","path":"/wallet_basic","pageKind":"list","mode":"readonly","searchFields":[{"key":"owner_type","kind":"text"}]},{"domain":"finance","name":"wallet_bank","path":"/wallet_bank","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"payment_order","path":"/payment_order","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_record","path":"/wallet_record","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"payment_refund","path":"/payment_refund","pageKind":"list","mode":"readonly","searchFields":[{"key":"refund_no","kind":"text"}]},{"domain":"finance","name":"wallet_apply_cash","path":"/wallet_apply_cash","pageKind":"list","mode":"append_only","searchFields":[{"key":"cash_no","kind":"text"}]},{"domain":"finance","name":"fin_settlement","path":"/fin_settlement","pageKind":"list","mode":"readonly","searchFields":[{"key":"settlement_no","kind":"text"},{"key":"subject_type","kind":"text"}]},{"domain":"finance","name":"fin_reconciliation","path":"/fin_reconciliation","pageKind":"list","mode":"readonly"},{"domain":"ticket","name":"cs_ticket","path":"/cs_ticket","pageKind":"list","mode":"writable","searchFields":[{"key":"ticket_no","kind":"text"},{"key":"category","kind":"text"},{"key":"priority","kind":"text"}]}],"routes":[{"method":"POST","path":"/gasorder_contract"},{"method":"POST","path":"/gasorder_contract/attachment/upload"},{"method":"POST","path":"/gasorder_contract/attachment/cleanup"},{"method":"POST","path":"/gasorder_contract/:identity/activate"},{"method":"POST","path":"/gasorder_contract/:identity/renew"},{"method":"POST","path":"/gasorder_contract/:identity/terminate"},{"method":"POST","path":"/gasorder_contract_product"},{"method":"POST","path":"/gasorder_contract_product/:identity/unbind"},{"method":"POST","path":"/gasorder_basic"},{"method":"POST","path":"/gasorder_basic/:identity/ready"},{"method":"POST","path":"/gasorder_basic/:identity/recover"},{"method":"POST","path":"/gasorder_basic/:identity/assign"},{"method":"POST","path":"/gasorder_basic/:identity/filling"},{"method":"POST","path":"/gasorder_basic/:identity/exception"},{"method":"POST","path":"/gasorder_basic/:identity/cancel"},{"method":"POST","path":"/delivery_basic"},{"method":"POST","path":"/delivery_account"},{"method":"POST","path":"/staff_account"},{"method":"POST","path":"/staff_credential"},{"method":"POST","path":"/user_account"},{"method":"POST","path":"/user_address"},{"method":"POST","path":"/cs_ticket"},{"method":"POST","path":"/cs_ticket/:identity/resolve-contract-request"},{"method":"POST","path":"/auth/login"},{"method":"POST","path":"/wallet_apply_cash"},{"method":"GET","path":"/gasorder_contract"},{"method":"GET","path":"/gasorder_contract_product"},{"method":"GET","path":"/gasorder_contract_product/:identity"},{"method":"GET","path":"/gasorder_contract_revision"},{"method":"GET","path":"/gasorder_contract_revision/:identity"},{"method":"GET","path":"/gasorder_contract/:identity"},{"method":"GET","path":"/gasorder_contract/:identity/attachment"},{"method":"GET","path":"/gasorder_basic"},{"method":"GET","path":"/gasorder_basic/:identity"},{"method":"GET","path":"/gas_menu"},{"method":"GET","path":"/wallet_basic"},{"method":"GET","path":"/wallet_basic/:identity"},{"method":"GET","path":"/wallet_bank"},{"method":"GET","path":"/wallet_bank/:identity"},{"method":"GET","path":"/wallet_record"},{"method":"GET","path":"/wallet_record/:identity"},{"method":"GET","path":"/wallet_apply_cash"},{"method":"GET","path":"/wallet_apply_cash/:identity"},{"method":"GET","path":"/delivery_basic"},{"method":"GET","path":"/delivery_basic/:identity"},{"method":"GET","path":"/delivery_account"},{"method":"GET","path":"/delivery_account/:identity"},{"method":"GET","path":"/dashboard/overview"},{"method":"GET","path":"/dashboard/reports"},{"method":"GET","path":"/payment_order"},{"method":"GET","path":"/payment_order/:identity"},{"method":"GET","path":"/payment_refund"},{"method":"GET","path":"/payment_refund/:identity"},{"method":"GET","path":"/product_info"},{"method":"GET","path":"/product_info/:identity"},{"method":"GET","path":"/staff_account"},{"method":"GET","path":"/staff_account/:identity"},{"method":"GET","path":"/staff_account/:identity/avatar"},{"method":"GET","path":"/staff_credential"},{"method":"GET","path":"/staff_credential/:identity"},{"method":"GET","path":"/user_account"},{"method":"GET","path":"/user_account/:identity"},{"method":"GET","path":"/user_account/:identity/avatar"},{"method":"GET","path":"/user_address"},{"method":"GET","path":"/user_address/:identity"},{"method":"GET","path":"/fin_settlement"},{"method":"GET","path":"/fin_settlement/:identity"},{"method":"GET","path":"/fin_reconciliation"},{"method":"GET","path":"/fin_reconciliation/:identity"},{"method":"GET","path":"/cs_ticket"},{"method":"GET","path":"/cs_ticket/:identity"},{"method":"GET","path":"/auth/profile"},{"method":"GET","path":"/invitation/qrcode"},{"method":"PUT","path":"/delivery_account/:identity"},{"method":"PUT","path":"/delivery_account/:identity/password"},{"method":"PUT","path":"/delivery_basic/:identity"},{"method":"PUT","path":"/staff_account/:identity"},{"method":"PUT","path":"/staff_account/:identity/password"},{"method":"PUT","path":"/staff_credential/:identity"},{"method":"PUT","path":"/user_account/:identity"},{"method":"PUT","path":"/user_account/:identity/password"},{"method":"PUT","path":"/user_address/:identity"},{"method":"PUT","path":"/auth/password"},{"method":"PUT","path":"/gasorder_contract/:identity"},{"method":"PUT","path":"/cs_ticket/:identity"},{"method":"PATCH","path":"/delivery_basic/:identity/status"},{"method":"PATCH","path":"/delivery_account/:identity/status"},{"method":"PATCH","path":"/staff_account/:identity/status"},{"method":"PATCH","path":"/staff_credential/:identity/status"},{"method":"PATCH","path":"/user_account/:identity/status"},{"method":"PATCH","path":"/user_address/:identity/status"},{"method":"PATCH","path":"/cs_ticket/:identity/status"},{"method":"DELETE","path":"/delivery_basic/:identity"},{"method":"DELETE","path":"/delivery_account/:identity"},{"method":"DELETE","path":"/staff_account/:identity"},{"method":"DELETE","path":"/staff_credential/:identity"},{"method":"DELETE","path":"/user_account/:identity"},{"method":"DELETE","path":"/user_address/:identity"},{"method":"DELETE","path":"/cs_ticket/:identity"}]} diff --git a/frontend/platform_admin/src/api/resource-page-rules.ts b/frontend/platform_admin/src/api/resource-page-rules.ts index 4f62c47..eecb0ee 100644 --- a/frontend/platform_admin/src/api/resource-page-rules.ts +++ b/frontend/platform_admin/src/api/resource-page-rules.ts @@ -103,6 +103,19 @@ const pageRules: Record = { 'produced_at', ], }, + dev_usage_stat: { + editKeys: [ + 'stat_date', + 'usage_kg', + 'breakfast_kg', + 'lunch_kg', + 'dinner_kg', + 'source', + 'calc_version', + 'calculated_at', + ], + editSubmitOnlyKeys: ['product_info_identity'], + }, product_repair: { editKeys: [ 'repair_no', @@ -144,6 +157,9 @@ const pageRules: Record = { ec_product_image: { editKeys: ['ec_product_identity', 'image_uri', 'sort_no', 'is_cover'], }, + deposit_policy: { + editKeys: ['product_type_identity', 'name', 'amount', 'rule_text'], + }, fin_settlement: { editKeys: [ 'settlement_no', diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts index 1d74ada..6c8035a 100644 --- a/frontend/platform_admin/src/api/resources.ts +++ b/frontend/platform_admin/src/api/resources.ts @@ -149,6 +149,14 @@ export type ResourceUiDefinition = { }; const fieldLabels: Record = { + // 用气统计字段须注册到公共字典,资源初始化和详情展示共用此名称。 + stat_date: '统计日期', + usage_kg: '用气量(kg)', + breakfast_kg: '早餐用气(kg)', + lunch_kg: '午餐用气(kg)', + dinner_kg: '晚餐用气(kg)', + calc_version: '计算口径版本', + calculated_at: '计算时间', code: '编码', producer_code: '生产商编码', name: '名称', @@ -190,6 +198,7 @@ const fieldLabels: Record = { started_at: '开始时间', completed_at: '完成时间', appointment_at: '预约时间', + deposit_amount: '气瓶押金(分)', result: '检修结果', target_product_status: '目标产品状态', content: '内容', @@ -218,6 +227,22 @@ const fieldLabels: Record = { pay_channel: '支付渠道', pay_type: '支付类型', amount: '金额(元)', + rule_text: '退押规则', + deposit_no: '押金编号', + deposit_status: '押金状态', + refunded_at: '退回时间', + policy_identity: '押金规则', + gasorder_identity: '来源供气订单', + return_status: '退瓶状态', + address_snapshot: '上门地址', + appointment_start: '预约开始时间', + appointment_end: '预约结束时间', + estimated_amount: '预计退款金额(分)', + deduction_amount: '验收扣减金额(分)', + refund_amount: '实际退款金额(分)', + inspection_remark: '验收说明', + picked_up_at: '实际回收时间', + inspected_at: '完成验收时间', fee: '手续费(元)', args: '支付参数', callback_msg: '回调信息', @@ -270,6 +295,8 @@ const fieldLabels: Record = { sort_no: '排序', product_code: '商品编码', product_type_name: '产品类型名称', + device_kind: '设备分类', + vendor_device_id: '厂商设备编号', product_params: '产品规格参数', price_amount: '售价(元)', stock_quantity: '库存', @@ -549,7 +576,14 @@ export const resources: ResourceUiDefinition[] = [ define('producer_account', '生产商管理', 'writable', [f('producer_code', { required: true }), f('name', { required: true }), f('credit_code'), f('principal'), f('phone'), f('address', { emptyText: '未填写', placeholder: '请输入地址' }), f('username', { required: true }), f('password', { required: true }), f('display_name'), producerAdminRole(), f('remark')]), define('product_type', '智能气阀类型', 'editable', [f('code', { required: true }), f('name', { required: true })]), define('product_warehouse', '智能气阀库房', 'editable', [f('code', { required: true }), f('name', { required: true }), f('address', { emptyText: '未填写', placeholder: '请输入地址' }), f('manager'), f('phone')]), - define('product_info', '智能气阀', 'editable', [f('code', { required: true }), f('name', { required: true }), f('product_status', { type: 'select', options: productStatusOptions, unknownValueLabel: '未知状态' }), relation('producer_account_identity', '/producer_account', true, { label: '生产商', listLabel: '生产商', listRelationNameOnly: true, displayRelationLabel: true, showIdentityCopy: true, emptyText: '未填写', placeholder: '请选择生产商' }), relation('product_type_identity', '/product_type', true, { label: '智能气阀类型', listLabel: '智能气阀类型', listRelationNameOnly: true, displayRelationLabel: true, showIdentityCopy: true, emptyText: '未填写', placeholder: '请选择智能气阀类型' }), f('params', { required: true }), relation('warehouse_identity', '/product_warehouse', false, { label: '库房', emptyText: '未填写', placeholder: '请选择库房' }), relation('gas_basic_identity', '/gas_basic', false, { label: '气站', emptyText: '未填写', placeholder: '请选择气站' }), relation('delivery_basic_identity', '/delivery_basic', false, { label: '配送点', emptyText: '未填写', placeholder: '请选择配送点' }), relation('user_account_identity', '/user_account', false, { label: '用户', emptyText: '未填写', placeholder: '请选择用户' }), f('produced_at', { required: true })], 'list', [ + define('product_info', '智能气阀', 'editable', [ + // 明确的后台分类和厂商编号用于后续用户设备查询,不推断在线或控制能力。 + f('device_kind', { label: '设备分类', type: 'select', options: [ + { label: '未分类', value: 'unknown' }, { label: '智能阀', value: 'valve' }, + { label: '报警器', value: 'alarm' }, { label: '钢瓶', value: 'cylinder' }, + ], emptyText: '未分类', placeholder: '请选择设备分类' }), + f('vendor_device_id', { label: '厂商设备编号', emptyText: '未关联', placeholder: '16位数字;非物联网产品留空' }), + f('code', { required: true }), f('name', { required: true }), f('product_status', { type: 'select', options: productStatusOptions, unknownValueLabel: '未知状态' }), relation('producer_account_identity', '/producer_account', true, { label: '生产商', listLabel: '生产商', listRelationNameOnly: true, displayRelationLabel: true, showIdentityCopy: true, emptyText: '未填写', placeholder: '请选择生产商' }), relation('product_type_identity', '/product_type', true, { label: '智能气阀类型', listLabel: '智能气阀类型', listRelationNameOnly: true, displayRelationLabel: true, showIdentityCopy: true, emptyText: '未填写', placeholder: '请选择智能气阀类型' }), f('params', { required: true }), relation('warehouse_identity', '/product_warehouse', false, { label: '库房', emptyText: '未填写', placeholder: '请选择库房' }), relation('gas_basic_identity', '/gas_basic', false, { label: '气站', emptyText: '未填写', placeholder: '请选择气站' }), relation('delivery_basic_identity', '/delivery_basic', false, { label: '配送点', emptyText: '未填写', placeholder: '请选择配送点' }), relation('user_account_identity', '/user_account', false, { label: '用户', emptyText: '未填写', placeholder: '请选择用户' }), f('produced_at', { required: true })], 'list', [ { name: '修改智能气阀状态', resource: '/product_info/:identity/lifecycle', method: 'PATCH', fields: [f('product_status', { label: '目标状态', required: true, type: 'select', options: productStatusOptions, placeholder: '请选择目标状态' })] }, { name: '变更智能气阀归属', resource: '/product_info/:identity', method: 'PUT', fields: [relation('warehouse_identity', '/product_warehouse'), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), relation('user_account_identity', '/user_account'), f('action', { required: true, type: 'select', options: [{ label: '入库', value: 'warehouse' }, { label: '分配', value: 'assigned' }, { label: '归还', value: 'returned' }, { label: '人工调整', value: 'manual' }] }), f('reason', { required: true }), f('remark')] }, ]), @@ -699,7 +733,11 @@ export const resources: ResourceUiDefinition[] = [ define('ec_category', '商品分类', 'writable', [relation('parent_identity', '/ec_category'), f('name', { required: true }), f('sort_no')], 'tree'), define('ec_product', '商品', 'writable', [relation('ec_category_identity', '/ec_category', true), f('product_code', { required: true }), f('name', { required: true }), f('price_amount', { required: true }), f('stock_quantity')]), define('ec_product_attribute', '商品属性', 'writable', [relation('ec_product_identity', '/ec_product', true), f('name', { required: true }), f('value', { required: true }), f('sort_no')]), - define('ec_product_image', '商品图片', 'writable', [relation('ec_product_identity', '/ec_product', true), f('image_uri', { required: true }), f('sort_no'), f('is_cover')]), + // 商品图片关联显示商品名称,列表沿用关系详情导航,内部标识仍用于保存。 + define('ec_product_image', '商品图片', 'writable', [relation('ec_product_identity', '/ec_product', true, { + label: '商品', listLabel: '商品', listRelationNameOnly: true, + displayRelationLabel: true, showIdentityCopy: true, + }), f('image_uri', { required: true, label: '商品图片' }), f('sort_no'), f('is_cover')]), define('ec_cart', '购物车', 'readonly', [ relation('user_account_identity', '/user_account', false, { label: '用户', listLabel: '用户', listRelationNameOnly: true, @@ -747,6 +785,54 @@ export const resources: ResourceUiDefinition[] = [ displayRelationLabel: true, showIdentityCopy: true, }), f('score'), f('content'), ]), + define('dev_usage_stat', '用气统计数据', 'editable', [ + relation('product_info_identity', '/product_info', true, { label: '智能设备', listLabel: '智能设备', listRelationNameOnly: true, displayRelationLabel: true, showIdentityCopy: true }), + f('stat_date', { label: '统计日期', required: true }), + f('usage_kg', { label: '用气量(kg)', required: true }), + f('breakfast_kg', { label: '早餐用气(kg)', required: true }), + f('lunch_kg', { label: '午餐用气(kg)', required: true }), + f('dinner_kg', { label: '晚餐用气(kg)', required: true }), + f('source', { label: '数据来源', required: true, type: 'select', options: [ + { label: '计量表', value: 'meter' }, { label: '设备上报', value: 'device' }, { label: '人工导入', value: 'manual' }, + ] }), + f('calc_version', { label: '计算口径版本', required: true }), + f('calculated_at', { label: '计算时间', required: true }), + ]), + define('deposit_policy', '押金规则', 'writable', [ + relation('product_type_identity', '/product_type', true, { label: '气瓶类型', listLabel: '气瓶类型' }), + f('name', { required: true, label: '规则名称' }), + f('amount', { required: true, label: '单瓶押金(分)' }), + f('rule_text', { required: true, label: '退押规则' }), + ]), + define('deposit_record', '押金记录', 'readonly', [ + relation('user_account_identity', '/user_account', false, { label: '用户' }), + relation('product_info_identity', '/product_info', false, { label: '绑定气瓶' }), + relation('policy_identity', '/deposit_policy', false, { label: '押金规则' }), + relation('gasorder_identity', '/gasorder_basic', false, { label: '来源供气订单', emptyText: '历史导入' }), + f('deposit_no', { label: '押金编号' }), + f('amount', { label: '押金金额(分)' }), + f('deposit_status', { type: 'select', label: '押金状态', options: [ + { label: '使用中', value: 10 }, { label: '退款中', value: 20 }, { label: '已退回', value: 23 }, + ] }), + f('paid_at', { label: '缴纳时间' }), f('refunded_at', { label: '退回时间', emptyText: '尚未退回' }), + ]), + define('deposit_return_request', '退瓶处理', 'readonly', [ + f('request_no', { label: '申请编号' }), + f('return_status', { type: 'select', options: [ + { label: '待上门', value: 10 }, { label: '已回收待验收', value: 20 }, + { label: '已验收待退款', value: 30 }, { label: '已完成', value: 40 }, { label: '已取消', value: 50 }, + ] }), + f('address_snapshot'), f('contact_name'), f('contact_phone'), + f('appointment_start'), f('appointment_end'), f('estimated_amount'), + f('deduction_amount'), f('refund_amount'), f('inspection_remark'), + f('operator_identity'), f('picked_up_at'), f('inspected_at'), f('completed_at'), + ], 'list', [ + { name: '确认已上门回收', resource: '/deposit_return_request/:identity/confirm-pickup', visibleFor: { field: 'return_status', values: [10] } }, + { name: '提交验收结果', resource: '/deposit_return_request/:identity/inspect', fields: [ + f('deduction_amount', { required: true }), f('remark', { required: true, label: '验收说明' }), + ], visibleFor: { field: 'return_status', values: [20] } }, + { name: '退入用户余额', resource: '/deposit_return_request/:identity/complete-refund', visibleFor: { field: 'return_status', values: [30] } }, + ]), define('wallet_basic', '钱包', 'readonly', [f('owner_type'), f('owner_identity', { type: 'identity', label: '归属主体', listRelationNameOnly: true, @@ -892,6 +978,8 @@ export const resources: ResourceUiDefinition[] = [ options: [ { label: '公告', value: 'notice' }, { label: '协议', value: 'agreement' }, + { label: '安全宣传', value: 'safety_article' }, + { label: '法律法规', value: 'law' }, ], unknownValueLabel: '未知内容类型', }), diff --git a/frontend/platform_admin/src/contracts/platform-resources.json b/frontend/platform_admin/src/contracts/platform-resources.json index b854e77..21a13d9 100644 --- a/frontend/platform_admin/src/contracts/platform-resources.json +++ b/frontend/platform_admin/src/contracts/platform-resources.json @@ -1 +1 @@ -{"resources":[{"domain":"gas","name":"gas_basic","path":"/gas_basic","pageKind":"list","mode":"writable","searchFields":[{"key":"code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"gas","name":"gas_account","path":"/gas_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"},{"key":"display_name","kind":"text"},{"key":"role_code","kind":"enum","values":[{"value":"admin","label":"气站管理员"}]}]},{"domain":"delivery","name":"delivery_basic","path":"/delivery_basic","pageKind":"list","mode":"writable","searchFields":[{"key":"delivery_code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"delivery","name":"delivery_account","path":"/delivery_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"},{"key":"display_name","kind":"text"},{"key":"role_code","kind":"enum","values":[{"value":"admin","label":"配送点管理员"}]}]},{"domain":"staff","name":"staff_account","path":"/staff_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"},{"key":"role_code","kind":"enum","values":[{"value":"installer","label":"安装人员"},{"value":"delivery","label":"配送人员"},{"value":"operations","label":"运维人员"}]}]},{"domain":"staff","name":"staff_credential","path":"/staff_credential","pageKind":"list","mode":"writable","searchFields":[{"key":"credential_type","kind":"text"}]},{"domain":"user","name":"user_account","path":"/user_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"}]},{"domain":"user","name":"user_address","path":"/user_address","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_service_relation","path":"/user_service_relation","pageKind":"list","mode":"writable"},{"domain":"product","name":"producer_account","path":"/producer_account","pageKind":"list","mode":"writable","searchFields":[{"key":"name","kind":"text"}]},{"domain":"product","name":"product_type","path":"/product_type","pageKind":"list","mode":"editable","searchFields":[{"key":"code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"product","name":"product_warehouse","path":"/product_warehouse","pageKind":"list","mode":"editable","searchFields":[{"key":"code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"product","name":"product_info","path":"/product_info","pageKind":"list","mode":"editable","searchFields":[{"key":"code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"product","name":"product_repair","path":"/product_repair","pageKind":"list","mode":"editable","searchFields":[{"key":"result","kind":"enum","values":[{"value":"pending","label":"待处理"},{"value":"passed","label":"通过"},{"value":"failed","label":"未通过"}]}]},{"domain":"product","name":"product_owner","path":"/product_owner","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_category","path":"/ec_category","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product","path":"/ec_product","pageKind":"list","mode":"writable","searchFields":[{"key":"product_code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"ec","name":"ec_product_attribute","path":"/ec_product_attribute","pageKind":"list","mode":"writable","searchFields":[{"key":"name","kind":"text"},{"key":"value","kind":"text"}]},{"domain":"ec","name":"ec_product_image","path":"/ec_product_image","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_cart","path":"/ec_cart","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_order","path":"/ec_order","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_order_item","path":"/ec_order_item","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_review","path":"/ec_review","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_contract","path":"/gasorder_contract","pageKind":"list","mode":"managed","searchFields":[{"key":"contract_no","kind":"text"},{"key":"title","kind":"text"}]},{"domain":"gasorder","name":"gasorder_contract_product","path":"/gasorder_contract_product","pageKind":"list","mode":"append_only"},{"domain":"gasorder","name":"gasorder_contract_revision","path":"/gasorder_contract_revision","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_basic","path":"/gasorder_basic","pageKind":"list","mode":"append_only","searchFields":[{"key":"request_no","kind":"text"},{"key":"creator_type","kind":"enum","values":[{"value":"user","label":"用户"},{"value":"staff","label":"工作人员"},{"value":"delivery","label":"配送站"},{"value":"gas","label":"气站"}]}]},{"domain":"gasorder","name":"gasorder_item","path":"/gasorder_item","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_assign","path":"/gasorder_assign","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_status","path":"/gasorder_status","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_track","path":"/gasorder_track","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_track_point","path":"/gasorder_track_point","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_confirm","path":"/gasorder_confirm","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_payment","path":"/gasorder_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"fin_payment","path":"/fin_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"fin_settlement","path":"/fin_settlement","pageKind":"list","mode":"writable","searchFields":[{"key":"settlement_no","kind":"text"},{"key":"subject_type","kind":"text"}]},{"domain":"finance","name":"fin_reconciliation","path":"/fin_reconciliation","pageKind":"list","mode":"readonly"},{"domain":"content","name":"cms_content","path":"/cms_content","pageKind":"list","mode":"writable","searchFields":[{"key":"content_type","kind":"text"},{"key":"title","kind":"text"},{"key":"publish_status","kind":"text"}]},{"domain":"customer_service","name":"cs_ticket","path":"/cs_ticket","pageKind":"list","mode":"writable","searchFields":[{"key":"ticket_no","kind":"text"},{"key":"category","kind":"text"},{"key":"priority","kind":"text"}]},{"domain":"platform","name":"platform_account","path":"/platform_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"},{"key":"display_name","kind":"text"}]},{"domain":"platform","name":"platform_role","path":"/platform_role","pageKind":"list","mode":"writable","searchFields":[{"key":"role_code","kind":"text"},{"key":"name","kind":"text"},{"key":"location_scope","kind":"enum","values":[{"value":"standard","label":"脱敏坐标"},{"value":"precise","label":"精确坐标"}]}]},{"domain":"platform","name":"platform_menu","path":"/platform_menu","pageKind":"tree","mode":"readonly"},{"domain":"wallet","name":"wallet_basic","path":"/wallet_basic","pageKind":"list","mode":"readonly","searchFields":[{"key":"owner_type","kind":"text"}]},{"domain":"wallet","name":"wallet_bank","path":"/wallet_bank","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"payment_order","path":"/payment_order","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_record","path":"/wallet_record","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"payment_refund","path":"/payment_refund","pageKind":"list","mode":"readonly","searchFields":[{"key":"refund_no","kind":"text"}]},{"domain":"wallet","name":"wallet_apply_cash","path":"/wallet_apply_cash","pageKind":"list","mode":"readonly","searchFields":[{"key":"cash_no","kind":"text"}]}],"routes":[{"method":"GET","path":"/gasorder_contract"},{"method":"GET","path":"/gasorder_contract_product"},{"method":"GET","path":"/gasorder_contract_product/:identity"},{"method":"GET","path":"/gasorder_contract_revision"},{"method":"GET","path":"/gasorder_contract_revision/:identity"},{"method":"GET","path":"/gasorder_contract/:identity"},{"method":"GET","path":"/gasorder_confirm"},{"method":"GET","path":"/gasorder_confirm/:identity"},{"method":"GET","path":"/gasorder_track"},{"method":"GET","path":"/gasorder_track_point"},{"method":"GET","path":"/gasorder_track_point/:identity"},{"method":"GET","path":"/gasorder_track/:identity"},{"method":"GET","path":"/gasorder_basic"},{"method":"GET","path":"/gasorder_basic/:identity"},{"method":"GET","path":"/gasorder_item"},{"method":"GET","path":"/gasorder_item/:identity"},{"method":"GET","path":"/gasorder_assign"},{"method":"GET","path":"/gasorder_assign/:identity"},{"method":"GET","path":"/gasorder_status"},{"method":"GET","path":"/gasorder_status/:identity"},{"method":"GET","path":"/gasorder_payment"},{"method":"GET","path":"/gasorder_payment/:identity"},{"method":"GET","path":"/gas_basic"},{"method":"GET","path":"/gas_basic/:identity"},{"method":"GET","path":"/gas_account"},{"method":"GET","path":"/gas_account/:identity"},{"method":"GET","path":"/product_type"},{"method":"GET","path":"/product_type/:identity"},{"method":"GET","path":"/product_warehouse"},{"method":"GET","path":"/product_warehouse/:identity"},{"method":"GET","path":"/product_info"},{"method":"GET","path":"/product_info/:identity"},{"method":"GET","path":"/product_repair"},{"method":"GET","path":"/product_repair/:identity"},{"method":"GET","path":"/product_owner"},{"method":"GET","path":"/product_owner/:identity"},{"method":"GET","path":"/producer_account"},{"method":"GET","path":"/producer_account/:identity"},{"method":"GET","path":"/platform_account"},{"method":"GET","path":"/platform_account/:identity"},{"method":"GET","path":"/platform_account/:identity/avatar"},{"method":"GET","path":"/platform_role"},{"method":"GET","path":"/platform_role/:identity"},{"method":"GET","path":"/platform_role/:identity/menu"},{"method":"GET","path":"/platform_menu"},{"method":"GET","path":"/platform_menu/:identity"},{"method":"GET","path":"/payment_order"},{"method":"GET","path":"/payment_order/:identity"},{"method":"GET","path":"/payment_refund"},{"method":"GET","path":"/payment_refund/:identity"},{"method":"GET","path":"/ping/hello"},{"method":"GET","path":"/ec_product"},{"method":"GET","path":"/ec_product_attribute"},{"method":"GET","path":"/ec_product_attribute/:identity"},{"method":"GET","path":"/ec_product_image"},{"method":"GET","path":"/ec_product_image/:identity"},{"method":"GET","path":"/ec_product/:identity"},{"method":"GET","path":"/ec_category"},{"method":"GET","path":"/ec_category/:identity"},{"method":"GET","path":"/ec_cart"},{"method":"GET","path":"/ec_cart/:identity"},{"method":"GET","path":"/ec_order"},{"method":"GET","path":"/ec_order_item"},{"method":"GET","path":"/ec_order_item/:identity"},{"method":"GET","path":"/ec_order/:identity"},{"method":"GET","path":"/ec_review"},{"method":"GET","path":"/ec_review/:identity"},{"method":"GET","path":"/wallet_basic"},{"method":"GET","path":"/wallet_basic/:identity"},{"method":"GET","path":"/wallet_bank"},{"method":"GET","path":"/wallet_bank/:identity"},{"method":"GET","path":"/wallet_record"},{"method":"GET","path":"/wallet_record/:identity"},{"method":"GET","path":"/wallet_apply_cash"},{"method":"GET","path":"/wallet_apply_cash/:identity"},{"method":"GET","path":"/user_account"},{"method":"GET","path":"/user_account/:identity"},{"method":"GET","path":"/user_account/:identity/avatar"},{"method":"GET","path":"/user_address"},{"method":"GET","path":"/user_address/:identity"},{"method":"GET","path":"/user_service_relation"},{"method":"GET","path":"/user_service_relation/:identity"},{"method":"GET","path":"/fin_payment"},{"method":"GET","path":"/fin_payment/:identity"},{"method":"GET","path":"/fin_settlement"},{"method":"GET","path":"/fin_settlement/:identity"},{"method":"GET","path":"/fin_reconciliation"},{"method":"GET","path":"/fin_reconciliation/:identity"},{"method":"GET","path":"/delivery_basic"},{"method":"GET","path":"/delivery_basic/:identity"},{"method":"GET","path":"/delivery_account"},{"method":"GET","path":"/delivery_account/:identity"},{"method":"GET","path":"/dashboard/overview"},{"method":"GET","path":"/staff_account"},{"method":"GET","path":"/staff_account/:identity"},{"method":"GET","path":"/staff_account/:identity/avatar"},{"method":"GET","path":"/staff_credential"},{"method":"GET","path":"/staff_credential/:identity"},{"method":"GET","path":"/cms_content"},{"method":"GET","path":"/cms_content/:identity"},{"method":"GET","path":"/cs_ticket"},{"method":"GET","path":"/cs_ticket/:identity"},{"method":"GET","path":"/auth/profile"},{"method":"POST","path":"/gasorder_basic"},{"method":"POST","path":"/gasorder_basic/:identity/assign"},{"method":"POST","path":"/gasorder_basic/:identity/awaiting-confirmation"},{"method":"POST","path":"/gasorder_basic/:identity/ready"},{"method":"POST","path":"/gasorder_basic/:identity/recover"},{"method":"POST","path":"/gasorder_basic/:identity/complete"},{"method":"POST","path":"/gasorder_basic/:identity/cancel"},{"method":"POST","path":"/gasorder_basic/:identity/filling"},{"method":"POST","path":"/gasorder_basic/:identity/delivering"},{"method":"POST","path":"/gasorder_basic/:identity/exception"},{"method":"POST","path":"/gasorder_contract"},{"method":"POST","path":"/gasorder_contract/:identity/activate"},{"method":"POST","path":"/gasorder_contract/:identity/renew"},{"method":"POST","path":"/gasorder_contract/:identity/terminate"},{"method":"POST","path":"/gasorder_contract_product"},{"method":"POST","path":"/gasorder_contract_product/:identity/unbind"},{"method":"POST","path":"/gas_basic"},{"method":"POST","path":"/gas_account"},{"method":"POST","path":"/product_type"},{"method":"POST","path":"/product_warehouse"},{"method":"POST","path":"/product_info"},{"method":"POST","path":"/product_repair"},{"method":"POST","path":"/producer_account"},{"method":"POST","path":"/payment_refund/:identity/approve"},{"method":"POST","path":"/payment_refund/:identity/reject"},{"method":"POST","path":"/platform_account"},{"method":"POST","path":"/platform_role"},{"method":"POST","path":"/wallet_apply_cash/:identity/approve"},{"method":"POST","path":"/wallet_apply_cash/:identity/reject"},{"method":"POST","path":"/wallet_apply_cash/:identity/complete"},{"method":"POST","path":"/wallet_basic/owner/:owner_type/:owner_identity"},{"method":"POST","path":"/wallet_basic/:identity/recharge"},{"method":"POST","path":"/ec_product"},{"method":"POST","path":"/ec_product_attribute"},{"method":"POST","path":"/ec_product_image"},{"method":"POST","path":"/ec_category"},{"method":"POST","path":"/user_account"},{"method":"POST","path":"/user_address"},{"method":"POST","path":"/user_service_relation"},{"method":"POST","path":"/delivery_basic"},{"method":"POST","path":"/delivery_account"},{"method":"POST","path":"/staff_account"},{"method":"POST","path":"/staff_credential"},{"method":"POST","path":"/cms_content"},{"method":"POST","path":"/cs_ticket"},{"method":"POST","path":"/auth/login"},{"method":"POST","path":"/fin_settlement"},{"method":"PUT","path":"/product_type/:identity"},{"method":"PUT","path":"/product_warehouse/:identity"},{"method":"PUT","path":"/product_info/:identity"},{"method":"PUT","path":"/product_repair/:identity"},{"method":"PUT","path":"/producer_account/:identity"},{"method":"PUT","path":"/platform_role/:identity"},{"method":"PUT","path":"/platform_role/:identity/menu"},{"method":"PUT","path":"/platform_account/:identity"},{"method":"PUT","path":"/ec_product_attribute/:identity"},{"method":"PUT","path":"/ec_product_image/:identity"},{"method":"PUT","path":"/ec_product/:identity"},{"method":"PUT","path":"/ec_category/:identity"},{"method":"PUT","path":"/gas_basic/:identity"},{"method":"PUT","path":"/gas_account/:identity"},{"method":"PUT","path":"/gasorder_contract/:identity"},{"method":"PUT","path":"/user_account/:identity"},{"method":"PUT","path":"/user_address/:identity"},{"method":"PUT","path":"/user_service_relation/:identity"},{"method":"PUT","path":"/delivery_basic/:identity"},{"method":"PUT","path":"/delivery_account/:identity"},{"method":"PUT","path":"/staff_account/:identity"},{"method":"PUT","path":"/staff_credential/:identity"},{"method":"PUT","path":"/cms_content/:identity"},{"method":"PUT","path":"/cs_ticket/:identity"},{"method":"PUT","path":"/auth/password"},{"method":"PUT","path":"/fin_settlement/:identity"},{"method":"PATCH","path":"/product_info/:identity/status"},{"method":"PATCH","path":"/product_info/:identity/lifecycle"},{"method":"PATCH","path":"/product_type/:identity/status"},{"method":"PATCH","path":"/product_warehouse/:identity/status"},{"method":"PATCH","path":"/product_repair/:identity/status"},{"method":"PATCH","path":"/producer_account/:identity/status"},{"method":"PATCH","path":"/platform_account/:identity/status"},{"method":"PATCH","path":"/platform_role/:identity/status"},{"method":"PATCH","path":"/ec_product_attribute/:identity/status"},{"method":"PATCH","path":"/ec_product_image/:identity/status"},{"method":"PATCH","path":"/ec_product/:identity/status"},{"method":"PATCH","path":"/ec_category/:identity/status"},{"method":"PATCH","path":"/user_account/:identity/status"},{"method":"PATCH","path":"/user_address/:identity/status"},{"method":"PATCH","path":"/user_service_relation/:identity/status"},{"method":"PATCH","path":"/gas_basic/:identity/status"},{"method":"PATCH","path":"/gas_account/:identity/status"},{"method":"PATCH","path":"/delivery_basic/:identity/status"},{"method":"PATCH","path":"/delivery_account/:identity/status"},{"method":"PATCH","path":"/staff_account/:identity/status"},{"method":"PATCH","path":"/staff_credential/:identity/status"},{"method":"PATCH","path":"/cms_content/:identity/status"},{"method":"PATCH","path":"/cs_ticket/:identity/status"},{"method":"PATCH","path":"/wallet_basic/:identity/status"},{"method":"PATCH","path":"/fin_settlement/:identity/status"},{"method":"DELETE","path":"/ec_product_attribute/:identity"},{"method":"DELETE","path":"/ec_product_image/:identity"},{"method":"DELETE","path":"/ec_product/:identity"},{"method":"DELETE","path":"/ec_category/:identity"},{"method":"DELETE","path":"/user_account/:identity"},{"method":"DELETE","path":"/user_address/:identity"},{"method":"DELETE","path":"/user_service_relation/:identity"},{"method":"DELETE","path":"/platform_account/:identity"},{"method":"DELETE","path":"/platform_role/:identity"},{"method":"DELETE","path":"/producer_account/:identity"},{"method":"DELETE","path":"/gas_basic/:identity"},{"method":"DELETE","path":"/gas_account/:identity"},{"method":"DELETE","path":"/delivery_basic/:identity"},{"method":"DELETE","path":"/delivery_account/:identity"},{"method":"DELETE","path":"/staff_account/:identity"},{"method":"DELETE","path":"/staff_credential/:identity"},{"method":"DELETE","path":"/cms_content/:identity"},{"method":"DELETE","path":"/cs_ticket/:identity"},{"method":"DELETE","path":"/fin_settlement/:identity"}]} +{"resources":[{"domain":"gas","name":"gas_basic","path":"/gas_basic","pageKind":"list","mode":"writable","searchFields":[{"key":"code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"gas","name":"gas_account","path":"/gas_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"},{"key":"display_name","kind":"text"},{"key":"role_code","kind":"enum","values":[{"value":"admin","label":"气站管理员"}]}]},{"domain":"delivery","name":"delivery_basic","path":"/delivery_basic","pageKind":"list","mode":"writable","searchFields":[{"key":"delivery_code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"delivery","name":"delivery_account","path":"/delivery_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"},{"key":"display_name","kind":"text"},{"key":"role_code","kind":"enum","values":[{"value":"admin","label":"配送点管理员"}]}]},{"domain":"staff","name":"staff_account","path":"/staff_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"},{"key":"role_code","kind":"enum","values":[{"value":"installer","label":"安装人员"},{"value":"delivery","label":"配送人员"},{"value":"operations","label":"运维人员"}]}]},{"domain":"staff","name":"staff_credential","path":"/staff_credential","pageKind":"list","mode":"writable","searchFields":[{"key":"credential_type","kind":"text"},{"key":"credential_no","kind":"text"}]},{"domain":"user","name":"user_account","path":"/user_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"}]},{"domain":"user","name":"user_address","path":"/user_address","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_service_relation","path":"/user_service_relation","pageKind":"list","mode":"writable"},{"domain":"product","name":"producer_account","path":"/producer_account","pageKind":"list","mode":"writable","searchFields":[{"key":"name","kind":"text"}]},{"domain":"product","name":"product_type","path":"/product_type","pageKind":"list","mode":"editable","searchFields":[{"key":"code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"product","name":"product_warehouse","path":"/product_warehouse","pageKind":"list","mode":"editable","searchFields":[{"key":"code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"product","name":"product_info","path":"/product_info","pageKind":"list","mode":"editable","searchFields":[{"key":"code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"product","name":"product_repair","path":"/product_repair","pageKind":"list","mode":"editable","searchFields":[{"key":"result","kind":"enum","values":[{"value":"pending","label":"待处理"},{"value":"passed","label":"通过"},{"value":"failed","label":"未通过"}]}]},{"domain":"product","name":"product_owner","path":"/product_owner","pageKind":"list","mode":"readonly"},{"domain":"product","name":"dev_usage_stat","path":"/dev_usage_stat","pageKind":"list","mode":"editable"},{"domain":"ec","name":"ec_category","path":"/ec_category","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product","path":"/ec_product","pageKind":"list","mode":"writable","searchFields":[{"key":"product_code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"ec","name":"ec_product_attribute","path":"/ec_product_attribute","pageKind":"list","mode":"writable","searchFields":[{"key":"name","kind":"text"},{"key":"value","kind":"text"}]},{"domain":"ec","name":"ec_product_image","path":"/ec_product_image","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_cart","path":"/ec_cart","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_order","path":"/ec_order","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_order_item","path":"/ec_order_item","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_review","path":"/ec_review","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"deposit_policy","path":"/deposit_policy","pageKind":"list","mode":"writable"},{"domain":"ec","name":"deposit_record","path":"/deposit_record","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"deposit_return_request","path":"/deposit_return_request","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_contract","path":"/gasorder_contract","pageKind":"list","mode":"managed","searchFields":[{"key":"contract_no","kind":"text"},{"key":"title","kind":"text"}]},{"domain":"gasorder","name":"gasorder_contract_product","path":"/gasorder_contract_product","pageKind":"list","mode":"append_only"},{"domain":"gasorder","name":"gasorder_contract_revision","path":"/gasorder_contract_revision","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_basic","path":"/gasorder_basic","pageKind":"list","mode":"append_only","searchFields":[{"key":"request_no","kind":"text"},{"key":"creator_type","kind":"enum","values":[{"value":"user","label":"用户"},{"value":"staff","label":"工作人员"},{"value":"delivery","label":"配送站"},{"value":"gas","label":"气站"}]}]},{"domain":"gasorder","name":"gasorder_item","path":"/gasorder_item","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_assign","path":"/gasorder_assign","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_status","path":"/gasorder_status","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_track","path":"/gasorder_track","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_track_point","path":"/gasorder_track_point","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_confirm","path":"/gasorder_confirm","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_payment","path":"/gasorder_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"fin_payment","path":"/fin_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"fin_settlement","path":"/fin_settlement","pageKind":"list","mode":"writable","searchFields":[{"key":"settlement_no","kind":"text"},{"key":"subject_type","kind":"text"}]},{"domain":"finance","name":"fin_reconciliation","path":"/fin_reconciliation","pageKind":"list","mode":"readonly"},{"domain":"content","name":"cms_content","path":"/cms_content","pageKind":"list","mode":"writable","searchFields":[{"key":"content_type","kind":"text"},{"key":"title","kind":"text"},{"key":"publish_status","kind":"text"}]},{"domain":"customer_service","name":"cs_ticket","path":"/cs_ticket","pageKind":"list","mode":"writable","searchFields":[{"key":"ticket_no","kind":"text"},{"key":"category","kind":"text"},{"key":"priority","kind":"text"}]},{"domain":"platform","name":"platform_account","path":"/platform_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"},{"key":"display_name","kind":"text"}]},{"domain":"platform","name":"platform_role","path":"/platform_role","pageKind":"list","mode":"writable","searchFields":[{"key":"role_code","kind":"text"},{"key":"name","kind":"text"},{"key":"location_scope","kind":"enum","values":[{"value":"standard","label":"脱敏坐标"},{"value":"precise","label":"精确坐标"}]}]},{"domain":"platform","name":"platform_menu","path":"/platform_menu","pageKind":"tree","mode":"readonly"},{"domain":"wallet","name":"wallet_basic","path":"/wallet_basic","pageKind":"list","mode":"readonly","searchFields":[{"key":"owner_type","kind":"text"}]},{"domain":"wallet","name":"wallet_bank","path":"/wallet_bank","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"payment_order","path":"/payment_order","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_record","path":"/wallet_record","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"payment_refund","path":"/payment_refund","pageKind":"list","mode":"readonly","searchFields":[{"key":"refund_no","kind":"text"},{"key":"request_no","kind":"text"}]},{"domain":"wallet","name":"wallet_apply_cash","path":"/wallet_apply_cash","pageKind":"list","mode":"readonly","searchFields":[{"key":"cash_no","kind":"text"},{"key":"request_no","kind":"text"},{"key":"channel","kind":"enum","values":[{"value":"bank","label":"银行卡"},{"value":"alipay","label":"支付宝"},{"value":"wechat","label":"微信"}]}]}],"routes":[{"method":"GET","path":"/gasorder_contract"},{"method":"GET","path":"/gasorder_contract_product"},{"method":"GET","path":"/gasorder_contract_product/:identity"},{"method":"GET","path":"/gasorder_contract_revision"},{"method":"GET","path":"/gasorder_contract_revision/:identity"},{"method":"GET","path":"/gasorder_contract/:identity"},{"method":"GET","path":"/gasorder_contract/:identity/attachment"},{"method":"GET","path":"/gasorder_confirm"},{"method":"GET","path":"/gasorder_confirm/:identity"},{"method":"GET","path":"/gasorder_track"},{"method":"GET","path":"/gasorder_track_point"},{"method":"GET","path":"/gasorder_track_point/:identity"},{"method":"GET","path":"/gasorder_track/:identity"},{"method":"GET","path":"/gasorder_basic"},{"method":"GET","path":"/gasorder_basic/:identity"},{"method":"GET","path":"/gasorder_item"},{"method":"GET","path":"/gasorder_item/:identity"},{"method":"GET","path":"/gasorder_assign"},{"method":"GET","path":"/gasorder_assign/:identity"},{"method":"GET","path":"/gasorder_status"},{"method":"GET","path":"/gasorder_status/:identity"},{"method":"GET","path":"/gasorder_payment"},{"method":"GET","path":"/gasorder_payment/:identity"},{"method":"GET","path":"/gas_basic"},{"method":"GET","path":"/gas_basic/:identity"},{"method":"GET","path":"/gas_account"},{"method":"GET","path":"/gas_account/:identity"},{"method":"GET","path":"/product_type"},{"method":"GET","path":"/product_type/:identity"},{"method":"GET","path":"/product_warehouse"},{"method":"GET","path":"/product_warehouse/:identity"},{"method":"GET","path":"/product_info"},{"method":"GET","path":"/product_info/:identity"},{"method":"GET","path":"/product_repair"},{"method":"GET","path":"/product_repair/:identity"},{"method":"GET","path":"/product_owner"},{"method":"GET","path":"/product_owner/:identity"},{"method":"GET","path":"/producer_account"},{"method":"GET","path":"/producer_account/:identity"},{"method":"GET","path":"/platform_account"},{"method":"GET","path":"/platform_account/:identity"},{"method":"GET","path":"/platform_account/:identity/avatar"},{"method":"GET","path":"/platform_role"},{"method":"GET","path":"/platform_role/:identity"},{"method":"GET","path":"/platform_role/:identity/menu"},{"method":"GET","path":"/platform_menu"},{"method":"GET","path":"/platform_menu/:identity"},{"method":"GET","path":"/payment_order"},{"method":"GET","path":"/payment_order/:identity"},{"method":"GET","path":"/payment_refund"},{"method":"GET","path":"/payment_refund/:identity"},{"method":"GET","path":"/ping/hello"},{"method":"GET","path":"/ec_product"},{"method":"GET","path":"/ec_product_attribute"},{"method":"GET","path":"/ec_product_attribute/:identity"},{"method":"GET","path":"/ec_product_image"},{"method":"GET","path":"/ec_product_image/:identity"},{"method":"GET","path":"/ec_product/:identity"},{"method":"GET","path":"/ec_category"},{"method":"GET","path":"/ec_category/:identity"},{"method":"GET","path":"/ec_cart"},{"method":"GET","path":"/ec_cart/:identity"},{"method":"GET","path":"/ec_order"},{"method":"GET","path":"/ec_order_item"},{"method":"GET","path":"/ec_order_item/:identity"},{"method":"GET","path":"/ec_order/:identity"},{"method":"GET","path":"/ec_review"},{"method":"GET","path":"/ec_review/:identity"},{"method":"GET","path":"/deposit_record"},{"method":"GET","path":"/deposit_record/:identity"},{"method":"GET","path":"/deposit_return_request"},{"method":"GET","path":"/deposit_return_request/:identity"},{"method":"GET","path":"/deposit_policy"},{"method":"GET","path":"/deposit_policy/:identity"},{"method":"GET","path":"/delivery_basic"},{"method":"GET","path":"/delivery_basic/:identity"},{"method":"GET","path":"/delivery_account"},{"method":"GET","path":"/delivery_account/:identity"},{"method":"GET","path":"/dev_usage_stat"},{"method":"GET","path":"/dev_usage_stat/:identity"},{"method":"GET","path":"/dashboard/overview"},{"method":"GET","path":"/wallet_basic"},{"method":"GET","path":"/wallet_basic/:identity"},{"method":"GET","path":"/wallet_bank"},{"method":"GET","path":"/wallet_bank/:identity"},{"method":"GET","path":"/wallet_record"},{"method":"GET","path":"/wallet_record/:identity"},{"method":"GET","path":"/wallet_apply_cash"},{"method":"GET","path":"/wallet_apply_cash/:identity"},{"method":"GET","path":"/user_account"},{"method":"GET","path":"/user_account/:identity"},{"method":"GET","path":"/user_account/:identity/avatar"},{"method":"GET","path":"/user_address"},{"method":"GET","path":"/user_address/:identity"},{"method":"GET","path":"/user_service_relation"},{"method":"GET","path":"/user_service_relation/:identity"},{"method":"GET","path":"/fin_payment"},{"method":"GET","path":"/fin_payment/:identity"},{"method":"GET","path":"/fin_settlement"},{"method":"GET","path":"/fin_settlement/:identity"},{"method":"GET","path":"/fin_reconciliation"},{"method":"GET","path":"/fin_reconciliation/:identity"},{"method":"GET","path":"/staff_account"},{"method":"GET","path":"/staff_account/:identity"},{"method":"GET","path":"/staff_account/:identity/avatar"},{"method":"GET","path":"/staff_credential"},{"method":"GET","path":"/staff_credential/:identity"},{"method":"GET","path":"/cms_content"},{"method":"GET","path":"/cms_content/:identity"},{"method":"GET","path":"/cs_ticket"},{"method":"GET","path":"/cs_ticket/:identity"},{"method":"GET","path":"/auth/profile"},{"method":"POST","path":"/gasorder_basic"},{"method":"POST","path":"/gasorder_basic/:identity/assign"},{"method":"POST","path":"/gasorder_basic/:identity/awaiting-confirmation"},{"method":"POST","path":"/gasorder_basic/:identity/ready"},{"method":"POST","path":"/gasorder_basic/:identity/recover"},{"method":"POST","path":"/gasorder_basic/:identity/complete"},{"method":"POST","path":"/gasorder_basic/:identity/cancel"},{"method":"POST","path":"/gasorder_basic/:identity/filling"},{"method":"POST","path":"/gasorder_basic/:identity/delivering"},{"method":"POST","path":"/gasorder_basic/:identity/exception"},{"method":"POST","path":"/gasorder_contract"},{"method":"POST","path":"/gasorder_contract/attachment/upload"},{"method":"POST","path":"/gasorder_contract/attachment/cleanup"},{"method":"POST","path":"/gasorder_contract/:identity/activate"},{"method":"POST","path":"/gasorder_contract/:identity/renew"},{"method":"POST","path":"/gasorder_contract/:identity/terminate"},{"method":"POST","path":"/gasorder_contract_product"},{"method":"POST","path":"/gasorder_contract_product/:identity/unbind"},{"method":"POST","path":"/gas_basic"},{"method":"POST","path":"/gas_account"},{"method":"POST","path":"/product_type"},{"method":"POST","path":"/product_warehouse"},{"method":"POST","path":"/product_info"},{"method":"POST","path":"/product_repair"},{"method":"POST","path":"/producer_account"},{"method":"POST","path":"/payment_refund/:identity/approve"},{"method":"POST","path":"/payment_refund/:identity/reject"},{"method":"POST","path":"/platform_account"},{"method":"POST","path":"/platform_role"},{"method":"POST","path":"/deposit_return_request/:identity/confirm-pickup"},{"method":"POST","path":"/deposit_return_request/:identity/complete-refund"},{"method":"POST","path":"/deposit_return_request/:identity/inspect"},{"method":"POST","path":"/deposit_policy"},{"method":"POST","path":"/delivery_basic"},{"method":"POST","path":"/delivery_account"},{"method":"POST","path":"/dev_usage_stat"},{"method":"POST","path":"/wallet_apply_cash/:identity/approve"},{"method":"POST","path":"/wallet_apply_cash/:identity/reject"},{"method":"POST","path":"/wallet_apply_cash/:identity/complete"},{"method":"POST","path":"/wallet_basic/owner/:owner_type/:owner_identity"},{"method":"POST","path":"/wallet_basic/:identity/recharge"},{"method":"POST","path":"/ec_product"},{"method":"POST","path":"/ec_product_attribute"},{"method":"POST","path":"/ec_product_image"},{"method":"POST","path":"/ec_category"},{"method":"POST","path":"/user_account"},{"method":"POST","path":"/user_address"},{"method":"POST","path":"/user_service_relation"},{"method":"POST","path":"/staff_account"},{"method":"POST","path":"/staff_credential"},{"method":"POST","path":"/cms_content"},{"method":"POST","path":"/cs_ticket"},{"method":"POST","path":"/auth/login"},{"method":"POST","path":"/fin_settlement"},{"method":"PUT","path":"/product_type/:identity"},{"method":"PUT","path":"/product_warehouse/:identity"},{"method":"PUT","path":"/product_info/:identity"},{"method":"PUT","path":"/product_repair/:identity"},{"method":"PUT","path":"/producer_account/:identity"},{"method":"PUT","path":"/platform_role/:identity"},{"method":"PUT","path":"/platform_role/:identity/menu"},{"method":"PUT","path":"/platform_account/:identity"},{"method":"PUT","path":"/ec_product_attribute/:identity"},{"method":"PUT","path":"/ec_product_image/:identity"},{"method":"PUT","path":"/ec_product/:identity"},{"method":"PUT","path":"/ec_category/:identity"},{"method":"PUT","path":"/delivery_basic/:identity"},{"method":"PUT","path":"/delivery_account/:identity"},{"method":"PUT","path":"/dev_usage_stat/:identity"},{"method":"PUT","path":"/deposit_policy/:identity"},{"method":"PUT","path":"/gas_basic/:identity"},{"method":"PUT","path":"/gas_account/:identity"},{"method":"PUT","path":"/gasorder_contract/:identity"},{"method":"PUT","path":"/user_account/:identity"},{"method":"PUT","path":"/user_address/:identity"},{"method":"PUT","path":"/user_service_relation/:identity"},{"method":"PUT","path":"/staff_account/:identity"},{"method":"PUT","path":"/staff_credential/:identity"},{"method":"PUT","path":"/cms_content/:identity"},{"method":"PUT","path":"/cs_ticket/:identity"},{"method":"PUT","path":"/auth/password"},{"method":"PUT","path":"/fin_settlement/:identity"},{"method":"PATCH","path":"/product_info/:identity/status"},{"method":"PATCH","path":"/product_info/:identity/lifecycle"},{"method":"PATCH","path":"/product_type/:identity/status"},{"method":"PATCH","path":"/product_warehouse/:identity/status"},{"method":"PATCH","path":"/product_repair/:identity/status"},{"method":"PATCH","path":"/producer_account/:identity/status"},{"method":"PATCH","path":"/platform_account/:identity/status"},{"method":"PATCH","path":"/platform_role/:identity/status"},{"method":"PATCH","path":"/ec_product_attribute/:identity/status"},{"method":"PATCH","path":"/ec_product_image/:identity/status"},{"method":"PATCH","path":"/ec_product/:identity/status"},{"method":"PATCH","path":"/ec_category/:identity/status"},{"method":"PATCH","path":"/delivery_basic/:identity/status"},{"method":"PATCH","path":"/delivery_account/:identity/status"},{"method":"PATCH","path":"/dev_usage_stat/:identity/status"},{"method":"PATCH","path":"/deposit_policy/:identity/status"},{"method":"PATCH","path":"/user_account/:identity/status"},{"method":"PATCH","path":"/user_address/:identity/status"},{"method":"PATCH","path":"/user_service_relation/:identity/status"},{"method":"PATCH","path":"/gas_basic/:identity/status"},{"method":"PATCH","path":"/gas_account/:identity/status"},{"method":"PATCH","path":"/staff_account/:identity/status"},{"method":"PATCH","path":"/staff_credential/:identity/status"},{"method":"PATCH","path":"/cms_content/:identity/status"},{"method":"PATCH","path":"/cs_ticket/:identity/status"},{"method":"PATCH","path":"/wallet_basic/:identity/status"},{"method":"PATCH","path":"/fin_settlement/:identity/status"},{"method":"DELETE","path":"/ec_product_attribute/:identity"},{"method":"DELETE","path":"/ec_product_image/:identity"},{"method":"DELETE","path":"/ec_product/:identity"},{"method":"DELETE","path":"/ec_category/:identity"},{"method":"DELETE","path":"/user_account/:identity"},{"method":"DELETE","path":"/user_address/:identity"},{"method":"DELETE","path":"/user_service_relation/:identity"},{"method":"DELETE","path":"/delivery_basic/:identity"},{"method":"DELETE","path":"/delivery_account/:identity"},{"method":"DELETE","path":"/deposit_policy/:identity"},{"method":"DELETE","path":"/platform_account/:identity"},{"method":"DELETE","path":"/platform_role/:identity"},{"method":"DELETE","path":"/producer_account/:identity"},{"method":"DELETE","path":"/gas_basic/:identity"},{"method":"DELETE","path":"/gas_account/:identity"},{"method":"DELETE","path":"/staff_account/:identity"},{"method":"DELETE","path":"/staff_credential/:identity"},{"method":"DELETE","path":"/cms_content/:identity"},{"method":"DELETE","path":"/cs_ticket/:identity"},{"method":"DELETE","path":"/fin_settlement/:identity"}]} diff --git a/frontend/platform_admin/src/router/routes/modules/platform.ts b/frontend/platform_admin/src/router/routes/modules/platform.ts index 99f63f7..ff1f807 100644 --- a/frontend/platform_admin/src/router/routes/modules/platform.ts +++ b/frontend/platform_admin/src/router/routes/modules/platform.ts @@ -223,6 +223,14 @@ const routes: AppRouteRecordRaw[] = [ '/product_info', 'product_info', ), + child( + 'product', + 'usage-statistics', + 'usage-statistics', + '用气统计数据', + '/dev_usage_stat', + 'dev_usage_stat', + ), child( 'product', 'repair', @@ -386,8 +394,6 @@ const routes: AppRouteRecordRaw[] = [ '商品图片', '/ec_product_image', 'ec_product', - true, - 'ec-products', ), child('ec', 'carts', 'carts', '购物车', '/ec_cart', 'ec_cart'), child('ec', 'orders', 'orders', '商城订单', '/ec_order', 'ec_order'), @@ -402,6 +408,9 @@ const routes: AppRouteRecordRaw[] = [ 'ec-orders', ), child('ec', 'reviews', 'reviews', '商品评价', '/ec_review', 'ec_review'), + child('ec', 'deposit-policies', 'deposit-policies', '押金规则', '/deposit_policy', 'deposit_policy'), + child('ec', 'deposit-records', 'deposit-records', '押金记录', '/deposit_record', 'deposit_record'), + child('ec', 'deposit-returns', 'deposit-returns', '退瓶处理', '/deposit_return_request', 'deposit_return_request'), ]), financeRoute, group('content', 'content', '内容管理', 'icon-file', 100, [ diff --git a/frontend/platform_admin/src/views/resource/ProductImageField.vue b/frontend/platform_admin/src/views/resource/ProductImageField.vue new file mode 100644 index 0000000..5f20657 --- /dev/null +++ b/frontend/platform_admin/src/views/resource/ProductImageField.vue @@ -0,0 +1,67 @@ + + + + + + diff --git a/frontend/platform_admin/src/views/resource/ProductImagePreview.vue b/frontend/platform_admin/src/views/resource/ProductImagePreview.vue new file mode 100644 index 0000000..7bdea40 --- /dev/null +++ b/frontend/platform_admin/src/views/resource/ProductImagePreview.vue @@ -0,0 +1,21 @@ + + + + diff --git a/frontend/platform_admin/src/views/resource/ResourceDetailContent.vue b/frontend/platform_admin/src/views/resource/ResourceDetailContent.vue index 1e2d8f8..20dfc8a 100644 --- a/frontend/platform_admin/src/views/resource/ResourceDetailContent.vue +++ b/frontend/platform_admin/src/views/resource/ResourceDetailContent.vue @@ -8,7 +8,8 @@
{{ entry.label }} - + + 查看内容 @@ -88,6 +89,7 @@ import { import type { ResourceRow } from '@/api/resource-page-rules'; import type { ResourceUiDefinition } from '@/api/resources'; import IdentityText from '@/components/IdentityText.vue'; +import ProductImagePreview from './ProductImagePreview.vue'; import RelationNameText from '@/views/shared/RelationNameText.vue'; const props = defineProps<{ diff --git a/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue b/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue index e61b311..fb63482 100644 --- a/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue +++ b/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue @@ -140,6 +140,11 @@
+ 重置 + + + 商品图片 + 返回来源列表 @@ -45,8 +49,12 @@ :tooltip="!isProtectedListAvatarField(definition.name, field.key) && !field.listRelationNameOnly" >