feat: add logto auth and backend metrics integration

This commit is contained in:
2026-04-22 15:52:12 +08:00
parent b1bb28f5aa
commit c7ae2fbfcb
38 changed files with 4886 additions and 34 deletions
+38
View File
@@ -0,0 +1,38 @@
export interface AuthConfig {
apiResource: string;
appId: string;
enableDevAuthPanel: boolean;
logtoEndpoint: string;
scopes: string[];
}
const defaultAuthConfig: AuthConfig = {
apiResource: "https://talent-search.intelligrow.cn",
appId: "i4jkllbvih0554r4n0fd3",
enableDevAuthPanel: true,
logtoEndpoint: "https://login-api.intelligrow.cn",
scopes: ["openid", "profile", "offline_access", "talent-search:read"]
};
export function readAuthConfig(
overrides: Partial<AuthConfig> = {}
): AuthConfig {
const nextConfig = {
...defaultAuthConfig,
...overrides
};
if (!nextConfig.logtoEndpoint.trim()) {
throw new Error("auth config logtoEndpoint is required");
}
if (!nextConfig.appId.trim()) {
throw new Error("auth config appId is required");
}
if (!nextConfig.apiResource.trim()) {
throw new Error("auth config apiResource is required");
}
return nextConfig;
}
+84
View File
@@ -0,0 +1,84 @@
export type AuthRequestMessage =
| { type: "auth:get-state" }
| { type: "auth:sign-in" }
| { type: "auth:sign-out" }
| { type: "auth:get-access-token" };
export interface AuthStateValue {
accessTokenExpiresAt?: number | null;
isAuthenticated: boolean;
lastError?: string | null;
resource?: string | null;
scopes?: string[];
tokenAvailable?: boolean;
userInfo?: {
email?: string;
name?: string;
sub?: string;
username?: string;
} | null;
}
export type AuthResponseMessage =
| { ok: true; type: "auth:state"; value: AuthStateValue }
| { ok: true; type: "auth:token"; value: { accessToken: string } }
| { ok: true; type: "auth:ack" }
| { ok: false; type: "auth:error"; error: string };
const authRequestTypes = new Set<AuthRequestMessage["type"]>([
"auth:get-state",
"auth:sign-in",
"auth:sign-out",
"auth:get-access-token"
]);
export function isAuthRequestMessage(
value: unknown
): value is AuthRequestMessage {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<AuthRequestMessage>;
return typeof candidate.type === "string" && authRequestTypes.has(candidate.type);
}
export function isAuthResponseMessage(
value: unknown
): value is AuthResponseMessage {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<AuthResponseMessage>;
if (candidate.ok === false) {
return candidate.type === "auth:error" && typeof candidate.error === "string";
}
if (candidate.ok !== true || typeof candidate.type !== "string") {
return false;
}
if (candidate.type === "auth:ack") {
return true;
}
if (candidate.type === "auth:token") {
return Boolean(
candidate.value &&
typeof candidate.value === "object" &&
typeof (candidate.value as { accessToken?: unknown }).accessToken === "string"
);
}
if (candidate.type === "auth:state") {
return Boolean(
candidate.value &&
typeof candidate.value === "object" &&
typeof (candidate.value as { isAuthenticated?: unknown }).isAuthenticated ===
"boolean"
);
}
return false;
}
+132
View File
@@ -0,0 +1,132 @@
import { DEFAULT_BACKEND_METRICS_BASE_URL } from "./backend-metrics-config";
export interface BackendMetricsRow {
a3IncreaseCount: string;
afterViewSearchCount: string;
afterViewSearchRate: string;
cpSearch: string;
cpa3: string;
newA3Rate: string;
starId: string;
}
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
}
type FetchLike = (
input: string,
init?: RequestInit
) => Promise<FetchResponseLike>;
interface BackendMetricsClientOptions {
baseUrl?: string;
fetchImpl?: FetchLike;
getAccessToken: () => Promise<string>;
}
export function createBackendMetricsClient(options: BackendMetricsClientOptions) {
const baseUrl = options.baseUrl ?? DEFAULT_BACKEND_METRICS_BASE_URL;
const fetchImpl = options.fetchImpl ?? defaultFetch;
return {
async searchByStarIds(starIds: string[]): Promise<BackendMetricsRow[]> {
const response = await fetchImpl(buildBackendMetricsSearchUrl(baseUrl), {
body: JSON.stringify(buildBackendMetricsSearchRequestBody(starIds)),
headers: {
Authorization: `Bearer ${await options.getAccessToken()}`,
"Content-Type": "application/json"
},
method: "POST"
});
if (!response.ok) {
throw new Error("backend metrics request failed");
}
return mapBackendMetricsSearchResponse(await response.json());
}
};
}
export function buildBackendMetricsSearchUrl(baseUrl: string): string {
return new URL("/api/v1/history/talents/search", baseUrl).toString();
}
export function buildBackendMetricsSearchRequestBody(starIds: string[]) {
return {
page: 1,
size: Math.max(20, starIds.length),
type: "star_id",
values: starIds
};
}
export function mapBackendMetricsSearchResponse(payload: unknown): BackendMetricsRow[] {
const rows = readResponseRows(payload);
if (!rows) {
throw new Error("backend metrics response is invalid");
}
return rows.flatMap((row) => {
if (!isRecord(row) || typeof row.star_id !== "string") {
return [];
}
return [
{
a3IncreaseCount: formatDecimalValue(row.avg_a3_increase_cnt),
afterViewSearchCount: formatDecimalValue(row.avg_after_view_search_cnt),
afterViewSearchRate: formatRateValue(row.avg_after_view_search_rate),
cpSearch: formatDecimalValue(row.cp_search),
cpa3: formatDecimalValue(row.cpa3),
newA3Rate: formatRateValue(row.avg_new_a3_rate),
starId: row.star_id
}
];
});
}
function readResponseRows(payload: unknown): unknown[] | null {
if (!isRecord(payload) || payload.success !== true) {
return null;
}
const topLevelData = isRecord(payload.data) ? payload.data : null;
return Array.isArray(topLevelData?.data) ? topLevelData.data : null;
}
function formatRateValue(value: unknown): string {
const number = typeof value === "number" ? value : Number(value);
if (Number.isFinite(number)) {
const percentage = number * 100;
const formatted = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 2,
minimumFractionDigits: percentage % 1 === 0 ? 0 : 2
}).format(percentage);
return `${formatted}%`;
}
return "";
}
function formatDecimalValue(value: unknown): string {
const number = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(number)) {
return "";
}
return new Intl.NumberFormat("en-US", {
maximumFractionDigits: 2,
minimumFractionDigits: 2
}).format(number);
}
async function defaultFetch(input: string, init?: RequestInit) {
return fetch(input, init);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
+1
View File
@@ -0,0 +1 @@
export const DEFAULT_BACKEND_METRICS_BASE_URL = "http://192.168.31.29:8083";
+67
View File
@@ -0,0 +1,67 @@
import type { BackendMetricsRow } from "./backend-metrics-client";
export type BackendMetricsSearchRequestMessage = {
type: "backend-metrics:search";
value: {
starIds: string[];
};
};
export type BackendMetricsResponseMessage =
| {
ok: true;
type: "backend-metrics:result";
value: {
rows: BackendMetricsRow[];
};
}
| {
error: string;
ok: false;
type: "backend-metrics:error";
};
export function isBackendMetricsSearchRequestMessage(
value: unknown
): value is BackendMetricsSearchRequestMessage {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<BackendMetricsSearchRequestMessage>;
return (
candidate.type === "backend-metrics:search" &&
Boolean(
candidate.value &&
typeof candidate.value === "object" &&
Array.isArray((candidate.value as { starIds?: unknown }).starIds) &&
(candidate.value as { starIds: unknown[] }).starIds.every(
(starId) => typeof starId === "string"
)
)
);
}
export function isBackendMetricsResponseMessage(
value: unknown
): value is BackendMetricsResponseMessage {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<BackendMetricsResponseMessage>;
if (candidate.ok === false) {
return (
candidate.type === "backend-metrics:error" &&
typeof candidate.error === "string"
);
}
return Boolean(
candidate.ok === true &&
candidate.type === "backend-metrics:result" &&
candidate.value &&
typeof candidate.value === "object" &&
Array.isArray((candidate.value as { rows?: unknown }).rows)
);
}
+5 -1
View File
@@ -12,18 +12,22 @@ type FetchLike = (
init?: RequestInit
) => Promise<FetchResponseLike>;
type GetAccessTokenLike = () => Promise<string>;
type SendMessageLike = (message: unknown) => Promise<unknown>;
export function createBatchSubmitClient(options: {
baseUrl: string;
fetchImpl?: FetchLike;
getAccessToken?: GetAccessTokenLike;
sendMessage: SendMessageLike;
}) {
const fetchImpl = options.fetchImpl ?? fetch;
const getAccessToken =
options.getAccessToken ?? (() => readAccessToken(options.sendMessage));
return {
async submitBatch(payload: BatchPayload) {
const token = await readAccessToken(options.sendMessage);
const token = await getAccessToken();
const response = await fetchImpl(
new URL("/api/mock/batches", options.baseUrl).toString(),
{