网络层
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "12bc03b2-c6a3-4f19-afd9-5b48aa490068",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
|
||||
import { HttpManager, HttpMethod } from "../transport/HttpManager";
|
||||
import { JsonCodec } from "../codec/JsonCodec";
|
||||
import { ProtoCodec } from "../codec/ProtoCodec";
|
||||
import type { Endpoint } from "./endpoints";
|
||||
import { ApiCode, type ApiResponse } from "./types";
|
||||
import { DataManager, DataId } from "db://assets/Scripts/chat18x/data/DataManager";
|
||||
import { LoginData } from "db://assets/Scripts/chat18x/data/LoginData";
|
||||
|
||||
export class ApiClient {
|
||||
private static _I: ApiClient | null = null;
|
||||
public static get I(): ApiClient {
|
||||
if (!ApiClient._I) ApiClient._I = new ApiClient();
|
||||
return ApiClient._I;
|
||||
}
|
||||
private constructor(private http = HttpManager.I) {}
|
||||
// TODO
|
||||
private get baseUrl() { return "http://43.139.27.67:8081" }
|
||||
private get token() { return DataManager.I.getDataById<LoginData>(DataId.Login).getToken();}
|
||||
|
||||
public async call<Req, Res>(ep: Endpoint<Req, Res>, req: Req): Promise<ApiResponse<Res>> {
|
||||
const url = `${this.baseUrl}/${ep.path.replace(/^\/+/, "")}`;
|
||||
const headers: Record<string, string> = {};
|
||||
let responseType: XMLHttpRequestResponseType | undefined;
|
||||
let rawBody: XMLHttpRequestBodyInit | null | undefined;
|
||||
let jsonData: any;
|
||||
|
||||
if (ep.needsAuth && this.token) {
|
||||
headers["Authorization"] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
// 编码
|
||||
if (ep.codec === "json") {
|
||||
jsonData = (ep.method === "GET") ? req : JsonCodec.encode(req);
|
||||
if (ep.method !== "GET") headers["Content-Type"] = "application/json";
|
||||
} else if (ep.codec === "proto") {
|
||||
if (!ep.reqType || !ep.resType) {
|
||||
return { code: ApiCode.UNKNOWN, msg: "proto types missing" };
|
||||
}
|
||||
rawBody = ProtoCodec.encode(ep.reqType, req);
|
||||
headers["Content-Type"] = "application/x-protobuf";
|
||||
headers["Accept"] = "application/x-protobuf";
|
||||
responseType = "arraybuffer";
|
||||
}
|
||||
|
||||
// 发送
|
||||
const r = await this.http.request(url, jsonData, ep.method, { headers, responseType, rawBody });
|
||||
|
||||
// HTTP/网络错误 → 统一返回
|
||||
if (!r.ok) {
|
||||
if (r.status === 401) {
|
||||
return { code: ApiCode.AUTH_EXPIRED, msg: "auth expired" };
|
||||
}
|
||||
if (r.error === "timeout") {
|
||||
return { code: ApiCode.TIMEOUT, msg: "request timeout" };
|
||||
}
|
||||
if (r.error === "network") {
|
||||
return { code: ApiCode.NETWORK_ERROR, msg: "network error" };
|
||||
}
|
||||
return { code: ApiCode.HTTP_ERROR, msg: `http error: ${r.status}` };
|
||||
}
|
||||
|
||||
// 解码 + 统一化
|
||||
try {
|
||||
if (ep.codec === "json") {
|
||||
const parsed = JsonCodec.decode<any>(r.body);
|
||||
// 如果后端本来就返回 {code,msg,data},直接透传(并确保有默认 msg)
|
||||
if (parsed && typeof parsed === "object" && typeof parsed.code === "number") {
|
||||
return {
|
||||
code: parsed.code,
|
||||
msg: parsed.msg ?? (parsed.code === 1 ? "ok" : "error"),
|
||||
data: parsed.data as Res
|
||||
};
|
||||
}
|
||||
// 否则我们包一层
|
||||
return { code: ApiCode.OK, msg: "ok", data: parsed as Res };
|
||||
} else if (ep.codec === "proto") {
|
||||
const data = ProtoCodec.decode<Res>(ep.resType!, r.body);
|
||||
// proto 场景一般没有 {code,msg} 包装,我们统一包一层
|
||||
return { code: ApiCode.OK, msg: "ok", data };
|
||||
}
|
||||
} catch (e) {
|
||||
return { code: ApiCode.DECODE_ERROR, msg: "decode error" };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "4e1d0cf6-8eff-4227-8fde-3233d639e798",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Type } from "protobufjs";
|
||||
import type { HttpMethod } from "../transport/HttpManager";
|
||||
|
||||
export type CodecKind = "json" | "proto";
|
||||
|
||||
export interface Endpoint<Req, Res> {
|
||||
path: string;
|
||||
method: HttpMethod;
|
||||
codec: CodecKind;
|
||||
reqType?: Type; // codec=proto 时必填
|
||||
resType?: Type; // codec=proto 时必填
|
||||
needsAuth?: boolean;
|
||||
lock?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "02c8cc0b-86fa-486d-8b9e-8729aaa5ec19",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number; // 1 表示成功;其他为错误
|
||||
msg: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
export const ApiCode = {
|
||||
OK: 1,
|
||||
AUTH_EXPIRED: -401,
|
||||
HTTP_ERROR: -400,
|
||||
TIMEOUT: -408,
|
||||
NETWORK_ERROR: -499,
|
||||
DECODE_ERROR: -600,
|
||||
UNKNOWN: -700,
|
||||
} as const;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "5027239d-2468-4562-bd01-87ffae8e96a1",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "27ff0e59-c502-4120-8258-3981a4b1e51d",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export const JsonCodec = {
|
||||
/** POST/PUT 的请求体直接用对象,让传输层序列化为 JSON */
|
||||
encode<T = any>(obj: T): any {
|
||||
return obj ?? {};
|
||||
},
|
||||
|
||||
/** 根据传输层返回的类型做解码:string → JSON.parse,其他直接返回 */
|
||||
decode<T = any>(resp: any): T {
|
||||
if (typeof resp === "string") {
|
||||
try { return JSON.parse(resp) as T; } catch { /* 可能是纯文本 */ }
|
||||
}
|
||||
return resp as T;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "4d188874-4871-46e7-bc80-a179ae1ed67a",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Type } from "protobufjs";
|
||||
|
||||
export const ProtoCodec = {
|
||||
encode(reqType: Type, obj: any): Uint8Array {
|
||||
const err = reqType.verify(obj);
|
||||
if (err) throw Error(err);
|
||||
return reqType.encode(reqType.create(obj)).finish();
|
||||
},
|
||||
|
||||
decode<T = any>(resType: Type, buf: ArrayBuffer | Uint8Array): T {
|
||||
const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
||||
return resType.decode(u8) as unknown as T;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "7e271138-d20e-4236-8355-ca4bcec0a23e",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "b0d9acd9-4ee1-40ec-857b-f1fbc5def44e",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiClient } from "../client/ApiClient";
|
||||
import type { Endpoint } from "../client/endpoints";
|
||||
import type { ApiResponse } from "../client/types";
|
||||
import proto from 'db://assets/Scripts/proto/proto.pb.js';
|
||||
|
||||
export class AuthService {
|
||||
private static _I: AuthService | null = null;
|
||||
public static get I(): AuthService {
|
||||
if (!AuthService._I) AuthService._I = new AuthService();
|
||||
return AuthService._I;
|
||||
}
|
||||
private constructor(private api = ApiClient.I) {}
|
||||
|
||||
private EP_LOGIN: Endpoint<proto.ProtoMsg.ILoginReq, proto.ProtoMsg.ILoginRsp> = {
|
||||
path: "login",
|
||||
method: "POST",
|
||||
codec: "json",
|
||||
needsAuth: false
|
||||
};
|
||||
|
||||
// 登录
|
||||
public async login(req: proto.ProtoMsg.ILoginReq): Promise<ApiResponse<proto.ProtoMsg.ILoginRsp>> {
|
||||
return this.api.call(this.EP_LOGIN, req);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "e7586869-13fe-4207-960b-eb1be7081812",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "426aac6d-7382-44fa-a017-3390f3412e96",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Http管理器
|
||||
*
|
||||
*/
|
||||
|
||||
import { sys } from "cc";
|
||||
import SubManager from 'db://assets/Scripts/Sub/SubManager';
|
||||
|
||||
// 请求类型
|
||||
export type HttpMethod = "GET" | "POST" | "PUT";
|
||||
// 请求参数结构
|
||||
export interface HttpSendOptions {
|
||||
headers?: Record<string, string>;
|
||||
responseType?: XMLHttpRequestResponseType;
|
||||
rawBody?: Document | XMLHttpRequestBodyInit | null;
|
||||
}
|
||||
// 响应结构
|
||||
export interface HttpRawResponse {
|
||||
ok: boolean; // 是否 2xx
|
||||
status: number; // HTTP 状态码,网络错误/超时为 0
|
||||
body?: any; // xhr.response 或 xhr.responseText
|
||||
error?: "timeout" | "network" | "http";
|
||||
}
|
||||
|
||||
export class HttpManager {
|
||||
private static _I: HttpManager | null = null;
|
||||
public static get I(): HttpManager {
|
||||
if (!HttpManager._I) HttpManager._I = new HttpManager();
|
||||
return HttpManager._I;
|
||||
}
|
||||
private constructor() {}
|
||||
|
||||
/** 发起请求,供 ApiClient 使用 */
|
||||
public async request(
|
||||
url: string,
|
||||
data: any,
|
||||
method: HttpMethod = "GET",
|
||||
opt: HttpSendOptions = {}
|
||||
): Promise<HttpRawResponse> {
|
||||
const xhr = new XMLHttpRequest();
|
||||
// 超时
|
||||
xhr.timeout = 10000;
|
||||
// 响应类型
|
||||
if (opt.responseType) xhr.responseType = opt.responseType;
|
||||
|
||||
if (method === "GET") {
|
||||
url = url + this.buildQuery(data);
|
||||
xhr.open(method, url, true);
|
||||
if (sys.isNative) {
|
||||
xhr.setRequestHeader("Accept-Encoding", "gzip,deflate");
|
||||
xhr.setRequestHeader("content-type", "text/html;charset=utf-8");
|
||||
}
|
||||
} else {
|
||||
xhr.open(method, url, true);
|
||||
if (!opt.rawBody) {
|
||||
xhr.setRequestHeader("Content-Type", "application/json");
|
||||
}
|
||||
}
|
||||
if (opt.headers) {
|
||||
Object.keys(opt.headers).forEach(k => xhr.setRequestHeader(k, opt.headers![k]));
|
||||
}
|
||||
|
||||
return new Promise<HttpRawResponse>((resolve) => {
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState === 4) {
|
||||
const ok = xhr.status >= 200 && xhr.status < 300;
|
||||
const body = xhr.response ?? xhr.responseText ?? null;
|
||||
if (!ok) {
|
||||
SubManager.ShowPrompt(`网络错误(${xhr.status}),请稍后再试`);
|
||||
}
|
||||
resolve({ ok, status: xhr.status, body, error: ok ? undefined : "http" });
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
SubManager.ShowPrompt("网络错误,请检查网络连接");
|
||||
resolve({ ok: false, status: 0, error: "network" });
|
||||
};
|
||||
xhr.ontimeout = () => {
|
||||
SubManager.ShowPrompt("请求超时,请检查网络连接");
|
||||
resolve({ ok: false, status: 0, error: "timeout" });
|
||||
};
|
||||
|
||||
if (method === "GET") {
|
||||
xhr.send();
|
||||
} else {
|
||||
if (opt.rawBody !== undefined) xhr.send(opt.rawBody ?? null);
|
||||
else xhr.send(JSON.stringify(data ?? {}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 把对象转 ?a=1&b=2 的查询串
|
||||
*/
|
||||
private buildQuery(json: any) {
|
||||
if (!json || typeof json !== "object") return "";
|
||||
const pairs = Object.keys(json).map(
|
||||
k => encodeURIComponent(k) + "=" + encodeURIComponent(json[k])
|
||||
);
|
||||
return pairs.length ? "?" + pairs.join("&") : "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "3be6cfe1-3205-4d3e-b00a-bdba016b2493",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
Reference in New Issue
Block a user