排查「发 11820 解歌不显示」时发现两处问题: - applyItemListPatch 的 isNewItemList 一直硬写全 '1'(新增),从没读服务器 已有行。真机 BuildListData 是按 (itemKind, itemId) 比对快照给 0/1 的, 重复解锁一首已存在的歌时服务器按插入冲突丢行,表现就是上传成功但没生效。 这和角色/地图/万花筒三处的处理不一致,现补齐。 - 新增 TitleApiService.getUserItems/getUserItemKeys。GetUserItemApi 是按 itemKind 分页的,首次 nextIndex 要按 itemKind * 1e10 编码 (PacketGetUserItem.cs:19),发 0 只会拿到空页。 - 解锁上传后回读复查,把「服务器没存这行道具」与「存了但曲目池不给进」分开报。 - 待解锁列表含 10000~19999 的 DX 曲时提示:Master/Re:Master 不像老曲那样 白送(IsUnlockMaster 里 id<10000 直接放过),必须显式带 itemKind 6/7。 需要说明:一首歌进不进选曲池由 NotesListManager.cs:70 决定 (IsOpenEvent(eventName) && !IsGameNgMusicId),事件窗口来自 GetGameEventApi、 曲目本体来自机台 Music.xml。11820 若卡在这一层,任何用户包都改不动, 复查那一步就是为了把这种情况和包的问题区分开。 README 补 isNew*List 语义、曲目可见性链路与 DX 段解锁规则三条说明。 删除因此不再被引用的 unlockMusicSuccess。
596 lines
18 KiB
Dart
596 lines
18 KiB
Dart
import 'dart:convert';
|
||
import 'dart:math';
|
||
import 'dart:typed_data';
|
||
|
||
import 'package:archive/archive.dart';
|
||
import 'package:crypto/crypto.dart';
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:pointycastle/export.dart';
|
||
|
||
import '../config/title_server_config.dart';
|
||
import '../models/user_data.dart';
|
||
import 'api_log.dart';
|
||
import '../models/user_character.dart';
|
||
import '../models/user_kaleidx_scope.dart';
|
||
import '../models/user_preview.dart';
|
||
import '../models/user_rating.dart';
|
||
|
||
class TitleApiException implements Exception {
|
||
final String message;
|
||
const TitleApiException(this.message);
|
||
@override
|
||
String toString() => 'TitleApiException: $message';
|
||
}
|
||
|
||
class UserLoginResult {
|
||
final String token;
|
||
final int loginId;
|
||
final String lastLoginDate;
|
||
final int loginDateTime;
|
||
|
||
const UserLoginResult({
|
||
required this.token,
|
||
required this.loginId,
|
||
required this.lastLoginDate,
|
||
required this.loginDateTime,
|
||
});
|
||
}
|
||
|
||
class TitleApiService {
|
||
static const String _obfuscateConstant = 'MaimaiChn';
|
||
|
||
static String? lastRawResponse;
|
||
|
||
final TitleServerConfig _config;
|
||
String? _cookies;
|
||
|
||
TitleApiService(this._config, {this._cookies});
|
||
|
||
/// 从 [TitleServerConfigHolder] 构造;未配置时返回 `null`。
|
||
///
|
||
/// 各页原本都写 `if (!holder.isConfigured) ...; TitleApiService(holder.config!, ...)`,
|
||
/// 收口成一个工厂,避免 `config!` 与 `isConfigured` 散落在十几个地方。
|
||
static TitleApiService? fromHolder({String? cookies}) {
|
||
final holder = TitleServerConfigHolder();
|
||
if (!holder.isConfigured) return null;
|
||
return TitleApiService(holder.config!, cookies: cookies);
|
||
}
|
||
|
||
String? get cookies => _cookies;
|
||
|
||
/// 组装 payload 时需要同一份配置(地点 / 区域 / clientId)。
|
||
TitleServerConfig get config => _config;
|
||
|
||
Uint8List _aesEncrypt(Uint8List plaintext) {
|
||
final cipher = PaddedBlockCipherImpl(
|
||
PKCS7Padding(),
|
||
CBCBlockCipher(AESEngine()),
|
||
)..init(
|
||
true,
|
||
PaddedBlockCipherParameters(
|
||
ParametersWithIV(
|
||
KeyParameter(Uint8List.fromList(_config.aesKeyBytes)),
|
||
Uint8List.fromList(_config.aesIvBytes),
|
||
),
|
||
null,
|
||
),
|
||
);
|
||
return cipher.process(plaintext);
|
||
}
|
||
|
||
Uint8List _aesDecrypt(Uint8List ciphertext) {
|
||
final cipher = PaddedBlockCipherImpl(
|
||
PKCS7Padding(),
|
||
CBCBlockCipher(AESEngine()),
|
||
)..init(
|
||
false,
|
||
PaddedBlockCipherParameters(
|
||
ParametersWithIV(
|
||
KeyParameter(Uint8List.fromList(_config.aesKeyBytes)),
|
||
Uint8List.fromList(_config.aesIvBytes),
|
||
),
|
||
null,
|
||
),
|
||
);
|
||
return cipher.process(ciphertext);
|
||
}
|
||
|
||
Uint8List _compress(List<int> data) {
|
||
return Uint8List.fromList(ZLibEncoder().encode(data));
|
||
}
|
||
|
||
Uint8List _decompress(List<int> data) {
|
||
return Uint8List.fromList(ZLibDecoder().decodeBytes(data));
|
||
}
|
||
|
||
Uint8List _buildRequestBody(Map<String, dynamic> packet) {
|
||
final jsonStr = jsonEncode(packet);
|
||
final jsonBytes = utf8.encode(jsonStr);
|
||
final compressed = _compress(jsonBytes);
|
||
return _aesEncrypt(compressed);
|
||
}
|
||
|
||
/// Port of empurple `HttpResult.cookieHeader()`: only the `JSESSIONID`
|
||
/// from the Set-Cookie response headers is used for subsequent requests.
|
||
void _captureCookiesFromResponse(http.Response response, String apiName) {
|
||
final headerKeys = response.headers.keys.toList();
|
||
for (final key in headerKeys) {
|
||
if (!key.toLowerCase().contains('set-cookie')) continue;
|
||
final value = response.headers[key]!;
|
||
final match =
|
||
RegExp(r'JSESSIONID=([^;,]+)', caseSensitive: false).firstMatch(value);
|
||
if (match != null) {
|
||
_cookies = 'JSESSIONID=${match.group(1)!.trim()}';
|
||
ApiLog.log('[$apiName] >>> Captured session cookie: $_cookies');
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
Map<String, dynamic> _processResponseBody(Uint8List bodyBytes) {
|
||
try {
|
||
final decrypted = _aesDecrypt(bodyBytes);
|
||
final decompressed = _decompress(decrypted);
|
||
final jsonStr = utf8.decode(decompressed);
|
||
return jsonDecode(jsonStr) as Map<String, dynamic>;
|
||
} on FormatException catch (e) {
|
||
throw TitleApiException('Response body is not valid JSON: $e');
|
||
} on Exception catch (e) {
|
||
throw TitleApiException('Failed to process response body: $e');
|
||
}
|
||
}
|
||
|
||
String _buildHash(String apiName) {
|
||
final raw = apiName + _obfuscateConstant + _config.obfuscateParam;
|
||
return md5.convert(utf8.encode(raw)).toString();
|
||
}
|
||
|
||
Future<Map<String, dynamic>> _callApi(
|
||
String apiName,
|
||
Map<String, dynamic> packet,
|
||
int userId, {
|
||
bool captureRaw = false,
|
||
}) async {
|
||
final hash = _buildHash(apiName);
|
||
final body = _buildRequestBody(packet);
|
||
final baseUrl = _normalizeUrl(_config.titleServerUrl);
|
||
final url = Uri.parse('$baseUrl/$hash');
|
||
|
||
ApiLog.log('══════════════════════════════════════');
|
||
ApiLog.log('[$apiName] >>> REQUEST >>>');
|
||
ApiLog.log('[$apiName] URL: $url');
|
||
ApiLog.log('[$apiName] userId: $userId');
|
||
ApiLog.log('[$apiName] hash: $hash');
|
||
ApiLog.log('[$apiName] body (encrypted): ${body.length} bytes');
|
||
final packetStr = const JsonEncoder.withIndent(' ').convert(packet);
|
||
ApiLog.log('[$apiName] packet (plain):');
|
||
ApiLog.log(packetStr);
|
||
|
||
final uaSuffix = userId != 0 ? '$userId' : _config.clientId;
|
||
final headers = <String, String>{
|
||
'User-Agent': '$hash#$uaSuffix',
|
||
'Content-Type': 'application/json',
|
||
'Mai-Encoding': _config.apiVersion,
|
||
'Accept-Encoding': '',
|
||
'Charset': 'UTF-8',
|
||
'Content-Encoding': 'deflate',
|
||
'number': '0',
|
||
};
|
||
final cookies = _cookies;
|
||
if (cookies != null) {
|
||
headers['Cookie'] = cookies;
|
||
ApiLog.log('[$apiName] Cookie: $cookies');
|
||
}
|
||
|
||
final response = await http
|
||
.post(
|
||
url,
|
||
headers: headers,
|
||
body: body,
|
||
)
|
||
.timeout(const Duration(seconds: 15));
|
||
|
||
_captureCookiesFromResponse(response, apiName);
|
||
|
||
ApiLog.log('[$apiName] HTTP status: ${response.statusCode}');
|
||
ApiLog.log('[$apiName] response bytes: ${response.bodyBytes.length}');
|
||
|
||
if (response.statusCode != 200) {
|
||
throw TitleApiException('$apiName returned ${response.statusCode}');
|
||
}
|
||
|
||
if (response.bodyBytes.isEmpty) {
|
||
throw TitleApiException('$apiName returned empty body');
|
||
}
|
||
|
||
final json = _processResponseBody(response.bodyBytes);
|
||
final raw = const JsonEncoder.withIndent(' ').convert(json);
|
||
if (captureRaw) lastRawResponse = raw;
|
||
ApiLog.log('[$apiName] <<< RESPONSE <<<');
|
||
ApiLog.log(raw);
|
||
ApiLog.log('══════════════════════════════════════');
|
||
|
||
return json;
|
||
}
|
||
|
||
static int calcRandom() {
|
||
final rand = _RandomHelper();
|
||
final max = 1037933;
|
||
final num2 = (rand.nextInt(max - 1) + 1) * 2069 + 1024;
|
||
var num3 = 0;
|
||
var n = num2;
|
||
for (var i = 0; i < 32; i++) {
|
||
num3 <<= 1;
|
||
num3 += n % 2;
|
||
n >>= 1;
|
||
}
|
||
// 原版 num5 是 C# 的 int,`<<=` 溢出按二进制补码环绕,所以结果恒为合法的
|
||
// signed int32(高位被置起时是负数)。Dart 的 int 是 64 位且 n 恒为正,
|
||
// 不重解释就会产出 >2^31 的正数,超出 UserGamePlaylog.playSpecial 的 int
|
||
// 声明,服务器反序列化失败直接返回 HTTP 500。
|
||
return num3.toSigned(32);
|
||
}
|
||
|
||
String _normalizeUrl(String url) {
|
||
var normalized = url.trim();
|
||
if (normalized.endsWith('/')) {
|
||
normalized = normalized.substring(0, normalized.length - 1);
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
Future<UserPreviewDataBean> getUserPreview({
|
||
required int userId,
|
||
required String token,
|
||
}) async {
|
||
// 走通用传输层,保留 `lastRawResponse`(主页调试卡要用 GetUserPreviewApi
|
||
// 的原文,而不是随后 UserLoginApi / GetUserDataApi 的)。
|
||
final json = await _callApi(
|
||
'GetUserPreviewApi',
|
||
{
|
||
'userId': userId,
|
||
'segaIdAuthKey': '',
|
||
'token': token,
|
||
'clientId': _config.clientId,
|
||
},
|
||
userId,
|
||
captureRaw: true,
|
||
);
|
||
return UserPreviewDataBean.fromJson(json);
|
||
}
|
||
|
||
// ---- Generic data APIs ----
|
||
|
||
Future<Map<String, dynamic>> getUserData(int userId) async {
|
||
return _callApi('GetUserDataApi', {'userId': userId}, userId);
|
||
}
|
||
|
||
Future<UserDataBean> getUserDataTyped(int userId) async {
|
||
final json = await getUserData(userId);
|
||
return UserDataBean.fromJson(json);
|
||
}
|
||
|
||
Future<Map<String, dynamic>> getUserExtend(int userId) async {
|
||
return _callApi('GetUserExtendApi', {'userId': userId}, userId);
|
||
}
|
||
|
||
Future<Map<String, dynamic>> getUserOption(int userId) async {
|
||
return _callApi('GetUserOptionApi', {'userId': userId}, userId);
|
||
}
|
||
|
||
Future<Map<String, dynamic>> getUserRating(int userId) async {
|
||
return _callApi('GetUserRatingApi', {'userId': userId}, userId);
|
||
}
|
||
|
||
Future<UserRatingBean> getUserRatingTyped(int userId) async {
|
||
final json = await getUserRating(userId);
|
||
return UserRatingBean.fromJson(json);
|
||
}
|
||
|
||
Future<Map<String, dynamic>> getUserCharge(int userId) async {
|
||
return _callApi('GetUserChargeApi', {'userId': userId}, userId);
|
||
}
|
||
|
||
Future<Map<String, dynamic>> getUserActivity(int userId) async {
|
||
return _callApi('GetUserActivityApi', {'userId': userId}, userId);
|
||
}
|
||
|
||
Future<Map<String, dynamic>> getUserMissionData(int userId) async {
|
||
return _callApi('GetUserMissionDataApi', {'userId': userId}, userId);
|
||
}
|
||
|
||
// ---- UserLogin (returns full result) ----
|
||
|
||
Future<UserLoginResult> userLoginFull({
|
||
required int userId,
|
||
required String token,
|
||
}) async {
|
||
const apiName = 'UserLoginApi';
|
||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||
|
||
final packet = {
|
||
'userId': userId,
|
||
'accessCode': '',
|
||
'regionId': _config.regionId,
|
||
'placeId': _config.placeId,
|
||
'clientId': _config.clientId,
|
||
'dateTime': now - 600,
|
||
'loginDateTime': now,
|
||
'isContinue': false,
|
||
'genericFlag': 0,
|
||
'token': token,
|
||
};
|
||
|
||
final json = await _callApi(apiName, packet, userId);
|
||
|
||
final returnCode = json['returnCode'] as int? ?? -1;
|
||
if (returnCode != 1) {
|
||
throw TitleApiException('UserLoginApi returnCode=$returnCode');
|
||
}
|
||
|
||
final loginId = (json['loginId'] as num?)?.toInt() ?? 0;
|
||
final lastLoginDate = json['lastLoginDate'] as String? ?? '';
|
||
final newToken = json['token'] as String? ?? '';
|
||
|
||
return UserLoginResult(
|
||
token: newToken,
|
||
loginId: loginId,
|
||
lastLoginDate: lastLoginDate,
|
||
loginDateTime: now,
|
||
);
|
||
}
|
||
|
||
// ---- UserLogout ----
|
||
|
||
/// Port of empurple/eaquira behavior: the server may omit `returnCode`
|
||
/// for fire-and-forget APIs. Only a present-but-not-1 code is an error.
|
||
void _checkReturnCode(Map<String, dynamic> json, String apiName) {
|
||
final returnCode = json['returnCode'];
|
||
if (returnCode == null) return;
|
||
final code = (returnCode as num).toInt();
|
||
if (code != 1) {
|
||
throw TitleApiException('$apiName returnCode=$code');
|
||
}
|
||
}
|
||
|
||
Future<void> userLogout({
|
||
required int userId,
|
||
required int loginDateTime,
|
||
}) async {
|
||
const apiName = 'UserLogoutApi';
|
||
|
||
final packet = {
|
||
'userId': userId,
|
||
'accessCode': '',
|
||
'regionId': _config.regionId,
|
||
'placeId': _config.placeId,
|
||
'clientId': _config.clientId,
|
||
'loginDateTime': loginDateTime,
|
||
'type': 1, // LogoutType.Logout
|
||
};
|
||
|
||
final json = await _callApi(apiName, packet, userId);
|
||
_checkReturnCode(json, apiName);
|
||
}
|
||
|
||
// ---- UpsertUserChargeLog (使用功能票) ----
|
||
|
||
Future<void> upsertUserChargeLog({
|
||
required int userId,
|
||
required int ticketId,
|
||
required int loginDateTime,
|
||
required int playerRating,
|
||
int playCount = 0,
|
||
}) async {
|
||
const apiName = 'UpsertUserChargelogApi';
|
||
final now = DateTime.now();
|
||
final validDate = DateTime(now.year, now.month, now.day, 4, 0, 0)
|
||
.add(const Duration(days: 90));
|
||
final purchaseDateStr = _formatDateTime(now);
|
||
final validDateStr =
|
||
'${validDate.year}-${validDate.month.toString().padLeft(2, '0')}-${validDate.day.toString().padLeft(2, '0')} '
|
||
'${validDate.hour.toString().padLeft(2, '0')}:${validDate.minute.toString().padLeft(2, '0')}:${validDate.second.toString().padLeft(2, '0')}';
|
||
|
||
final packet = {
|
||
'userId': userId,
|
||
'userChargelog': {
|
||
'chargeId': ticketId,
|
||
'price': 0,
|
||
'purchaseDate': purchaseDateStr,
|
||
'playCount': playCount,
|
||
'playerRating': playerRating,
|
||
'placeId': _config.placeId,
|
||
'regionId': _config.regionId,
|
||
'clientId': _config.clientId,
|
||
},
|
||
'userCharge': {
|
||
'chargeId': ticketId,
|
||
'stock': 1,
|
||
'purchaseDate': purchaseDateStr,
|
||
'validDate': validDateStr,
|
||
},
|
||
'loginDateTime': loginDateTime,
|
||
};
|
||
|
||
final json = await _callApi(apiName, packet, userId);
|
||
_checkReturnCode(json, apiName);
|
||
}
|
||
|
||
String _formatDateTime(DateTime dt) {
|
||
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
|
||
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}.0';
|
||
}
|
||
|
||
// ---- UpsertUserAll (发包) ----
|
||
|
||
/// Upload a single playlog record (fake game log).
|
||
Future<void> uploadUserPlaylog(
|
||
Map<String, dynamic> userPlaylog,
|
||
int userId,
|
||
) async {
|
||
const apiName = 'UploadUserPlaylogApi';
|
||
final json = await _callApi(apiName, {
|
||
'userId': userId,
|
||
'userPlaylog': userPlaylog,
|
||
}, userId);
|
||
_checkReturnCode(json, apiName);
|
||
}
|
||
|
||
/// Fetch all user data needed to build an UpsertUserAll payload.
|
||
/// Returns a map keyed by the raw API names (e.g. 'GetUserDataApi').
|
||
Future<Map<String, Map<String, dynamic>>> fetchUserAllData(int userId) async {
|
||
final results = await Future.wait([
|
||
getUserData(userId),
|
||
getUserExtend(userId),
|
||
getUserOption(userId),
|
||
getUserRating(userId),
|
||
getUserCharge(userId),
|
||
getUserActivity(userId),
|
||
getUserMissionData(userId),
|
||
]);
|
||
|
||
return {
|
||
'GetUserDataApi': results[0],
|
||
'GetUserExtendApi': results[1],
|
||
'GetUserOptionApi': results[2],
|
||
'GetUserRatingApi': results[3],
|
||
'GetUserChargeApi': results[4],
|
||
'GetUserActivityApi': results[5],
|
||
'GetUserMissionDataApi': results[6],
|
||
};
|
||
}
|
||
|
||
/// Send an UpsertUserAll payload to the server.
|
||
Future<void> upsertUserAll(
|
||
Map<String, dynamic> packet,
|
||
int userId,
|
||
) async {
|
||
const apiName = 'UpsertUserAllApi';
|
||
final json = await _callApi(apiName, packet, userId);
|
||
_checkReturnCode(json, apiName);
|
||
}
|
||
|
||
/// Fetch user character list.
|
||
Future<List<Map<String, dynamic>>> getUserCharacter(int userId) async {
|
||
final json = await _callApi(
|
||
'GetUserCharacterApi',
|
||
{'userId': userId},
|
||
userId,
|
||
);
|
||
final list = json['userCharacterList'] as List<dynamic>? ?? [];
|
||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||
}
|
||
|
||
/// 旅行伙伴(角色)列表,对应 UserCharacterResponseVO。
|
||
Future<List<UserCharacterBean>> getUserCharacters(int userId) async {
|
||
final json = await _callApi(
|
||
'GetUserCharacterApi',
|
||
{'userId': userId},
|
||
userId,
|
||
);
|
||
return UserCharacterBean.listFromResponse(json);
|
||
}
|
||
|
||
/// 区域进度列表,对应 UserMapResponseVO。
|
||
///
|
||
/// 客户端 `PacketGetUserMap.cs:51-57` 是分页拉的:`nextIndex != 0` 就用它续拉,
|
||
/// 把所有页的 `userMapList` 拼起来。这里照做,否则只能看到第一页的区域。
|
||
Future<List<Map<String, dynamic>>> getUserMaps(int userId) async {
|
||
const apiName = 'GetUserMapApi';
|
||
final all = <Map<String, dynamic>>[];
|
||
var nextIndex = 0;
|
||
var pages = 0;
|
||
|
||
do {
|
||
final json = await _callApi(apiName, {
|
||
'userId': userId,
|
||
'nextIndex': nextIndex,
|
||
}, userId);
|
||
|
||
final list = json['userMapList'];
|
||
if (list is List) {
|
||
all.addAll(list.whereType<Map<String, dynamic>>());
|
||
}
|
||
nextIndex = (json['nextIndex'] as num?)?.toInt() ?? 0;
|
||
pages++;
|
||
} while (nextIndex != 0 && pages < 20);
|
||
|
||
return all;
|
||
}
|
||
|
||
/// 万花宿命之门进度,对应 UserKaleidxScopeResponseVO。
|
||
///
|
||
/// 与 `GetUserMapApi` 不同,这个接口不分页(`UserKaleidxScopeRequestVO`
|
||
/// 只有 `userId` 一个字段),一次拿全。
|
||
Future<List<UserKaleidxScopeBean>> getUserKaleidxScopes(int userId) async {
|
||
final json = await _callApi(
|
||
'GetUserKaleidxScopeApi',
|
||
{'userId': userId},
|
||
userId,
|
||
);
|
||
return UserKaleidxScopeBean.listFromResponse(json);
|
||
}
|
||
|
||
/// 某一类道具的持有情况,对应 UserItemResponseVO。
|
||
///
|
||
/// `GetUserItemApi` 是**按 itemKind 分页**的:客户端
|
||
/// `PacketGetUserItem.cs:19` 首次请求把 `nextIndex` 塞成
|
||
/// `itemKind * 10000000000`(`ConstParameter.ItemKindConvert = 1e10`),
|
||
/// 之后拿响应里的 `nextIndex` 续拉,直到它为 0。
|
||
/// 直接发 `nextIndex: 0` 只会拿到空页,所以要照这个编码来。
|
||
Future<List<Map<String, dynamic>>> getUserItems(
|
||
int userId,
|
||
int itemKind,
|
||
) async {
|
||
const apiName = 'GetUserItemApi';
|
||
final all = <Map<String, dynamic>>[];
|
||
var nextIndex = itemKind * 10000000000;
|
||
var pages = 0;
|
||
|
||
do {
|
||
final json = await _callApi(apiName, {
|
||
'userId': userId,
|
||
'nextIndex': nextIndex,
|
||
}, userId);
|
||
|
||
final list = json['userItemList'];
|
||
if (list is List) {
|
||
all.addAll(list.whereType<Map<String, dynamic>>());
|
||
}
|
||
nextIndex = (json['nextIndex'] as num?)?.toInt() ?? 0;
|
||
pages++;
|
||
} while (nextIndex != 0 && pages < 40);
|
||
|
||
return all;
|
||
}
|
||
|
||
/// 一次读多个 itemKind,返回 `'kind:itemId'` 的键集合。
|
||
///
|
||
/// 用于给 `isNewItemList` 判每一位是 '1'(服务器没有这行、插入)
|
||
/// 还是 '0'(已有、更新)——`VOExtensions.BuildListData:274` 的
|
||
/// userItemList 主键是 (itemKind, itemId) 二元组。
|
||
Future<Set<String>> getUserItemKeys(
|
||
int userId,
|
||
Iterable<int> itemKinds,
|
||
) async {
|
||
final keys = <String>{};
|
||
await Future.wait([
|
||
for (final kind in itemKinds.toSet())
|
||
getUserItems(userId, kind).then((rows) {
|
||
for (final row in rows) {
|
||
final id = (row['itemId'] as num?)?.toInt();
|
||
if (id == null) continue;
|
||
keys.add('$kind:$id');
|
||
}
|
||
}),
|
||
]);
|
||
return keys;
|
||
}
|
||
}
|
||
|
||
class _RandomHelper {
|
||
final _random = Random();
|
||
|
||
int nextInt(int max) => _random.nextInt(max);
|
||
}
|