Files
jinzhi/lib/features/mall/application/mall_tab_config.dart
T
2026-09-08 14:27:32 +08:00

349 lines
9.9 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../../../common/network/dio_provider.dart';
/// 金值商城广告位的公共契约;商城是否展示完全由服务端配置决定。
const mallSourceApp = 'jinzhi';
/// The native entry that opened the resident mall. H5 uses this context to
/// choose the appropriate landing flow while sharing the same WebView host.
class MallEntryRequest {
const MallEntryRequest({
required this.sourceEntry,
required this.hookType,
this.entryScene,
this.entryMetal,
});
static const home = MallEntryRequest(
sourceEntry: 'mall_tab',
hookType: 'mall_tab',
);
static const quoteRetailGold = MallEntryRequest(
sourceEntry: 'market_quote',
hookType: 'quote_cta',
entryScene: 'retail',
entryMetal: 'au',
);
final String sourceEntry;
final String hookType;
final String? entryScene;
final String? entryMetal;
}
class MallTabConfig {
const MallTabConfig({
required this.enabled,
required this.url,
this.title = '商城',
this.adId = '',
this.trackingId = '',
this.openMode = 'in_app',
this.entryPromoEnabled = false,
this.entryBadgeText = '',
this.entryTipTitle = '',
this.entryTipBody = '',
});
const MallTabConfig.disabled() : this(enabled: false, url: '');
final bool enabled;
final String url;
final String title;
final String adId;
final String trackingId;
final String openMode;
final bool entryPromoEnabled;
final String entryBadgeText;
final String entryTipTitle;
final String entryTipBody;
bool get isVisible => enabled && initialUri != null;
bool get opensInApp => openMode == 'in_app';
bool get showsEntryBadge =>
isVisible && entryPromoEnabled && entryBadgeText.trim().isNotEmpty;
bool get showsEntryTip =>
isVisible &&
entryPromoEnabled &&
(entryTipTitle.trim().isNotEmpty || entryTipBody.trim().isNotEmpty);
Uri? get initialUri => attributedUri(clickId: '');
Uri? attributedUri({
required String clickId,
bool isDark = false,
MallEntryRequest entry = MallEntryRequest.home,
}) {
final uri = Uri.tryParse(url.trim());
if (!_isAllowed(uri)) return null;
return uri!.replace(
queryParameters: {
...uri.queryParameters,
'from': uri.queryParameters['from']?.trim().isNotEmpty == true
? uri.queryParameters['from']!
: mallSourceApp,
'source_app': mallSourceApp,
'source_entry': entry.sourceEntry,
'placement': 'mall_tab',
'hook_type': entry.hookType,
if (entry.entryScene?.isNotEmpty == true)
'entry_scene': entry.entryScene!,
if (entry.entryMetal?.isNotEmpty == true)
'entry_metal': entry.entryMetal!,
'target_app': 'mall',
if (adId.isNotEmpty) 'ad_id': adId,
if (trackingId.isNotEmpty) 'tracking_id': trackingId,
if (clickId.isNotEmpty) 'click_id': clickId,
if (clickId.isNotEmpty)
'ts': DateTime.now().millisecondsSinceEpoch.toString(),
'isNight': isDark ? '1' : '0',
},
);
}
static bool _isAllowed(Uri? uri) {
if (uri == null || uri.host.isEmpty) return false;
if (uri.scheme == 'https') return true;
return !kReleaseMode &&
uri.scheme == 'http' &&
const {'localhost', '127.0.0.1', '::1'}.contains(uri.host);
}
}
class MallTabConfigController extends StateNotifier<MallTabConfig> {
MallTabConfigController(this._dio) : super(const MallTabConfig.disabled()) {
refresh();
}
final Dio _dio;
Future<void>? _loading;
Future<MallTabConfig> ensureLoaded() async {
if (state.isVisible) return state;
await (_loading ??= refresh().whenComplete(() => _loading = null));
return state;
}
Future<void> refresh() async {
try {
final response = await _dio.get<List<int>>(
'/api/v1/ads',
queryParameters: const {'placement': 'mall_tab'},
options: protobufOptions(),
);
state = parseMallTabConfigResponse(response.data ?? const []);
if (!state.isVisible) return;
await _report('ad_impression');
} catch (_) {
state = const MallTabConfig.disabled();
}
}
Future<String?> recordClick() async {
if (!state.isVisible) return null;
final clickId = const Uuid().v4();
try {
await _report('ad_click', clickId: clickId);
} catch (_) {
// 归因上报失败不能阻断商城入口。
}
return clickId;
}
Future<void> _report(String event, {String clickId = ''}) =>
_dio.post<List<int>>(
'/api/v1/ads/events',
data: _ProtoWriter()
.string(1, event)
.string(2, 'mall_tab')
.string(3, state.adId)
.string(4, state.trackingId)
.string(5, clickId)
.integer(6, DateTime.now().millisecondsSinceEpoch)
.toBytes(),
options: protobufOptions(),
);
}
final mallTabConfigProvider =
StateNotifierProvider<MallTabConfigController, MallTabConfig>(
(ref) => MallTabConfigController(ref.watch(dioProvider)),
);
class MallClickAttribution {
const MallClickAttribution({
required this.adId,
required this.trackingId,
required this.clickId,
});
final String adId;
final String trackingId;
final String clickId;
}
final mallClickAttributionProvider = StateProvider<MallClickAttribution?>(
(ref) => null,
);
Map<String, dynamic> buildEnterWebMallBridgeArguments({
required Uri currentUri,
required bool logged,
required bool isDark,
String? authToken,
String? scene,
String? sourceApp,
MallClickAttribution? attribution,
}) {
final query = currentUri.queryParameters;
final resolvedSourceApp = sourceApp?.trim().isNotEmpty == true
? sourceApp!.trim()
: mallSourceApp;
final arguments = <String, dynamic>{
...query,
'from': resolvedSourceApp,
'source_app': resolvedSourceApp,
'source_entry': query['source_entry'] ?? 'mall_tab',
'placement': query['placement'] ?? 'mall_tab',
'hook_type': query['hook_type'] ?? 'mall_tab',
'target_app': query['target_app'] ?? 'mall',
'logged': logged,
'isNight': isDark ? '1' : '0',
};
final adId = attribution?.adId.trim() ?? '';
if (adId.isNotEmpty) arguments['ad_id'] = adId;
final trackingId = attribution?.trackingId.trim() ?? '';
if (trackingId.isNotEmpty) arguments['tracking_id'] = trackingId;
final clickId = attribution?.clickId.trim() ?? '';
if (clickId.isNotEmpty) arguments['click_id'] = clickId;
final token = authToken?.trim() ?? '';
if (token.isNotEmpty) {
arguments['auth_token'] = token;
arguments['authToken'] = token;
}
final resolvedScene = scene?.trim() ?? '';
if (resolvedScene.isNotEmpty) arguments['scene'] = resolvedScene;
return arguments;
}
String _nonEmpty(String value, String fallback) =>
value.trim().isEmpty ? fallback : value.trim();
String? _validUrl(String raw) {
final uri = Uri.tryParse(raw.trim());
return MallTabConfig._isAllowed(uri) ? uri.toString() : null;
}
String _openMode(int value) => switch (value) {
1 => 'native_app',
2 => 'in_app',
_ => 'external',
};
/// Parses the protobuf reply returned by `/api/v1/ads?placement=mall_tab`.
MallTabConfig parseMallTabConfigResponse(List<int> bytes) {
final response = _ProtoMessage(bytes);
final reply = response.message(2);
final trackedUrl = reply.string(14);
final h5Url = reply.string(9);
final url = _validUrl(trackedUrl) ?? _validUrl(h5Url);
if (response.integer(1) == 0 || url == null) {
return const MallTabConfig.disabled();
}
return MallTabConfig(
enabled: true,
url: url,
title: _nonEmpty(reply.string(4), '商城'),
adId: reply.string(1),
trackingId: reply.string(10),
openMode: _openMode(reply.integer(15)),
entryPromoEnabled: reply.integer(16) != 0,
entryBadgeText: reply.string(17).trim(),
entryTipTitle: reply.string(18).trim(),
entryTipBody: reply.string(19).trim(),
);
}
class _ProtoMessage {
_ProtoMessage(this._bytes);
final List<int> _bytes;
int integer(int wanted) =>
_fields()[wanted] is int ? _fields()[wanted]! as int : 0;
String string(int wanted) => _fields()[wanted] is List<int>
? utf8.decode(_fields()[wanted]! as List<int>, allowMalformed: true)
: '';
_ProtoMessage message(int wanted) => _ProtoMessage(
_fields()[wanted] is List<int> ? _fields()[wanted]! as List<int> : const [],
);
Map<int, Object> _fields() {
final values = <int, Object>{};
var index = 0;
int readVarint() {
var value = 0;
var shift = 0;
while (index < _bytes.length && shift < 64) {
final byte = _bytes[index++];
value |= (byte & 127) << shift;
if (byte < 128) return value;
shift += 7;
}
throw const FormatException('Invalid protobuf');
}
while (index < _bytes.length) {
final key = readVarint();
final tag = key >> 3;
switch (key & 7) {
case 0:
values[tag] = readVarint();
case 2:
final length = readVarint();
final end = index + length;
if (end > _bytes.length) {
throw const FormatException('Invalid protobuf');
}
values[tag] = _bytes.sublist(index, end);
index = end;
default:
throw const FormatException('Unsupported protobuf');
}
}
return values;
}
}
class _ProtoWriter {
final _bytes = BytesBuilder(copy: false);
_ProtoWriter string(int tag, String value) {
final data = value.codeUnits;
_varint((tag << 3) | 2);
_varint(data.length);
_bytes.add(data);
return this;
}
_ProtoWriter integer(int tag, int value) {
_varint(tag << 3);
_varint(value);
return this;
}
Uint8List toBytes() => _bytes.toBytes();
void _varint(int value) {
while (value > 127) {
_bytes.addByte((value & 127) | 128);
value >>= 7;
}
_bytes.addByte(value);
}
}