Files
keyboard/Shared/KBMaiPointReporter.m
2026-01-06 19:25:34 +08:00

400 lines
17 KiB
Objective-C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// KBMaiPointReporter.m
// keyBoard
//
#import "KBMaiPointReporter.h"
#import "KBLog.h"
#import "KBAuthManager.h"
#if __has_include(<UIKit/UIKit.h>)
#import <UIKit/UIKit.h>
#import <objc/runtime.h>
#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(<UIKit/UIKit.h>)
// ============================
// 自动页面曝光viewDidAppear
// 说明:仅对“在表里登记过的 VC”生效其它 VC 不处理。
// ============================
static NSDictionary<NSString *, NSDictionary *> *KBMaiPoint_PageExposureMap(void) {
static NSDictionary<NSString *, 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