Initial project import
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
|
||||
class ApiClient {
|
||||
ApiClient._();
|
||||
static final ApiClient instance = ApiClient._();
|
||||
|
||||
static const _storage = FlutterSecureStorage();
|
||||
static const _tokenKey = 'auth_token';
|
||||
static final sessionExpired = ValueNotifier<int>(0);
|
||||
static bool _handlingUnauthorized = false;
|
||||
static const _internalBuild = bool.fromEnvironment('INTERNAL_BUILD');
|
||||
static const _internalTestBaseUrl = 'https://lt.frp-say.com:38012';
|
||||
static const _configuredBaseUrl = String.fromEnvironment('API_BASE_URL');
|
||||
static const _temporaryFrpHost = 'lt.frp-say.com';
|
||||
static const _temporaryFrpPort = 38012;
|
||||
static const _temporaryFrpCertificateSha1 =
|
||||
'509C3210161E72FB9DA5183D3D2152F63871C31F';
|
||||
|
||||
static const String baseUrl = _configuredBaseUrl != ''
|
||||
? _configuredBaseUrl
|
||||
: _internalBuild
|
||||
? _internalTestBaseUrl
|
||||
: 'https://api.invalid';
|
||||
|
||||
static bool get isInternalBuild => _internalBuild;
|
||||
|
||||
late final Dio dio = _createDio();
|
||||
|
||||
Dio _createDio() {
|
||||
final client =
|
||||
Dio(
|
||||
BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 120),
|
||||
),
|
||||
)
|
||||
..interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
final token = await _storage.read(key: _tokenKey);
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
onError: (error, handler) async {
|
||||
final unauthorized = error.response?.statusCode == 401;
|
||||
final data = error.response?.data;
|
||||
final aiDenied =
|
||||
error.response?.statusCode == 403 &&
|
||||
data is Map &&
|
||||
data['code'] == 'AI_PERMISSION_DENIED';
|
||||
if (aiDenied) {
|
||||
await SessionStore.instance.setAiEnabled(false);
|
||||
}
|
||||
final isAuthRequest = error.requestOptions.path.startsWith(
|
||||
'/api/auth/',
|
||||
);
|
||||
if (unauthorized && !isAuthRequest && !_handlingUnauthorized) {
|
||||
_handlingUnauthorized = true;
|
||||
await SessionStore.instance.markNeedsReauth();
|
||||
sessionExpired.value++;
|
||||
Future<void>.delayed(
|
||||
const Duration(seconds: 1),
|
||||
() => _handlingUnauthorized = false,
|
||||
);
|
||||
}
|
||||
handler.next(error);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final apiUri = Uri.tryParse(baseUrl);
|
||||
if (_internalBuild &&
|
||||
apiUri?.scheme == 'https' &&
|
||||
apiUri?.host == _temporaryFrpHost &&
|
||||
apiUri?.port == _temporaryFrpPort) {
|
||||
client.httpClientAdapter = IOHttpClientAdapter(
|
||||
createHttpClient: () {
|
||||
final httpClient = HttpClient();
|
||||
httpClient.badCertificateCallback = (certificate, host, port) {
|
||||
final certificateSha1 = certificate.sha1
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toUpperCase();
|
||||
return host == _temporaryFrpHost &&
|
||||
port == _temporaryFrpPort &&
|
||||
certificateSha1 == _temporaryFrpCertificateSha1;
|
||||
};
|
||||
return httpClient;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
Future<void> saveToken(String token) =>
|
||||
_storage.write(key: _tokenKey, value: token);
|
||||
Future<String?> readToken() => _storage.read(key: _tokenKey);
|
||||
Future<void> clearToken() => _storage.delete(key: _tokenKey);
|
||||
}
|
||||
|
||||
bool isConnectivityError(Object error) =>
|
||||
error is DioException &&
|
||||
(error.type == DioExceptionType.connectionTimeout ||
|
||||
error.type == DioExceptionType.connectionError ||
|
||||
error.type == DioExceptionType.receiveTimeout ||
|
||||
error.type == DioExceptionType.sendTimeout);
|
||||
String apiErrorMessage(Object e) {
|
||||
if (e is OnlineFeatureRequiredException) return e.message;
|
||||
if (e is StateError) return e.message;
|
||||
if (e is DioException) {
|
||||
final data = e.response?.data;
|
||||
if (data is Map && data['message'] != null)
|
||||
return data['message'] as String;
|
||||
if (e.type == DioExceptionType.connectionTimeout ||
|
||||
e.type == DioExceptionType.connectionError ||
|
||||
e.type == DioExceptionType.receiveTimeout) {
|
||||
return '无法连接服务器,请检查网络';
|
||||
}
|
||||
}
|
||||
return '出错了,请稍后再试';
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
|
||||
import 'package:miaoji_zhang/shared/services/local_export_service.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
||||
|
||||
class AiCompanion {
|
||||
final String avatarKey;
|
||||
final String personaKey;
|
||||
final String? customName;
|
||||
final int roastLevel, stickerFrequency, proactiveLevel;
|
||||
|
||||
AiCompanion.fromJson(Map<String, dynamic> json)
|
||||
: avatarKey = json['avatarKey'] as String,
|
||||
personaKey = json['personaKey'] as String,
|
||||
customName = json['customName'] as String?,
|
||||
roastLevel = json['roastLevel'] as int,
|
||||
stickerFrequency = json['stickerFrequency'] as int,
|
||||
proactiveLevel = json['proactiveLevel'] as int;
|
||||
}
|
||||
|
||||
class UserProfile {
|
||||
final int userId;
|
||||
final String username;
|
||||
final String? nickname;
|
||||
final String appMode;
|
||||
final AiCompanion? aiCompanion;
|
||||
final bool onboardingDone;
|
||||
final bool aiEnabled;
|
||||
|
||||
UserProfile.fromJson(Map<String, dynamic> json)
|
||||
: userId = (json['userId'] as num).toInt(),
|
||||
username = json['username'] as String,
|
||||
nickname = json['nickname'] as String?,
|
||||
appMode = json['appMode'] as String,
|
||||
aiCompanion = json['aiCompanion'] == null
|
||||
? null
|
||||
: AiCompanion.fromJson(json['aiCompanion'] as Map<String, dynamic>),
|
||||
onboardingDone = json['onboardingDone'] as bool,
|
||||
aiEnabled =
|
||||
((json['permissions'] as Map<String, dynamic>?)?['ai'] as bool?) ??
|
||||
true;
|
||||
|
||||
const UserProfile.local({
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.nickname,
|
||||
required this.appMode,
|
||||
required this.onboardingDone,
|
||||
this.aiEnabled = false,
|
||||
}) : aiCompanion = null;
|
||||
}
|
||||
|
||||
class AuthApi {
|
||||
static final _dio = ApiClient.instance.dio;
|
||||
|
||||
static Future<void> register(
|
||||
String username,
|
||||
String password, {
|
||||
required bool agreedToTerms,
|
||||
}) async {
|
||||
final response = await _dio.post(
|
||||
'/api/auth/register',
|
||||
data: {
|
||||
'username': username,
|
||||
'password': password,
|
||||
'agreedToTerms': agreedToTerms,
|
||||
},
|
||||
);
|
||||
await ApiClient.instance.saveToken(response.data['token'] as String);
|
||||
await me();
|
||||
}
|
||||
|
||||
static Future<bool> login(String username, String password) async {
|
||||
final response = await _dio.post(
|
||||
'/api/auth/login',
|
||||
data: {'username': username, 'password': password},
|
||||
);
|
||||
await ApiClient.instance.saveToken(response.data['token'] as String);
|
||||
return response.data['accountClosureCancelled'] as bool? ?? false;
|
||||
}
|
||||
|
||||
static Future<UserProfile> me() async {
|
||||
final session = SessionStore.instance;
|
||||
if (session.isGuest || (session.isAccount && session.shouldUseLocalOnly)) {
|
||||
return _localProfile();
|
||||
}
|
||||
try {
|
||||
final response = await _dio.get('/api/users/me');
|
||||
final profile = UserProfile.fromJson(
|
||||
response.data as Map<String, dynamic>,
|
||||
);
|
||||
await _cacheProfile(profile);
|
||||
return profile;
|
||||
} catch (error) {
|
||||
if (session.isAccount &&
|
||||
(isConnectivityError(error) || session.needsReauth)) {
|
||||
return _localProfile();
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<UserProfile> completeOnboarding({
|
||||
required String appMode,
|
||||
required String avatarKey,
|
||||
required String personaKey,
|
||||
String? customName,
|
||||
}) async {
|
||||
SessionStore.instance.requireOnline('完成账号初始化需要连接网络');
|
||||
final response = await _dio.post(
|
||||
'/api/users/me/onboarding',
|
||||
data: {
|
||||
'appMode': appMode,
|
||||
'avatarKey': avatarKey,
|
||||
'personaKey': personaKey,
|
||||
'customName': customName,
|
||||
},
|
||||
);
|
||||
final profile = UserProfile.fromJson(response.data as Map<String, dynamic>);
|
||||
await _cacheProfile(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
static Future<UserProfile> switchMode(String appMode) async {
|
||||
SessionStore.instance.requireOnline('切换 AI 模式需要登录并连接网络');
|
||||
final response = await _dio.put(
|
||||
'/api/users/me/mode',
|
||||
data: {'appMode': appMode},
|
||||
);
|
||||
final profile = UserProfile.fromJson(response.data as Map<String, dynamic>);
|
||||
await _cacheProfile(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
static Future<UserProfile> updateCompanion({
|
||||
String? avatarKey,
|
||||
String? personaKey,
|
||||
String? customName,
|
||||
int? roastLevel,
|
||||
int? stickerFrequency,
|
||||
int? proactiveLevel,
|
||||
}) async {
|
||||
SessionStore.instance.requireOnline('AI 伙伴设置需要登录并连接网络');
|
||||
final response = await _dio.put(
|
||||
'/api/users/me/companion',
|
||||
data: {
|
||||
'avatarKey': avatarKey,
|
||||
'personaKey': personaKey,
|
||||
'customName': customName,
|
||||
'roastLevel': roastLevel,
|
||||
'stickerFrequency': stickerFrequency,
|
||||
'proactiveLevel': proactiveLevel,
|
||||
},
|
||||
);
|
||||
final profile = UserProfile.fromJson(response.data as Map<String, dynamic>);
|
||||
await _cacheProfile(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
static Future<UserProfile> updateProfile(String nickname) async {
|
||||
final session = SessionStore.instance;
|
||||
final normalized = nickname.trim();
|
||||
if (session.isGuest) {
|
||||
await session.updateNickname(normalized);
|
||||
return _localProfile();
|
||||
}
|
||||
session.requireOnline('修改云端资料需要连接网络');
|
||||
final previous = session.nickname;
|
||||
await session.updateNickname(normalized);
|
||||
try {
|
||||
final response = await _dio.put(
|
||||
'/api/users/me/profile',
|
||||
data: {'nickname': normalized},
|
||||
);
|
||||
final profile = UserProfile.fromJson(
|
||||
response.data as Map<String, dynamic>,
|
||||
);
|
||||
await _cacheProfile(profile);
|
||||
return profile;
|
||||
} catch (_) {
|
||||
await session.updateNickname(previous);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> changePassword(
|
||||
String currentPassword,
|
||||
String newPassword,
|
||||
) async {
|
||||
SessionStore.instance.requireOnline('修改密码需要连接网络');
|
||||
final response = await _dio.put(
|
||||
'/api/users/me/password',
|
||||
data: {'currentPassword': currentPassword, 'newPassword': newPassword},
|
||||
);
|
||||
await ApiClient.instance.saveToken(response.data['token'] as String);
|
||||
}
|
||||
|
||||
static Future<List<int>> exportData() async {
|
||||
if (SessionStore.instance.shouldUseLocalOnly) {
|
||||
return LocalExportService.buildZip();
|
||||
}
|
||||
try {
|
||||
final response = await _dio.get<List<int>>(
|
||||
'/api/users/me/export',
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
return response.data ?? const [];
|
||||
} catch (error) {
|
||||
if (isConnectivityError(error)) return LocalExportService.buildZip();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<DateTime> requestAccountClosure(
|
||||
String password,
|
||||
String confirmationText,
|
||||
) async {
|
||||
SessionStore.instance.requireOnline('注销账号需要连接网络');
|
||||
final response = await _dio.post(
|
||||
'/api/users/me/closure',
|
||||
data: {'password': password, 'confirmationText': confirmationText},
|
||||
);
|
||||
final scheduledAt = ShanghaiTime.parseCivil(
|
||||
response.data['scheduledAt'] as String,
|
||||
);
|
||||
await logout();
|
||||
return scheduledAt;
|
||||
}
|
||||
|
||||
static Future<void> deleteAccount(String password) async {
|
||||
SessionStore.instance.requireOnline('注销账号需要连接网络');
|
||||
await _dio.delete('/api/users/me', data: {'password': password});
|
||||
await logout();
|
||||
}
|
||||
|
||||
static Future<void> logout() async {
|
||||
CurrentLedgerStore.instance.clear();
|
||||
await ApiClient.instance.clearToken();
|
||||
await SessionStore.instance.clearActiveSession();
|
||||
}
|
||||
|
||||
static UserProfile _localProfile() {
|
||||
final session = SessionStore.instance;
|
||||
return UserProfile.local(
|
||||
userId: session.userId ?? 0,
|
||||
username: session.username ?? '游客',
|
||||
nickname: session.nickname,
|
||||
appMode: session.isGuest ? 'normal' : session.appMode,
|
||||
onboardingDone: session.isGuest || session.onboardingDone,
|
||||
aiEnabled: session.aiEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _cacheProfile(UserProfile profile) =>
|
||||
SessionStore.instance.activateAccount(
|
||||
userId: profile.userId,
|
||||
username: profile.username,
|
||||
nickname: profile.nickname,
|
||||
appMode: profile.appMode,
|
||||
onboardingDone: profile.onboardingDone,
|
||||
aiEnabled: profile.aiEnabled,
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
|
||||
class BrandConfig {
|
||||
final String appName, slogan, logoUrl;
|
||||
final bool voiceEnabled;
|
||||
final bool imageEnabled;
|
||||
final bool screenshotEnabled;
|
||||
final bool stickersEnabled;
|
||||
final bool aiAutoBookEnabled;
|
||||
|
||||
BrandConfig.fromJson(Map<String, dynamic> j)
|
||||
: appName = _appName(j['appName']),
|
||||
slogan = j['slogan'] as String? ?? '',
|
||||
logoUrl = j['logoUrl'] as String? ?? '',
|
||||
voiceEnabled = _feature(j, 'voice'),
|
||||
imageEnabled = _feature(j, 'image'),
|
||||
screenshotEnabled = _feature(j, 'screenshot'),
|
||||
stickersEnabled = _feature(j, 'stickers'),
|
||||
aiAutoBookEnabled = _feature(j, 'aiAutoBook');
|
||||
|
||||
static String _appName(Object? value) {
|
||||
final name = value?.toString().trim();
|
||||
return name == null || name.isEmpty || name == '喵记账' || name == '喵记'
|
||||
? '记之'
|
||||
: name;
|
||||
}
|
||||
|
||||
static bool _feature(Map<String, dynamic> json, String key) {
|
||||
final features = json['features'];
|
||||
return features is Map ? features[key] as bool? ?? true : true;
|
||||
}
|
||||
}
|
||||
|
||||
class AvatarItem {
|
||||
final String key, defaultName, speechTic;
|
||||
final String? imageUrl;
|
||||
AvatarItem.fromJson(Map<String, dynamic> j)
|
||||
: key = j['key'] as String,
|
||||
defaultName = j['defaultName'] as String,
|
||||
speechTic = j['speechTic'] as String? ?? '',
|
||||
imageUrl = j['imageUrl'] as String?;
|
||||
}
|
||||
|
||||
class PersonaItem {
|
||||
final String key, name, description, sampleLine;
|
||||
PersonaItem.fromJson(Map<String, dynamic> j)
|
||||
: key = j['key'] as String,
|
||||
name = j['name'] as String,
|
||||
description = j['description'] as String? ?? '',
|
||||
sampleLine = j['sampleLine'] as String? ?? '';
|
||||
}
|
||||
|
||||
class CompanionDisplay {
|
||||
final String name;
|
||||
final String avatarKey;
|
||||
|
||||
const CompanionDisplay({required this.name, required this.avatarKey});
|
||||
}
|
||||
|
||||
class PublicConfigApi {
|
||||
static final _dio = ApiClient.instance.dio;
|
||||
static BrandConfig? _cachedBrand;
|
||||
|
||||
static Future<void> init() async {
|
||||
if (!SessionStore.instance.isGuest) {
|
||||
try {
|
||||
final b = await _dio.get('/api/public/brand');
|
||||
_cachedBrand = BrandConfig.fromJson(b.data as Map<String, dynamic>);
|
||||
} catch (_) {}
|
||||
}
|
||||
await refreshCompanion();
|
||||
}
|
||||
|
||||
static String get appName => _cachedBrand?.appName ?? '记之';
|
||||
static String get slogan => _cachedBrand?.slogan ?? '';
|
||||
static bool get voiceEnabled => _cachedBrand?.voiceEnabled ?? true;
|
||||
static bool get imageEnabled => _cachedBrand?.imageEnabled ?? true;
|
||||
static bool get screenshotEnabled => _cachedBrand?.screenshotEnabled ?? true;
|
||||
static bool get stickersEnabled => _cachedBrand?.stickersEnabled ?? true;
|
||||
static bool get aiAutoBookEnabled => _cachedBrand?.aiAutoBookEnabled ?? true;
|
||||
|
||||
static final companionNotifier = ValueNotifier<CompanionDisplay>(
|
||||
const CompanionDisplay(name: 'AI', avatarKey: 'cat'),
|
||||
);
|
||||
|
||||
static String get companionName => companionNotifier.value.name;
|
||||
static String get companionAvatarKey => companionNotifier.value.avatarKey;
|
||||
|
||||
static Future<void> refreshCompanion() async {
|
||||
final session = SessionStore.instance;
|
||||
if (!session.isAccount || session.shouldUseLocalOnly) {
|
||||
companionNotifier.value = const CompanionDisplay(
|
||||
name: 'AI',
|
||||
avatarKey: 'cat',
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final responses = await Future.wait([
|
||||
_dio.get('/api/users/me'),
|
||||
_dio.get('/api/public/avatars'),
|
||||
]);
|
||||
final profile = responses[0].data as Map<String, dynamic>;
|
||||
final avatars = responses[1].data as List;
|
||||
final companion = profile['aiCompanion'] as Map<String, dynamic>?;
|
||||
final selectedAvatarKey = companion?['avatarKey'] as String?;
|
||||
final customName = _normalizeName(companion?['customName'] as String?);
|
||||
|
||||
var avatarKey = selectedAvatarKey;
|
||||
String? defaultName;
|
||||
|
||||
for (final item in avatars) {
|
||||
final avatar = item as Map<String, dynamic>;
|
||||
final key = avatar['key'] as String?;
|
||||
if ((avatarKey == null || avatarKey.isEmpty) &&
|
||||
key != null &&
|
||||
key.isNotEmpty) {
|
||||
avatarKey = key;
|
||||
}
|
||||
if (key == selectedAvatarKey) {
|
||||
defaultName = _normalizeName(avatar['defaultName'] as String?);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
companionNotifier.value = CompanionDisplay(
|
||||
name: customName ?? defaultName ?? '小账喵',
|
||||
avatarKey: avatarKey ?? 'cat',
|
||||
);
|
||||
} catch (_) {
|
||||
try {
|
||||
final a = await _dio.get('/api/public/avatars');
|
||||
final list = a.data as List;
|
||||
if (list.isEmpty) return;
|
||||
final first = list.first as Map<String, dynamic>;
|
||||
companionNotifier.value = CompanionDisplay(
|
||||
name: _normalizeName(first['defaultName'] as String?) ?? '小账喵',
|
||||
avatarKey: first['key'] as String? ?? 'cat',
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<AvatarItem>> avatars() async {
|
||||
final res = await _dio.get('/api/public/avatars');
|
||||
return (res.data as List)
|
||||
.map((e) => AvatarItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Future<List<PersonaItem>> personas() async {
|
||||
final res = await _dio.get('/api/public/personas');
|
||||
return (res.data as List)
|
||||
.map((e) => PersonaItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static String? _normalizeName(String? value) {
|
||||
final text = value?.trim();
|
||||
if (text == null || text.isEmpty) return null;
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
class SseFrameAccumulator {
|
||||
String _pending = '';
|
||||
|
||||
List<String> add(String chunk) {
|
||||
_pending = (_pending + chunk).replaceAll('\r\n', '\n');
|
||||
final frames = <String>[];
|
||||
var separator = _pending.indexOf('\n\n');
|
||||
while (separator >= 0) {
|
||||
final frame = _pending.substring(0, separator);
|
||||
_pending = _pending.substring(separator + 2);
|
||||
if (frame.trim().isNotEmpty) frames.add(frame);
|
||||
separator = _pending.indexOf('\n\n');
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
List<String> close() {
|
||||
final trailing = _pending.trim();
|
||||
_pending = '';
|
||||
return trailing.isEmpty ? const [] : [trailing];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user