// // KBMaiPointReporter.m // keyBoard // #import "KBMaiPointReporter.h" #import "KBLog.h" #import "KBAuthManager.h" #if __has_include() #import #import #endif NSString * const KBMaiPointErrorDomain = @"KBMaiPointErrorDomain"; NSString * const KBMaiPointEventTypePageExposure = @"page_exposure"; NSString * const KBMaiPointEventTypeClick = @"click"; #if DEBUG static void KBMaiPoint_DebugLogURL(NSURLRequest *request) { NSString *url = request.URL.absoluteString ?: @""; KBLOG(@"🍃[KBMaiPointReporter] url=%@", url); } static void KBMaiPoint_DebugLogError(NSURLResponse *response, NSError *error) { if (error) { NSString *msg = error.localizedDescription ?: @"(no description)"; KBLOG(@"🍃[KBMaiPointReporter] error=%@ domain=%@ code=%ld", msg, error.domain ?: @"", (long)error.code); return; } if ([response isKindOfClass:NSHTTPURLResponse.class]) { NSInteger statusCode = ((NSHTTPURLResponse *)response).statusCode; if (statusCode >= 200 && statusCode < 300) { KBLOG(@"🍃[KBMaiPointReporter] status=HTTP_%ld", (long)statusCode); } else { KBLOG(@"🍃[KBMaiPointReporter] error=HTTP_%ld", (long)statusCode); } } } #endif @implementation KBMaiPointReporter + (instancetype)sharedReporter { static KBMaiPointReporter *reporter = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ reporter = [[KBMaiPointReporter alloc] init]; }); return reporter; } - (NSString *)kb_trimmedStringOrEmpty:(NSString * _Nullable)string { NSString *value = [string isKindOfClass:[NSString class]] ? string : @""; return [value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] ?: @""; } - (NSString *)kb_currentTokenOrEmpty { NSString *t = [KBAuthManager shared].current.accessToken; return [self kb_trimmedStringOrEmpty:t]; } - (void)reportEventType:(NSString *)eventType eventName:(NSString *)eventName value:(NSDictionary * _Nullable)value completion:(KBMaiPointReportCompletion _Nullable)completion { NSString *trimmedType = [self kb_trimmedStringOrEmpty:eventType]; NSString *trimmedName = [self kb_trimmedStringOrEmpty:eventName]; if (trimmedType.length == 0 || trimmedName.length == 0) { NSError *error = [NSError errorWithDomain:KBMaiPointErrorDomain code:-1 userInfo:@{NSLocalizedDescriptionKey: @"Invalid parameter"}]; if (completion) { dispatch_async(dispatch_get_main_queue(), ^{ completion(NO, error); }); } return; } NSMutableDictionary *val = [NSMutableDictionary dictionary]; if ([value isKindOfClass:[NSDictionary class]] && value.count > 0) { [val addEntriesFromDictionary:value]; } if (![val[@"token"] isKindOfClass:NSString.class]) { val[@"token"] = [self kb_currentTokenOrEmpty]; } else { // 若外部传了 token,也做一次兜底(nil -> @"" / trim) val[@"token"] = [self kb_trimmedStringOrEmpty:val[@"token"]]; } NSDictionary *params = @{ // 字段兼容:后端若用 eventId 统计,也能直接用 eventName @"eventType": trimmedType, @"eventName": trimmedName, @"eventId": trimmedName, @"value": val.copy }; [self postPath:KB_MAI_POINT_PATH_GENERIC_DATA parameters:params completion:completion]; } - (void)reportPageExposureWithEventName:(NSString *)eventName pageId:(NSString *)pageId extra:(NSDictionary * _Nullable)extra completion:(KBMaiPointReportCompletion _Nullable)completion { NSString *pid = [self kb_trimmedStringOrEmpty:pageId]; NSMutableDictionary *val = [NSMutableDictionary dictionary]; if (pid.length > 0) { val[@"page_id"] = pid; } if ([extra isKindOfClass:[NSDictionary class]] && extra.count > 0) { [val addEntriesFromDictionary:extra]; } [self reportEventType:KBMaiPointEventTypePageExposure eventName:eventName value:val completion:completion]; } - (void)reportClickWithEventName:(NSString *)eventName pageId:(NSString *)pageId elementId:(NSString *)elementId extra:(NSDictionary * _Nullable)extra completion:(KBMaiPointReportCompletion _Nullable)completion { NSString *pid = [self kb_trimmedStringOrEmpty:pageId]; NSString *eid = [self kb_trimmedStringOrEmpty:elementId]; NSMutableDictionary *val = [NSMutableDictionary dictionary]; if (pid.length > 0) { val[@"page_id"] = pid; } if (eid.length > 0) { val[@"element_id"] = eid; } if ([extra isKindOfClass:[NSDictionary class]] && extra.count > 0) { [val addEntriesFromDictionary:extra]; } [self reportEventType:KBMaiPointEventTypeClick eventName:eventName value:val completion:completion]; } - (void)reportNewAccountWithType:(NSString *)type account:(NSString * _Nullable)account completion:(KBMaiPointReportCompletion _Nullable)completion { NSString *trimmedType = [self kb_trimmedStringOrEmpty:type]; NSString *trimmedAccount = [self kb_trimmedStringOrEmpty:account]; if (trimmedType.length == 0) { NSError *error = [NSError errorWithDomain:KBMaiPointErrorDomain code:-1 userInfo:@{NSLocalizedDescriptionKey: @"Invalid parameter"}]; if (completion) { dispatch_async(dispatch_get_main_queue(), ^{ completion(NO, error); }); } return; } NSDictionary *params = @{ @"type": trimmedType, @"account": trimmedAccount ?: @"", @"token": [self kb_currentTokenOrEmpty] }; [self postPath:KB_MAI_POINT_PATH_NEW_ACCOUNT parameters:params completion:completion]; } //- (void)reportGenericDataWithEvent:(NSString *)event // account:(NSString * _Nullable)account // completion:(KBMaiPointReportCompletion _Nullable)completion { // [self reportGenericDataWithType:KBMaiPointGenericReportTypeUnknown // event:event // account:account // completion:completion]; //} - (void)reportGenericDataWithEventType:(KBMaiPointGenericReportType)eventType account:(nullable NSString *)account completion:(KBMaiPointReportCompletion _Nullable)completion{ // 兼容旧接口:没有 eventName 时给一个默认值,避免调用方崩溃 NSString *typeStr = @"unknown"; switch (eventType) { case KBMaiPointGenericReportTypeClick: typeStr = KBMaiPointEventTypeClick; break; case KBMaiPointGenericReportTypeExposure: typeStr = @"exposure"; break; case KBMaiPointGenericReportTypePage: typeStr = KBMaiPointEventTypePageExposure; break; default: break; } NSMutableDictionary *val = [NSMutableDictionary dictionary]; NSString *trimmedAccount = [self kb_trimmedStringOrEmpty:account]; if (trimmedAccount.length > 0) { val[@"account"] = trimmedAccount; } [self reportEventType:typeStr eventName:@"generic_event" value:val completion:completion]; } - (void)postPath:(NSString *)path parameters:(NSDictionary *)parameters completion:(KBMaiPointReportCompletion _Nullable)completion { if (path.length == 0 || ![parameters isKindOfClass:[NSDictionary class]]) { NSError *error = [NSError errorWithDomain:KBMaiPointErrorDomain code:-1 userInfo:@{NSLocalizedDescriptionKey: @"Invalid parameter"}]; if (completion) { dispatch_async(dispatch_get_main_queue(), ^{ completion(NO, error); }); } return; } NSString *safePath = [path hasPrefix:@"/"] ? path : [@"/" stringByAppendingString:path]; NSString *urlString = [NSString stringWithFormat:@"%@%@", KB_MAI_POINT_BASE_URL, safePath]; NSURL *url = [NSURL URLWithString:urlString]; if (!url) { NSError *error = [NSError errorWithDomain:KBMaiPointErrorDomain code:-2 userInfo:@{NSLocalizedDescriptionKey: @"Invalid URL"}]; if (completion) { dispatch_async(dispatch_get_main_queue(), ^{ completion(NO, error); }); } return; } NSError *jsonError = nil; NSData *body = [NSJSONSerialization dataWithJSONObject:parameters options:0 error:&jsonError]; if (jsonError) { if (completion) { dispatch_async(dispatch_get_main_queue(), ^{ completion(NO, jsonError); }); } return; } NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; request.HTTPMethod = @"POST"; request.timeoutInterval = 10.0; [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; request.HTTPBody = body; #if DEBUG KBMaiPoint_DebugLogURL(request); #endif NSURLSessionConfiguration *config = [NSURLSessionConfiguration ephemeralSessionConfiguration]; config.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData; NSURLSession *session = [NSURLSession sessionWithConfiguration:config]; NSURLSessionDataTask *task = [session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { BOOL success = NO; NSError *finalError = error; if (!finalError) { if ([response isKindOfClass:[NSHTTPURLResponse class]]) { NSInteger statusCode = ((NSHTTPURLResponse *)response).statusCode; success = (statusCode >= 200 && statusCode < 300); if (!success) { finalError = [NSError errorWithDomain:KBMaiPointErrorDomain code:statusCode userInfo:@{NSLocalizedDescriptionKey: @"Invalid response"}]; } } else { finalError = [NSError errorWithDomain:KBMaiPointErrorDomain code:-3 userInfo:@{NSLocalizedDescriptionKey: @"Invalid response"}]; } } #if DEBUG KBMaiPoint_DebugLogError(response, finalError); #endif if (completion) { dispatch_async(dispatch_get_main_queue(), ^{ completion(success, finalError); }); } }]; [task resume]; } @end #if __has_include() // ============================ // 自动页面曝光(viewDidAppear) // 说明:仅对“在表里登记过的 VC”生效;其它 VC 不处理。 // ============================ static NSDictionary *KBMaiPoint_PageExposureMap(void) { static NSDictionary *m; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ m = @{ // 主工程 @"HomeMainVC": @{@"event_name": @"enter_home_main", @"page_id": @"home_main"}, @"HomeVC": @{@"event_name": @"enter_home", @"page_id": @"home"}, @"HomeHotVC": @{@"event_name": @"enter_home_hot", @"page_id": @"home_hot"}, @"HomeRankVC": @{@"event_name": @"enter_home_rank", @"page_id": @"home_rank"}, @"HomeRankContentVC": @{@"event_name": @"enter_home_rank_content", @"page_id": @"home_rank_content"}, @"HomeSheetVC": @{@"event_name": @"enter_home_sheet", @"page_id": @"home_sheet"}, @"KBCommunityVC": @{@"event_name": @"enter_community", @"page_id": @"community"}, @"KBSearchVC": @{@"event_name": @"enter_search", @"page_id": @"search"}, @"KBSearchResultVC": @{@"event_name": @"enter_search_result", @"page_id": @"search_result"}, @"KBShopVC": @{@"event_name": @"enter_shop", @"page_id": @"shop"}, @"KBShopItemVC": @{@"event_name": @"enter_shop_item_list", @"page_id": @"shop_item_list"}, @"KBSkinDetailVC": @{@"event_name": @"enter_skin_detail", @"page_id": @"skin_detail"}, @"MyVC": @{@"event_name": @"enter_my", @"page_id": @"my"}, @"MySkinVC": @{@"event_name": @"enter_my_skin", @"page_id": @"my_skin"}, @"KBMyKeyBoardVC": @{@"event_name": @"enter_my_keyboard", @"page_id": @"my_keyboard"}, @"KBPersonInfoVC": @{@"event_name": @"enter_person_info", @"page_id": @"person_info"}, @"KBFeedBackVC": @{@"event_name": @"enter_feedback", @"page_id": @"feedback"}, @"KBNoticeVC": @{@"event_name": @"enter_notice", @"page_id": @"notice"}, @"KBConsumptionRecordVC": @{@"event_name": @"enter_consumption_record", @"page_id": @"consumption_record"}, @"KBVipPay": @{@"event_name": @"enter_vip_pay", @"page_id": @"vip_pay"}, @"KBJfPay": @{@"event_name": @"enter_points_recharge", @"page_id": @"points_recharge"}, @"KBLoginVC": @{@"event_name": @"enter_login", @"page_id": @"login"}, @"KBEmailLoginVC": @{@"event_name": @"enter_login_email", @"page_id": @"login_email"}, @"KBEmailRegistVC": @{@"event_name": @"enter_register_email", @"page_id": @"register_email"}, @"KBRegistVerEmailVC": @{@"event_name": @"enter_register_verify_email", @"page_id": @"register_verify_email"}, @"KBForgetPwdVC": @{@"event_name": @"enter_forgot_password_email", @"page_id": @"forgot_password_email"}, @"KBForgetVerPwdVC": @{@"event_name": @"enter_forgot_password_verify", @"page_id": @"forgot_password_verify"}, @"KBForgetPwdNewPwdVC": @{@"event_name": @"enter_forgot_password_newpwd", @"page_id": @"forgot_password_newpwd"}, @"KBPermissionViewController": @{@"event_name": @"enter_keyboard_permission_guide", @"page_id": @"keyboard_permission_guide"}, @"KBGuideVC": @{@"event_name": @"enter_guide", @"page_id": @"guide"}, @"KBSexSelVC": @{@"event_name": @"enter_sex_select", @"page_id": @"sex_select"}, @"KBWebViewViewController": @{@"event_name": @"enter_webview", @"page_id": @"webview"}, // 键盘扩展 @"KeyboardViewController": @{@"event_name": @"enter_keyboard", @"page_id": @"keyboard"}, }; }); return m; } static inline void KBMaiPoint_SwizzleInstanceMethod(Class cls, SEL originalSel, SEL swizzledSel) { Method original = class_getInstanceMethod(cls, originalSel); Method swizzled = class_getInstanceMethod(cls, swizzledSel); if (!original || !swizzled) return; BOOL added = class_addMethod(cls, originalSel, method_getImplementation(swizzled), method_getTypeEncoding(swizzled)); if (added) { class_replaceMethod(cls, swizzledSel, method_getImplementation(original), method_getTypeEncoding(original)); } else { method_exchangeImplementations(original, swizzled); } } @interface UIViewController (KBMaiPointAutoReport) @end @implementation UIViewController (KBMaiPointAutoReport) + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ KBMaiPoint_SwizzleInstanceMethod(self, @selector(viewDidAppear:), @selector(kb_maipoint_viewDidAppear:)); }); } - (void)kb_maipoint_viewDidAppear:(BOOL)animated { [self kb_maipoint_viewDidAppear:animated]; NSString *clsName = NSStringFromClass(self.class); NSDictionary *cfg = KBMaiPoint_PageExposureMap()[clsName]; if (![cfg isKindOfClass:NSDictionary.class]) { return; } NSString *eventName = cfg[@"event_name"]; NSString *pageId = cfg[@"page_id"]; if (![eventName isKindOfClass:NSString.class] || ![pageId isKindOfClass:NSString.class]) { return; } // 少数页面带额外参数(尽量不取敏感信息) NSMutableDictionary *extra = [NSMutableDictionary dictionary]; if ([clsName isEqualToString:@"KBSkinDetailVC"]) { id themeId = nil; @try { themeId = [self valueForKey:@"themeId"]; } @catch (__unused NSException *e) { themeId = nil; } if ([themeId isKindOfClass:NSString.class] && ((NSString *)themeId).length > 0) { extra[@"theme_id"] = themeId; } } else if ([clsName isEqualToString:@"KBWebViewViewController"]) { id url = nil; @try { url = [self valueForKey:@"url"]; } @catch (__unused NSException *e) { url = nil; } if ([url isKindOfClass:NSString.class] && ((NSString *)url).length > 0) { extra[@"url"] = url; } } [[KBMaiPointReporter sharedReporter] reportPageExposureWithEventName:eventName pageId:pageId extra:(extra.count > 0 ? extra.copy : nil) completion:nil]; } @end #endif