feat(app): 完成 P0 钱包解锁、下载落盘与手机号登录

This commit is contained in:
Zhou Weidong committed 2026-10-19 23:46:47 +08:00
1 parent de941c841c
commit 136ee74618
47 files changed
+3264 -70

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 金币钱包与流水:对应 `GET /app/wallet` 的 `wallet` / `ledger`。
class CoinWallet {
const CoinWallet({
required this.coinBalance,
required this.totalRechargedCoin,
required this.totalConsumedCoin,
required this.totalRefundedCoin,
});
final int coinBalance;
final int totalRechargedCoin;
final int totalConsumedCoin;
final int totalRefundedCoin;
factory CoinWallet.fromJson(Map<String, dynamic> json) {
return CoinWallet(
coinBalance: jsonToInt(json['coinBalance']),
totalRechargedCoin: jsonToInt(json['totalRechargedCoin']),
totalConsumedCoin: jsonToInt(json['totalConsumedCoin']),
totalRefundedCoin: jsonToInt(json['totalRefundedCoin']),
);
}
}
/// 一笔金币变动;[changeAmount] 正数入账、负数出账。
class CoinLedgerEntry {
const CoinLedgerEntry({
required this.id,
required this.changeAmount,
required this.balanceAfter,
required this.bizType,
required this.remark,
required this.createdAt,
});
final String id;
final int changeAmount;
final int balanceAfter;
final String bizType;
final String remark;
final DateTime? createdAt;
factory CoinLedgerEntry.fromJson(Map<String, dynamic> json) {
return CoinLedgerEntry(
id: (json['id'] ?? '').toString(),
changeAmount: jsonToInt(json['changeAmount']),
balanceAfter: jsonToInt(json['balanceAfter']),
bizType: json['bizType'] as String? ?? '',
remark: json['remark'] as String? ?? '',
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? ''),
);
}
}
/// 钱包页一次渲染所需的余额与最近流水。
class WalletSnapshot {
const WalletSnapshot({required this.wallet, this.ledger = const []});
final CoinWallet wallet;
final List<CoinLedgerEntry> ledger;
factory WalletSnapshot.fromJson(Map<String, dynamic> json) {
final wallet = json['wallet'];
return WalletSnapshot(
wallet: wallet is Map<String, dynamic>
? CoinWallet.fromJson(wallet)
: const CoinWallet(
coinBalance: 0,
totalRechargedCoin: 0,
totalConsumedCoin: 0,
totalRefundedCoin: 0,
),
ledger: (json['ledger'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(CoinLedgerEntry.fromJson)
.toList(),
);
}
}
/// 金币解锁结果:`POST /app/episodes/{id}/unlock`。
class UnlockResult {
const UnlockResult({
required this.unlocked,
required this.accessType,
required this.coinCost,
required this.coinBalance,
});
final bool unlocked;
final String accessType;
final int coinCost;
final int coinBalance;
factory UnlockResult.fromJson(Map<String, dynamic> json) {
return UnlockResult(
unlocked: json['unlocked'] == true,
accessType: json['accessType'] as String? ?? '',
coinCost: jsonToInt(json['coinCost']),
coinBalance: jsonToInt(json['coinBalance']),
);
}
}
+61
View File
@@ -10,6 +10,67 @@ class AuthApi {
final ApiClient _client;
/// 发送短信验证码;[scene] 为 REGISTER / LOGIN / RESET_PWD / BIND。
Future<void> sendOtp({
required String target,
required String scene,
required String deviceId,
}) async {
await _client.post(
'/app/auth/otp',
body: {
'scene': scene,
'targetType': 'PHONE',
'target': target,
'deviceId': deviceId,
},
);
}
/// 手机号 + 验证码登录。
Future<AuthSession> loginWithPhone({
required String target,
required String code,
required String deviceId,
String platform = '',
String appVersion = '',
}) async {
final data = await _client.post(
'/app/auth/login',
body: {
'targetType': 'PHONE',
'target': target,
'code': code,
'deviceId': deviceId,
'platform': platform,
'appVersion': appVersion,
},
);
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
}
/// 手机号 + 验证码注册。
Future<AuthSession> registerWithPhone({
required String target,
required String code,
required String deviceId,
String platform = '',
String appVersion = '',
}) async {
final data = await _client.post(
'/app/auth/register',
body: {
'targetType': 'PHONE',
'target': target,
'code': code,
'deviceId': deviceId,
'platform': platform,
'appVersion': appVersion,
},
);
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
}
Future<AuthSession> register({
required String email,
required String password,
+23
View File
@@ -0,0 +1,23 @@
// Author: fruit.com.cn
import '../models/wallet.dart';
import 'api_client.dart';
/// 金币与解锁接口,对应契约 §1.8。
class CommerceApi {
const CommerceApi(this._client);
final ApiClient _client;
Future<WalletSnapshot> wallet() async {
final data = await _client.get('/app/wallet');
if (data is Map<String, dynamic>) return WalletSnapshot.fromJson(data);
throw const ApiException(-1, '返回数据格式异常');
}
Future<UnlockResult> unlock(String episodeId) async {
final data = await _client.post('/app/episodes/$episodeId/unlock');
if (data is Map<String, dynamic>) return UnlockResult.fromJson(data);
throw const ApiException(-1, '返回数据格式异常');
}
}
@@ -0,0 +1,23 @@
// Author: fruit.com.cn
import '../models/wallet.dart';
import '../remote/commerce_api.dart';
/// 钱包与金币解锁的数据来源。
abstract interface class CommerceRepository {
Future<WalletSnapshot> fetchWallet();
Future<UnlockResult> unlock(String episodeId);
}
class HttpCommerceRepository implements CommerceRepository {
const HttpCommerceRepository(this._api);
final CommerceApi _api;
@override
Future<WalletSnapshot> fetchWallet() => _api.wallet();
@override
Future<UnlockResult> unlock(String episodeId) => _api.unlock(episodeId);
}
@@ -57,6 +57,43 @@ class AuthController extends ChangeNotifier {
await _apply(session);
}
Future<void> sendOtp({
required String target,
String scene = 'LOGIN',
}) async {
await _api.sendOtp(
target: target,
scene: scene,
deviceId: await _store.deviceId(),
);
}
Future<void> signInWithPhone({
required String target,
required String code,
}) async {
final session = await _api.loginWithPhone(
target: target,
code: code,
deviceId: await _store.deviceId(),
platform: _platform,
);
await _apply(session);
}
Future<void> registerWithPhone({
required String target,
required String code,
}) async {
final session = await _api.registerWithPhone(
target: target,
code: code,
deviceId: await _store.deviceId(),
platform: _platform,
);
await _apply(session);
}
Future<void> signOut() async {
final refreshToken = _session?.refreshToken ?? '';
_session = null;
+16 -15
View File
@@ -14,6 +14,11 @@ import '../models/auth_session.dart';
/// 设备号是后端注册风控与后续"下载设备绑定"的依据,所以**首次生成后就固定下来**;
/// 不引 UUID 库(保持极简依赖),用「时间戳 + 随机数」拼一个够用的 id。
class AuthSessionStore {
AuthSessionStore({SharedPreferencesAsync? preferences})
: _preferences = preferences ?? SharedPreferencesAsync();
final SharedPreferencesAsync _preferences;
static const _kAccessToken = 'auth.accessToken';
static const _kRefreshToken = 'auth.refreshToken';
static const _kUser = 'auth.user';
@@ -23,25 +28,23 @@ class AuthSessionStore {
Future<String> deviceId() async {
if (_cachedDeviceId != null) return _cachedDeviceId!;
final prefs = await SharedPreferences.getInstance();
var value = await prefs.getString(_kDeviceId) ?? '';
var value = await _preferences.getString(_kDeviceId) ?? '';
if (value.isEmpty) {
final random = Random();
value =
'd-${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}-'
'${random.nextInt(1 << 32).toRadixString(36)}';
await prefs.setString(_kDeviceId, value);
await _preferences.setString(_kDeviceId, value);
}
_cachedDeviceId = value;
return value;
}
Future<AuthSession?> read() async {
final prefs = await SharedPreferences.getInstance();
final accessToken = await prefs.getString(_kAccessToken) ?? '';
final refreshToken = await prefs.getString(_kRefreshToken) ?? '';
final accessToken = await _preferences.getString(_kAccessToken) ?? '';
final refreshToken = await _preferences.getString(_kRefreshToken) ?? '';
if (accessToken.isEmpty || refreshToken.isEmpty) return null;
final rawUser = await prefs.getString(_kUser);
final rawUser = await _preferences.getString(_kUser);
Map<String, dynamic> user = const {};
if (rawUser != null && rawUser.isNotEmpty) {
try {
@@ -58,17 +61,15 @@ class AuthSessionStore {
}
Future<void> save(AuthSession session) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kAccessToken, session.accessToken);
await prefs.setString(_kRefreshToken, session.refreshToken);
await prefs.setString(_kUser, jsonEncode(session.user.toJson()));
await _preferences.setString(_kAccessToken, session.accessToken);
await _preferences.setString(_kRefreshToken, session.refreshToken);
await _preferences.setString(_kUser, jsonEncode(session.user.toJson()));
}
Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kAccessToken);
await prefs.remove(_kRefreshToken);
await prefs.remove(_kUser);
await _preferences.remove(_kAccessToken);
await _preferences.remove(_kRefreshToken);
await _preferences.remove(_kUser);
// 设备号不清:它代表"这台设备",与登录态无关
}
}
+137 -1
View File
@@ -1,11 +1,15 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/download_license.dart';
import '../models/download_policy.dart';
import '../../config/app_config.dart';
import '../remote/api_exception.dart';
import '../repositories/download_repository.dart';
import 'download_storage.dart';
/// 一次下载授权申请的结果;后端业务拒绝时保留原始 message。
class DownloadActionResult {
@@ -18,9 +22,15 @@ class DownloadActionResult {
/// 下载授权与策略状态。
class DownloadController extends ChangeNotifier {
DownloadController(this._repository);
DownloadController(this._repository, {DownloadStorage? storage})
: _storage = storage ?? FileDownloadStorage();
final DownloadRepository _repository;
final DownloadStorage _storage;
final Map<String, String> _localPaths = {};
final Set<String> _downloading = {};
final Map<String, double> _progress = {};
final Map<String, DateTime> _lastReport = {};
DownloadPolicy? policy;
List<DownloadLicense> licenses = const [];
@@ -30,6 +40,18 @@ class DownloadController extends ChangeNotifier {
String? actionError;
bool _disposed = false;
bool isDownloaded(String episodeId) => _localPaths.containsKey(episodeId);
bool isDownloading(String episodeId) => _downloading.contains(episodeId);
double progressFor(String episodeId) => _progress[episodeId] ?? 0;
String? localPathFor(String episodeId) => _localPaths[episodeId];
/// 播放器使用:只读已同步的本地缓存;下载页加载后会把磁盘文件补进来。
Future<String?> findLocalPath(String episodeId) async =>
_localPaths[episodeId];
/// 同时拉取策略与当前设备授权列表。
Future<void> load() async {
loading = true;
@@ -42,6 +64,7 @@ class DownloadController extends ChangeNotifier {
]);
policy = results[0] as DownloadPolicy;
licenses = results[1] as List<DownloadLicense>;
await _syncLocalFiles();
} catch (_) {
error = 'downloadsError';
} finally {
@@ -50,6 +73,22 @@ class DownloadController extends ChangeNotifier {
}
}
Future<void> _syncLocalFiles() async => restore();
/// 冷启动时恢复已落盘文件,播放器只依赖这份内存索引。
Future<void> restore() async {
try {
final paths = await _storage.restore();
_localPaths.addAll(paths);
for (final id in paths.keys) {
_progress[id] = 1;
}
_notify();
} catch (_) {
// 本地目录不可用时保持未下载状态。
}
}
/// 申请授权;付费、未解锁、配额满等判定完全以后端 message 为准。
Future<DownloadActionResult> grant(
String episodeId, {
@@ -76,6 +115,93 @@ class DownloadController extends ChangeNotifier {
}
}
/// 下载并落盘:授权已在 [grant] 完成,播放地址缺失时向服务端刷新一次。
Future<DownloadActionResult> startDownload(
String episodeId, {
DownloadLicense? license,
}) async {
if (_downloading.contains(episodeId)) return const DownloadActionResult(ok: true);
final source = license ?? _licenseFor(episodeId);
if (source == null) return const DownloadActionResult(ok: false);
_beginDownload(episodeId);
try {
final resolved = await _resolvePlayUrl(source);
final media = await _storage.download(
episodeId: episodeId,
url: resolved,
onProgress: (received, total) => _onProgress(episodeId, received, total),
);
_localPaths[episodeId] = media.filePath;
_progress[episodeId] = 1;
unawaited(_report(episodeId, 'COMPLETED', 100, media.bytes, media.bytes));
return DownloadActionResult(ok: true, license: source);
} catch (_) {
unawaited(_report(episodeId, 'FAILED', 0, null, null, 'downloadFailed'));
actionError = 'downloadFailed';
return const DownloadActionResult(ok: false);
} finally {
_downloading.remove(episodeId);
_notify();
}
}
DownloadLicense? _licenseFor(String episodeId) {
for (final item in licenses) {
if (item.episodeId == episodeId) return item;
}
return null;
}
void _beginDownload(String episodeId) {
_downloading.add(episodeId);
_progress[episodeId] = 0;
actionError = null;
unawaited(_report(episodeId, 'DOWNLOADING', 0, 0, 0));
_notify();
}
Future<String> _resolvePlayUrl(DownloadLicense license) async {
final current = license.playUrl.isEmpty
? await _repository.refreshUrl(license.episodeId)
: license;
return AppConfig.mediaUrl(current.playUrl);
}
void _onProgress(String episodeId, int received, int total) {
final fraction = total > 0 ? received / total : 0.0;
_progress[episodeId] = fraction.clamp(0.0, 1.0);
final now = DateTime.now();
final last = _lastReport[episodeId];
if (last == null || now.difference(last) >= const Duration(seconds: 2)) {
_lastReport[episodeId] = now;
final percent = (_progress[episodeId]! * 100).round();
unawaited(_report(episodeId, 'DOWNLOADING', percent, received, total));
}
_notify();
}
Future<void> _report(
String episodeId,
String status,
int percent,
int? received,
int? total, [
String? error,
]) async {
try {
await _repository.reportProgress(
episodeId,
status: status,
progressPercent: percent,
downloadedBytes: received,
totalBytes: total,
errorMsg: error,
);
} catch (_) {
// 进度上报失败不阻断本地播放,下次打开下载页会重新同步。
}
}
/// 更新设置,先本地展示,失败恢复服务端旧值。
Future<bool> updateSettings({bool? wifiOnly, String? maxQuality}) async {
final previous = policy;
@@ -109,6 +235,9 @@ class DownloadController extends ChangeNotifier {
_notify();
try {
await _repository.remove(episodeId);
await _storage.delete(episodeId);
_localPaths.remove(episodeId);
_progress.remove(episodeId);
return true;
} catch (_) {
final restored = [...licenses];
@@ -134,6 +263,9 @@ class DownloadController extends ChangeNotifier {
_notify();
try {
await _repository.clear();
await _storage.clear();
_localPaths.clear();
_progress.clear();
return true;
} catch (_) {
licenses = previous;
@@ -153,6 +285,10 @@ class DownloadController extends ChangeNotifier {
clearing = false;
error = null;
actionError = null;
_localPaths.clear();
_downloading.clear();
_progress.clear();
_lastReport.clear();
_notify();
}
@@ -0,0 +1,182 @@
// Author: fruit.com.cn
import 'dart:io';
import 'package:path_provider/path_provider.dart';
/// 下载进度回调:[received] 与 [total] 均为字节数,未知总长时 [total] 为 0。
typedef DownloadProgress = void Function(int received, int total);
/// 已落盘的媒体文件。
class DownloadedMedia {
const DownloadedMedia({
required this.episodeId,
required this.filePath,
required this.bytes,
});
final String episodeId;
final String filePath;
final int bytes;
}
/// 本地媒体存储边界;页面与控制器不直接操作文件系统。
abstract interface class DownloadStorage {
Future<Map<String, String>> restore();
Future<String?> localPathFor(String episodeId);
Future<DownloadedMedia> download({
required String episodeId,
required String url,
DownloadProgress? onProgress,
});
Future<void> delete(String episodeId);
Future<void> clear();
}
/// 将分集落盘到应用文档目录;临时文件完成后原子改名,避免半文件被播放。
class FileDownloadStorage implements DownloadStorage {
FileDownloadStorage({
Future<Directory> Function()? directoryProvider,
HttpClient Function()? clientFactory,
}) : _directoryProvider = directoryProvider ?? getApplicationDocumentsDirectory,
_clientFactory = clientFactory ?? HttpClient.new;
final Future<Directory> Function() _directoryProvider;
final HttpClient Function() _clientFactory;
static const String _folder = 'downloads';
static const String _partSuffix = '.part';
@override
Future<Map<String, String>> restore() async {
try {
final directory = await _downloadDirectory();
final files = <String, String>{};
await for (final entity in directory.list()) {
if (entity is! File) continue;
final name = entity.uri.pathSegments.last;
if (name.endsWith(_partSuffix)) continue;
final dot = name.lastIndexOf('.');
final id = dot > 0 ? name.substring(0, dot) : name;
files[id] = entity.path;
}
return files;
} catch (_) {
return const {};
}
}
@override
Future<String?> localPathFor(String episodeId) async {
try {
final file = await _findFile(episodeId, includePartial: false);
return file?.path;
} catch (_) {
// 本地目录不可用时视为未下载;真正下载会单独返回错误。
return null;
}
}
@override
Future<DownloadedMedia> download({
required String episodeId,
required String url,
DownloadProgress? onProgress,
}) async {
final existing = await _findFile(episodeId, includePartial: false);
if (existing != null) {
final length = await existing.length();
onProgress?.call(length, length);
return DownloadedMedia(
episodeId: episodeId,
filePath: existing.path,
bytes: length,
);
}
final directory = await _downloadDirectory();
final target = File('${directory.path}/${_safe(episodeId)}${_extension(url)}');
final partial = File('${target.path}$_partSuffix');
final client = _clientFactory();
var received = 0;
IOSink? sink;
try {
final request = await client.getUrl(Uri.parse(url));
final response = await request.close();
if (response.statusCode != HttpStatus.ok) {
throw HttpException('HTTP ${response.statusCode}', uri: Uri.parse(url));
}
sink = partial.openWrite();
await for (final chunk in response) {
sink.add(chunk);
received += chunk.length;
final total = response.contentLength > 0 ? response.contentLength : 0;
onProgress?.call(received, total);
}
await sink.close();
sink = null;
if (await target.exists()) await target.delete();
final file = await partial.rename(target.path);
final bytes = await file.length();
onProgress?.call(bytes, bytes);
return DownloadedMedia(
episodeId: episodeId,
filePath: file.path,
bytes: bytes,
);
} catch (_) {
await sink?.close();
if (await partial.exists()) await partial.delete();
rethrow;
} finally {
client.close(force: true);
}
}
@override
Future<void> delete(String episodeId) async {
final file = await _findFile(episodeId, includePartial: true);
if (file != null && await file.exists()) await file.delete();
}
@override
Future<void> clear() async {
final directory = await _downloadDirectory();
await for (final entity in directory.list()) {
if (entity is File) await entity.delete();
}
}
Future<File?> _findFile(
String episodeId, {
required bool includePartial,
}) async {
final directory = await _downloadDirectory();
final prefix = _safe(episodeId);
await for (final entity in directory.list()) {
if (entity is! File) continue;
final name = entity.uri.pathSegments.last;
if (!name.startsWith(prefix)) continue;
final isPartial = name.endsWith(_partSuffix);
if (includePartial || !isPartial) return entity;
}
return null;
}
Future<Directory> _downloadDirectory() async {
final base = await _directoryProvider();
return Directory('${base.path}/$_folder').create(recursive: true);
}
String _safe(String episodeId) =>
episodeId.replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_');
String _extension(String url) {
final path = Uri.tryParse(url)?.path.toLowerCase() ?? '';
if (path.endsWith('.m3u8')) return '.m3u8';
return '.mp4';
}
}
+12 -2
View File
@@ -1,5 +1,7 @@
// Author: fruit.com.cn
import 'dart:io';
import 'package:video_player/video_player.dart';
import '../../config/app_config.dart';
@@ -21,10 +23,18 @@ class VideoPlayerPort implements PlayerPort {
Episode episode,
Future<ClosedCaptionFile>? closedCaptionFile,
) {
final options = VideoPlayerOptions(mixWithOthers: false);
// 片源统一由后端下发(详情里的可播放地址 / 播放票据)
final uri = resolveUri(episode);
final options = VideoPlayerOptions(mixWithOthers: false);
if (uri.scheme == 'file') {
return VideoPlayerController.file(
File.fromUri(uri),
closedCaptionFile: closedCaptionFile,
videoPlayerOptions: options,
);
}
return VideoPlayerController.networkUrl(
resolveUri(episode),
uri,
closedCaptionFile: closedCaptionFile,
videoPlayerOptions: options,
);
@@ -0,0 +1,96 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/wallet.dart';
import '../remote/api_exception.dart';
import '../repositories/commerce_repository.dart';
/// 钱包状态:余额、最近流水与金币解锁。
///
/// 余额是解锁后 UI 的唯一来源;接口失败保留后端 message,页面原样展示。
class WalletController extends ChangeNotifier {
WalletController(this._repository);
final CommerceRepository _repository;
WalletSnapshot? snapshot;
bool loading = false;
bool unlocking = false;
String? error;
String? unlockError;
String? unlockingEpisodeId;
bool _disposed = false;
int get balance => snapshot?.wallet.coinBalance ?? 0;
List<CoinLedgerEntry> get ledger => snapshot?.ledger ?? const [];
Future<void> load() async {
loading = true;
error = null;
_notify();
try {
snapshot = await _repository.fetchWallet();
} on ApiException catch (exception) {
error = exception.message;
} catch (_) {
error = 'walletLoadFailed';
} finally {
loading = false;
_notify();
}
}
/// 金币解锁;成功后就地更新余额并返回后端结果。
Future<UnlockResult?> unlock(String episodeId) async {
if (unlocking) return null;
unlocking = true;
unlockingEpisodeId = episodeId;
unlockError = null;
_notify();
try {
final result = await _repository.unlock(episodeId);
final current = snapshot;
snapshot = WalletSnapshot(
wallet: CoinWallet(
coinBalance: result.coinBalance,
totalRechargedCoin: current?.wallet.totalRechargedCoin ?? 0,
totalConsumedCoin: current?.wallet.totalConsumedCoin ?? 0,
totalRefundedCoin: current?.wallet.totalRefundedCoin ?? 0,
),
ledger: current?.ledger ?? const [],
);
return result;
} on ApiException catch (exception) {
unlockError = exception.message;
return null;
} catch (_) {
unlockError = 'unlockFailed';
return null;
} finally {
unlocking = false;
unlockingEpisodeId = null;
_notify();
}
}
void reset() {
snapshot = null;
loading = false;
unlocking = false;
error = null;
unlockError = null;
unlockingEpisodeId = null;
_notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}