feat: 切换邮箱认证并重构前后端启动与门禁

This commit is contained in:
qzl
2026-04-02 18:39:35 +08:00
parent 92cdfd9fca
commit 31594558eb
116 changed files with 5608 additions and 628 deletions
+69
View File
@@ -0,0 +1,69 @@
import '../../data/storage/local_kv_store.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class SessionStore {
SessionStore(this._kvStore);
final LocalKvStore _kvStore;
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
static const String _tokenKey = 'auth_token';
static const String _refreshTokenKey = 'auth_refresh_token';
static const String _emailKey = 'saved_email';
static const String _welcomeReadKey = 'has_seen_welcome_dialog';
static const String _localeKey = 'selected_locale';
Future<void> saveToken(String token) async {
await _secureStorage.write(key: _tokenKey, value: token);
}
Future<String?> getToken() async {
return _secureStorage.read(key: _tokenKey);
}
Future<void> clearToken() async {
await _secureStorage.delete(key: _tokenKey);
}
Future<void> saveRefreshToken(String refreshToken) async {
await _secureStorage.write(key: _refreshTokenKey, value: refreshToken);
}
Future<String?> getRefreshToken() async {
return _secureStorage.read(key: _refreshTokenKey);
}
Future<void> clearRefreshToken() async {
await _secureStorage.delete(key: _refreshTokenKey);
}
Future<void> saveEmail(String email) async {
await _secureStorage.write(key: _emailKey, value: email);
}
Future<String?> getEmail() async {
return _secureStorage.read(key: _emailKey);
}
Future<void> clearEmail() async {
await _secureStorage.delete(key: _emailKey);
}
Future<void> setWelcomeRead(bool value) async {
await _kvStore.setBool(_welcomeReadKey, value);
}
Future<bool> hasReadWelcome() async {
return _kvStore.getBool(_welcomeReadKey);
}
Future<void> saveLocaleCode(String localeCode) async {
await _kvStore.setString(_localeKey, localeCode);
}
Future<String?> getLocaleCode() async {
return _kvStore.getString(_localeKey);
}
}
+17
View File
@@ -0,0 +1,17 @@
import 'dart:io';
class Env {
static String get backendUrl {
final injected = const String.fromEnvironment('BACKEND_URL');
if (injected.isNotEmpty && injected != 'false') {
return injected;
}
if (Platform.isAndroid) {
return 'http://10.0.2.2:5775';
}
return 'http://localhost:5775';
}
static Future<void> init() async {}
}
+18
View File
@@ -0,0 +1,18 @@
import 'package:flutter/foundation.dart';
import 'logger.dart';
class AppErrorHandler {
final Logger _logger = getLogger('flutter.error');
void register() {
FlutterError.onError = (details) {
_logger.error(
message: 'FlutterError: ${details.exceptionAsString()}',
error: details.exceptionAsString(),
stackTrace: details.stack ?? StackTrace.current,
extra: {'context': 'FlutterError.onError'},
);
FlutterError.presentError(details);
};
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'log_entry.dart';
enum LogOutput { console, file }
class LogConfig {
final LogLevel minLevel;
final LogOutput output;
final String logFileName;
final String logDir;
const LogConfig({
this.minLevel = LogLevel.debug,
this.output = LogOutput.console,
this.logFileName = 'app.log',
this.logDir = 'logs',
});
static LogConfig forDebug() =>
const LogConfig(minLevel: LogLevel.debug, output: LogOutput.console);
static LogConfig forRelease() => const LogConfig(
minLevel: LogLevel.warning,
output: LogOutput.file,
logFileName: 'app.log',
logDir: 'logs',
);
}
+78
View File
@@ -0,0 +1,78 @@
enum LogLevel { debug, info, warning, error }
class LogEntry {
final DateTime timestamp;
final LogLevel level;
final String message;
final String module;
final String? funcName;
final int? lineNo;
final String? errorType;
final String? errorMessage;
final String? stackTrace;
final Map<String, dynamic>? extra;
LogEntry({
required this.timestamp,
required this.level,
required this.message,
required this.module,
this.funcName,
this.lineNo,
this.errorType,
this.errorMessage,
this.stackTrace,
this.extra,
});
Map<String, dynamic> toJson() => {
'timestamp': timestamp.toIso8601String(),
'level': level.name,
'message': message,
'module': module,
if (funcName != null) 'func_name': funcName,
if (lineNo != null) 'line_no': lineNo,
if (errorType != null) 'error_type': errorType,
if (errorMessage != null) 'error_message': errorMessage,
if (stackTrace != null) 'stack_trace': stackTrace,
if (extra != null && extra!.isNotEmpty) 'extra': extra,
};
String toConsoleString() {
final ts = timestamp.toIso8601String();
final location = [
if (funcName != null) funcName,
if (lineNo != null) '@$lineNo',
].join('');
final locationStr = location.isNotEmpty ? ' [$location]' : '';
final errorStr = errorType != null ? ' [$errorType]' : '';
final errorMsgStr = errorMessage != null ? ' $errorMessage' : '';
final extraStr = extra != null && extra!.isNotEmpty ? ' $extra' : '';
return '$ts ${level.name.toUpperCase().padRight(7)} [$module$locationStr]$errorStr $message$errorMsgStr$extraStr';
}
String toFileString() {
final sb = StringBuffer();
sb.writeln('[$timestamp] ${level.name.toUpperCase()} [$module]');
if (funcName != null || lineNo != null) {
sb.write(' at ${funcName ?? ''}');
if (lineNo != null) sb.write(':$lineNo');
sb.writeln();
}
sb.writeln(' $message');
if (errorType != null) {
sb.writeln(' Error: $errorType');
}
if (errorMessage != null) {
sb.writeln(' ErrorMessage: $errorMessage');
}
if (stackTrace != null) {
sb.writeln(' StackTrace:');
sb.writeln(stackTrace);
}
if (extra != null && extra!.isNotEmpty) {
sb.writeln(' Extra: $extra');
}
return sb.toString();
}
}
@@ -0,0 +1,36 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
class LogFileHandler {
File? _file;
IOSink? _sink;
Future<void> init(String logDir, String logFileName) async {
final dir = await getApplicationDocumentsDirectory();
final logPath = '${dir.path}/$logDir';
await Directory(logPath).create(recursive: true);
_file = File('$logPath/$logFileName');
_sink = _file!.openWrite(mode: FileMode.append);
}
void write(String content) {
_sink?.writeln(content);
}
Future<void> flush() async {
await _sink?.flush();
}
Future<void> close() async {
await _sink?.close();
_sink = null;
_file = null;
}
Future<List<String>> readAllLines() async {
if (_file == null || !await _file!.exists()) return [];
return await _file!.readAsLines();
}
String? get filePath => _file?.path;
}
+172
View File
@@ -0,0 +1,172 @@
import 'package:flutter/foundation.dart';
import 'log_config.dart';
import 'log_entry.dart';
import 'log_file_handler.dart';
class LogService {
final LogConfig _config;
LogFileHandler? _fileHandler;
final _buffer = <String>[];
static const _maxBufferSize = 50;
LogService._({required LogConfig config}) : _config = config;
static Future<LogService> create({LogConfig? config}) async {
final isRelease = kReleaseMode;
final effectiveConfig =
config ?? (isRelease ? LogConfig.forRelease() : LogConfig.forDebug());
final service = LogService._(config: effectiveConfig);
if (effectiveConfig.output == LogOutput.file) {
service._fileHandler = LogFileHandler();
await service._fileHandler!.init(
effectiveConfig.logDir,
effectiveConfig.logFileName,
);
}
return service;
}
String? get logFilePath => _fileHandler?.filePath;
void _log(LogEntry entry) {
if (entry.level.index < _config.minLevel.index) return;
if (_config.output == LogOutput.console) {
debugPrint(entry.toConsoleString());
if (entry.stackTrace != null) {
debugPrint(entry.stackTrace!);
}
} else {
_buffer.add(entry.toFileString());
if (_buffer.length >= _maxBufferSize) {
_flushBuffer();
}
}
}
void _flushBuffer() {
for (final line in _buffer) {
_fileHandler?.write(line);
}
_buffer.clear();
_fileHandler?.flush();
}
(String?, int?) _extractLocation(StackTrace stackTrace) {
final frames = stackTrace.toString().split('\n');
for (final frame in frames) {
if (frame.contains('.dart')) {
final match = RegExp(
r'#\d+\s+(.+?)\s+\((.+?):(\d+)\)',
).firstMatch(frame);
if (match != null) {
return (match.group(1), int.tryParse(match.group(3) ?? ''));
}
}
}
return (null, null);
}
void debug({
required String message,
required String module,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
final trace = stackTrace ?? StackTrace.current;
final (funcName, lineNo) = _extractLocation(trace);
_log(
LogEntry(
timestamp: DateTime.now(),
level: LogLevel.debug,
message: message,
module: module,
funcName: funcName,
lineNo: lineNo,
extra: extra,
stackTrace: trace.toString(),
),
);
}
void info({
required String message,
required String module,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
final trace = stackTrace ?? StackTrace.current;
final (funcName, lineNo) = _extractLocation(trace);
_log(
LogEntry(
timestamp: DateTime.now(),
level: LogLevel.info,
message: message,
module: module,
funcName: funcName,
lineNo: lineNo,
extra: extra,
stackTrace: trace.toString(),
),
);
}
void warning({
required String message,
required String module,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
final trace = stackTrace ?? StackTrace.current;
final (funcName, lineNo) = _extractLocation(trace);
_log(
LogEntry(
timestamp: DateTime.now(),
level: LogLevel.warning,
message: message,
module: module,
funcName: funcName,
lineNo: lineNo,
extra: extra,
stackTrace: trace.toString(),
),
);
}
void error({
required String message,
required Object error,
required StackTrace stackTrace,
required String module,
Map<String, dynamic>? extra,
}) {
final (funcName, lineNo) = _extractLocation(stackTrace);
_log(
LogEntry(
timestamp: DateTime.now(),
level: LogLevel.error,
message: message,
module: module,
funcName: funcName,
lineNo: lineNo,
errorType: error.runtimeType.toString(),
errorMessage: error.toString(),
stackTrace: stackTrace.toString(),
extra: extra,
),
);
}
void flush() {
_flushBuffer();
_fileHandler?.flush();
}
Future<List<String>> readLogs() async {
return await _fileHandler?.readAllLines() ?? [];
}
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/foundation.dart';
import 'log_entry.dart';
import 'log_service.dart';
LogService? _globalLogService;
class Logger {
final String module;
final LogService? _service;
final bool _isNoOp;
Logger(this.module, this._service) : _isNoOp = _service == null;
factory Logger.get(String module) {
return Logger(module, _globalLogService);
}
static void setLogService(LogService service) {
_globalLogService = service;
}
void debug({
required String message,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
if (_isNoOp) return;
_service!.debug(
message: message,
module: module,
extra: extra ?? {},
stackTrace: stackTrace,
);
}
void info({
required String message,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
if (_isNoOp) return;
_service!.info(
message: message,
module: module,
extra: extra ?? {},
stackTrace: stackTrace,
);
}
void warning({
required String message,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
if (_isNoOp) return;
_service!.warning(
message: message,
module: module,
extra: extra ?? {},
stackTrace: stackTrace,
);
}
void error({
required String message,
required Object error,
required StackTrace stackTrace,
Map<String, dynamic>? extra,
}) {
final entry = LogEntry(
timestamp: DateTime.now(),
level: LogLevel.error,
message: message,
module: module,
errorType: error.runtimeType.toString(),
errorMessage: error.toString(),
stackTrace: stackTrace.toString(),
extra: extra,
);
if (_isNoOp) {
debugPrint(entry.toConsoleString());
return;
}
_service!.error(
message: message,
error: error,
stackTrace: stackTrace,
module: module,
extra: extra,
);
}
}
Logger getLogger(String module) => Logger.get(module);
+22
View File
@@ -0,0 +1,22 @@
class ApiProblem implements Exception {
ApiProblem({
required this.status,
required this.title,
required this.detail,
this.code,
});
final int status;
final String title;
final String detail;
final String? code;
String toUserMessage() {
return 'Request failed';
}
@override
String toString() {
return toUserMessage();
}
}
@@ -0,0 +1,22 @@
import '../../l10n/app_localizations.dart';
import 'api_problem.dart';
String mapApiProblemToMessage(ApiProblem problem, AppLocalizations l10n) {
switch (problem.code) {
case 'AUTH_TOO_MANY_REQUESTS':
return l10n.errorTooManyRequests;
case 'AUTH_VERIFICATION_CODE_INVALID':
return l10n.errorInvalidVerificationCode;
case 'AUTH_REFRESH_TOKEN_INVALID':
return l10n.errorSessionExpired;
case 'AUTH_SERVICE_UNAVAILABLE':
return l10n.errorServiceUnavailable;
default:
break;
}
if (problem.status >= 500) {
return l10n.errorServerGeneric;
}
return l10n.errorRequestGeneric;
}