已完成用户APP首期功能开发

交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
This commit is contained in:
czl231
2026-09-13 00:57:32 +08:00
parent a0a4bb1218
commit 3ef33b531d
793 changed files with 40959 additions and 1204 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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<List<PublishedContent>> 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<ServiceRelation?> 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<List<ProductSummary>> 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);
}

View File

@@ -57,6 +57,10 @@ const Map<int, String> _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<Object?> get(String path, {bool authenticated = true}) =>
_send('GET', path, authenticated: authenticated);
/// 读取需要鉴权的二进制资源;资源不存在时返回空值。
Future<Uint8List?> getBytes(String path) async {
Future<Uint8List?> 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<Object?> put(String path, {Map<String, Object?>? body}) => _send('PUT', path, body: body);
/// 上传头像二进制,沿用统一鉴权、错误解析和会话失效处理。
Future<Object?> uploadAvatar(Uint8List bytes, String filename) async {
return uploadImage('/upload/avatar', bytes, filename);
}
/// 仅由仓储传入固定受控端点,沿用统一鉴权与图片体积约束。
Future<Object?> 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<Object?> delete(String path, {Map<String, Object?>? body}) =>
_send('DELETE', path, body: body);

View File

@@ -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<String> 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();
}

View File

@@ -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<String, Object?> toJson() => {
'schema': 1,
'request': request,
'amount': amount,
'channel': channel,
'pay_type': payType,
};
factory PendingRecharge.fromJson(Map<String, Object?> 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<PendingRecharge?> read(String owner);
Future<void> write(String owner, PendingRecharge draft);
Future<void> 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<PendingRecharge?> read(String owner) async {
final raw = await _storage.read(key: _key(owner));
if (raw == null) return null;
return PendingRecharge.fromJson(Map<String, Object?>.from(jsonDecode(raw) as Map));
}
@override
Future<void> write(String owner, PendingRecharge draft) {
final valid = PendingRecharge.fromJson(draft.toJson());
return _storage.write(key: _key(owner), value: jsonEncode(valid.toJson()));
}
@override
Future<void> delete(String owner) => _storage.delete(key: _key(owner));
}

View File

@@ -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<Map<String, Object?>> 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<RechargeRecord?> 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;
}
}
}

View File

@@ -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<Map<String, Object?>?> read(String owner);
Future<void> write(String owner, Map<String, Object?> draft);
Future<void> 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<Map<String, Object?>?> 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<String, dynamic> || value['schema'] != 1) {
throw const FormatException('草稿版本无法读取');
}
return Map<String, Object?>.from(value);
}
@override
Future<void> write(String owner, Map<String, Object?> draft) =>
_storage.write(key: _key(owner), value: jsonEncode(draft));
@override
Future<void> delete(String owner) => _storage.delete(key: _key(owner));
}

View File

@@ -0,0 +1,77 @@
// 功能描述:故障描述语音识别适配,应用只复用一个系统识别实例,不保存录音。
// 版本1.0.0。
import 'package:speech_to_text/speech_to_text.dart';
/// 识别结果为本轮完整短句;调用方负责替换临时结果,不能逐次追加。
abstract interface class RepairSpeech {
Future<bool> start({
required void Function(String) onWords,
required void Function(String) onError,
required void Function() onDone,
});
Future<void> stop();
Future<void> 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<bool> 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<void> stop() => _speech.stop();
@override
Future<void> cancel() async {
++_generation;
_words = null;
_error = null;
_done = null;
await _speech.cancel();
}
}