import 'package:dio/dio.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/legacy.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 {}, }); 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 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 { MallTabConfigController(this._dio, {bool autoRefresh = true}) : super(const MallTabConfig.disabled()) { if (autoRefresh) refresh(); } final Dio _dio; Future? _loading; Future ensureLoaded() async { if (state.isVisible) return state; await (_loading ??= refresh().whenComplete(() => _loading = null)); return state; } Future refresh() async { try { final response = await _dio.get>( '/api/v1/ads', queryParameters: const {'placement': 'mall_tab'}, options: protobufOptions(), ); state = parseMallTabConfigResponse(response.data ?? const []); if (!state.isVisible) return; await _report(state, 'ad_impression'); } catch (_) { state = const MallTabConfig.disabled(); } } Future 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 _report(MallTabConfig config, String event) async { final response = await _dio.post>( '/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: { '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 []); } } final mallTabConfigProvider = StateNotifierProvider( (ref) => MallTabConfigController(ref.watch(dioProvider)), ); Map buildEnterWebMallBridgeArguments({ required MallTabConfig config, required bool logged, Uri? currentUri, bool isDark = false, String? authToken, String? scene, String? sourceApp, }) { final arguments = { ...?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 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: {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 [trackedUrl, h5Url]) { final uri = Uri.tryParse(raw.trim()); if (MallTabConfig._isAllowed(uri)) return uri.toString(); } return null; }