297 lines
8.8 KiB
Dart
297 lines
8.8 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:fixnum/fixnum.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import '../../../common/network/dio_provider.dart';
|
|
import '../../../models/pb/jinzhi/v1/ads.pb.dart' as pb;
|
|
|
|
/// 标识打开商城的宿主项目。
|
|
const mallSourceApp = 'jinzhi';
|
|
|
|
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.clickId = '',
|
|
this.openMode = 'in_app',
|
|
this.entryPromoEnabled = false,
|
|
this.entryBadgeText = '',
|
|
this.entryTipTitle = '',
|
|
this.entryTipBody = '',
|
|
this.quoteCtaEnabled = false,
|
|
this.entryRequest = MallEntryRequest.home,
|
|
this.allowedHosts = const <String>{},
|
|
});
|
|
|
|
const MallTabConfig.disabled() : this(enabled: false, url: '');
|
|
|
|
final bool enabled;
|
|
final String url;
|
|
final String title;
|
|
final String adId;
|
|
final String trackingId;
|
|
final String clickId;
|
|
final String openMode;
|
|
final bool entryPromoEnabled;
|
|
final String entryBadgeText;
|
|
final String entryTipTitle;
|
|
final String entryTipBody;
|
|
final bool quoteCtaEnabled;
|
|
final MallEntryRequest entryRequest;
|
|
final Set<String> allowedHosts;
|
|
|
|
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);
|
|
|
|
String get from {
|
|
final value = Uri.tryParse(url.trim())?.queryParameters['from']?.trim();
|
|
return value == null || value.isEmpty ? mallSourceApp : value;
|
|
}
|
|
|
|
Uri? get initialUri => attributedUri();
|
|
|
|
Uri? attributedUri({
|
|
String? clickId,
|
|
bool isDark = false,
|
|
MallEntryRequest? entry,
|
|
}) {
|
|
final uri = Uri.tryParse(url.trim());
|
|
if (!_isAllowed(uri)) return null;
|
|
final effectiveClickId = clickId ?? this.clickId;
|
|
final effectiveEntry = entry ?? entryRequest;
|
|
return uri!.replace(
|
|
queryParameters: {
|
|
...uri.queryParameters,
|
|
'from': from,
|
|
'source_app': from,
|
|
'source_entry': effectiveEntry.sourceEntry,
|
|
'placement': 'mall_tab',
|
|
'hook_type': effectiveEntry.hookType,
|
|
'entry_scene': ?effectiveEntry.entryScene,
|
|
'entry_metal': ?effectiveEntry.entryMetal,
|
|
'target_app': 'mall',
|
|
if (adId.isNotEmpty) 'ad_id': adId,
|
|
if (trackingId.isNotEmpty) 'tracking_id': trackingId,
|
|
if (effectiveClickId.isNotEmpty) 'click_id': effectiveClickId,
|
|
if (effectiveClickId.isNotEmpty)
|
|
'ts': DateTime.now().millisecondsSinceEpoch.toString(),
|
|
'isNight': isDark ? '1' : '0',
|
|
},
|
|
);
|
|
}
|
|
|
|
MallTabConfig withClick(
|
|
String value, {
|
|
MallEntryRequest request = MallEntryRequest.home,
|
|
}) => MallTabConfig(
|
|
enabled: enabled,
|
|
url: url,
|
|
title: title,
|
|
adId: adId,
|
|
trackingId: trackingId,
|
|
clickId: value,
|
|
openMode: openMode,
|
|
entryPromoEnabled: entryPromoEnabled,
|
|
entryBadgeText: entryBadgeText,
|
|
entryTipTitle: entryTipTitle,
|
|
entryTipBody: entryTipBody,
|
|
quoteCtaEnabled: quoteCtaEnabled,
|
|
entryRequest: request,
|
|
allowedHosts: allowedHosts,
|
|
);
|
|
|
|
static bool _isAllowed(Uri? uri) {
|
|
if (uri == null || uri.host.isEmpty) return false;
|
|
if (uri.scheme.toLowerCase() == 'https') return true;
|
|
return !kReleaseMode &&
|
|
uri.scheme.toLowerCase() == 'http' &&
|
|
const {
|
|
'localhost',
|
|
'127.0.0.1',
|
|
'::1',
|
|
}.contains(uri.host.toLowerCase());
|
|
}
|
|
}
|
|
|
|
class MallTabConfigController extends StateNotifier<MallTabConfig> {
|
|
MallTabConfigController(this._dio, {bool autoRefresh = true})
|
|
: super(const MallTabConfig.disabled()) {
|
|
if (autoRefresh) 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 <int>[]);
|
|
if (!state.isVisible) return;
|
|
await _report(state, 'ad_impression');
|
|
} catch (_) {
|
|
state = const MallTabConfig.disabled();
|
|
}
|
|
}
|
|
|
|
Future<MallTabConfig> recordClick({
|
|
MallEntryRequest request = MallEntryRequest.home,
|
|
}) async {
|
|
if (!state.isVisible) return state;
|
|
final clicked = state.withClick(const Uuid().v4(), request: request);
|
|
state = clicked;
|
|
try {
|
|
await _report(clicked, 'ad_click');
|
|
} catch (_) {
|
|
// 归因上报失败不能阻断商城入口。
|
|
}
|
|
return clicked;
|
|
}
|
|
|
|
Future<void> _report(MallTabConfig config, String event) async {
|
|
final response = await _dio.post<List<int>>(
|
|
'/api/v1/ads/events',
|
|
data: pb.ReportAdEventRequest(
|
|
eventName: event,
|
|
placement: 'mall_tab',
|
|
adId: config.adId,
|
|
trackingId: config.trackingId,
|
|
clickId: config.clickId,
|
|
clientTsMs: Int64(DateTime.now().millisecondsSinceEpoch),
|
|
attrs: <String, String>{
|
|
'source_app': config.from,
|
|
'source_entry': config.entryRequest.sourceEntry,
|
|
'hook_type': config.entryRequest.hookType,
|
|
'entry_scene': ?config.entryRequest.entryScene,
|
|
'entry_metal': ?config.entryRequest.entryMetal,
|
|
'open_mode': config.openMode,
|
|
},
|
|
).writeToBuffer(),
|
|
options: protobufOptions(),
|
|
);
|
|
pb.ReportAdEventReply.fromBuffer(response.data ?? const <int>[]);
|
|
}
|
|
}
|
|
|
|
final mallTabConfigProvider =
|
|
StateNotifierProvider<MallTabConfigController, MallTabConfig>(
|
|
(ref) => MallTabConfigController(ref.watch(dioProvider)),
|
|
);
|
|
|
|
Map<String, dynamic> buildEnterWebMallBridgeArguments({
|
|
required MallTabConfig config,
|
|
required bool logged,
|
|
Uri? currentUri,
|
|
bool isDark = false,
|
|
String? authToken,
|
|
String? scene,
|
|
String? sourceApp,
|
|
}) {
|
|
final arguments = <String, dynamic>{
|
|
...?currentUri?.queryParameters,
|
|
...?config.attributedUri(isDark: isDark)?.queryParameters,
|
|
'from': config.from,
|
|
'logged': logged,
|
|
};
|
|
final token = authToken?.trim() ?? '';
|
|
if (token.isNotEmpty) {
|
|
arguments['auth_token'] = token;
|
|
arguments['authToken'] = token;
|
|
}
|
|
final resolvedScene = scene?.trim() ?? '';
|
|
if (resolvedScene.isNotEmpty) arguments['scene'] = resolvedScene;
|
|
final resolvedSourceApp = sourceApp?.trim() ?? '';
|
|
if (resolvedSourceApp.isNotEmpty) {
|
|
arguments['from'] = resolvedSourceApp;
|
|
arguments['source_app'] = resolvedSourceApp;
|
|
}
|
|
return arguments;
|
|
}
|
|
|
|
MallTabConfig parseMallTabConfigResponse(List<int> bytes) {
|
|
final reply = pb.GetAdReply.fromBuffer(bytes);
|
|
if (!reply.enabled || !reply.hasAd()) {
|
|
return const MallTabConfig.disabled();
|
|
}
|
|
final creative = reply.ad;
|
|
final url = _firstAllowedUrl(creative.trackedUrl, creative.h5Url);
|
|
if (url == null) return const MallTabConfig.disabled();
|
|
final promotion = creative.hasMallPromotion() ? creative.mallPromotion : null;
|
|
return MallTabConfig(
|
|
enabled: true,
|
|
url: url,
|
|
title: _nonEmpty(creative.title, '商城'),
|
|
adId: creative.adId,
|
|
trackingId: creative.trackingId,
|
|
openMode: _openMode(creative.openMode),
|
|
entryPromoEnabled: creative.entryPromoEnabled,
|
|
entryBadgeText: creative.entryBadgeText.trim(),
|
|
entryTipTitle: creative.entryTipTitle.trim(),
|
|
entryTipBody: creative.entryTipBody.trim(),
|
|
quoteCtaEnabled: promotion?.quoteCtaEnabled ?? false,
|
|
allowedHosts: <String>{Uri.parse(url).host.toLowerCase()},
|
|
);
|
|
}
|
|
|
|
String _nonEmpty(String value, String fallback) =>
|
|
value.trim().isEmpty ? fallback : value.trim();
|
|
|
|
String _openMode(pb.AdOpenMode value) => switch (value) {
|
|
pb.AdOpenMode.AD_OPEN_MODE_NATIVE_APP => 'native_app',
|
|
pb.AdOpenMode.AD_OPEN_MODE_IN_APP => 'in_app',
|
|
_ => 'external',
|
|
};
|
|
|
|
String? _firstAllowedUrl(String trackedUrl, String h5Url) {
|
|
for (final raw in <String>[trackedUrl, h5Url]) {
|
|
final uri = Uri.tryParse(raw.trim());
|
|
if (MallTabConfig._isAllowed(uri)) return uri.toString();
|
|
}
|
|
return null;
|
|
}
|