已完成用户APP首期功能开发
交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
21
apps/user_app/lib/data/services/app_settings_service.dart
Normal file
21
apps/user_app/lib/data/services/app_settings_service.dart
Normal 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();
|
||||
}
|
||||
73
apps/user_app/lib/data/services/recharge_draft_store.dart
Normal file
73
apps/user_app/lib/data/services/recharge_draft_store.dart
Normal 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));
|
||||
}
|
||||
74
apps/user_app/lib/data/services/recharge_flow.dart
Normal file
74
apps/user_app/lib/data/services/recharge_flow.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
apps/user_app/lib/data/services/repair_draft_store.dart
Normal file
34
apps/user_app/lib/data/services/repair_draft_store.dart
Normal 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));
|
||||
}
|
||||
77
apps/user_app/lib/data/services/repair_speech.dart
Normal file
77
apps/user_app/lib/data/services/repair_speech.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user