622 lines
28 KiB
Objective-C
622 lines
28 KiB
Objective-C
//
|
||
// KBNetworkManager.m
|
||
// CustomKeyboard
|
||
//
|
||
|
||
#import "KBNetworkManager.h"
|
||
#import <TargetConditionals.h>
|
||
#import "AFNetworking.h"
|
||
#import "KBAuthManager.h"
|
||
#import "KBBizCode.h"
|
||
// 仅在主 App 内需要的会话管理与 HUD 组件
|
||
#import "KBUserSessionManager.h"
|
||
#import "KBHUD.h"
|
||
#import "KBSignUtils.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;
|
||
|
||
NSString *lang = [KBLocalizationManager shared].currentLanguageCode ?: KBLanguageCodeEnglish;
|
||
|
||
// 如果还有 query 参数也塞进来
|
||
// signParams[@"lang"] = @"zh";
|
||
|
||
|
||
// 2. 计算签名
|
||
_defaultHeaders = @{
|
||
@"Accept": @"*/*",
|
||
@"Accept-Language": lang,
|
||
};
|
||
// 设置基础域名,路径可相对该地址拼接
|
||
_baseURL = [NSURL URLWithString:KB_BASE_URL];
|
||
}
|
||
return self;
|
||
}
|
||
|
||
- (void)getSignWithParare:(NSDictionary *)bodyParams{
|
||
|
||
NSString *appId = @"loveKeyboard";
|
||
NSString *secret = @"kZJM39HYvhxwbJkG1fmquQRVkQiLAh2H"; // 和服务端保持一致
|
||
NSString *timestamp = [KBSignUtils currentTimestamp];
|
||
NSString *nonce = [KBSignUtils generateNonceWithLength:16];
|
||
// 1. 组装参与签名的所有参数
|
||
NSMutableDictionary<NSString *, NSString *> *signParams = [NSMutableDictionary dictionary];
|
||
signParams[@"appId"] = appId;
|
||
signParams[@"timestamp"] = timestamp;
|
||
signParams[@"nonce"] = nonce;
|
||
// 把 body 里的字段也加入签名参数
|
||
[bodyParams enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
|
||
if ([obj isKindOfClass:[NSString class]]) {
|
||
signParams[key] = obj;
|
||
} else {
|
||
signParams[key] = [obj description];
|
||
}
|
||
}];
|
||
NSString *sign = [KBSignUtils signWithParams:signParams secret:secret];
|
||
|
||
// 将签名相关字段合并进默认请求头
|
||
NSMutableDictionary<NSString *, NSString *> *headers =
|
||
[self.defaultHeaders mutableCopy] ?: [NSMutableDictionary dictionary];
|
||
|
||
if (sign.length > 0) {
|
||
headers[@"X-Sign"] = sign;
|
||
}
|
||
headers[@"X-App-Id"] = appId;
|
||
headers[@"X-Timestamp"] = timestamp;
|
||
headers[@"X-Nonce"] = nonce;
|
||
|
||
// 触发 copy 语义,确保对外仍是不可变字典
|
||
self.defaultHeaders = headers;
|
||
}
|
||
|
||
#
|
||
#pragma mark - Public
|
||
|
||
// JSON GET:可控制业务错误是否由内部弹出
|
||
- (NSURLSessionDataTask *)GET:(NSString *)path
|
||
parameters:(NSDictionary *)parameters
|
||
headers:(NSDictionary<NSString *,NSString *> *)headers
|
||
autoShowBusinessError:(BOOL)autoShowBusinessError
|
||
completion:(KBNetworkCompletion)completion {
|
||
NSLog(@"[KBNetworkManager] GET called, enabled=%d, path=%@", self.isEnabled, path);
|
||
[self getSignWithParare:parameters];
|
||
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<NSString *,NSString *> *)headers
|
||
completion:(KBNetworkCompletion)completion {
|
||
[self getSignWithParare:parameters];
|
||
|
||
return [self GET:path
|
||
parameters:parameters
|
||
headers:headers
|
||
autoShowBusinessError:YES
|
||
completion:completion];
|
||
}
|
||
|
||
// JSON POST:可控制业务错误是否由内部弹出
|
||
- (NSURLSessionDataTask *)POST:(NSString *)path
|
||
jsonBody:(id)jsonBody
|
||
headers:(NSDictionary<NSString *,NSString *> *)headers
|
||
autoShowBusinessError:(BOOL)autoShowBusinessError
|
||
completion:(KBNetworkCompletion)completion {
|
||
NSLog(@"[KBNetworkManager] POST called, enabled=%d, path=%@", self.isEnabled, path);
|
||
[self getSignWithParare:jsonBody];
|
||
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<NSString *,NSString *> *)headers
|
||
completion:(KBNetworkCompletion)completion {
|
||
[self getSignWithParare:jsonBody];
|
||
return [self POST:path
|
||
jsonBody:jsonBody
|
||
headers:headers
|
||
autoShowBusinessError:YES
|
||
completion:completion];
|
||
}
|
||
|
||
// 原始二进制 GET,用于下载 zip、图片等
|
||
- (NSURLSessionDataTask *)GETData:(NSString *)path
|
||
parameters:(NSDictionary *)parameters
|
||
headers:(NSDictionary<NSString *,NSString *> *)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 - Upload (multipart/form-data)
|
||
|
||
- (NSURLSessionDataTask *)uploadFile:(NSString *)path
|
||
fileData:(NSData *)fileData
|
||
fileName:(NSString *)fileName
|
||
mimeType:(NSString *)mimeType
|
||
headers:(NSDictionary<NSString *,NSString *> *)headers
|
||
autoShowBusinessError:(BOOL)autoShowBusinessError
|
||
completion:(KBNetworkCompletion)completion
|
||
{
|
||
NSLog(@"[KBNetworkManager] UPLOAD 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;
|
||
}
|
||
|
||
if (!fileData || fileData.length == 0) {
|
||
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain
|
||
code:KBNetworkErrorInvalidResponse
|
||
userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Empty file data")}];
|
||
if (completion) completion(nil, nil, e);
|
||
return nil;
|
||
}
|
||
|
||
AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer];
|
||
serializer.timeoutInterval = self.timeout;
|
||
|
||
NSError *error = nil;
|
||
NSMutableURLRequest *req =
|
||
[serializer multipartFormRequestWithMethod:@"POST"
|
||
URLString:urlString
|
||
parameters:nil
|
||
constructingBodyWithBlock:^(id<AFMultipartFormData> _Nonnull formData) {
|
||
|
||
// Apifox 后端要求:表单字段名为 "file"
|
||
NSString *fieldName = @"file";
|
||
NSString *fname = fileName ?: @"file";
|
||
NSString *type = mimeType ?: @"application/octet-stream";
|
||
|
||
[formData appendPartWithFileData:fileData
|
||
name:fieldName
|
||
fileName:fname
|
||
mimeType:type];
|
||
} error:&error];
|
||
|
||
if (error || !req) {
|
||
if (completion) {
|
||
completion(nil, nil,
|
||
error ?: [NSError errorWithDomain:KBNetworkErrorDomain
|
||
code:KBNetworkErrorInvalidURL
|
||
userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid upload request")}]);
|
||
}
|
||
return nil;
|
||
}
|
||
|
||
// 不强行指定 Content-Type,留给 AFN 里带 boundary 的那一套
|
||
[self applyHeaders:headers toMutableRequest:req contentType:nil];
|
||
|
||
#if DEBUG
|
||
KBLOG(@"HTTP UPLOAD (multipart)\nURL: %@\nHeaders: %@\nfileName: %@\nlength: %lu",
|
||
req.URL.absoluteString,
|
||
req.allHTTPHeaderFields ?: @{},
|
||
fileName,
|
||
(unsigned long)fileData.length);
|
||
#endif
|
||
|
||
// 按 JSON 响应处理(和 POST 一样:解析 {code,message,data,...})
|
||
return [self startJSONTaskWithRequest:req
|
||
autoShowBusinessError:autoShowBusinessError
|
||
completion:completion];
|
||
}
|
||
|
||
- (NSURLSessionDataTask *)uploadFile:(NSString *)path
|
||
fileData:(NSData *)fileData
|
||
fileName:(NSString *)fileName
|
||
mimeType:(NSString *)mimeType
|
||
headers:(NSDictionary<NSString *,NSString *> *)headers
|
||
completion:(KBNetworkCompletion)completion
|
||
{
|
||
return [self uploadFile:path
|
||
fileData:fileData
|
||
fileName:fileName
|
||
mimeType:mimeType
|
||
headers:headers
|
||
autoShowBusinessError:YES
|
||
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<NSString *,NSString *> *)headers toMutableRequest:(NSMutableURLRequest *)req contentType:(NSString *)contentType {
|
||
// 合并默认头与局部头,并注入授权头(若可用)。局部覆盖优先。
|
||
NSMutableDictionary *all = [self.defaultHeaders mutableCopy] ?: [NSMutableDictionary new];
|
||
NSString *token = [KBAuthManager shared].current.accessToken;
|
||
if (token.length > 0) {
|
||
all[@"auth-token"] = token;
|
||
} else {
|
||
[all removeObjectForKey:@"auth-token"];
|
||
}
|
||
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 (handledByAuth == false) {
|
||
if (autoShowBusinessError) {
|
||
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];
|
||
[[KBUserSessionManager shared] goLoginVC];
|
||
// 友好提示
|
||
[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:@"<binary %lu bytes>", (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...<trimmed>..."];
|
||
}
|
||
|
||
@end
|
||
#endif
|