[codex] improve proxy stability, routing, and site subscription visibility (#194)
* wip: preserve local changes before upstream merge * Improve proxy routing stability and add stable-first strategy
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import {
|
||||
buildStoredSub2ApiSubscriptionSummary,
|
||||
mergeAccountExtraConfig,
|
||||
} from '../../services/accountExtraConfig.js';
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
|
||||
describe('sites route subscription summary aggregation', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-sites-subscription-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const dbModule = await import('../../db/index.js');
|
||||
const routesModule = await import('./sites.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
|
||||
app = Fastify();
|
||||
await app.register(routesModule.sitesRoutes);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('aggregates stored sub2api subscription summaries into /api/sites', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'sub2api-site',
|
||||
url: 'https://sub2api.example.com',
|
||||
platform: 'sub2api',
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.accounts).values([
|
||||
{
|
||||
siteId: site.id,
|
||||
username: 'user-a',
|
||||
accessToken: 'token-a',
|
||||
balance: 10,
|
||||
status: 'active',
|
||||
extraConfig: mergeAccountExtraConfig(null, {
|
||||
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({
|
||||
activeCount: 1,
|
||||
totalUsedUsd: 3,
|
||||
subscriptions: [
|
||||
{
|
||||
id: 11,
|
||||
groupName: 'Pro',
|
||||
expiresAt: '2026-04-10T00:00:00.000Z',
|
||||
monthlyUsedUsd: 3,
|
||||
monthlyLimitUsd: 20,
|
||||
},
|
||||
],
|
||||
}, 1760000000000),
|
||||
}),
|
||||
},
|
||||
{
|
||||
siteId: site.id,
|
||||
username: 'user-b',
|
||||
accessToken: 'token-b',
|
||||
balance: 5,
|
||||
status: 'active',
|
||||
extraConfig: mergeAccountExtraConfig(null, {
|
||||
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({
|
||||
activeCount: 1,
|
||||
totalUsedUsd: 2,
|
||||
subscriptions: [
|
||||
{
|
||||
id: 12,
|
||||
groupName: 'Lite',
|
||||
expiresAt: '2026-03-25T00:00:00.000Z',
|
||||
monthlyUsedUsd: 2,
|
||||
monthlyLimitUsd: 10,
|
||||
},
|
||||
],
|
||||
}, 1760000002000),
|
||||
}),
|
||||
},
|
||||
]).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/sites',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as Array<{
|
||||
totalBalance: number;
|
||||
subscriptionSummary: {
|
||||
activeCount: number;
|
||||
totalUsedUsd: number;
|
||||
totalMonthlyLimitUsd: number | null;
|
||||
totalRemainingUsd: number | null;
|
||||
nextExpiresAt: string | null;
|
||||
planNames: string[];
|
||||
updatedAt: number | null;
|
||||
} | null;
|
||||
}>;
|
||||
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0]?.totalBalance).toBe(15);
|
||||
expect(body[0]?.subscriptionSummary).toEqual({
|
||||
activeCount: 2,
|
||||
totalUsedUsd: 5,
|
||||
totalMonthlyLimitUsd: 30,
|
||||
totalRemainingUsd: 25,
|
||||
nextExpiresAt: '2026-03-25T00:00:00.000Z',
|
||||
planNames: ['Pro', 'Lite'],
|
||||
updatedAt: 1760000002000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { detectSite } from '../../services/siteDetector.js';
|
||||
import { invalidateSiteProxyCache } from '../../services/siteProxy.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
|
||||
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
|
||||
import { getSub2ApiSubscriptionFromExtraConfig } from '../../services/accountExtraConfig.js';
|
||||
|
||||
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
||||
if (input === undefined || input === null) return null;
|
||||
@@ -75,6 +76,65 @@ function normalizeOptionalExternalCheckinUrl(input: unknown): {
|
||||
return { valid: true, present: true, url: parsed.toString().replace(/\/+$/, '') };
|
||||
}
|
||||
|
||||
type SiteSubscriptionAggregate = {
|
||||
activeCount: number;
|
||||
totalUsedUsd: number;
|
||||
totalMonthlyLimitUsd: number | null;
|
||||
totalRemainingUsd: number | null;
|
||||
nextExpiresAt: string | null;
|
||||
planNames: string[];
|
||||
updatedAt: number | null;
|
||||
};
|
||||
|
||||
function roundMetric(value: number): number {
|
||||
return Math.round(value * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
function pickEarlierIsoDate(current?: string | null, next?: string | null): string | null {
|
||||
if (!current) return next || null;
|
||||
if (!next) return current;
|
||||
const currentMs = Date.parse(current);
|
||||
const nextMs = Date.parse(next);
|
||||
if (!Number.isFinite(currentMs)) return next;
|
||||
if (!Number.isFinite(nextMs)) return current;
|
||||
return nextMs < currentMs ? next : current;
|
||||
}
|
||||
|
||||
function aggregateSiteSubscription(
|
||||
current: SiteSubscriptionAggregate | undefined,
|
||||
extraConfig?: string | null,
|
||||
): SiteSubscriptionAggregate | undefined {
|
||||
const stored = getSub2ApiSubscriptionFromExtraConfig(extraConfig);
|
||||
if (!stored) return current;
|
||||
|
||||
const planNames = new Set(current?.planNames || []);
|
||||
let totalMonthlyLimitUsd = current?.totalMonthlyLimitUsd ?? null;
|
||||
let nextExpiresAt = current?.nextExpiresAt ?? null;
|
||||
|
||||
for (const item of stored.subscriptions) {
|
||||
if (item.groupName) planNames.add(item.groupName);
|
||||
if (typeof item.monthlyLimitUsd === 'number' && Number.isFinite(item.monthlyLimitUsd)) {
|
||||
totalMonthlyLimitUsd = roundMetric((totalMonthlyLimitUsd ?? 0) + item.monthlyLimitUsd);
|
||||
}
|
||||
nextExpiresAt = pickEarlierIsoDate(nextExpiresAt, item.expiresAt);
|
||||
}
|
||||
|
||||
const totalUsedUsd = roundMetric((current?.totalUsedUsd || 0) + stored.totalUsedUsd);
|
||||
const totalRemainingUsd = totalMonthlyLimitUsd == null
|
||||
? null
|
||||
: roundMetric(Math.max(0, totalMonthlyLimitUsd - totalUsedUsd));
|
||||
|
||||
return {
|
||||
activeCount: (current?.activeCount || 0) + stored.activeCount,
|
||||
totalUsedUsd,
|
||||
totalMonthlyLimitUsd,
|
||||
totalRemainingUsd,
|
||||
nextExpiresAt,
|
||||
planNames: Array.from(planNames),
|
||||
updatedAt: Math.max(current?.updatedAt || 0, stored.updatedAt || 0) || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sitesRoutes(app: FastifyInstance) {
|
||||
function invalidateSiteCaches() {
|
||||
invalidateSiteProxyCache();
|
||||
@@ -137,21 +197,23 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
// List all sites
|
||||
app.get('/api/sites', async () => {
|
||||
const siteRows = await db.select().from(schema.sites).all();
|
||||
const accountBalanceRows = await db.select({
|
||||
const accountRows = await db.select({
|
||||
siteId: schema.accounts.siteId,
|
||||
totalBalance: sql<number>`coalesce(sum(${schema.accounts.balance}), 0)`,
|
||||
}).from(schema.accounts)
|
||||
.groupBy(schema.accounts.siteId)
|
||||
.all();
|
||||
balance: schema.accounts.balance,
|
||||
extraConfig: schema.accounts.extraConfig,
|
||||
}).from(schema.accounts).all();
|
||||
|
||||
const totalBalanceBySiteId: Record<number, number> = {};
|
||||
for (const row of accountBalanceRows) {
|
||||
totalBalanceBySiteId[row.siteId] = Number(row.totalBalance || 0);
|
||||
const subscriptionBySiteId: Record<number, SiteSubscriptionAggregate | undefined> = {};
|
||||
for (const row of accountRows) {
|
||||
totalBalanceBySiteId[row.siteId] = roundMetric((totalBalanceBySiteId[row.siteId] || 0) + Number(row.balance || 0));
|
||||
subscriptionBySiteId[row.siteId] = aggregateSiteSubscription(subscriptionBySiteId[row.siteId], row.extraConfig);
|
||||
}
|
||||
|
||||
return siteRows.map((site) => ({
|
||||
...site,
|
||||
totalBalance: Math.round((totalBalanceBySiteId[site.id] || 0) * 1_000_000) / 1_000_000,
|
||||
subscriptionSummary: subscriptionBySiteId[site.id] || null,
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user