Merge remote-tracking branch 'origin/dev' into cody/netwrok_SDK
# Conflicts: # apps/im_app/lib/features/chat/presentation/chat_db_test_view_model.dart # apps/im_app/lib/features/login/presentation/login_view_model.dart 修复逻辑漏洞,性能优化
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
library;
|
||||
|
||||
export 'src/presentation/facade/cipher_guard_sdk_api.dart';
|
||||
export 'src/data/datasources/encryption_flutter_service.dart' show KdfMode;
|
||||
export 'src/domain/entities/rsa_key_pair.dart';
|
||||
export 'src/domain/entities/session_key.dart';
|
||||
export 'src/domain/entities/encrypted_message.dart';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
@@ -7,30 +8,96 @@ import 'package:crypto/crypto.dart';
|
||||
import 'package:encrypt/encrypt.dart' as encrypt_pkg;
|
||||
import 'package:pointycastle/api.dart';
|
||||
import 'package:pointycastle/asymmetric/api.dart';
|
||||
import 'package:pointycastle/asymmetric/pkcs1.dart';
|
||||
import 'package:pointycastle/asymmetric/rsa.dart';
|
||||
import 'package:pointycastle/digests/sha256.dart';
|
||||
import 'package:pointycastle/key_derivators/api.dart';
|
||||
import 'package:pointycastle/key_derivators/pbkdf2.dart';
|
||||
import 'package:pointycastle/key_generators/api.dart';
|
||||
import 'package:pointycastle/key_generators/rsa_key_generator.dart';
|
||||
import 'package:pointycastle/macs/hmac.dart';
|
||||
import 'package:pointycastle/random/fortuna_random.dart';
|
||||
import 'package:pointycastle/asymmetric/rsa.dart';
|
||||
import 'package:pointycastle/asymmetric/pkcs1.dart';
|
||||
|
||||
/// Flutter Encryption Service
|
||||
/// Implements all encryption logic in Flutter using pointycastle and encrypt packages
|
||||
/// Replaces native Android/iOS encryption implementations
|
||||
/// 密钥派生模式
|
||||
///
|
||||
/// 决定 [EncryptionFlutterService._deriveKeyForRound] 使用哪种算法。
|
||||
/// 默认 [md5](UU 兼容),可选 [pbkdf2](增强安全性)。
|
||||
///
|
||||
/// 解密旧数据时必须使用加密时相同的模式,
|
||||
/// 通过消息的 version 字段区分。
|
||||
enum KdfMode {
|
||||
/// MD5 简单哈希(UU 兼容默认模式)
|
||||
///
|
||||
/// 适用于 session key 已是 32 字节强随机值的场景。
|
||||
/// 性能好,每次调用 < 0.1ms。
|
||||
md5,
|
||||
|
||||
/// PBKDF2-HMAC-SHA256(可选增强模式)
|
||||
///
|
||||
/// 适用于从弱密码派生密钥的场景。
|
||||
/// 性能取决于迭代次数,10000 次约 10-50ms。
|
||||
pbkdf2,
|
||||
}
|
||||
|
||||
/// Flutter 加密服务
|
||||
///
|
||||
/// 端对端加密的核心引擎,纯 Dart 实现。
|
||||
/// 使用 pointycastle(RSA)+ encrypt(AES)+ crypto(MD5)。
|
||||
///
|
||||
/// ## 性能优化
|
||||
///
|
||||
/// - **RSA 密钥生成**:通过 [generateRsaKeyPairAsync] 在 Isolate 中运行,
|
||||
/// 避免阻塞主线程(1024-bit 约 150ms,2048-bit 约 300ms)
|
||||
/// - **派生密钥缓存**:[_deriveKeyForRound] 结果按 (sessionKey, round) 缓存,
|
||||
/// 同一 session 的重复加解密直接命中缓存
|
||||
/// - **Random.secure() 复用**:全局单例,不再每次调用创建新实例
|
||||
/// - **KDF 双模式**:MD5(默认,UU 兼容)/ PBKDF2(可选,增强安全性)
|
||||
class EncryptionFlutterService {
|
||||
// ==================== Constants ====================
|
||||
// ==================== 配置 ====================
|
||||
|
||||
/// 密钥派生模式,默认 MD5(UU 兼容)
|
||||
final KdfMode kdfMode;
|
||||
|
||||
/// PBKDF2 迭代次数(仅 PBKDF2 模式有效,默认 10000)
|
||||
final int pbkdf2Iterations;
|
||||
|
||||
EncryptionFlutterService({
|
||||
this.kdfMode = KdfMode.md5,
|
||||
this.pbkdf2Iterations = 10000,
|
||||
});
|
||||
|
||||
// ==================== 常量 ====================
|
||||
|
||||
static const int sessionKeySize = 32;
|
||||
static const int gcmIvLength = 12;
|
||||
static const int _maxDerivedKeyCacheSize = 64;
|
||||
|
||||
// ==================== RSA Key Management ====================
|
||||
// ==================== 性能优化:复用 Random 实例 ====================
|
||||
|
||||
/// Generate RSA key pair in PEM format
|
||||
/// 全局 Random.secure() 单例,避免每次调用创建新实例
|
||||
static final Random _secureRandom = Random.secure();
|
||||
|
||||
// ==================== 性能优化:派生密钥 LRU 缓存 ====================
|
||||
|
||||
/// 派生密钥缓存:'sessionKey:round:mode' -> Uint8List
|
||||
///
|
||||
/// 同一 session + round 的加解密只派生一次,后续直接命中缓存。
|
||||
/// LinkedHashMap 保持插入顺序,满时淘汰最早条目。
|
||||
final _derivedKeyCache = <String, Uint8List>{};
|
||||
|
||||
/// 清空派生密钥缓存(session key 轮换时调用)
|
||||
void clearDerivedKeyCache() => _derivedKeyCache.clear();
|
||||
|
||||
// ==================== RSA 密钥管理 ====================
|
||||
|
||||
/// 生成 RSA 密钥对(同步,阻塞主线程)
|
||||
///
|
||||
/// 建议使用 [generateRsaKeyPairAsync] 代替,避免 UI 卡顿。
|
||||
RsaKeyPairResult generateRsaKeyPair({int keySize = 1024}) {
|
||||
try {
|
||||
// Get secure random
|
||||
final secureRandom = FortunaRandom();
|
||||
secureRandom.seed(KeyParameter(_generateSecureRandomBytes(32)));
|
||||
|
||||
// Create RSA key generator
|
||||
final keyGen = RSAKeyGenerator();
|
||||
keyGen.init(
|
||||
ParametersWithRandom(
|
||||
@@ -39,12 +106,10 @@ class EncryptionFlutterService {
|
||||
),
|
||||
);
|
||||
|
||||
// Generate key pair
|
||||
final keyPair = keyGen.generateKeyPair();
|
||||
final rsaPublicKey = keyPair.publicKey;
|
||||
final rsaPrivateKey = keyPair.privateKey;
|
||||
|
||||
// Export to PEM format
|
||||
final publicKeyPem = _encodeRSAPublicKey(rsaPublicKey);
|
||||
final privateKeyPem = _encodeRSAPrivateKey(rsaPrivateKey);
|
||||
|
||||
@@ -57,26 +122,38 @@ class EncryptionFlutterService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode RSA public key to PEM format using asn1lib
|
||||
/// 生成 RSA 密钥对(异步,在 Isolate 中运行,不阻塞主线程)
|
||||
///
|
||||
/// RSA 密钥生成是 CPU 密集型操作(1024-bit 约 150ms,2048-bit 约 300ms),
|
||||
/// 放在 Isolate 中避免主线程卡顿。
|
||||
///
|
||||
/// **Isolate 隔离说明**:
|
||||
/// Isolate 内会创建一个**默认配置**的 EncryptionFlutterService(KdfMode.md5),
|
||||
/// 不会继承当前实例的 kdfMode / pbkdf2Iterations。
|
||||
/// 这对 RSA 密钥生成没有影响(RSA 不走 KDF),但如果将来需要在
|
||||
/// Isolate 中执行依赖 KDF 的操作(如消息加解密),需要传递配置参数。
|
||||
Future<RsaKeyPairResult> generateRsaKeyPairAsync({int keySize = 1024}) async {
|
||||
return await Isolate.run(
|
||||
() => EncryptionFlutterService().generateRsaKeyPair(keySize: keySize),
|
||||
);
|
||||
}
|
||||
|
||||
/// 编码 RSA 公钥为 PEM 格式
|
||||
String _encodeRSAPublicKey(RSAPublicKey publicKey) {
|
||||
// Build RSAPublicKeyInfo structure
|
||||
final topSeq = ASN1Sequence();
|
||||
|
||||
// AlgorithmIdentifier: OID 1.2.840.113549.1.1.1 + NULL
|
||||
final algoSeq = ASN1Sequence();
|
||||
algoSeq.add(ASN1ObjectIdentifier([1, 2, 840, 113549, 1, 1, 1])); // RSA
|
||||
algoSeq.add(ASN1ObjectIdentifier([1, 2, 840, 113549, 1, 1, 1]));
|
||||
algoSeq.add(ASN1Null());
|
||||
topSeq.add(algoSeq);
|
||||
|
||||
// RSAPublicKey: modulus + publicExponent
|
||||
final keySeq = ASN1Sequence();
|
||||
keySeq.add(ASN1Integer(publicKey.n!));
|
||||
keySeq.add(ASN1Integer(publicKey.exponent!));
|
||||
|
||||
// BitString wrapping the key (with 0 unused bits prefix)
|
||||
final keyBytes = keySeq.encodedBytes;
|
||||
final keyList = List<int>.from(keyBytes);
|
||||
keyList.insert(0, 0); // Add unused bits byte
|
||||
keyList.insert(0, 0);
|
||||
topSeq.add(ASN1BitString(keyList));
|
||||
|
||||
final derBytes = topSeq.encodedBytes;
|
||||
@@ -84,51 +161,32 @@ class EncryptionFlutterService {
|
||||
return '-----BEGIN PUBLIC KEY-----\n$base64\n-----END PUBLIC KEY-----';
|
||||
}
|
||||
|
||||
/// Encode RSA private key to PEM format using asn1lib
|
||||
/// 编码 RSA 私钥为 PEM 格式
|
||||
String _encodeRSAPrivateKey(RSAPrivateKey privateKey) {
|
||||
// Build RSAPrivateKey structure (PKCS#8 format)
|
||||
final topSeq = ASN1Sequence();
|
||||
|
||||
// Version (0)
|
||||
topSeq.add(ASN1Integer(BigInt.zero));
|
||||
|
||||
// Modulus
|
||||
topSeq.add(ASN1Integer(privateKey.n!));
|
||||
|
||||
// Public Exponent
|
||||
topSeq.add(ASN1Integer(privateKey.exponent!));
|
||||
|
||||
// Private Exponent
|
||||
topSeq.add(ASN1Integer(privateKey.privateExponent!));
|
||||
|
||||
// Prime P
|
||||
topSeq.add(ASN1Integer(privateKey.p!));
|
||||
|
||||
// Prime Q
|
||||
topSeq.add(ASN1Integer(privateKey.q!));
|
||||
|
||||
// (Optional CRT params omitted for simplicity)
|
||||
|
||||
final derBytes = topSeq.encodedBytes;
|
||||
final base64 = base64Encode(derBytes.toList());
|
||||
return '-----BEGIN PRIVATE KEY-----\n$base64\n-----END PRIVATE KEY-----';
|
||||
}
|
||||
|
||||
// ==================== Private Key Encryption/Decryption ====================
|
||||
// ==================== 私钥加密/解密 ====================
|
||||
|
||||
/// Encrypt private key with password (AES-CBC with MD5-derived key)
|
||||
/// 用密码加密私钥(AES-CBC,密码通过 MD5 派生密钥)
|
||||
String encryptPrivateKey({
|
||||
required String privateKey,
|
||||
required String password,
|
||||
}) {
|
||||
try {
|
||||
// Generate AES key from MD5(password)
|
||||
final aesKey = _md5Hash(password);
|
||||
|
||||
// Generate random IV (16 bytes)
|
||||
final iv = _generateSecureRandomBytes(16);
|
||||
|
||||
// AES encrypt using encrypt package
|
||||
final secretKey = encrypt_pkg.Key(aesKey);
|
||||
final encryptor = encrypt_pkg.Encrypter(
|
||||
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.cbc),
|
||||
@@ -137,7 +195,6 @@ class EncryptionFlutterService {
|
||||
final encrypted = encryptor.encrypt(privateKey, iv: encrypt_pkg.IV(iv));
|
||||
final encryptedBytes = encrypted.bytes;
|
||||
|
||||
// Combine IV + encrypted data
|
||||
final combined = Uint8List(iv.length + encryptedBytes.length);
|
||||
combined.setAll(0, iv);
|
||||
combined.setAll(iv.length, encryptedBytes);
|
||||
@@ -148,23 +205,17 @@ class EncryptionFlutterService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt private key with password (AES-CBC with MD5-derived key)
|
||||
/// 用密码解密私钥(AES-CBC,密码通过 MD5 派生密钥)
|
||||
String decryptPrivateKey({
|
||||
required String encryptedPrivateKey,
|
||||
required String password,
|
||||
}) {
|
||||
try {
|
||||
// Generate AES key from MD5(password)
|
||||
final aesKey = _md5Hash(password);
|
||||
|
||||
// Decode Base64
|
||||
final combined = base64Decode(encryptedPrivateKey);
|
||||
|
||||
// Extract IV and encrypted data
|
||||
final iv = combined.sublist(0, 16);
|
||||
final encBytes = combined.sublist(16);
|
||||
|
||||
// AES decrypt
|
||||
final secretKey = encrypt_pkg.Key(aesKey);
|
||||
final encryptor = encrypt_pkg.Encrypter(
|
||||
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.cbc),
|
||||
@@ -181,9 +232,9 @@ class EncryptionFlutterService {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Session Key Management ====================
|
||||
// ==================== 会话密钥管理 ====================
|
||||
|
||||
/// Generate session key (32 bytes random)
|
||||
/// 生成会话密钥(32 字节随机)
|
||||
SessionKeyResult generateSessionKey({int initialRound = 1}) {
|
||||
final keyBytes = _generateSecureRandomBytes(sessionKeySize);
|
||||
final key = base64Encode(keyBytes);
|
||||
@@ -191,16 +242,14 @@ class EncryptionFlutterService {
|
||||
return SessionKeyResult(key: key, round: initialRound);
|
||||
}
|
||||
|
||||
/// Encrypt session key with RSA public key
|
||||
/// 用 RSA 公钥加密会话密钥
|
||||
String encryptSessionKey({
|
||||
required String sessionKey,
|
||||
required String publicKey,
|
||||
}) {
|
||||
try {
|
||||
// Parse RSA public key
|
||||
final rsaPublicKey = _parsePublicKey(publicKey);
|
||||
|
||||
// RSA encrypt using PKCS1 padding (like native implementations)
|
||||
final cipher = PKCS1Encoding(RSAEngine());
|
||||
cipher.init(true, PublicKeyParameter<RSAPublicKey>(rsaPublicKey));
|
||||
|
||||
@@ -211,16 +260,14 @@ class EncryptionFlutterService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt session key with RSA private key
|
||||
/// 用 RSA 私钥解密会话密钥
|
||||
String decryptSessionKey({
|
||||
required String encryptedSessionKey,
|
||||
required String privateKey,
|
||||
}) {
|
||||
try {
|
||||
// Parse RSA private key
|
||||
final rsaPrivateKey = _parsePrivateKey(privateKey);
|
||||
|
||||
// RSA decrypt using PKCS1 padding (like native implementations)
|
||||
final cipher = PKCS1Encoding(RSAEngine());
|
||||
cipher.init(false, PrivateKeyParameter<RSAPrivateKey>(rsaPrivateKey));
|
||||
|
||||
@@ -231,22 +278,18 @@ class EncryptionFlutterService {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Message Encryption/Decryption ====================
|
||||
// ==================== 消息加密/解密 ====================
|
||||
|
||||
/// Encrypt message (AES-CTR with round-based key derivation)
|
||||
/// 加密消息(AES-CTR,使用 round 派生密钥)
|
||||
EncryptedMessageResult encryptMessage({
|
||||
required String plaintext,
|
||||
required String sessionKey,
|
||||
required int round,
|
||||
}) {
|
||||
try {
|
||||
// Derive key for round
|
||||
final actualKey = _deriveKeyForRound(sessionKey, round);
|
||||
|
||||
// Generate random IV (16 bytes for CTR)
|
||||
final iv = _generateSecureRandomBytes(16);
|
||||
|
||||
// AES-CTR encrypt
|
||||
final secretKey = encrypt_pkg.Key(actualKey);
|
||||
final encryptor = encrypt_pkg.Encrypter(
|
||||
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.ctr),
|
||||
@@ -255,7 +298,6 @@ class EncryptionFlutterService {
|
||||
final encrypted = encryptor.encrypt(plaintext, iv: encrypt_pkg.IV(iv));
|
||||
final encryptedBytes = encrypted.bytes;
|
||||
|
||||
// Combine IV + encrypted data
|
||||
final combined = Uint8List(iv.length + encryptedBytes.length);
|
||||
combined.setAll(0, iv);
|
||||
combined.setAll(iv.length, encryptedBytes);
|
||||
@@ -268,24 +310,18 @@ class EncryptionFlutterService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt message (AES-CTR with round-based key derivation)
|
||||
/// 解密消息(AES-CTR,使用 round 派生密钥)
|
||||
String decryptMessage({
|
||||
required String encryptedData,
|
||||
required String sessionKey,
|
||||
required int round,
|
||||
}) {
|
||||
try {
|
||||
// Derive key for round
|
||||
final actualKey = _deriveKeyForRound(sessionKey, round);
|
||||
|
||||
// Decode Base64
|
||||
final combined = base64Decode(encryptedData);
|
||||
|
||||
// Extract IV and encrypted data
|
||||
final iv = combined.sublist(0, 16);
|
||||
final encBytes = combined.sublist(16);
|
||||
|
||||
// AES-CTR decrypt
|
||||
final secretKey = encrypt_pkg.Key(actualKey);
|
||||
final encryptor = encrypt_pkg.Encrypter(
|
||||
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.ctr),
|
||||
@@ -302,16 +338,16 @@ class EncryptionFlutterService {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Push Notification Decryption ====================
|
||||
// ==================== 推送通知解密 ====================
|
||||
|
||||
/// Set AES secret for push notification decryption
|
||||
/// 设置 AES secret(用于推送通知解密)
|
||||
void setAesSecret(String aesSecret) {
|
||||
_aesSecret = aesSecret;
|
||||
}
|
||||
|
||||
String? _aesSecret;
|
||||
|
||||
/// Decrypt push notification (AES-GCM)
|
||||
/// 解密推送通知(AES-GCM)
|
||||
String decryptPushNotification({required String encryptedData}) {
|
||||
try {
|
||||
final secret = _aesSecret;
|
||||
@@ -319,17 +355,11 @@ class EncryptionFlutterService {
|
||||
throw Exception('AES_SECRET not set');
|
||||
}
|
||||
|
||||
// Convert hex string to bytes
|
||||
final secretBytes = _hexStringToBytes(secret);
|
||||
|
||||
// Decode Base64
|
||||
final combined = base64Decode(encryptedData);
|
||||
|
||||
// Extract IV and encrypted data
|
||||
final iv = combined.sublist(0, gcmIvLength);
|
||||
final encBytes = combined.sublist(gcmIvLength);
|
||||
|
||||
// AES-GCM decrypt
|
||||
final secretKey = encrypt_pkg.Key(secretBytes);
|
||||
final encryptor = encrypt_pkg.Encrypter(
|
||||
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.gcm),
|
||||
@@ -346,37 +376,91 @@ class EncryptionFlutterService {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
// ==================== 内部方法 ====================
|
||||
|
||||
/// Generate secure random bytes
|
||||
/// 生成安全随机字节(复用全局 Random.secure() 实例)
|
||||
Uint8List _generateSecureRandomBytes(int length) {
|
||||
final random = Random.secure();
|
||||
final bytes = Uint8List(length);
|
||||
for (var i = 0; i < length; i++) {
|
||||
bytes[i] = random.nextInt(256);
|
||||
bytes[i] = _secureRandom.nextInt(256);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// MD5 hash
|
||||
/// MD5 哈希(用于密码派生密钥)
|
||||
Uint8List _md5Hash(String input) {
|
||||
final bytes = utf8.encode(input);
|
||||
final hash = md5.convert(bytes).bytes as Uint8List;
|
||||
return hash;
|
||||
final hash = md5.convert(bytes).bytes;
|
||||
return Uint8List.fromList(hash);
|
||||
}
|
||||
|
||||
/// Derive key for round (MD5 hash of session key)
|
||||
/// 按 round 派生 AES 密钥(带 LRU 缓存)
|
||||
///
|
||||
/// 支持两种模式:
|
||||
/// - [KdfMode.md5]:MD5(sessionKey + round),兼容模式,< 0.1ms
|
||||
/// - [KdfMode.pbkdf2]:PBKDF2-HMAC-SHA256(sessionKey, salt=round),约 10-50ms
|
||||
///
|
||||
/// 两种模式都会将 round 参与派生计算,保证不同 round 产出不同密钥。
|
||||
/// 缓存命中时直接返回,跳过计算。
|
||||
/// 缓存满时淘汰最久未访问的条目(LRU)。
|
||||
Uint8List _deriveKeyForRound(String sessionKey, int targetRound) {
|
||||
// Base64 decode session key
|
||||
final keyBytes = base64Decode(sessionKey);
|
||||
final modeName = kdfMode == KdfMode.md5 ? 'md5' : 'pbkdf2';
|
||||
final cacheKey = '$sessionKey:$targetRound:$modeName';
|
||||
|
||||
// Apply MD5 for the round (simplified version)
|
||||
final hash = md5.convert(keyBytes).bytes as Uint8List;
|
||||
// 缓存命中 — 移至末尾以维护 LRU 顺序
|
||||
final cached = _derivedKeyCache.remove(cacheKey);
|
||||
if (cached != null) {
|
||||
_derivedKeyCache[cacheKey] = cached;
|
||||
return cached;
|
||||
}
|
||||
|
||||
return hash;
|
||||
// 计算派生密钥
|
||||
final Uint8List result;
|
||||
switch (kdfMode) {
|
||||
case KdfMode.md5:
|
||||
// 将 sessionKey + round 一起参与 hash,保证不同 round 产出不同密钥
|
||||
final keyBytes = base64Decode(sessionKey);
|
||||
final roundBytes = utf8.encode(':$targetRound');
|
||||
final combined = Uint8List(keyBytes.length + roundBytes.length)
|
||||
..setRange(0, keyBytes.length, keyBytes)
|
||||
..setRange(
|
||||
keyBytes.length,
|
||||
keyBytes.length + roundBytes.length,
|
||||
roundBytes,
|
||||
);
|
||||
final hash = md5.convert(combined).bytes;
|
||||
result = Uint8List.fromList(hash);
|
||||
case KdfMode.pbkdf2:
|
||||
result = _pbkdf2Derive(sessionKey, targetRound);
|
||||
}
|
||||
|
||||
// LRU 淘汰:满时移除最久未访问的条目(Map 头部)
|
||||
if (_derivedKeyCache.length >= _maxDerivedKeyCacheSize) {
|
||||
_derivedKeyCache.remove(_derivedKeyCache.keys.first);
|
||||
}
|
||||
_derivedKeyCache[cacheKey] = result;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Parse RSA public key from PEM
|
||||
/// PBKDF2-HMAC-SHA256 密钥派生
|
||||
///
|
||||
/// salt 包含 round 信息,不同 round 派生不同密钥。
|
||||
/// 迭代次数由 [pbkdf2Iterations] 控制(默认 10000)。
|
||||
/// 输出 16 字节(AES-128 密钥)。
|
||||
Uint8List _pbkdf2Derive(String sessionKey, int targetRound) {
|
||||
final keyBytes = base64Decode(sessionKey);
|
||||
final salt = utf8.encode('round:$targetRound');
|
||||
|
||||
final derivator = PBKDF2KeyDerivator(HMac(SHA256Digest(), 64));
|
||||
derivator.init(
|
||||
Pbkdf2Parameters(Uint8List.fromList(salt), pbkdf2Iterations, 16),
|
||||
);
|
||||
|
||||
return derivator.process(Uint8List.fromList(keyBytes));
|
||||
}
|
||||
|
||||
/// 解析 RSA 公钥 PEM
|
||||
RSAPublicKey _parsePublicKey(String pem) {
|
||||
final base64 = pem
|
||||
.replaceAll('-----BEGIN PUBLIC KEY-----', '')
|
||||
@@ -385,7 +469,6 @@ class EncryptionFlutterService {
|
||||
.trim();
|
||||
final bytes = base64Decode(base64);
|
||||
|
||||
// Parse ASN.1 DER format
|
||||
final asn1Parser = ASN1Parser(Uint8List.fromList(bytes));
|
||||
final topLevelSeq = asn1Parser.nextObject() as ASN1Sequence;
|
||||
|
||||
@@ -403,7 +486,7 @@ class EncryptionFlutterService {
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse RSA private key from PEM
|
||||
/// 解析 RSA 私钥 PEM
|
||||
RSAPrivateKey _parsePrivateKey(String pem) {
|
||||
final base64 = pem
|
||||
.replaceAll('-----BEGIN PRIVATE KEY-----', '')
|
||||
@@ -412,7 +495,6 @@ class EncryptionFlutterService {
|
||||
.trim();
|
||||
final bytes = base64Decode(base64);
|
||||
|
||||
// Parse ASN.1 DER format
|
||||
final asn1Parser = ASN1Parser(Uint8List.fromList(bytes));
|
||||
final keySeq = asn1Parser.nextObject() as ASN1Sequence;
|
||||
|
||||
@@ -429,7 +511,7 @@ class EncryptionFlutterService {
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert hex string to bytes
|
||||
/// Hex 字符串转字节
|
||||
Uint8List _hexStringToBytes(String hex) {
|
||||
final len = hex.length;
|
||||
final data = Uint8List(len ~/ 2);
|
||||
|
||||
@@ -16,7 +16,8 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
|
||||
|
||||
@override
|
||||
Future<RsaKeyPair> generateRsaKeyPair({int keySize = 1024}) async {
|
||||
final result = _service.generateRsaKeyPair(keySize: keySize);
|
||||
// 在 Isolate 中运行,避免阻塞主线程(1024-bit 约 150ms)
|
||||
final result = await _service.generateRsaKeyPairAsync(keySize: keySize);
|
||||
return RsaKeyPair(
|
||||
publicKey: result.publicKey,
|
||||
privateKey: result.privateKey,
|
||||
@@ -50,10 +51,7 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
|
||||
@override
|
||||
Future<SessionKey> generateSessionKey({int initialRound = 1}) async {
|
||||
final result = _service.generateSessionKey(initialRound: initialRound);
|
||||
return SessionKey(
|
||||
key: result.key,
|
||||
round: result.round,
|
||||
);
|
||||
return SessionKey(key: result.key, round: result.round);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -91,10 +89,7 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
|
||||
sessionKey: sessionKey,
|
||||
round: round,
|
||||
);
|
||||
return EncryptedMessage(
|
||||
round: result.round,
|
||||
data: result.data,
|
||||
);
|
||||
return EncryptedMessage(round: result.round, data: result.data);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -110,6 +105,11 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 缓存管理 ====================
|
||||
|
||||
@override
|
||||
void clearDerivedKeyCache() => _service.clearDerivedKeyCache();
|
||||
|
||||
// ==================== 原生平台同步 ====================
|
||||
|
||||
@override
|
||||
@@ -147,4 +147,3 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
|
||||
return _service.decryptPushNotification(encryptedData: encryptedData);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import '../entities/encrypted_message.dart';
|
||||
|
||||
abstract class EncryptionRepository {
|
||||
// ==================== RSA 金鑰管理 ====================
|
||||
|
||||
|
||||
/// 生成 RSA 金鑰對
|
||||
/// [keySize] 金鑰長度 (預設 1024, 可用 2048)
|
||||
Future<RsaKeyPair> generateRsaKeyPair({int keySize = 1024});
|
||||
@@ -84,6 +84,14 @@ abstract class EncryptionRepository {
|
||||
required Map<String, Map<String, dynamic>> chatMap,
|
||||
});
|
||||
|
||||
// ==================== 缓存管理 ====================
|
||||
|
||||
/// 清空派生密钥缓存
|
||||
///
|
||||
/// 在 session key 轮换时调用,确保旧密钥的派生结果不会被复用。
|
||||
/// 不影响已加密的消息,只影响后续加解密操作的密钥派生。
|
||||
void clearDerivedKeyCache();
|
||||
|
||||
// ==================== 配置相關 ====================
|
||||
|
||||
/// 設置 AES_SECRET (用於推送解密)
|
||||
@@ -91,8 +99,5 @@ abstract class EncryptionRepository {
|
||||
|
||||
/// 解密 APNS 推送通知內容
|
||||
/// 使用 release.json 中的 AES_SECRET
|
||||
Future<String?> decryptPushNotification({
|
||||
required String encryptedData,
|
||||
});
|
||||
Future<String?> decryptPushNotification({required String encryptedData});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,59 +7,93 @@ import 'package:cipher_guard_sdk/src/domain/entities/session_key.dart';
|
||||
import 'package:cipher_guard_sdk/src/domain/entities/encrypted_message.dart';
|
||||
import 'package:cipher_guard_sdk/src/presentation/wiring/cipher_guard_sdk_wiring.dart';
|
||||
|
||||
abstract class CipherGuardSdkApi
|
||||
{
|
||||
abstract class CipherGuardSdkApi {
|
||||
factory CipherGuardSdkApi() => CipherGuardSdkWiring.build();
|
||||
|
||||
// ==================== 平台版本 ====================
|
||||
|
||||
|
||||
/// 獲取平台版本
|
||||
Future<String?> platformVersion();
|
||||
|
||||
// ==================== RSA 金鑰管理 ====================
|
||||
|
||||
|
||||
/// 生成 RSA 金鑰對
|
||||
Future<RsaKeyPair> generateRsaKeyPair({int keySize = 1024});
|
||||
|
||||
/// 用密碼加密私鑰
|
||||
Future<String> encryptPrivateKey({required String privateKey, required String password,});
|
||||
Future<String> encryptPrivateKey({
|
||||
required String privateKey,
|
||||
required String password,
|
||||
});
|
||||
|
||||
/// 解密私鑰
|
||||
Future<String> decryptPrivateKey({required String encryptedPrivateKey, required String password,});
|
||||
Future<String> decryptPrivateKey({
|
||||
required String encryptedPrivateKey,
|
||||
required String password,
|
||||
});
|
||||
|
||||
// ==================== 會話金鑰管理 ====================
|
||||
|
||||
|
||||
/// 生成 AES 會話金鑰
|
||||
Future<SessionKey> generateSessionKey({int initialRound = 1});
|
||||
|
||||
/// 用 RSA 公鑰加密會話金鑰
|
||||
Future<String> encryptSessionKey({required String sessionKey, required String publicKey,});
|
||||
Future<String> encryptSessionKey({
|
||||
required String sessionKey,
|
||||
required String publicKey,
|
||||
});
|
||||
|
||||
/// 用 RSA 私鑰解密會話金鑰
|
||||
Future<String> decryptSessionKey({required String encryptedSessionKey, required String privateKey,});
|
||||
Future<String> decryptSessionKey({
|
||||
required String encryptedSessionKey,
|
||||
required String privateKey,
|
||||
});
|
||||
|
||||
// ==================== 訊息加解密 ====================
|
||||
|
||||
|
||||
/// 加密訊息
|
||||
Future<EncryptedMessage> encryptMessage({required String plaintext, required String sessionKey, required int round,});
|
||||
Future<EncryptedMessage> encryptMessage({
|
||||
required String plaintext,
|
||||
required String sessionKey,
|
||||
required int round,
|
||||
});
|
||||
|
||||
/// 解密訊息
|
||||
Future<String> decryptMessage({required String encryptedData, required String sessionKey, required int round,});
|
||||
Future<String> decryptMessage({
|
||||
required String encryptedData,
|
||||
required String sessionKey,
|
||||
required int round,
|
||||
});
|
||||
|
||||
// ==================== 缓存管理 ====================
|
||||
|
||||
/// 清空派生密钥缓存
|
||||
///
|
||||
/// session key 轮换后必须调用,否则旧 key 的派生结果可能被复用,
|
||||
/// 导致加解密使用错误的密钥。
|
||||
void clearDerivedKeyCache();
|
||||
|
||||
// ==================== 原生平台同步 ====================
|
||||
|
||||
|
||||
/// 同步加密金鑰到原生平台 (iOS App Group)
|
||||
Future<void> syncEncryptionKey({required String chatId, required int activeRound, required int round, required String activeKey, required bool isSingle,});
|
||||
Future<void> syncEncryptionKey({
|
||||
required String chatId,
|
||||
required int activeRound,
|
||||
required int round,
|
||||
required String activeKey,
|
||||
required bool isSingle,
|
||||
});
|
||||
|
||||
/// 批量同步所有加密聊天室的金鑰
|
||||
Future<void> syncAllEncryptionKeys({required Map<String, Map<String, dynamic>> chatMap,});
|
||||
Future<void> syncAllEncryptionKeys({
|
||||
required Map<String, Map<String, dynamic>> chatMap,
|
||||
});
|
||||
|
||||
// ==================== 推送通知解密 ====================
|
||||
|
||||
|
||||
/// 設置 AES_SECRET (用於推送解密)
|
||||
Future<void> setAesSecret({required String aesSecret});
|
||||
|
||||
/// 解密 APNS 推送通知內容
|
||||
Future<String?> decryptPushNotification({required String encryptedData,});
|
||||
Future<String?> decryptPushNotification({required String encryptedData});
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,9 @@ class CipherGuardSdkApiImpl implements CipherGuardSdkApi {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void clearDerivedKeyCache() => _core.encryptionRepo.clearDerivedKeyCache();
|
||||
|
||||
@override
|
||||
Future<void> syncEncryptionKey({
|
||||
required String chatId,
|
||||
@@ -123,9 +126,9 @@ class CipherGuardSdkApiImpl implements CipherGuardSdkApi {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> decryptPushNotification({
|
||||
required String encryptedData,
|
||||
}) {
|
||||
return _core.encryptionRepo.decryptPushNotification(encryptedData: encryptedData);
|
||||
Future<String?> decryptPushNotification({required String encryptedData}) {
|
||||
return _core.encryptionRepo.decryptPushNotification(
|
||||
encryptedData: encryptedData,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,18 @@ import 'package:cipher_guard_sdk/src/presentation/wiring/cipher_guard_sdk_api_im
|
||||
/// 使用 Flutter 本地加密服務,無需原生平台處理加密邏輯
|
||||
class CipherGuardSdkWiring {
|
||||
/// 構建 SDK 實例
|
||||
static CipherGuardSdkApi build() {
|
||||
///
|
||||
/// [kdfMode] — 密钥派生模式,默认 [KdfMode.md5](兼容模式)
|
||||
/// [pbkdf2Iterations] — PBKDF2 迭代次数(仅 pbkdf2 模式生效,默认 10000)
|
||||
static CipherGuardSdkApi build({
|
||||
KdfMode kdfMode = KdfMode.md5,
|
||||
int pbkdf2Iterations = 10000,
|
||||
}) {
|
||||
// 1. 創建 Flutter 加密服務
|
||||
final flutterService = EncryptionFlutterService();
|
||||
final flutterService = EncryptionFlutterService(
|
||||
kdfMode: kdfMode,
|
||||
pbkdf2Iterations: pbkdf2Iterations,
|
||||
);
|
||||
|
||||
// 2. 創建 Repository (使用 Flutter 服務)
|
||||
final repository = EncryptionRepositoryImpl(flutterService);
|
||||
@@ -39,4 +48,3 @@ class _CipherGuardPlatformImpl implements CipherGuardPlatform {
|
||||
return 'Flutter Native'; // 所有加密邏輯現在都在 Flutter 端執行
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,14 @@ import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
/// REST API 客户端
|
||||
/// 基于 Dio,提供请求执行入口
|
||||
///
|
||||
/// 拦截器链顺序:Auth → Encryption → 自定义 → Retry → Logging
|
||||
/// 拦截器链顺序(onRequest):Auth → 自定义 → Retry → Logging → Encryption
|
||||
///
|
||||
/// Dio 的 onResponse / onError 按 **逆序** 执行,因此实际响应处理为:
|
||||
/// `Encryption(解密) → Logging → Retry(业务码判断) → 自定义 → Auth`
|
||||
///
|
||||
/// EncryptionInterceptor 放最后,保证:
|
||||
/// - onRequest 最后加密(其他拦截器操作明文)
|
||||
/// - onResponse 最先解密(其他拦截器看到明文,业务码判断正常工作)
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
@@ -31,13 +38,15 @@ class ApiClient {
|
||||
receiveTimeout: const Duration(seconds: 60),
|
||||
);
|
||||
|
||||
// 挂载拦截器(顺序:Auth → Encryption → 自定义 → Retry → Logging)
|
||||
// 挂载拦截器
|
||||
// onRequest 顺序:Auth → 自定义 → Retry → Logging → Encryption
|
||||
// onResponse 逆序:Encryption(解密) → Logging → Retry(业务码) → 自定义 → Auth
|
||||
_dio.interceptors.addAll([
|
||||
AuthInterceptor(config),
|
||||
EncryptionInterceptor(config),
|
||||
if (additionalInterceptors != null) ...additionalInterceptors,
|
||||
RetryInterceptor(config: config, dio: _dio),
|
||||
LoggingInterceptor(onLog: config.onLog),
|
||||
EncryptionInterceptor(config),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,12 @@ import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
|
||||
/// 加密拦截器(预留给 cipher_guard_sdk)
|
||||
///
|
||||
/// 在拦截器链中位于 Auth 之后、Retry 之前:
|
||||
/// `Auth → Encryption → Custom → Retry → Logging`
|
||||
/// 在拦截器链中位于最末位:
|
||||
/// onRequest 顺序:`Auth → Custom → Retry → Logging → Encryption`
|
||||
/// onResponse 逆序:`Encryption(解密) → Logging → Retry(业务码) → Custom → Auth`
|
||||
///
|
||||
/// 放最后是因为 Dio onResponse 按逆序执行——加密拦截器最先解密,
|
||||
/// 后续的 RetryInterceptor 才能正确判断业务错误码、Token 过期等。
|
||||
///
|
||||
/// 回调为 null 时自动跳过,不影响正常请求流程。
|
||||
/// 后续 cipher_guard_sdk 接入后,App 层在 ApiConfig 中注入
|
||||
@@ -20,7 +24,23 @@ import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
///
|
||||
/// 加密回调接收原始 path、headers、body,返回 [EncryptedRequest],
|
||||
/// 拦截器根据非 null 字段覆盖请求。
|
||||
///
|
||||
/// ## Token 重试与重新加密
|
||||
///
|
||||
/// 瞬态错误重试(5xx / 超时):复用已加密的请求,不重复加密。
|
||||
/// Token 刷新重试:加密 headers 可能包含过期 token 衍生值
|
||||
/// (如 X-Token、X-Signature),需要恢复加密前状态并用新 token 重新加密。
|
||||
/// RetryInterceptor 通过 `_needsReEncryption` 标记通知本拦截器重新加密。
|
||||
class EncryptionInterceptor extends Interceptor {
|
||||
/// extra 标记键:请求已加密,瞬态重试时跳过
|
||||
static const _encryptedKey = '__encrypted__';
|
||||
|
||||
/// extra 标记键:加密前的请求快照(path / body / contentType)
|
||||
static const _preEncryptSnapshotKey = '__preEncryptSnapshot__';
|
||||
|
||||
/// extra 标记键:加密回调注入的 header key 列表
|
||||
static const _encryptionAddedHeadersKey = '__encryptionAddedHeaders__';
|
||||
|
||||
final ApiConfig _config;
|
||||
|
||||
EncryptionInterceptor(this._config);
|
||||
@@ -36,7 +56,28 @@ class EncryptionInterceptor extends Interceptor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Token 重试 + 已加密 → 恢复加密前状态,用新 token 上下文重新加密
|
||||
// 旧的加密 headers(如 X-Token、X-Signature)可能包含过期 token 信息
|
||||
if (options.extra[_encryptedKey] == true &&
|
||||
options.extra['_needsReEncryption'] == true) {
|
||||
_restorePreEncryptState(options);
|
||||
options.extra.remove('_needsReEncryption');
|
||||
}
|
||||
|
||||
// 已加密(瞬态错误重试)→ 复用加密请求,不重复加密
|
||||
if (options.extra[_encryptedKey] == true) {
|
||||
handler.next(options);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 保存加密前快照,Token 重试时恢复
|
||||
options.extra[_preEncryptSnapshotKey] = {
|
||||
'path': options.path,
|
||||
'data': options.data,
|
||||
'contentType': options.contentType,
|
||||
};
|
||||
|
||||
// 收集当前 headers(转为 Map<String, String>)
|
||||
final currentHeaders = <String, String>{};
|
||||
options.headers.forEach((key, value) {
|
||||
@@ -54,11 +95,17 @@ class EncryptionInterceptor extends Interceptor {
|
||||
}
|
||||
if (result.headers != null) {
|
||||
options.headers.addAll(result.headers!);
|
||||
// 记录加密注入的 header key,Token 重试时移除
|
||||
options.extra[_encryptionAddedHeadersKey] = result.headers!.keys
|
||||
.toList();
|
||||
}
|
||||
if (result.contentType != null) {
|
||||
options.contentType = result.contentType;
|
||||
}
|
||||
|
||||
// 标记已加密,防止瞬态重试时重复加密
|
||||
options.extra[_encryptedKey] = true;
|
||||
|
||||
_config.onLog?.call(
|
||||
'Request encrypted: ${options.path}',
|
||||
tag: 'Encryption',
|
||||
@@ -76,6 +123,41 @@ class EncryptionInterceptor extends Interceptor {
|
||||
}
|
||||
}
|
||||
|
||||
/// 恢复加密前的请求状态
|
||||
///
|
||||
/// Token 重试场景:旧的加密数据(path / body / headers)可能包含过期 token,
|
||||
/// 需要恢复原始状态后用新 token 上下文重新加密。
|
||||
///
|
||||
/// AuthInterceptor 已在本轮重试中注入了新 token headers,
|
||||
/// 这里只需移除上次加密注入的 headers(如 X-Token、X-Signature),
|
||||
/// 保留 Auth 设置的新 token。
|
||||
void _restorePreEncryptState(RequestOptions options) {
|
||||
final snapshot =
|
||||
options.extra[_preEncryptSnapshotKey] as Map<String, dynamic>?;
|
||||
if (snapshot != null) {
|
||||
options.path = snapshot['path'] as String;
|
||||
options.data = snapshot['data'];
|
||||
options.contentType = snapshot['contentType'] as String?;
|
||||
}
|
||||
|
||||
// 移除上次加密注入的 headers
|
||||
final addedHeaders = options.extra[_encryptionAddedHeadersKey] as List?;
|
||||
if (addedHeaders != null) {
|
||||
for (final key in addedHeaders) {
|
||||
options.headers.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 清除加密标记和快照
|
||||
options.extra.remove(_encryptedKey);
|
||||
options.extra.remove(_encryptionAddedHeadersKey);
|
||||
|
||||
_config.onLog?.call(
|
||||
'Pre-encrypt state restored for token retry',
|
||||
tag: 'Encryption',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) async {
|
||||
final decrypt = _config.onDecryptResponse;
|
||||
@@ -90,6 +172,14 @@ class EncryptionInterceptor extends Interceptor {
|
||||
return;
|
||||
}
|
||||
|
||||
// 响应已是 Map → 未加密(health check、非加密端点等),跳过解密
|
||||
// 加密模式下响应通常是 String(base64)或 List<int>(bytes)
|
||||
// TODO: 接入加密后,若服务端所有端点都加密,可移除此判断
|
||||
if (response.data is Map<String, dynamic>) {
|
||||
handler.next(response);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final decrypted = await decrypt(response.data as Object);
|
||||
response.data = decrypted;
|
||||
|
||||
@@ -78,7 +78,8 @@ class RetryInterceptor extends Interceptor {
|
||||
if (code != 0 && config.onBusinessError != null) {
|
||||
final handled = config.onBusinessError!(code, message, requestPath);
|
||||
if (handled) {
|
||||
// App 层已处理,正常传递响应
|
||||
// App 层已处理 → 标记,让 decodeResponse 跳过二次抛错
|
||||
response.requestOptions.extra['_businessErrorHandled'] = true;
|
||||
handler.next(response);
|
||||
return;
|
||||
}
|
||||
@@ -115,6 +116,9 @@ class RetryInterceptor extends Interceptor {
|
||||
options.headers['token'] = newToken;
|
||||
// 标记为 token 重试请求,防止重试后再次进入 _handleTokenExpired 造成递归
|
||||
options.extra['_isTokenRetry'] = true;
|
||||
// 通知 EncryptionInterceptor:token 变了,需要用新 token 上下文重新加密
|
||||
// 旧的加密 headers(如 X-Token、X-Signature)可能包含过期 token 信息
|
||||
options.extra['_needsReEncryption'] = true;
|
||||
|
||||
final retryResponse = await dio.fetch(options);
|
||||
handler.resolve(retryResponse);
|
||||
|
||||
@@ -194,6 +194,9 @@ class NetworksSdkMethodChannelDataSource {
|
||||
///
|
||||
/// Dio.download 内部用 FileMode.write(从头覆盖),无法正确续传。
|
||||
/// 这里手动读流并追加写入文件。
|
||||
///
|
||||
/// 如果服务端不支持 Range 请求(返回 200 而非 206),
|
||||
/// 自动回退为覆盖写入,防止文件损坏。
|
||||
Future<void> _downloadWithResume({
|
||||
required String url,
|
||||
required String savePath,
|
||||
@@ -215,14 +218,33 @@ class NetworksSdkMethodChannelDataSource {
|
||||
final stream = response.data?.stream;
|
||||
if (stream == null) return;
|
||||
|
||||
// Content-Length 是本次传输量(不含已下载部分)
|
||||
// 检查服务端是否接受了 Range 请求
|
||||
// 206 = 支持续传,追加写入
|
||||
// 200 = 不支持 Range,返回完整文件,需要覆盖写入
|
||||
final isPartialContent = response.statusCode == 206;
|
||||
final effectiveStartBytes = isPartialContent ? startBytes : 0;
|
||||
|
||||
if (!isPartialContent) {
|
||||
apiClient.config.onLog?.call(
|
||||
'Server does not support Range, falling back to full download',
|
||||
tag: 'Download',
|
||||
);
|
||||
}
|
||||
|
||||
// Content-Length 是本次传输量
|
||||
final contentLength =
|
||||
int.tryParse(response.headers.value('content-length') ?? '') ?? -1;
|
||||
final totalBytes = contentLength > 0 ? contentLength + startBytes : -1;
|
||||
final totalBytes = contentLength > 0
|
||||
? contentLength + effectiveStartBytes
|
||||
: -1;
|
||||
|
||||
final file = File(savePath);
|
||||
final raf = file.openSync(mode: FileMode.writeOnlyAppend);
|
||||
int received = startBytes;
|
||||
// 不支持续传时用 write 覆盖,支持时用 append 追加
|
||||
final fileMode = isPartialContent
|
||||
? FileMode.writeOnlyAppend
|
||||
: FileMode.writeOnly;
|
||||
final raf = file.openSync(mode: fileMode);
|
||||
int received = effectiveStartBytes;
|
||||
|
||||
try {
|
||||
await for (final chunk in stream) {
|
||||
|
||||
@@ -16,7 +16,7 @@ import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
/// - Stream 输出(JSON 消息、原始字符串、二进制、连接状态、错误)
|
||||
/// - 生命周期感知(前后台切换)
|
||||
/// - Token 热更新(不断连)
|
||||
/// - 消息加密/解密钩子(预留给 cipher_guard_sdk)
|
||||
/// - 消息加密/解密钩子(预留给 cipher_guard_sdk,ping/pong 走明文不加密)
|
||||
///
|
||||
/// ## 使用方式
|
||||
///
|
||||
@@ -60,6 +60,15 @@ class SocketClient {
|
||||
Timer? _reconnectTimer;
|
||||
final _random = Random();
|
||||
|
||||
// ── 消息处理 ──
|
||||
|
||||
/// 异步消息处理链,保证解密场景下消息按到达顺序处理
|
||||
///
|
||||
/// 无解密回调时不使用(同步处理,天然有序)。
|
||||
/// 有解密回调时,每条消息的处理链在前一条之后执行,
|
||||
/// 即使解密耗时不同也不会乱序。
|
||||
Future<void>? _messageProcessingChain;
|
||||
|
||||
// ── Stream Controllers ──
|
||||
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
final _rawMessageController = StreamController<String>.broadcast();
|
||||
@@ -324,45 +333,73 @@ class SocketClient {
|
||||
// 内部 — 消息处理
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
void _handleMessage(dynamic data) async {
|
||||
// 二进制消息
|
||||
void _handleMessage(dynamic data) {
|
||||
// 二进制消息不需要解密,直接分发
|
||||
if (data is List<int>) {
|
||||
_binaryMessageController.add(
|
||||
data is Uint8List ? data : Uint8List.fromList(data),
|
||||
);
|
||||
if (!_binaryMessageController.isClosed) {
|
||||
_binaryMessageController.add(
|
||||
data is Uint8List ? data : Uint8List.fromList(data),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data is! String) {
|
||||
_rawMessageController.add(data.toString());
|
||||
if (!_rawMessageController.isClosed) {
|
||||
_rawMessageController.add(data.toString());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 解密(如果配置了解密回调)
|
||||
String text = data;
|
||||
if (config.onDecryptMessage != null) {
|
||||
try {
|
||||
text = await config.onDecryptMessage!(data);
|
||||
} catch (e) {
|
||||
_log('Message decryption failed: $e');
|
||||
_rawMessageController.add(data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 pong 心跳回复(解密后检查,加密场景下也能正确匹配)
|
||||
if (text == 'pong') {
|
||||
// pong 是传输层心跳,不经过业务加解密,直接匹配
|
||||
if (data == 'pong') {
|
||||
_onPongReceived();
|
||||
return;
|
||||
}
|
||||
|
||||
// 尝试 JSON 解析
|
||||
if (config.onDecryptMessage != null) {
|
||||
// 有解密回调 → 链式异步处理,保证消息按到达顺序分发
|
||||
// 避免解密耗时不同导致后到的消息先完成解密、先分发
|
||||
final previous = _messageProcessingChain ?? Future.value();
|
||||
_messageProcessingChain = previous.then((_) => _processTextMessage(data));
|
||||
} else {
|
||||
// 无解密回调 → 同步处理,天然有序
|
||||
_dispatchTextMessage(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// 异步处理文本消息(解密 → 分发)
|
||||
Future<void> _processTextMessage(String data) async {
|
||||
// dispose 期间可能有残留的链式任务,直接跳过
|
||||
if (_messageController.isClosed) return;
|
||||
|
||||
String text;
|
||||
try {
|
||||
text = await config.onDecryptMessage!(data);
|
||||
} catch (e) {
|
||||
_log('Message decryption failed: $e');
|
||||
if (!_rawMessageController.isClosed) {
|
||||
_rawMessageController.add(data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_dispatchTextMessage(text);
|
||||
}
|
||||
|
||||
/// 分发文本消息(JSON 解析 → 投递到对应 stream)
|
||||
///
|
||||
/// pong 已在 `_handleMessage` 中提前拦截,不会到这里。
|
||||
void _dispatchTextMessage(String text) {
|
||||
try {
|
||||
final json = jsonDecode(text) as Map<String, dynamic>;
|
||||
_messageController.add(json);
|
||||
if (!_messageController.isClosed) {
|
||||
_messageController.add(json);
|
||||
}
|
||||
} catch (_) {
|
||||
// JSON 解析失败,走原始消息流
|
||||
_rawMessageController.add(text);
|
||||
if (!_rawMessageController.isClosed) {
|
||||
_rawMessageController.add(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,26 +438,20 @@ class SocketClient {
|
||||
_waitingForPong = false;
|
||||
}
|
||||
|
||||
void _sendPing() async {
|
||||
void _sendPing() {
|
||||
if (_waitingForPong) return;
|
||||
|
||||
_waitingForPong = true;
|
||||
|
||||
// 加密场景下 ping 也要加密,与 pong 解密对称
|
||||
String pingPayload = 'ping';
|
||||
if (config.onEncryptMessage != null) {
|
||||
try {
|
||||
pingPayload = await config.onEncryptMessage!('ping');
|
||||
} catch (e) {
|
||||
_log('Ping encryption failed: $e');
|
||||
}
|
||||
}
|
||||
_channel?.sink.add(pingPayload);
|
||||
// ping/pong 是传输层心跳,不经过业务加解密
|
||||
// 保证即使加密密钥过期/轮换失败,心跳仍然正常工作
|
||||
_channel?.sink.add('ping');
|
||||
_log('♥ ping');
|
||||
|
||||
// 启动 pong 超时计时器
|
||||
_pongTimeoutTimer = Timer(config.pongTimeout, () {
|
||||
if (_waitingForPong) {
|
||||
_log('Pong timeout, reconnecting...');
|
||||
_log('♥ pong timeout, reconnecting...');
|
||||
_waitingForPong = false;
|
||||
_emitError(const SocketError.pingTimeout());
|
||||
_doDisconnect(reason: 'Pong timeout');
|
||||
@@ -430,6 +461,7 @@ class SocketClient {
|
||||
}
|
||||
|
||||
void _onPongReceived() {
|
||||
_log('♥ pong');
|
||||
_waitingForPong = false;
|
||||
_pongTimeoutTimer?.cancel();
|
||||
_pongTimeoutTimer = null;
|
||||
@@ -488,7 +520,9 @@ class SocketClient {
|
||||
try {
|
||||
await config.onBeforeReconnect!();
|
||||
} catch (e) {
|
||||
_log('onBeforeReconnect failed: $e, skip this reconnect');
|
||||
_log('onBeforeReconnect failed: $e, schedule next reconnect');
|
||||
// 重置状态以允许下次 _startReconnect 进入(防止卡死在 reconnecting)
|
||||
_updateConnectionState(SocketConnectionState.disconnected);
|
||||
_startReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,35 +4,36 @@ import 'package:networks_sdk/src/domain/entities/api_error.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/api_request_type.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/http_method.dart';
|
||||
|
||||
|
||||
/// API 请求基类
|
||||
///
|
||||
/// 使用侧只需:字段 + path + method,其余全部有默认实现。
|
||||
/// 只需 `@ApiRequest` 一个注解,声明字段和构造函数即可:
|
||||
/// - `toJson()` 由 mixin 自动生成(只序列化类自身字段,不含继承属性)
|
||||
/// - `path / method / requestType / includeToken` 由 mixin 自动提供
|
||||
/// - Response 的 fromJson 在 mixin 的 `parameters` getter 中自动注册
|
||||
/// - **不需要** `@JsonSerializable`,**不需要** 手写 `fromJson`
|
||||
///
|
||||
/// ```dart
|
||||
/// @JsonSerializable()
|
||||
/// class LoginRequest extends ApiRequestable<LoginData> {
|
||||
/// @ApiRequest(
|
||||
/// path: ApiPaths.authLogin,
|
||||
/// method: HttpMethod.post,
|
||||
/// responseType: LoginData,
|
||||
/// requestType: ApiRequestType.login,
|
||||
/// )
|
||||
/// class LoginRequest extends ApiRequestable<LoginData>
|
||||
/// with _$LoginRequestApi {
|
||||
/// final String email;
|
||||
/// final String password;
|
||||
///
|
||||
/// LoginRequest({required this.email, required this.password});
|
||||
///
|
||||
/// factory LoginRequest.fromJson(Map<String, dynamic> json) =>
|
||||
/// _$LoginRequestFromJson(json);
|
||||
/// @override
|
||||
/// Map<String, dynamic> toJson() => _$LoginRequestToJson(this);
|
||||
///
|
||||
/// @override
|
||||
/// String get path => '/auth/login';
|
||||
/// @override
|
||||
/// HttpMethod get method => HttpMethod.post;
|
||||
/// @override
|
||||
/// bool get includeToken => false;
|
||||
/// // 完毕!一行样板代码都不用写
|
||||
/// }
|
||||
///
|
||||
/// // 文件顶层注册一次(一行)
|
||||
/// final _reg = registerResponse<LoginData>(LoginData.fromJson);
|
||||
/// ```
|
||||
///
|
||||
/// 字段名映射:在字段上加 `@JsonKey(name: 'server_name')` 即可,
|
||||
/// 生成器会读取并使用该名称作为 JSON 键。
|
||||
///
|
||||
/// 特殊请求(如 upload):在类中 override `toJson()` 即可,
|
||||
/// 类的 override 优先于 mixin。
|
||||
abstract class ApiRequestable<T> {
|
||||
/// API 路径(如 '/auth/login')
|
||||
String get path;
|
||||
@@ -40,8 +41,8 @@ abstract class ApiRequestable<T> {
|
||||
/// HTTP 方法
|
||||
HttpMethod get method;
|
||||
|
||||
/// 序列化为 JSON(由 @JsonSerializable 自动生成)
|
||||
/// 子类 override 返回 `_$XxxToJson(this)` 即可
|
||||
/// 序列化为 JSON(由 @ApiRequest 生成器在 mixin 中自动生成)
|
||||
/// Upload 等特殊请求可在类中 override 返回空 map
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
/// 请求参数 — 默认调用 toJson(),upload 类型返回 null
|
||||
@@ -95,7 +96,7 @@ abstract class ApiRequestable<T> {
|
||||
if (fromJsonFunc == null) {
|
||||
throw StateError(
|
||||
'fromJson not registered for type $T. '
|
||||
'Add: final _reg = registerResponse<$T>($T.fromJson);',
|
||||
'Add: final _reg = registerResponse<$T>($T.fromJson);',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,13 +107,17 @@ abstract class ApiRequestable<T> {
|
||||
final mapFunc = fromJsonFunc as T Function(Map<String, dynamic>);
|
||||
return mapFunc(json);
|
||||
}
|
||||
throw FormatException('Expected Map<String, dynamic>, got ${json.runtimeType}',);
|
||||
throw FormatException(
|
||||
'Expected Map<String, dynamic>, got ${json.runtimeType}',
|
||||
);
|
||||
}
|
||||
|
||||
final wrapper = ApiResponseWrapper<T>.fromJson(data, fromJsonObject);
|
||||
|
||||
// 业务错误码检查
|
||||
if (wrapper.code != 0) {
|
||||
// 业务错误码检查(RetryInterceptor 已处理的跳过,防止双重抛错)
|
||||
final handledByInterceptor =
|
||||
response.requestOptions.extra['_businessErrorHandled'] == true;
|
||||
if (wrapper.code != 0 && !handledByInterceptor) {
|
||||
throw ApiError.apiError(
|
||||
code: wrapper.code,
|
||||
message: wrapper.message ?? 'API error (code: ${wrapper.code})',
|
||||
@@ -141,8 +146,9 @@ final fromJsonRegistry = <Type, Function>{};
|
||||
/// ```dart
|
||||
/// final _reg = registerResponse<LoginData>(LoginData.fromJson);
|
||||
/// ```
|
||||
T Function(Map<String, dynamic>)? registerResponse<T>(T Function(Map<String, dynamic>) fromJson,)
|
||||
{
|
||||
T Function(Map<String, dynamic>)? registerResponse<T>(
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
fromJsonRegistry[T] = fromJson;
|
||||
return fromJson;
|
||||
}
|
||||
@@ -152,9 +158,7 @@ T Function(Map<String, dynamic>)? registerResponse<T>(T Function(Map<String, dyn
|
||||
/// ```dart
|
||||
/// final _reg = registerResponseObject<DeviceList>(DeviceList.fromJson);
|
||||
/// ```
|
||||
T Function(Object?)? registerResponseObject<T>(
|
||||
T Function(Object?) fromJson,
|
||||
) {
|
||||
T Function(Object?)? registerResponseObject<T>(T Function(Object?) fromJson) {
|
||||
fromJsonRegistry[T] = fromJson;
|
||||
return fromJson;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
/// API 响应信封解析器
|
||||
/// API 响应包装解析器
|
||||
/// 统一处理 { code, message/msg, data } 格式的服务器响应
|
||||
class ApiResponseWrapper<T> {
|
||||
final int code;
|
||||
final String? message;
|
||||
final T? data;
|
||||
|
||||
const ApiResponseWrapper({
|
||||
required this.code,
|
||||
this.message,
|
||||
this.data,
|
||||
});
|
||||
const ApiResponseWrapper({required this.code, this.message, this.data});
|
||||
|
||||
factory ApiResponseWrapper.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(Object?) fromJsonT,
|
||||
) {
|
||||
Map<String, dynamic> json,
|
||||
T Function(Object?) fromJsonT,
|
||||
) {
|
||||
// code 字段:兼容 int 和 String
|
||||
final int codeValue;
|
||||
if (json['code'] is int) {
|
||||
@@ -28,8 +24,7 @@ class ApiResponseWrapper<T> {
|
||||
}
|
||||
|
||||
// message 字段:兼容 message 和 msg
|
||||
final message =
|
||||
json['message'] as String? ?? json['msg'] as String?;
|
||||
final message = json['message'] as String? ?? json['msg'] as String?;
|
||||
|
||||
// 解码 data(null-safe:logout / delete 等接口可能无 data)
|
||||
final rawData = json['data'];
|
||||
|
||||
@@ -4,17 +4,50 @@ import 'package:networks_sdk/src/annotations/api_request.dart';
|
||||
import 'package:source_gen/source_gen.dart';
|
||||
import 'package:build/build.dart';
|
||||
|
||||
/// @JsonKey 检测器(用于读取字段的 JSON 键名映射)
|
||||
const _jsonKeyChecker = TypeChecker.fromUrl(
|
||||
'package:json_annotation/src/json_key.dart#JsonKey',
|
||||
);
|
||||
|
||||
/// @ApiRequest 代码生成器
|
||||
///
|
||||
/// 为标注了 `@ApiRequest` 的类自动生成 mixin,提供:
|
||||
/// - `path`, `method`, `requestType`, `includeToken` 协议实现
|
||||
/// - 自动注册响应类型的 `fromJson`(在 `parameters` getter 中触发,
|
||||
/// 保证首次请求前完成注册,无需手动调用 `registerApiResponses()`)
|
||||
/// - `toJson()` — 从类的声明字段自动生成,只序列化自身字段,
|
||||
/// 不含 ApiRequestable 的继承属性,避免递归
|
||||
/// - 自动注册响应类型的 `fromJson`(在 `parameters` getter 中触发)
|
||||
///
|
||||
/// 生成的 mixin 命名规则:`_$<ClassName>Api`
|
||||
/// 支持 `@JsonKey(name: '...')` 字段重命名。
|
||||
/// 如有 `@JsonKey(includeToJson: false)` 则跳过该字段。
|
||||
///
|
||||
/// ## 使用模式
|
||||
///
|
||||
/// Request 类只需 `@ApiRequest` 注解,无需 `@JsonSerializable`:
|
||||
///
|
||||
/// ```dart
|
||||
/// @ApiRequest(
|
||||
/// path: ApiPaths.authLogin,
|
||||
/// method: HttpMethod.post,
|
||||
/// responseType: LoginData,
|
||||
/// requestType: ApiRequestType.login,
|
||||
/// )
|
||||
/// class LoginRequest extends ApiRequestable<LoginData>
|
||||
/// with _$LoginRequestApi {
|
||||
/// final String email;
|
||||
/// final String password;
|
||||
///
|
||||
/// LoginRequest({required this.email, required this.password});
|
||||
/// // 完毕!toJson / path / method 全部由 mixin 自动生成
|
||||
/// // Response 的 fromJson 在 parameters getter 中自动注册
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ## mixin 命名规则
|
||||
///
|
||||
/// `_$<ClassName>Api`
|
||||
///
|
||||
/// ## 生成示例
|
||||
///
|
||||
/// 示例输出:
|
||||
/// ```dart
|
||||
/// mixin _$LoginRequestApi on ApiRequestable<LoginData> {
|
||||
/// @override String get path => '/auth/login';
|
||||
@@ -22,17 +55,29 @@ import 'package:build/build.dart';
|
||||
/// @override ApiRequestType get requestType => ApiRequestType.login;
|
||||
/// @override bool get includeToken => false;
|
||||
/// @override
|
||||
/// Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
/// 'email': (this as LoginRequest).email,
|
||||
/// 'password': (this as LoginRequest).password,
|
||||
/// };
|
||||
/// @override
|
||||
/// Map<String, dynamic>? get parameters {
|
||||
/// registerResponse<LoginData>(LoginData.fromJson);
|
||||
/// return super.parameters;
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest>
|
||||
{
|
||||
///
|
||||
/// ## Upload 等特殊请求
|
||||
///
|
||||
/// 如需自定义 toJson(如 upload 返回空 map),在类中 override 即可,
|
||||
/// 类的 override 优先于 mixin。
|
||||
class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest> {
|
||||
@override
|
||||
String generateForAnnotatedElement(Element element, ConstantReader annotation, BuildStep buildStep,)
|
||||
{
|
||||
String generateForAnnotatedElement(
|
||||
Element element,
|
||||
ConstantReader annotation,
|
||||
BuildStep buildStep,
|
||||
) {
|
||||
if (element is! ClassElement) {
|
||||
throw InvalidGenerationSourceError(
|
||||
'@ApiRequest can only be applied to classes.',
|
||||
@@ -40,7 +85,7 @@ class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest>
|
||||
);
|
||||
}
|
||||
|
||||
final className = element.name;
|
||||
final className = element.name!;
|
||||
final path = annotation.read('path').stringValue;
|
||||
|
||||
// 读取 HttpMethod 枚举值
|
||||
@@ -68,6 +113,9 @@ class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest>
|
||||
includeToken = requestTypeName != 'login';
|
||||
}
|
||||
|
||||
// 从类的声明字段生成 toJson()
|
||||
final toJsonBody = _buildToJsonBody(element, className);
|
||||
|
||||
return '''
|
||||
/// Generated by @ApiRequest for [$className]
|
||||
mixin _\$${className}Api on ApiRequestable<$responseTypeName> {
|
||||
@@ -80,6 +128,8 @@ mixin _\$${className}Api on ApiRequestable<$responseTypeName> {
|
||||
@override
|
||||
bool get includeToken => $includeToken;
|
||||
@override
|
||||
Map<String, dynamic> toJson() => $toJsonBody;
|
||||
@override
|
||||
Map<String, dynamic>? get parameters {
|
||||
registerResponse<$responseTypeName>($responseTypeName.fromJson);
|
||||
return super.parameters;
|
||||
@@ -88,6 +138,46 @@ mixin _\$${className}Api on ApiRequestable<$responseTypeName> {
|
||||
''';
|
||||
}
|
||||
|
||||
/// 从类的声明字段构建 toJson() 方法体
|
||||
///
|
||||
/// 只读取类自身声明的实例字段(非 static、非 synthetic),
|
||||
/// 不含继承自 ApiRequestable 的属性,避免递归。
|
||||
/// 支持 @JsonKey(name: '...') 字段重命名,
|
||||
/// 以及 @JsonKey(includeToJson: false) 跳过字段。
|
||||
String _buildToJsonBody(ClassElement element, String className) {
|
||||
final fields = element.fields
|
||||
.where((f) => !f.isStatic && !f.isSynthetic)
|
||||
.toList();
|
||||
|
||||
if (fields.isEmpty) {
|
||||
return '<String, dynamic>{}';
|
||||
}
|
||||
|
||||
final entries = <String>[];
|
||||
for (final field in fields) {
|
||||
// 检查 @JsonKey 注解
|
||||
final jsonKeyAnnotation = _jsonKeyChecker.firstAnnotationOfExact(field);
|
||||
|
||||
// @JsonKey(includeToJson: false) → 跳过
|
||||
final includeToJson = jsonKeyAnnotation
|
||||
?.getField('includeToJson')
|
||||
?.toBoolValue();
|
||||
if (includeToJson == false) continue;
|
||||
|
||||
// JSON 键名:@JsonKey(name: '...') 或字段名
|
||||
final jsonName =
|
||||
jsonKeyAnnotation?.getField('name')?.toStringValue() ?? field.name;
|
||||
|
||||
entries.add("'$jsonName': (this as $className).${field.name}");
|
||||
}
|
||||
|
||||
if (entries.isEmpty) {
|
||||
return '<String, dynamic>{}';
|
||||
}
|
||||
|
||||
return '<String, dynamic>{${entries.join(', ')}}';
|
||||
}
|
||||
|
||||
/// 从 DartObject 提取枚举常量名称
|
||||
String _readEnumName(dynamic dartObject, String defaultValue) {
|
||||
final index = dartObject.getField('index')?.toIntValue();
|
||||
@@ -105,4 +195,4 @@ mixin _\$${className}Api on ApiRequestable<$responseTypeName> {
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,15 @@ typedef OnEncryptRequest =
|
||||
///
|
||||
/// [responseData] 的实际类型取决于服务端响应格式:
|
||||
/// - 加密模式下通常是 base64 字符串
|
||||
/// - 非加密模式下是 `Map<String, dynamic>`
|
||||
/// - 非加密模式下是 `Map<String, dynamic>`(拦截器会自动跳过,不调用此回调)
|
||||
///
|
||||
/// 实现时建议做类型判断兜底,应对非预期的响应格式:
|
||||
/// ```dart
|
||||
/// onDecryptResponse: (data) async {
|
||||
/// if (data is! String) throw FormatException('Expected String, got ${data.runtimeType}');
|
||||
/// return jsonDecode(aesDecrypt(data));
|
||||
/// }
|
||||
/// ```
|
||||
typedef OnDecryptResponse =
|
||||
Future<Map<String, dynamic>> Function(Object responseData);
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ class DatabaseRepositoryImpl implements DatabaseRepository {
|
||||
) async {
|
||||
final db = _db;
|
||||
if (db == null) return;
|
||||
await db.into(table).insertOnConflictUpdate(companion);
|
||||
await db.into(table).insert(companion, mode: InsertMode.insertOrReplace);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -44,7 +44,8 @@ class DatabaseRepositoryImpl implements DatabaseRepository {
|
||||
final db = _db;
|
||||
if (db == null) return;
|
||||
await db.batch(
|
||||
(batch) => batch.insertAllOnConflictUpdate(table, companions),
|
||||
(batch) =>
|
||||
batch.insertAll(table, companions, mode: InsertMode.insertOrReplace),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,7 +107,10 @@ class DatabaseRepositoryImpl implements DatabaseRepository {
|
||||
) async {
|
||||
final db = _db;
|
||||
if (db == null) return null;
|
||||
return (db.select(table)..where(filter)..limit(1)).getSingleOrNull();
|
||||
return (db.select(table)
|
||||
..where(filter)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
// ── 监听 ─────────────────────────────────────────────────────────────────
|
||||
@@ -135,7 +139,10 @@ class DatabaseRepositoryImpl implements DatabaseRepository {
|
||||
) {
|
||||
final db = _db;
|
||||
if (db == null) return const Stream.empty();
|
||||
return (db.select(table)..where(filter)..limit(1)).watchSingleOrNull();
|
||||
return (db.select(table)
|
||||
..where(filter)
|
||||
..limit(1))
|
||||
.watchSingleOrNull();
|
||||
}
|
||||
|
||||
// ── 原始 SQL ─────────────────────────────────────────────────────────────
|
||||
@@ -148,18 +155,12 @@ class DatabaseRepositoryImpl implements DatabaseRepository {
|
||||
final db = _db;
|
||||
if (db == null) return [];
|
||||
return db
|
||||
.customSelect(
|
||||
sql,
|
||||
variables: args.map((e) => Variable(e)).toList(),
|
||||
)
|
||||
.customSelect(sql, variables: args.map((e) => Variable(e)).toList())
|
||||
.get();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> rawExecute(
|
||||
String sql, [
|
||||
List<Object?> args = const [],
|
||||
]) async {
|
||||
Future<void> rawExecute(String sql, [List<Object?> args = const []]) async {
|
||||
final db = _db;
|
||||
if (db == null) return;
|
||||
await db.customStatement(sql, args);
|
||||
|
||||
Reference in New Issue
Block a user