// // KBNetworkManager.m // CustomKeyboard // #import "KBNetworkManager.h" #import #import "AFNetworking.h" #import "KBAuthManager.h" #import "KBBizCode.h" // 仅在主 App 内需要的会话管理与 HUD 组件 #import "KBUserSessionManager.h" #import "KBHUD.h" NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network"; @interface KBNetworkManager () @property (nonatomic, strong) AFHTTPSessionManager *manager; // AFN 管理器(ephemeral 配置) // 私有错误派发 - (void)fail:(KBNetworkError)code completion:(KBNetworkCompletion)completion; @end #if DEBUG // 在使用到 Debug 辅助方法之前做前置声明,避免出现 // “No visible @interface declares the selector …” 的编译错误。 @interface KBNetworkManager (Debug) - (NSString *)kb_prettyJSONStringFromObject:(id)obj; - (NSString *)kb_textFromData:(NSData *)data; - (NSString *)kb_trimmedString:(NSString *)s maxLength:(NSUInteger)maxLen; @end #endif @implementation KBNetworkManager + (instancetype)shared { static KBNetworkManager *m; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ m = [KBNetworkManager new]; }); return m; } - (instancetype)init { if (self = [super init]) { _enabled = NO; // 键盘扩展默认无网络能力,需外部显式开启 _timeout = 10.0; // 默认请求头:Accept 任意类型 + 使用项目多语言管理器设置 Accept-Language NSString *lang = [KBLocalizationManager shared].currentLanguageCode ?: KBLanguageCodeEnglish; _defaultHeaders = @{ @"Accept": @"*/*", @"Accept-Language": lang, @"auth-token" : @"" }; // 设置基础域名,路径可相对该地址拼接 _baseURL = [NSURL URLWithString:KB_BASE_URL]; } return self; } # #pragma mark - Public // JSON GET:可控制业务错误是否由内部弹出 - (NSURLSessionDataTask *)GET:(NSString *)path parameters:(NSDictionary *)parameters headers:(NSDictionary *)headers autoShowBusinessError:(BOOL)autoShowBusinessError completion:(KBNetworkCompletion)completion { NSLog(@"[KBNetworkManager] GET called, enabled=%d, path=%@", self.isEnabled, path); if (![self ensureEnabled:completion]) return nil; NSString *urlString = [self buildURLStringWithPath:path]; if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; } // 使用 AFHTTPRequestSerializer 生成带参数与头的请求 AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer]; serializer.timeoutInterval = self.timeout; NSError *serror = nil; NSMutableURLRequest *req = [serializer requestWithMethod:@"GET" URLString:urlString parameters:parameters error:&serror]; if (serror || !req) { if (completion) completion(nil, nil, serror ?: [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidURL userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid URL")}]); return nil; } [self applyHeaders:headers toMutableRequest:req contentType:nil]; #if DEBUG // 打印请求信息(GET) NSString *paramStr = [self kb_prettyJSONStringFromObject:parameters] ?: @"(null)"; KBLOG(@"HTTP GET\nURL: %@\nHeaders: %@\n参数: %@", req.URL.absoluteString, req.allHTTPHeaderFields ?: @{}, paramStr); #endif return [self startJSONTaskWithRequest:req autoShowBusinessError:autoShowBusinessError completion:completion]; } // 默认 GET:自动弹业务错误 - (NSURLSessionDataTask *)GET:(NSString *)path parameters:(NSDictionary *)parameters headers:(NSDictionary *)headers completion:(KBNetworkCompletion)completion { return [self GET:path parameters:parameters headers:headers autoShowBusinessError:YES completion:completion]; } // JSON POST:可控制业务错误是否由内部弹出 - (NSURLSessionDataTask *)POST:(NSString *)path jsonBody:(id)jsonBody headers:(NSDictionary *)headers autoShowBusinessError:(BOOL)autoShowBusinessError completion:(KBNetworkCompletion)completion { NSLog(@"[KBNetworkManager] POST called, enabled=%d, path=%@", self.isEnabled, path); if (![self ensureEnabled:completion]) return nil; NSString *urlString = [self buildURLStringWithPath:path]; if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; } // 用 JSON 序列化器生成 JSON Body 的请求 AFJSONRequestSerializer *serializer = [AFJSONRequestSerializer serializer]; serializer.timeoutInterval = self.timeout; NSError *error = nil; NSMutableURLRequest *req = [serializer requestWithMethod:@"POST" URLString:urlString parameters:jsonBody error:&error]; if (error) { if (completion) completion(nil, nil, error); return nil; } [self applyHeaders:headers toMutableRequest:req contentType:nil]; #if DEBUG // 打印请求信息(POST JSON) NSString *bodyStr = [self kb_prettyJSONStringFromObject:jsonBody] ?: @"(null)"; KBLOG(@"HTTP POST\nURL: %@\nHeaders: %@\nJSON: %@", req.URL.absoluteString, req.allHTTPHeaderFields ?: @{}, bodyStr); #endif return [self startJSONTaskWithRequest:req autoShowBusinessError:autoShowBusinessError completion:completion]; } // 默认 POST:自动弹业务错误 - (NSURLSessionDataTask *)POST:(NSString *)path jsonBody:(id)jsonBody headers:(NSDictionary *)headers completion:(KBNetworkCompletion)completion { return [self POST:path jsonBody:jsonBody headers:headers autoShowBusinessError:YES completion:completion]; } // 原始二进制 GET,用于下载 zip、图片等 - (NSURLSessionDataTask *)GETData:(NSString *)path parameters:(NSDictionary *)parameters headers:(NSDictionary *)headers completion:(KBNetworkDataCompletion)completion { if (!self.isEnabled) { NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDisabled userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Network disabled (Full Access may be off)")}]; if (completion) completion(nil, nil, e); return nil; } NSString *urlString = [self buildURLStringWithPath:path]; if (!urlString) { NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidURL userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid URL")}]; if (completion) completion(nil, nil, e); return nil; } AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer]; serializer.timeoutInterval = self.timeout; NSError *serror = nil; NSMutableURLRequest *req = [serializer requestWithMethod:@"GET" URLString:urlString parameters:parameters error:&serror]; if (serror || !req) { if (completion) completion(nil, nil, serror ?: [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidURL userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid URL")}]); return nil; } [self applyHeaders:headers toMutableRequest:req contentType:nil]; return [self startDataTaskWithRequest:req completion:completion]; } #pragma mark - Core - (BOOL)ensureEnabled:(KBNetworkCompletion)completion { if (!self.isEnabled) { NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDisabled userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Network disabled (Full Access may be off)")}]; if (completion) completion(nil, nil, e); return NO; } return YES; } - (NSString *)buildURLStringWithPath:(NSString *)path { if (path.length == 0) return nil; if ([path hasPrefix:@"http://"] || [path hasPrefix:@"https://"]) { return path; } if (self.baseURL) { // 1) 统一为目录型 base(确保以 / 结尾),否则相对路径会把最后一段当文件替换 NSString *base = self.baseURL.absoluteString ?: @""; if (![base hasSuffix:@"/"]) { base = [base stringByAppendingString:@"/"]; } NSURL *dirBase = [NSURL URLWithString:base]; // 2) 防呆:调用方若传了以“/”开头的 path,会导致相对路径从根覆盖,丢失 /api NSString *relative = ([path hasPrefix:@"/"]) ? [path substringFromIndex:1] : path; return [NSURL URLWithString:relative relativeToURL:dirBase].absoluteURL.absoluteString; } return path; // 当无 baseURL 且 path 不是完整 URL 时,让 AFN 处理(可能失败) } - (void)applyHeaders:(NSDictionary *)headers toMutableRequest:(NSMutableURLRequest *)req contentType:(NSString *)contentType { // 合并默认头与局部头,并注入授权头(若可用)。局部覆盖优先。 NSMutableDictionary *all = [self.defaultHeaders mutableCopy] ?: [NSMutableDictionary new]; NSDictionary *auth = [[KBAuthManager shared] authorizationHeader]; [auth enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj, BOOL *stop) { all[key] = obj; }]; if (contentType) all[@"Content-Type"] = contentType; [headers enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj, BOOL *stop) { all[key] = obj; }]; [all enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj, BOOL *stop) { [req setValue:obj forHTTPHeaderField:key]; }]; } - (NSURLSessionDataTask *)startJSONTaskWithRequest:(NSURLRequest *)req autoShowBusinessError:(BOOL)autoShowBusinessError completion:(KBNetworkCompletion)completion { NSLog(@"[KBNetworkManager] startAFTaskWithRequest: %@", req.URL.absoluteString); // 响应先用原始数据返回,再按 Content-Type 解析 JSON(与原实现一致) self.manager.responseSerializer = [AFHTTPResponseSerializer serializer]; NSURLSessionDataTask *task = [self.manager dataTaskWithRequest:req uploadProgress:nil downloadProgress:nil completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) { NSLog(@"[KBNetworkManager] task finished, error = %@", error); // AFN 默认对非 2xx 的状态码返回 error;这里先日志,再直接回调上层 if (error) { #if DEBUG NSInteger status = [response isKindOfClass:NSHTTPURLResponse.class] ? ((NSHTTPURLResponse *)response).statusCode : 0; KBLOG(@"请求失败\nURL: %@\n状态: %ld\n错误: %@\nUserInfo: %@", req.URL.absoluteString, (long)status, error.localizedDescription, error.userInfo ?: @{}); #endif if (completion) completion(nil, response, error); return; } NSData *data = (NSData *)responseObject; if (![data isKindOfClass:[NSData class]]) { #if DEBUG KBLOG(@"无效响应\nURL: %@\n说明: %@", req.URL.absoluteString, KBLocalized(@"未获取到数据")); #endif if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"No data")}]); return; } NSString *ct = nil; if ([response isKindOfClass:[NSHTTPURLResponse class]]) { ct = ((NSHTTPURLResponse *)response).allHeaderFields[@"Content-Type"]; } // 更宽松的 JSON 判定:Content-Type 里包含 json;或首字符是 { / [ BOOL looksJSON = (ct && [[ct lowercaseString] containsString:@"json"]); if (!looksJSON) { // 内容嗅探(不依赖服务端声明) const unsigned char *bytes = data.bytes; NSUInteger len = data.length; for (NSUInteger i = 0; !looksJSON && i < len; i++) { unsigned char c = bytes[i]; if (c == ' ' || c == '\n' || c == '\r' || c == '\t') continue; looksJSON = (c == '{' || c == '['); break; } } if (looksJSON) { NSError *jsonErr = nil; id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonErr]; if (jsonErr) { #if DEBUG KBLOG(@"响应解析失败(JSON)\nURL: %@\n错误: %@", req.URL.absoluteString, jsonErr.localizedDescription); #endif if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDecodeFailed userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Failed to parse JSON")}]); return; } // 必须为字典结构,否则视为无效响应 if (![json isKindOfClass:[NSDictionary class]]) { if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]); return; } #if DEBUG NSString *pretty = [self kb_prettyJSONStringFromObject:json] ?: @"(null)"; pretty = [self kb_trimmedString:pretty maxLength:4096]; NSInteger status = [response isKindOfClass:NSHTTPURLResponse.class] ? ((NSHTTPURLResponse *)response).statusCode : 0; KBLOG(@"\n[KBNetwork] 响应成功(JSON)\nURL: %@\n状态: %ld\nContent-Type: %@\n数据: %@\n", req.URL.absoluteString, (long)status, ct ?: @"", pretty); #endif NSDictionary *dict = (NSDictionary *)json; // 统一解析业务 code:约定后端顶层包含 { code, message, data } NSInteger bizCode = KBBizCodeFromJSONObject(dict); if (bizCode != NSNotFound && bizCode != KBBizCodeSuccess) { // 非成功业务 code:执行通用处理(如 token 失效)并通过 error 方式回调 BOOL handledByAuth = [self kb_handleBizCode:bizCode json:dict response:response]; NSString *msg = KBBizMessageFromJSONObject(dict) ?: KBLocalized(@"Server error"); if (autoShowBusinessError && !handledByAuth) { dispatch_async(dispatch_get_main_queue(), ^{ [KBHUD showInfo:msg]; }); } NSError *bizErr = [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorBusiness userInfo:@{ NSLocalizedDescriptionKey : msg, @"code" : @(bizCode) }]; if (completion) completion(dict, response, bizErr); return; } // code 缺失或为成功,按正常成功回调 if (completion) completion(dict, response, nil); } else { // 预期 JSON,但未检测到 JSON 内容 if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]); } }]; [task resume]; return task; } // 原始二进制任务 - (NSURLSessionDataTask *)startDataTaskWithRequest:(NSURLRequest *)req completion:(KBNetworkDataCompletion)completion { NSLog(@"[KBNetworkManager] startDataTaskWithRequest: %@", req.URL.absoluteString); self.manager.responseSerializer = [AFHTTPResponseSerializer serializer]; NSURLSessionDataTask *task = [self.manager dataTaskWithRequest:req uploadProgress:nil downloadProgress:nil completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) { NSLog(@"[KBNetworkManager] data task finished, error = %@", error); if (error) { if (completion) completion(nil, response, error); return; } NSData *data = (NSData *)responseObject; if (![data isKindOfClass:[NSData class]]) { if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"No data")}]); return; } if (completion) completion(data, response, nil); }]; [task resume]; return task; } #pragma mark - AFHTTPSessionManager - (AFHTTPSessionManager *)manager { if (!_manager) { NSURLSessionConfiguration *cfg = [NSURLSessionConfiguration ephemeralSessionConfiguration]; cfg.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData; // 不在会话级别设置超时,避免与 per-request 的 serializer.timeoutInterval 产生不一致 if (@available(iOS 11.0, *)) { cfg.waitsForConnectivity = YES; } _manager = [[AFHTTPSessionManager alloc] initWithBaseURL:nil sessionConfiguration:cfg]; // 默认不使用 JSON 解析器,保持原生数据,再按需解析 _manager.responseSerializer = [AFHTTPResponseSerializer serializer]; } return _manager; } #pragma mark - Private helpers /// 处理通用业务 code(token 失效、被踢下线等); /// 返回 YES 表示该 code 已在此处消费(例如已清理登录态并提示),外部无需再提示。 - (BOOL)kb_handleBizCode:(NSInteger)bizCode json:(id)json response:(NSURLResponse *)response { switch (bizCode) { // 登录态相关:未登录 / 无权限 / 各种 token 异常、被顶号/踢下线等 case KBBizCodeNotLogin: case KBBizCodeNoAuth: case KBBizCodeTokenNotFound: case KBBizCodeTokenInvalid: case KBBizCodeTokenTimeout: case KBBizCodeTokenBeReplaced: case KBBizCodeTokenKickOut: case KBBizCodeTokenFreeze: case KBBizCodeTokenNoPrefix: case KBBizCodeForbidden: { // 登录态异常:统一清理本地会话并提示用户重新登录 NSString *msg = KBBizMessageFromJSONObject(json); if (msg.length == 0) { msg = KBLocalized(@"Your session has expired. Please sign in again."); } dispatch_async(dispatch_get_main_queue(), ^{ // 清理本地用户会话(Keychain + 用户缓存) [[KBUserSessionManager shared] logout]; // 友好提示 [KBHUD showInfo:msg]; // 广播通知,便于 UI 侧跳转登录页等 NSDictionary *info = @{ @"code": @(bizCode), @"message": msg ?: @"" }; [[NSNotificationCenter defaultCenter] postNotificationName:@"KBUserSessionInvalidNotification" object:nil userInfo:info]; }); return YES; } break; default: break; } return NO; } - (void)fail:(KBNetworkError)code completion:(KBNetworkCompletion)completion { NSString *msg = KBLocalized(@"Network error"); switch (code) { case KBNetworkErrorDisabled: msg = KBLocalized(@"Network disabled (Full Access may be off)"); break; case KBNetworkErrorInvalidURL: msg = KBLocalized(@"Invalid URL"); break; case KBNetworkErrorInvalidResponse: msg = KBLocalized(@"Invalid response"); break; case KBNetworkErrorDecodeFailed: msg = KBLocalized(@"Parse failed"); break; case KBNetworkErrorBusiness: msg = KBLocalized(@"Server error"); break; default: break; } NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey: msg}]; if (completion) completion(nil, nil, e); } @end #pragma mark - Debug helpers #if DEBUG @implementation KBNetworkManager (Debug) // 将对象转为漂亮的 JSON 字符串;否则返回 description - (NSString *)kb_prettyJSONStringFromObject:(id)obj { if (!obj || obj == (id)kCFNull) return nil; if (![NSJSONSerialization isValidJSONObject:obj]) { // 非标准 JSON 对象,直接 description return [obj description]; } NSData *data = [NSJSONSerialization dataWithJSONObject:obj options:NSJSONWritingPrettyPrinted error:NULL]; if (!data) return [obj description]; return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: [obj description]; } // 尝试把二进制数据转为可读文本;失败则返回占位带长度 - (NSString *)kb_textFromData:(NSData *)data { if (!data) return @"(null)"; if (data.length == 0) return @""; // 优先 UTF-8 NSString *text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; if (text.length > 0) return text; // 再尝试常见编码 NSArray *encs = @[@(NSASCIIStringEncoding), @(NSISOLatin1StringEncoding), @(NSUnicodeStringEncoding)]; for (NSNumber *n in encs) { text = [[NSString alloc] initWithData:data encoding:n.unsignedIntegerValue]; if (text.length > 0) return text; } return [NSString stringWithFormat:@"", (unsigned long)data.length]; } // 过长时裁剪,避免刷屏 - (NSString *)kb_trimmedString:(NSString *)s maxLength:(NSUInteger)maxLen { if (!s) return @""; if (s.length <= maxLen) return s; NSString *head = [s substringToIndex:maxLen]; return [head stringByAppendingString:@"\n......"]; } @end #endif