feat(server): flux aduit (#1482)
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "flux_audit_log" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"amount" integer NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "flux_audit_log" ADD CONSTRAINT "flux_audit_log_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
||||
"when": 1773229668722,
|
||||
"tag": "0003_old_titania",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1773421458174,
|
||||
"tag": "0004_bouncy_devos",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+14
-18
@@ -28,7 +28,7 @@ import { createCharacterService } from './services/characters'
|
||||
import { createChatService } from './services/chats'
|
||||
import { createConfigKVService } from './services/config-kv'
|
||||
import { createFluxService } from './services/flux'
|
||||
import { createFluxWriteBack } from './services/flux-write-back'
|
||||
import { createFluxAuditService } from './services/flux-audit'
|
||||
import { createProviderService } from './services/providers'
|
||||
import { createRequestLogService } from './services/request-log'
|
||||
import { createStripeService } from './services/stripe'
|
||||
@@ -41,6 +41,7 @@ type ChatService = ReturnType<typeof createChatService>
|
||||
type ProviderService = ReturnType<typeof createProviderService>
|
||||
type FluxService = ReturnType<typeof createFluxService>
|
||||
type ConfigKVService = ReturnType<typeof createConfigKVService>
|
||||
type FluxAuditService = ReturnType<typeof createFluxAuditService>
|
||||
type RequestLogService = ReturnType<typeof createRequestLogService>
|
||||
type StripeDBService = ReturnType<typeof createStripeService>
|
||||
|
||||
@@ -52,6 +53,7 @@ interface AppDeps {
|
||||
chatService: ChatService
|
||||
providerService: ProviderService
|
||||
fluxService: FluxService
|
||||
fluxAuditService: FluxAuditService
|
||||
requestLogService: RequestLogService
|
||||
stripeService: StripeDBService
|
||||
configKV: ConfigKVService
|
||||
@@ -65,6 +67,7 @@ function buildApp({
|
||||
chatService,
|
||||
providerService,
|
||||
fluxService,
|
||||
fluxAuditService,
|
||||
requestLogService,
|
||||
stripeService,
|
||||
configKV,
|
||||
@@ -143,7 +146,7 @@ function buildApp({
|
||||
/**
|
||||
* Flux routes.
|
||||
*/
|
||||
.route('/api/flux', createFluxRoutes(fluxService))
|
||||
.route('/api/flux', createFluxRoutes(fluxService, fluxAuditService))
|
||||
|
||||
/**
|
||||
* Stripe routes.
|
||||
@@ -224,9 +227,14 @@ async function createApp() {
|
||||
build: ({ dependsOn }) => createStripeService(dependsOn.db),
|
||||
})
|
||||
|
||||
const fluxAuditService = injeca.provide('services:fluxAudit', {
|
||||
dependsOn: { db },
|
||||
build: ({ dependsOn }) => createFluxAuditService(dependsOn.db),
|
||||
})
|
||||
|
||||
const fluxService = injeca.provide('services:flux', {
|
||||
dependsOn: { db, redis, configKV },
|
||||
build: ({ dependsOn }) => createFluxService(dependsOn.db, dependsOn.redis, dependsOn.configKV),
|
||||
dependsOn: { db, redis, configKV, fluxAuditService },
|
||||
build: ({ dependsOn }) => createFluxService(dependsOn.db, dependsOn.redis, dependsOn.configKV, dependsOn.fluxAuditService),
|
||||
})
|
||||
|
||||
const requestLogService = injeca.provide('services:requestLog', {
|
||||
@@ -234,19 +242,6 @@ async function createApp() {
|
||||
build: ({ dependsOn }) => createRequestLogService(dependsOn.db),
|
||||
})
|
||||
|
||||
const fluxWriteBack = injeca.provide('services:fluxWriteBack', {
|
||||
dependsOn: { db, lifecycle },
|
||||
build: ({ dependsOn }) => {
|
||||
const wb = createFluxWriteBack(dependsOn.db)
|
||||
wb.start()
|
||||
dependsOn.lifecycle.appHooks.onStop(async () => {
|
||||
wb.stop()
|
||||
await wb.flush()
|
||||
})
|
||||
return wb
|
||||
},
|
||||
})
|
||||
|
||||
await injeca.start()
|
||||
const resolved = await injeca.resolve({
|
||||
auth,
|
||||
@@ -254,12 +249,12 @@ async function createApp() {
|
||||
chatService,
|
||||
providerService,
|
||||
fluxService,
|
||||
fluxAuditService,
|
||||
requestLogService,
|
||||
stripeService,
|
||||
configKV,
|
||||
env: parsedEnv,
|
||||
otel,
|
||||
fluxWriteBack,
|
||||
})
|
||||
const app = buildApp({
|
||||
auth: resolved.auth,
|
||||
@@ -267,6 +262,7 @@ async function createApp() {
|
||||
chatService: resolved.chatService,
|
||||
providerService: resolved.providerService,
|
||||
fluxService: resolved.fluxService,
|
||||
fluxAuditService: resolved.fluxAuditService,
|
||||
requestLogService: resolved.requestLogService,
|
||||
stripeService: resolved.stripeService,
|
||||
configKV: resolved.configKV,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { migrate } from '@proj-airi/drizzle-orm-browser-migrator/pg'
|
||||
import { migrations } from '@proj-airi/server-schema'
|
||||
import { drizzle } from 'drizzle-orm/node-postgres'
|
||||
@@ -5,10 +6,24 @@ import { Pool } from 'pg'
|
||||
|
||||
import * as fullSchema from '../schemas'
|
||||
|
||||
const logger = useLogger('db')
|
||||
|
||||
export type Database = ReturnType<typeof createDrizzle>['db']
|
||||
|
||||
export function createDrizzle(dsn: string) {
|
||||
const pool = new Pool({ connectionString: dsn })
|
||||
const pool = new Pool({
|
||||
connectionString: dsn,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30_000,
|
||||
connectionTimeoutMillis: 5_000,
|
||||
keepAlive: true,
|
||||
keepAliveInitialDelayMillis: 10_000,
|
||||
})
|
||||
|
||||
pool.on('error', (err) => {
|
||||
logger.withError(err).error('Unexpected pool error on idle client')
|
||||
})
|
||||
|
||||
const db = drizzle(pool, { schema: fullSchema })
|
||||
return { db, pool }
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@ import type { MiddlewareHandler } from 'hono'
|
||||
import type { createAuth } from '../libs/auth'
|
||||
import type { HonoEnv } from '../types/hono'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
|
||||
import { createUnauthorizedError } from '../utils/error'
|
||||
|
||||
const logger = useLogger('auth')
|
||||
|
||||
type AuthInstance = ReturnType<typeof createAuth>
|
||||
|
||||
/**
|
||||
@@ -34,6 +38,7 @@ export function sessionMiddleware(auth: AuthInstance): MiddlewareHandler<HonoEnv
|
||||
export const authGuard: MiddlewareHandler<HonoEnv> = async (c, next) => {
|
||||
const user = c.get('user')
|
||||
if (!user) {
|
||||
logger.withFields({ path: c.req.path, method: c.req.method }).warn('Unauthorized request blocked')
|
||||
throw createUnauthorizedError()
|
||||
}
|
||||
await next()
|
||||
|
||||
@@ -150,7 +150,10 @@ describe('v1CompletionsRoutes', () => {
|
||||
expect(data.id).toBe('chatcmpl-1')
|
||||
|
||||
// Verify flux was consumed
|
||||
expect(fluxService.consumeFlux).toHaveBeenCalledWith('user-1', 1)
|
||||
expect(fluxService.consumeFlux).toHaveBeenCalledWith('user-1', 1, {
|
||||
description: 'openai/gpt-5-mini',
|
||||
metadata: undefined,
|
||||
})
|
||||
|
||||
// Verify upstream was called with correct URL and resolved model
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { FluxService } from '../services/flux'
|
||||
import type { FluxAuditService } from '../services/flux-audit'
|
||||
import type { HonoEnv } from '../types/hono'
|
||||
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import { authGuard } from '../middlewares/auth'
|
||||
|
||||
export function createFluxRoutes(fluxService: FluxService) {
|
||||
export function createFluxRoutes(fluxService: FluxService, fluxAuditService: FluxAuditService) {
|
||||
return new Hono<HonoEnv>()
|
||||
.use('*', authGuard)
|
||||
.get('/', async (c) => {
|
||||
@@ -13,4 +14,23 @@ export function createFluxRoutes(fluxService: FluxService) {
|
||||
const flux = await fluxService.getFlux(user.id)
|
||||
return c.json(flux)
|
||||
})
|
||||
.get('/history', async (c) => {
|
||||
const user = c.get('user')!
|
||||
const limit = Math.min(Math.max(Number(c.req.query('limit') || '20'), 1), 100)
|
||||
const offset = Math.max(Number(c.req.query('offset') || '0'), 0)
|
||||
|
||||
const { records, hasMore } = await fluxAuditService.getHistory(user.id, limit, offset)
|
||||
|
||||
return c.json({
|
||||
records: records.map(r => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
amount: r.amount,
|
||||
description: r.description,
|
||||
metadata: r.metadata,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})),
|
||||
hasMore,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { HonoEnv } from '../types/hono'
|
||||
|
||||
import Stripe from 'stripe'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { Hono } from 'hono'
|
||||
import { integer, minValue, number, object, pipe, safeParse } from 'valibot'
|
||||
|
||||
@@ -13,6 +14,8 @@ import { authGuard } from '../middlewares/auth'
|
||||
import { configGuard } from '../middlewares/config-guard'
|
||||
import { createBadRequestError, createServiceUnavailableError } from '../utils/error'
|
||||
|
||||
const logger = useLogger('stripe')
|
||||
|
||||
const CheckoutBodySchema = object({
|
||||
amount: pipe(number(), integer(), minValue(1)),
|
||||
})
|
||||
@@ -140,6 +143,8 @@ export function createStripeRoutes(fluxService: FluxService, stripeService: Stri
|
||||
throw createBadRequestError(`Webhook Error: ${message}`, 'WEBHOOK_ERROR')
|
||||
}
|
||||
|
||||
logger.withFields({ type: event.type, id: event.id }).log('Webhook event received')
|
||||
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
await handleCheckoutSessionCompleted(event.data.object, fluxService, stripeService, configKV)
|
||||
@@ -178,8 +183,12 @@ async function handleCheckoutSessionCompleted(
|
||||
configKV: ConfigKVService,
|
||||
) {
|
||||
const userId = session.metadata?.userId
|
||||
if (!userId)
|
||||
if (!userId) {
|
||||
logger.withFields({ sessionId: session.id }).warn('Checkout session missing userId in metadata')
|
||||
return
|
||||
}
|
||||
|
||||
logger.withFields({ userId, sessionId: session.id, mode: session.mode, amount: session.amount_total, currency: session.currency }).log('Processing checkout session')
|
||||
|
||||
// Upsert customer record if we got a customer back
|
||||
if (session.customer) {
|
||||
@@ -214,7 +223,9 @@ async function handleCheckoutSessionCompleted(
|
||||
// Add flux for one-time payments
|
||||
if (session.mode === 'payment' && session.amount_total) {
|
||||
const fluxPerCent = await configKV.getOrThrow('FLUX_PER_CENT')
|
||||
await fluxService.addFlux(userId, session.amount_total * fluxPerCent)
|
||||
const fluxAmount = session.amount_total * fluxPerCent
|
||||
logger.withFields({ userId, fluxAmount, fluxPerCent, amountTotal: session.amount_total }).log('Adding flux for one-time payment')
|
||||
await fluxService.addFlux(userId, fluxAmount, `Stripe payment ${session.currency?.toUpperCase()} ${(session.amount_total / 100).toFixed(2)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +315,8 @@ async function handleInvoiceEvent(
|
||||
// Add flux when a subscription invoice is paid
|
||||
if (invoice.status === 'paid' && invoice.amount_paid && subscriptionId) {
|
||||
const fluxPerCent = await configKV.getOrThrow('FLUX_PER_CENT')
|
||||
await fluxService.addFlux(customer.userId, invoice.amount_paid * fluxPerCent)
|
||||
const fluxAmount = invoice.amount_paid * fluxPerCent
|
||||
logger.withFields({ userId: customer.userId, fluxAmount, invoiceId: invoice.id }).log('Adding flux for subscription invoice')
|
||||
await fluxService.addFlux(customer.userId, fluxAmount, `Subscription invoice ${invoice.currency?.toUpperCase()} ${(invoice.amount_paid / 100).toFixed(2)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,15 @@ function calculateFluxFromUsage(usage: UsageInfo, fluxPer1kTokens: number, fallb
|
||||
return fallbackRate
|
||||
}
|
||||
|
||||
function buildFluxAuditMetadata(usage: UsageInfo): Record<string, number> | undefined {
|
||||
const metadata: Record<string, number> = {}
|
||||
if (usage.promptTokens != null)
|
||||
metadata.promptTokens = usage.promptTokens
|
||||
if (usage.completionTokens != null)
|
||||
metadata.completionTokens = usage.completionTokens
|
||||
return Object.keys(metadata).length > 0 ? metadata : undefined
|
||||
}
|
||||
|
||||
export function createV1CompletionsRoutes(fluxService: FluxService, configKV: ConfigKVService, requestLogService: RequestLogService, otel: OtelMetrics | null) {
|
||||
const logger = useLogger('v1-completions').useGlobalConfig()
|
||||
|
||||
@@ -173,7 +182,10 @@ export function createV1CompletionsRoutes(fluxService: FluxService, configKV: Co
|
||||
|
||||
// Best-effort billing — don't throw on insufficient flux during streaming
|
||||
try {
|
||||
await fluxService.consumeFlux(user.id, fluxConsumed)
|
||||
await fluxService.consumeFlux(user.id, fluxConsumed, {
|
||||
description: requestModel,
|
||||
metadata: buildFluxAuditMetadata(usage),
|
||||
})
|
||||
}
|
||||
catch (err) { logger.withError(err).withFields({ userId: user.id, fluxConsumed }).warn('Failed to consume flux after streaming') }
|
||||
|
||||
@@ -211,7 +223,10 @@ export function createV1CompletionsRoutes(fluxService: FluxService, configKV: Co
|
||||
// Best-effort billing — gateway already processed the request,
|
||||
// don't return 402 after work is done
|
||||
try {
|
||||
await fluxService.consumeFlux(user.id, fluxConsumed)
|
||||
await fluxService.consumeFlux(user.id, fluxConsumed, {
|
||||
description: requestModel,
|
||||
metadata: buildFluxAuditMetadata(usage),
|
||||
})
|
||||
}
|
||||
catch (err) { logger.withError(err).withFields({ userId: user.id, fluxConsumed }).warn('Failed to consume flux') }
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
|
||||
|
||||
import { nanoid } from '../utils/id'
|
||||
import { user } from './accounts'
|
||||
|
||||
export const fluxAuditLog = pgTable('flux_audit_log', {
|
||||
id: text('id').primaryKey().$defaultFn(() => nanoid()),
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
type: text('type').notNull(), // 'consumption' | 'addition' | 'initial'
|
||||
amount: integer('amount').notNull(), // positive = gain, negative = spend
|
||||
description: text('description').notNull(), // model name, "Stripe payment", "Initial grant", etc.
|
||||
metadata: jsonb('metadata'), // { promptTokens, completionTokens, stripeSessionId, ... }
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
})
|
||||
@@ -2,6 +2,7 @@ export * from './accounts'
|
||||
export * from './characters'
|
||||
export * from './chats'
|
||||
export * from './flux'
|
||||
export * from './flux-audit-log'
|
||||
export * from './llm-request-log'
|
||||
export * from './providers'
|
||||
export * from './stripe'
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { mockDB } from '../../libs/mock-db'
|
||||
import { createFluxAuditService } from '../flux-audit'
|
||||
|
||||
import * as schema from '../../schemas'
|
||||
|
||||
describe('fluxAuditService', () => {
|
||||
let db: any
|
||||
let service: ReturnType<typeof createFluxAuditService>
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
await db.insert(schema.user).values({
|
||||
id: 'user-audit',
|
||||
name: 'Audit User',
|
||||
email: 'audit@example.com',
|
||||
})
|
||||
service = createFluxAuditService(db)
|
||||
})
|
||||
|
||||
it('log should insert a single audit entry', async () => {
|
||||
await service.log({
|
||||
userId: 'user-audit',
|
||||
type: 'addition',
|
||||
amount: 500,
|
||||
description: 'Stripe payment',
|
||||
metadata: { stripeSessionId: 'sess_123' },
|
||||
})
|
||||
|
||||
const { records } = await service.getHistory('user-audit', 10, 0)
|
||||
expect(records).toHaveLength(1)
|
||||
expect(records[0].type).toBe('addition')
|
||||
expect(records[0].amount).toBe(500)
|
||||
})
|
||||
|
||||
it('logBatch should insert multiple entries', async () => {
|
||||
await service.logBatch([
|
||||
{ userId: 'user-audit', type: 'consumption', amount: -10, description: 'gpt-4o' },
|
||||
{ userId: 'user-audit', type: 'consumption', amount: -5, description: 'gpt-4o-mini' },
|
||||
])
|
||||
|
||||
const { records } = await service.getHistory('user-audit', 10, 0)
|
||||
expect(records).toHaveLength(3) // 1 from previous test + 2 batch
|
||||
})
|
||||
|
||||
it('logBatch with empty array should be a no-op', async () => {
|
||||
await service.logBatch([])
|
||||
const { records } = await service.getHistory('user-audit', 10, 0)
|
||||
expect(records).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('getHistory should paginate correctly with hasMore', async () => {
|
||||
const { records, hasMore } = await service.getHistory('user-audit', 2, 0)
|
||||
expect(records).toHaveLength(2)
|
||||
expect(hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('getHistory should return hasMore=false on last page', async () => {
|
||||
const { records, hasMore } = await service.getHistory('user-audit', 10, 0)
|
||||
expect(records).toHaveLength(3)
|
||||
expect(hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('getHistory should respect offset', async () => {
|
||||
const { records } = await service.getHistory('user-audit', 10, 2)
|
||||
expect(records).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('getHistory should return records ordered by createdAt desc', async () => {
|
||||
const { records } = await service.getHistory('user-audit', 10, 0)
|
||||
for (let i = 1; i < records.length; i++) {
|
||||
expect(new Date(records[i - 1].createdAt).getTime())
|
||||
.toBeGreaterThanOrEqual(new Date(records[i].createdAt).getTime())
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,105 +0,0 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { mockDB } from '../../libs/mock-db'
|
||||
import { createFluxWriteBack } from '../flux-write-back'
|
||||
|
||||
import * as schema from '../../schemas'
|
||||
|
||||
describe('fluxWriteBack', () => {
|
||||
let db: any
|
||||
let testUser: any
|
||||
let writeBack: ReturnType<typeof createFluxWriteBack>
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
|
||||
const [user] = await db.insert(schema.user).values({
|
||||
id: 'user-wb-1',
|
||||
name: 'Write-back User',
|
||||
email: 'wb@example.com',
|
||||
}).returning()
|
||||
testUser = user
|
||||
|
||||
await db.insert(schema.userFlux).values({
|
||||
userId: testUser.id,
|
||||
flux: 1000,
|
||||
})
|
||||
|
||||
writeBack = createFluxWriteBack(db)
|
||||
})
|
||||
|
||||
it('should aggregate unsettled logs and deduct from user_flux', async () => {
|
||||
await db.insert(schema.llmRequestLog).values([
|
||||
{ userId: testUser.id, model: 'gpt-4', status: 200, durationMs: 100, fluxConsumed: 10, settled: false },
|
||||
{ userId: testUser.id, model: 'gpt-4', status: 200, durationMs: 200, fluxConsumed: 20, settled: false },
|
||||
{ userId: testUser.id, model: 'gpt-4', status: 200, durationMs: 150, fluxConsumed: 30, settled: false },
|
||||
])
|
||||
|
||||
await writeBack.flush()
|
||||
|
||||
const record = await db.query.userFlux.findFirst({
|
||||
where: eq(schema.userFlux.userId, testUser.id),
|
||||
})
|
||||
expect(record.flux).toBe(940)
|
||||
|
||||
const unsettled = await db.query.llmRequestLog.findMany({
|
||||
where: eq(schema.llmRequestLog.settled, false),
|
||||
})
|
||||
expect(unsettled).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should not re-settle already settled logs', async () => {
|
||||
await db.insert(schema.llmRequestLog).values({
|
||||
userId: testUser.id,
|
||||
model: 'gpt-4',
|
||||
status: 200,
|
||||
durationMs: 100,
|
||||
fluxConsumed: 5,
|
||||
settled: false,
|
||||
})
|
||||
|
||||
await writeBack.flush()
|
||||
|
||||
const record = await db.query.userFlux.findFirst({
|
||||
where: eq(schema.userFlux.userId, testUser.id),
|
||||
})
|
||||
expect(record.flux).toBe(935)
|
||||
})
|
||||
|
||||
it('should be a no-op when there are no unsettled logs', async () => {
|
||||
await writeBack.flush()
|
||||
|
||||
const record = await db.query.userFlux.findFirst({
|
||||
where: eq(schema.userFlux.userId, testUser.id),
|
||||
})
|
||||
expect(record.flux).toBe(935)
|
||||
})
|
||||
|
||||
it('should aggregate across multiple users correctly', async () => {
|
||||
const [user2] = await db.insert(schema.user).values({
|
||||
id: 'user-wb-2',
|
||||
name: 'Write-back User 2',
|
||||
email: 'wb2@example.com',
|
||||
}).returning()
|
||||
await db.insert(schema.userFlux).values({ userId: user2.id, flux: 500 })
|
||||
|
||||
await db.insert(schema.llmRequestLog).values([
|
||||
{ userId: testUser.id, model: 'gpt-4', status: 200, durationMs: 100, fluxConsumed: 15, settled: false },
|
||||
{ userId: user2.id, model: 'gpt-4', status: 200, durationMs: 100, fluxConsumed: 25, settled: false },
|
||||
{ userId: user2.id, model: 'gpt-4', status: 200, durationMs: 100, fluxConsumed: 35, settled: false },
|
||||
])
|
||||
|
||||
await writeBack.flush()
|
||||
|
||||
const record1 = await db.query.userFlux.findFirst({
|
||||
where: eq(schema.userFlux.userId, testUser.id),
|
||||
})
|
||||
expect(record1.flux).toBe(920)
|
||||
|
||||
const record2 = await db.query.userFlux.findFirst({
|
||||
where: eq(schema.userFlux.userId, user2.id),
|
||||
})
|
||||
expect(record2.flux).toBe(440)
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { mockDB } from '../../libs/mock-db'
|
||||
import { createFluxService } from '../flux'
|
||||
import { createFluxAuditService } from '../flux-audit'
|
||||
|
||||
import * as schema from '../../schemas'
|
||||
|
||||
@@ -23,7 +24,10 @@ function createMockRedis(): Redis {
|
||||
const store = new Map<string, string>()
|
||||
return {
|
||||
get: vi.fn(async (key: string) => store.get(key) ?? null),
|
||||
set: vi.fn(async (key: string, value: string) => { store.set(key, value); return 'OK' }),
|
||||
set: vi.fn(async (key: string, value: string) => {
|
||||
store.set(key, value)
|
||||
return 'OK'
|
||||
}),
|
||||
decrby: vi.fn(async (key: string, amount: number) => {
|
||||
const current = Number.parseInt(store.get(key) ?? '0', 10)
|
||||
const next = current - amount
|
||||
@@ -43,10 +47,12 @@ describe('fluxService (Redis-backed)', () => {
|
||||
let db: any
|
||||
let redis: Redis
|
||||
let service: ReturnType<typeof createFluxService>
|
||||
let fluxAuditService: ReturnType<typeof createFluxAuditService>
|
||||
let testUser: any
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
fluxAuditService = createFluxAuditService(db)
|
||||
|
||||
const [user] = await db.insert(schema.user).values({
|
||||
id: 'user-1',
|
||||
@@ -58,7 +64,7 @@ describe('fluxService (Redis-backed)', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
redis = createMockRedis()
|
||||
service = createFluxService(db, redis, createMockConfigKV())
|
||||
service = createFluxService(db, redis, createMockConfigKV(), fluxAuditService)
|
||||
})
|
||||
|
||||
it('getFlux should load from DB on cache miss and populate Redis', async () => {
|
||||
@@ -73,6 +79,21 @@ describe('fluxService (Redis-backed)', () => {
|
||||
expect(redis.get).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('getFlux should create an initial audit entry for a new user', async () => {
|
||||
const [user] = await db.insert(schema.user).values({
|
||||
id: 'user-audit-initial',
|
||||
name: 'Audit Initial',
|
||||
email: 'audit-initial@example.com',
|
||||
}).returning()
|
||||
|
||||
await service.getFlux(user.id)
|
||||
|
||||
const { records } = await fluxAuditService.getHistory(user.id, 10, 0)
|
||||
expect(records).toHaveLength(1)
|
||||
expect(records[0].type).toBe('initial')
|
||||
expect(records[0].amount).toBe(100)
|
||||
})
|
||||
|
||||
it('consumeFlux should deduct via Redis DECRBY', async () => {
|
||||
await service.getFlux(testUser.id)
|
||||
const result = await service.consumeFlux(testUser.id, 10)
|
||||
@@ -89,10 +110,61 @@ describe('fluxService (Redis-backed)', () => {
|
||||
})
|
||||
|
||||
it('addFlux should update both DB and Redis', async () => {
|
||||
await service.getFlux(testUser.id)
|
||||
const result = await service.addFlux(testUser.id, 50)
|
||||
const [user] = await db.insert(schema.user).values({
|
||||
id: 'user-add',
|
||||
name: 'Add User',
|
||||
email: 'add@example.com',
|
||||
}).returning()
|
||||
await service.getFlux(user.id)
|
||||
const result = await service.addFlux(user.id, 50)
|
||||
expect(result.flux).toBe(150)
|
||||
expect(redis.incrby).toHaveBeenCalledWith(`flux:${testUser.id}`, 50)
|
||||
expect(redis.incrby).toHaveBeenCalledWith(`flux:${user.id}`, 50)
|
||||
})
|
||||
|
||||
it('consumeFlux should write a consumption audit entry with metadata', async () => {
|
||||
const [user] = await db.insert(schema.user).values({
|
||||
id: 'user-audit-consume',
|
||||
name: 'Audit Consume',
|
||||
email: 'audit-consume@example.com',
|
||||
}).returning()
|
||||
|
||||
await service.getFlux(user.id)
|
||||
await service.consumeFlux(user.id, 10, {
|
||||
description: 'openai/gpt-5-mini',
|
||||
metadata: {
|
||||
promptTokens: 12,
|
||||
completionTokens: 34,
|
||||
},
|
||||
})
|
||||
|
||||
const { records } = await fluxAuditService.getHistory(user.id, 10, 0)
|
||||
expect(records).toHaveLength(2)
|
||||
const consumptionRecord = records.find(record => record.type === 'consumption')
|
||||
expect(consumptionRecord).toBeDefined()
|
||||
expect(consumptionRecord!.amount).toBe(-10)
|
||||
expect(consumptionRecord!.description).toBe('openai/gpt-5-mini')
|
||||
expect(consumptionRecord!.metadata).toEqual({
|
||||
promptTokens: 12,
|
||||
completionTokens: 34,
|
||||
})
|
||||
})
|
||||
|
||||
it('addFlux should write an addition audit entry', async () => {
|
||||
const [user] = await db.insert(schema.user).values({
|
||||
id: 'user-audit-add',
|
||||
name: 'Audit Add',
|
||||
email: 'audit-add@example.com',
|
||||
}).returning()
|
||||
|
||||
await service.getFlux(user.id)
|
||||
await service.addFlux(user.id, 50, 'Stripe payment USD 0.50')
|
||||
|
||||
const { records } = await fluxAuditService.getHistory(user.id, 10, 0)
|
||||
expect(records).toHaveLength(2)
|
||||
const additionRecord = records.find(record => record.type === 'addition')
|
||||
expect(additionRecord).toBeDefined()
|
||||
expect(additionRecord!.amount).toBe(50)
|
||||
expect(additionRecord!.description).toBe('Stripe payment USD 0.50')
|
||||
})
|
||||
|
||||
it('consumeFlux should lazy-load cache if not preloaded', async () => {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { Database } from '../libs/db'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm'
|
||||
|
||||
import * as schema from '../schemas/characters'
|
||||
import * as userCharacterSchema from '../schemas/user-character'
|
||||
|
||||
const logger = useLogger('characters')
|
||||
|
||||
export function createCharacterService(db: Database) {
|
||||
return {
|
||||
async findById(id: string) {
|
||||
@@ -140,6 +143,7 @@ export function createCharacterService(db: Database) {
|
||||
}) {
|
||||
return await db.transaction(async (tx) => {
|
||||
const [inserted] = await tx.insert(schema.character).values(data.character).returning()
|
||||
logger.withFields({ id: inserted.id, ownerId: data.character.ownerId }).log('Created character')
|
||||
|
||||
if (data.cover) {
|
||||
await tx.insert(schema.characterCovers).values({
|
||||
@@ -177,23 +181,27 @@ export function createCharacterService(db: Database) {
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<schema.NewCharacter>) {
|
||||
return await db.update(schema.character)
|
||||
const result = await db.update(schema.character)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(
|
||||
eq(schema.character.id, id),
|
||||
isNull(schema.character.deletedAt),
|
||||
))
|
||||
.returning()
|
||||
logger.withFields({ id }).log('Updated character')
|
||||
return result
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
return await db.update(schema.character)
|
||||
const result = await db.update(schema.character)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(and(
|
||||
eq(schema.character.id, id),
|
||||
isNull(schema.character.deletedAt),
|
||||
))
|
||||
.returning()
|
||||
logger.withFields({ id }).log('Deleted character')
|
||||
return result
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { Database } from '../libs/db'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm'
|
||||
|
||||
import { createConflictError, createForbiddenError } from '../utils/error'
|
||||
|
||||
import * as schema from '../schemas/chats'
|
||||
|
||||
const logger = useLogger('chats')
|
||||
|
||||
type ChatType = 'private' | 'bot' | 'group' | 'channel'
|
||||
type MessageRole = 'system' | 'user' | 'assistant' | 'tool' | 'error'
|
||||
type ChatMemberType = 'user' | 'character' | 'bot'
|
||||
@@ -67,8 +70,10 @@ export function createChatService(db: Database) {
|
||||
),
|
||||
})
|
||||
|
||||
if (!member)
|
||||
if (!member) {
|
||||
logger.withFields({ userId, chatId }).warn('User not a member of chat, sync forbidden')
|
||||
throw createForbiddenError()
|
||||
}
|
||||
}
|
||||
|
||||
if (!existingChat) {
|
||||
@@ -134,8 +139,10 @@ export function createChatService(db: Database) {
|
||||
.where(inArray(schema.messages.id, messageIds))
|
||||
|
||||
const conflicting = existingMessages.find(m => m.chatId !== chatId)
|
||||
if (conflicting)
|
||||
if (conflicting) {
|
||||
logger.withFields({ messageId: conflicting.id, expectedChatId: chatId, actualChatId: conflicting.chatId }).warn('Message conflict detected')
|
||||
throw createConflictError('Message already belongs to another chat')
|
||||
}
|
||||
|
||||
await tx.insert(schema.messages)
|
||||
.values(payload.messages.map(message => ({
|
||||
@@ -160,6 +167,7 @@ export function createChatService(db: Database) {
|
||||
})
|
||||
}
|
||||
|
||||
logger.withFields({ userId, chatId, messageCount: payload.messages.length, isNew: !existingChat }).log('Synced chat')
|
||||
return { chatId }
|
||||
})
|
||||
},
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Database } from '../libs/db'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { desc, eq } from 'drizzle-orm'
|
||||
|
||||
import * as schema from '../schemas/flux-audit-log'
|
||||
|
||||
const logger = useLogger('flux-audit')
|
||||
|
||||
export interface AuditEntry {
|
||||
userId: string
|
||||
type: 'consumption' | 'addition' | 'initial'
|
||||
amount: number
|
||||
description: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function createFluxAuditService(db: Database) {
|
||||
return {
|
||||
async log(entry: AuditEntry) {
|
||||
await db.insert(schema.fluxAuditLog).values(entry)
|
||||
logger.withFields({ userId: entry.userId, type: entry.type, amount: entry.amount }).log('Audit entry recorded')
|
||||
},
|
||||
|
||||
async logBatch(entries: AuditEntry[]) {
|
||||
if (entries.length === 0)
|
||||
return
|
||||
await db.insert(schema.fluxAuditLog).values(entries)
|
||||
logger.withFields({ count: entries.length }).log('Audit batch recorded')
|
||||
},
|
||||
|
||||
async getHistory(userId: string, limit: number, offset: number) {
|
||||
const records = await db.query.fluxAuditLog.findMany({
|
||||
where: eq(schema.fluxAuditLog.userId, userId),
|
||||
orderBy: [desc(schema.fluxAuditLog.createdAt)],
|
||||
limit: limit + 1, // fetch one extra to determine hasMore
|
||||
offset,
|
||||
})
|
||||
|
||||
const hasMore = records.length > limit
|
||||
if (hasMore)
|
||||
records.pop()
|
||||
|
||||
return { records, hasMore }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type FluxAuditService = ReturnType<typeof createFluxAuditService>
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { Database } from '../libs/db'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { and, eq, lte, sql } from 'drizzle-orm'
|
||||
|
||||
import * as fluxSchema from '../schemas/flux'
|
||||
import * as logSchema from '../schemas/llm-request-log'
|
||||
|
||||
/**
|
||||
* NOTE: Flux balances are deducted in real-time via Redis (DECRBY) in FluxService.consumeFlux().
|
||||
* This write-back service only syncs the DB — it does NOT touch Redis.
|
||||
* It periodically aggregates unsettled request logs and batch-updates the DB's user_flux table
|
||||
* so that the persistent balance stays consistent with the Redis cache.
|
||||
*/
|
||||
export function createFluxWriteBack(db: Database) {
|
||||
const logger = useLogger('flux-write-back').useGlobalConfig()
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function flush() {
|
||||
const snapshotTime = new Date()
|
||||
|
||||
// 1. Aggregate unsettled logs inserted before (or at) this tick
|
||||
const totals = await db
|
||||
.select({
|
||||
userId: logSchema.llmRequestLog.userId,
|
||||
total: sql<number>`SUM(${logSchema.llmRequestLog.fluxConsumed})`.as('total'),
|
||||
})
|
||||
.from(logSchema.llmRequestLog)
|
||||
.where(and(eq(logSchema.llmRequestLog.settled, false), lte(logSchema.llmRequestLog.createdAt, snapshotTime)))
|
||||
.groupBy(logSchema.llmRequestLog.userId)
|
||||
|
||||
if (totals.length === 0)
|
||||
return
|
||||
|
||||
// 2. Batch update in transaction
|
||||
await db.transaction(async (tx) => {
|
||||
for (const { userId, total } of totals) {
|
||||
await tx.update(fluxSchema.userFlux)
|
||||
.set({
|
||||
flux: sql`${fluxSchema.userFlux.flux} - ${total}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(fluxSchema.userFlux.userId, userId))
|
||||
}
|
||||
|
||||
await tx.update(logSchema.llmRequestLog)
|
||||
.set({ settled: true })
|
||||
.where(and(eq(logSchema.llmRequestLog.settled, false), lte(logSchema.llmRequestLog.createdAt, snapshotTime)))
|
||||
})
|
||||
|
||||
logger.withFields({ userCount: totals.length }).log('Write-back completed')
|
||||
}
|
||||
|
||||
return {
|
||||
flush,
|
||||
|
||||
start(intervalMs = 60_000) {
|
||||
timer = setInterval(() => {
|
||||
flush().catch((err) => {
|
||||
logger.withError(err).error('Write-back failed')
|
||||
})
|
||||
}, intervalMs)
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type FluxWriteBack = ReturnType<typeof createFluxWriteBack>
|
||||
@@ -2,18 +2,26 @@ import type Redis from 'ioredis'
|
||||
|
||||
import type { Database } from '../libs/db'
|
||||
import type { ConfigKVService } from './config-kv'
|
||||
import type { FluxAuditService } from './flux-audit'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
|
||||
import { createPaymentRequiredError } from '../utils/error'
|
||||
|
||||
import * as schema from '../schemas/flux'
|
||||
|
||||
const logger = useLogger('flux-service')
|
||||
|
||||
function redisKey(userId: string): string {
|
||||
return `flux:${userId}`
|
||||
}
|
||||
|
||||
export function createFluxService(db: Database, redis: Redis, configKV: ConfigKVService) {
|
||||
function buildAuditMetadata(metadata?: Record<string, unknown>) {
|
||||
return metadata && Object.keys(metadata).length > 0 ? metadata : undefined
|
||||
}
|
||||
|
||||
export function createFluxService(db: Database, redis: Redis, configKV: ConfigKVService, fluxAuditService: FluxAuditService) {
|
||||
return {
|
||||
async getFlux(userId: string) {
|
||||
// 1. Try Redis cache
|
||||
@@ -33,6 +41,16 @@ export function createFluxService(db: Database, redis: Redis, configKV: ConfigKV
|
||||
userId,
|
||||
flux: initialFlux,
|
||||
}).returning()
|
||||
|
||||
logger.withFields({ userId, initialFlux }).log('Initialized new user flux')
|
||||
|
||||
// Audit: initial grant
|
||||
await fluxAuditService.log({
|
||||
userId,
|
||||
type: 'initial',
|
||||
amount: initialFlux,
|
||||
description: 'Initial grant',
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Populate Redis cache
|
||||
@@ -41,7 +59,7 @@ export function createFluxService(db: Database, redis: Redis, configKV: ConfigKV
|
||||
return record
|
||||
},
|
||||
|
||||
async consumeFlux(userId: string, amount: number) {
|
||||
async consumeFlux(userId: string, amount: number, options?: { description?: string, metadata?: Record<string, unknown> }) {
|
||||
// Ensure Redis key exists before DECRBY
|
||||
// (DECRBY on a nonexistent key creates it at 0, giving wrong balance)
|
||||
await this.getFlux(userId)
|
||||
@@ -55,13 +73,41 @@ export function createFluxService(db: Database, redis: Redis, configKV: ConfigKV
|
||||
const newBalance = await redis.decrby(redisKey(userId), amount)
|
||||
if (newBalance < 0) {
|
||||
await redis.incrby(redisKey(userId), amount)
|
||||
logger.withFields({ userId, amount }).warn('Insufficient flux, rolled back')
|
||||
throw createPaymentRequiredError('Insufficient flux')
|
||||
}
|
||||
|
||||
try {
|
||||
const [updated] = await db.update(schema.userFlux)
|
||||
.set({
|
||||
flux: sql`${schema.userFlux.flux} - ${amount}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.userFlux.userId, userId))
|
||||
.returning()
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(`Flux record missing for user ${userId}`)
|
||||
}
|
||||
|
||||
await fluxAuditService.log({
|
||||
userId,
|
||||
type: 'consumption',
|
||||
amount: -amount,
|
||||
description: options?.description ?? 'Usage',
|
||||
metadata: buildAuditMetadata(options?.metadata),
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
await redis.incrby(redisKey(userId), amount)
|
||||
throw error
|
||||
}
|
||||
|
||||
logger.withFields({ userId, amount, newBalance }).log('Consumed flux')
|
||||
return { userId, flux: newBalance }
|
||||
},
|
||||
|
||||
async addFlux(userId: string, amount: number) {
|
||||
async addFlux(userId: string, amount: number, description = 'Top-up') {
|
||||
// Ensure user record exists in DB
|
||||
await this.getFlux(userId)
|
||||
|
||||
@@ -76,6 +122,16 @@ export function createFluxService(db: Database, redis: Redis, configKV: ConfigKV
|
||||
// Sync Redis cache
|
||||
const newBalance = await redis.incrby(redisKey(userId), amount)
|
||||
|
||||
logger.withFields({ userId, amount, newBalance, description }).log('Added flux')
|
||||
|
||||
// Audit: addition
|
||||
await fluxAuditService.log({
|
||||
userId,
|
||||
type: 'addition',
|
||||
amount,
|
||||
description,
|
||||
})
|
||||
|
||||
return { userId, flux: newBalance }
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Database } from '../libs/db'
|
||||
|
||||
import * as schema from '../schemas/llm-request-log'
|
||||
|
||||
export interface RequestLogEntry {
|
||||
userId: string
|
||||
model: string
|
||||
status: number
|
||||
durationMs: number
|
||||
fluxConsumed: number
|
||||
promptTokens?: number
|
||||
completionTokens?: number
|
||||
}
|
||||
|
||||
export function createLLMRequestLogService(db: Database) {
|
||||
return {
|
||||
async logRequest(entry: RequestLogEntry) {
|
||||
await db.insert(schema.llmRequestLog).values(entry)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type RequestLogService = ReturnType<typeof createLLMRequestLogService>
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Database } from '../libs/db'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm'
|
||||
|
||||
import * as schema from '../schemas/providers'
|
||||
|
||||
const logger = useLogger('providers')
|
||||
|
||||
export function createProviderService(db: Database) {
|
||||
return {
|
||||
async findAll(ownerId: string) {
|
||||
@@ -92,6 +95,7 @@ export function createProviderService(db: Database) {
|
||||
|
||||
async createUserConfig(data: schema.NewUserProviderConfig) {
|
||||
const [inserted] = await db.insert(schema.userProviderConfigs).values(data).returning()
|
||||
logger.withFields({ id: inserted.id, ownerId: data.ownerId, definitionId: data.definitionId }).log('Created user provider config')
|
||||
return inserted
|
||||
},
|
||||
|
||||
@@ -103,17 +107,20 @@ export function createProviderService(db: Database) {
|
||||
isNull(schema.userProviderConfigs.deletedAt),
|
||||
))
|
||||
.returning()
|
||||
logger.withFields({ id }).log('Updated user provider config')
|
||||
return updated
|
||||
},
|
||||
|
||||
async deleteUserConfig(id: string) {
|
||||
return await db.update(schema.userProviderConfigs)
|
||||
const result = await db.update(schema.userProviderConfigs)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(and(
|
||||
eq(schema.userProviderConfigs.id, id),
|
||||
isNull(schema.userProviderConfigs.deletedAt),
|
||||
))
|
||||
.returning()
|
||||
logger.withFields({ id }).log('Deleted user provider config')
|
||||
return result
|
||||
},
|
||||
|
||||
// System Provider Configs
|
||||
@@ -134,6 +141,7 @@ export function createProviderService(db: Database) {
|
||||
|
||||
async createSystemConfig(data: schema.NewSystemProviderConfig) {
|
||||
const [inserted] = await db.insert(schema.systemProviderConfigs).values(data).returning()
|
||||
logger.withFields({ id: inserted.id, definitionId: data.definitionId }).log('Created system provider config')
|
||||
return inserted
|
||||
},
|
||||
|
||||
@@ -145,17 +153,20 @@ export function createProviderService(db: Database) {
|
||||
isNull(schema.systemProviderConfigs.deletedAt),
|
||||
))
|
||||
.returning()
|
||||
logger.withFields({ id }).log('Updated system provider config')
|
||||
return updated
|
||||
},
|
||||
|
||||
async deleteSystemConfig(id: string) {
|
||||
return await db.update(schema.systemProviderConfigs)
|
||||
const result = await db.update(schema.systemProviderConfigs)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(and(
|
||||
eq(schema.systemProviderConfigs.id, id),
|
||||
isNull(schema.systemProviderConfigs.deletedAt),
|
||||
))
|
||||
.returning()
|
||||
logger.withFields({ id }).log('Deleted system provider config')
|
||||
return result
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { Database } from '../libs/db'
|
||||
import type { NewStripeCheckoutSession, NewStripeCustomer, NewStripeInvoice, NewStripeSubscription } from '../schemas/stripe'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
import * as schema from '../schemas/stripe'
|
||||
|
||||
const logger = useLogger('stripe-service')
|
||||
|
||||
export function createStripeService(db: Database) {
|
||||
return {
|
||||
// ---- Customer ----
|
||||
@@ -19,12 +22,14 @@ export function createStripeService(db: Database) {
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(schema.stripeCustomer.stripeCustomerId, data.stripeCustomerId))
|
||||
.returning()
|
||||
logger.withFields({ userId: data.userId, stripeCustomerId: data.stripeCustomerId }).log('Updated Stripe customer')
|
||||
return updated
|
||||
}
|
||||
|
||||
const [created] = await db.insert(schema.stripeCustomer)
|
||||
.values(data)
|
||||
.returning()
|
||||
logger.withFields({ userId: data.userId, stripeCustomerId: data.stripeCustomerId }).log('Created Stripe customer')
|
||||
return created
|
||||
},
|
||||
|
||||
@@ -52,12 +57,14 @@ export function createStripeService(db: Database) {
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(schema.stripeCheckoutSession.stripeSessionId, data.stripeSessionId))
|
||||
.returning()
|
||||
logger.withFields({ userId: data.userId, sessionId: data.stripeSessionId, status: data.status }).log('Updated checkout session')
|
||||
return updated
|
||||
}
|
||||
|
||||
const [created] = await db.insert(schema.stripeCheckoutSession)
|
||||
.values(data)
|
||||
.returning()
|
||||
logger.withFields({ userId: data.userId, sessionId: data.stripeSessionId, status: data.status }).log('Created checkout session')
|
||||
return created
|
||||
},
|
||||
|
||||
@@ -80,12 +87,14 @@ export function createStripeService(db: Database) {
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(schema.stripeSubscription.stripeSubscriptionId, data.stripeSubscriptionId))
|
||||
.returning()
|
||||
logger.withFields({ userId: data.userId, subscriptionId: data.stripeSubscriptionId, status: data.status }).log('Updated subscription')
|
||||
return updated
|
||||
}
|
||||
|
||||
const [created] = await db.insert(schema.stripeSubscription)
|
||||
.values(data)
|
||||
.returning()
|
||||
logger.withFields({ userId: data.userId, subscriptionId: data.stripeSubscriptionId, status: data.status }).log('Created subscription')
|
||||
return created
|
||||
},
|
||||
|
||||
@@ -108,12 +117,14 @@ export function createStripeService(db: Database) {
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(schema.stripeInvoice.stripeInvoiceId, data.stripeInvoiceId))
|
||||
.returning()
|
||||
logger.withFields({ userId: data.userId, invoiceId: data.stripeInvoiceId, status: data.status }).log('Updated invoice')
|
||||
return updated
|
||||
}
|
||||
|
||||
const [created] = await db.insert(schema.stripeInvoice)
|
||||
.values(data)
|
||||
.returning()
|
||||
logger.withFields({ userId: data.userId, invoiceId: data.stripeInvoiceId, status: data.status }).log('Created invoice')
|
||||
return created
|
||||
},
|
||||
|
||||
|
||||
@@ -20,12 +20,8 @@ dialogs:
|
||||
baseUrlHelp: API endpoint URL (use default if unsure)
|
||||
accountId: Account ID
|
||||
validationSuccess: Configuration validation success
|
||||
validationPartial: Configuration partially validated
|
||||
validationFailed: Configuration validation failed
|
||||
validationError: 'Validation error: {error}'
|
||||
testGeneration: Test Generation
|
||||
testGenerationRunning: Testing...
|
||||
testGenerationFailed: Generation test failed
|
||||
skipForNow: Skip for now
|
||||
saveAndContinue: Save and Continue
|
||||
next: Next
|
||||
@@ -56,7 +52,9 @@ dialogs:
|
||||
stateNotGranted: Not granted
|
||||
language:
|
||||
title: Language
|
||||
description: UI language
|
||||
description: >
|
||||
Change the language of the AIRI interface. This will not affect the language
|
||||
of the character's responses.
|
||||
controls-island:
|
||||
icon-size:
|
||||
title: Controls Island Icon Size
|
||||
@@ -64,19 +62,6 @@ controls-island:
|
||||
auto: Auto (responsive)
|
||||
large: Large (default)
|
||||
small: Small
|
||||
analytics:
|
||||
notice:
|
||||
title: Usage analytics
|
||||
description: AIRI collects anonymous usage analytics to help us understand how the app is used and improve stability. No personal data is collected.
|
||||
privacyPrefix: Read the
|
||||
privacyLink: privacy policy
|
||||
onboardingHint: You can turn analytics off later in Settings > System > General.
|
||||
settingsHint: You can turn analytics off at any time.
|
||||
toggle:
|
||||
title: Enable usage analytics
|
||||
description: Turn this off to opt out of analytics collection.
|
||||
disabled:
|
||||
title: Analytics disabled for this development build
|
||||
live2d:
|
||||
change-model:
|
||||
from-file: Load from File
|
||||
@@ -242,10 +227,6 @@ pages:
|
||||
title: Delete all data
|
||||
description: Wipe every local setting, provider config, and model.
|
||||
delete: Delete all data
|
||||
desktop-folder:
|
||||
title: Open app data folder
|
||||
description: Open AIRI's data folder in your file manager.
|
||||
open: Open folder
|
||||
desktop:
|
||||
title: Reset desktop settings & states
|
||||
description: Clear AIRI desktop settings and runtime state.
|
||||
@@ -492,6 +473,19 @@ pages:
|
||||
success: Payment successful! Your Flux has been topped up.
|
||||
canceled: Payment was canceled.
|
||||
error: Something went wrong. Please try again later.
|
||||
audit:
|
||||
title: Transaction History
|
||||
time: Time
|
||||
type: Type
|
||||
detail: Detail
|
||||
amount: Amount
|
||||
typeAddition: Top-up
|
||||
typeConsumption: Usage
|
||||
typeInitial: Initial Grant
|
||||
loading: Loading history...
|
||||
empty: No transaction records yet.
|
||||
loadMore: Load More
|
||||
delayHint: Usage records may be delayed by up to 1 minute
|
||||
packages:
|
||||
title: Flux Packages
|
||||
buy: Charge
|
||||
@@ -800,9 +794,6 @@ pages:
|
||||
openai:
|
||||
description: openai.com
|
||||
title: OpenAI
|
||||
azure-openai:
|
||||
description: Azure OpenAI API
|
||||
title: Azure OpenAI
|
||||
openai-compatible:
|
||||
description: OpenAI Compatible
|
||||
title: OpenAI Compatible
|
||||
@@ -815,9 +806,6 @@ pages:
|
||||
openrouter:
|
||||
description: openrouter.ai
|
||||
title: OpenRouter
|
||||
openrouter-audio-speech:
|
||||
description: openrouter.ai
|
||||
title: OpenRouter
|
||||
perplexity:
|
||||
description: perplexity.ai
|
||||
title: Perplexity
|
||||
@@ -853,9 +841,6 @@ pages:
|
||||
xai:
|
||||
description: x.ai
|
||||
title: xAI
|
||||
zai:
|
||||
description: z.ai
|
||||
title: Z.ai
|
||||
302-ai:
|
||||
description: 302.AI
|
||||
title: 302.AI
|
||||
|
||||
@@ -19,12 +19,8 @@ dialogs:
|
||||
baseUrlHelp: API 端点 URL(如果不确定请使用默认值)
|
||||
accountId: 账户 ID
|
||||
validationSuccess: 配置验证成功
|
||||
validationPartial: Configuration partially validated
|
||||
validationFailed: 配置验证失败
|
||||
validationError: '验证错误:{error}'
|
||||
testGeneration: Test Generation
|
||||
testGenerationRunning: Testing...
|
||||
testGenerationFailed: Generation test failed
|
||||
skipForNow: 暂时跳过
|
||||
saveAndContinue: 保存并继续
|
||||
next: 下一步
|
||||
@@ -54,7 +50,8 @@ dialogs:
|
||||
stateNotGranted: Not granted
|
||||
language:
|
||||
title: 语言
|
||||
description: UI language
|
||||
description: >
|
||||
切换显示界面的语言
|
||||
controls-island:
|
||||
icon-size:
|
||||
title: 控制岛图标大小
|
||||
@@ -62,19 +59,6 @@ controls-island:
|
||||
auto: 自动(响应式)
|
||||
large: 大(默认)
|
||||
small: 小
|
||||
analytics:
|
||||
notice:
|
||||
title: Usage analytics
|
||||
description: AIRI collects anonymous usage analytics to help us understand how the app is used and improve stability. No personal data is collected.
|
||||
privacyPrefix: Read the
|
||||
privacyLink: privacy policy
|
||||
onboardingHint: You can turn analytics off later in Settings > System > General.
|
||||
settingsHint: You can turn analytics off at any time.
|
||||
toggle:
|
||||
title: Enable usage analytics
|
||||
description: Turn this off to opt out of analytics collection.
|
||||
disabled:
|
||||
title: Analytics disabled for this development build
|
||||
live2d:
|
||||
change-model:
|
||||
from-file: 从文件加载
|
||||
@@ -193,7 +177,7 @@ pages:
|
||||
scenario: 场景
|
||||
search: 搜索角色卡...
|
||||
sort_by: 排序方式
|
||||
body-model: Body / Display Model
|
||||
body-model: 形体 / 显示模型
|
||||
speech:
|
||||
provider: 语音 / 提供商
|
||||
model: 声音 / 模型
|
||||
@@ -235,10 +219,6 @@ pages:
|
||||
title: 删除所有数据
|
||||
description: 清除每个本地设置、提供商配置和模型。
|
||||
delete: 删除所有数据
|
||||
desktop-folder:
|
||||
title: Open app data folder
|
||||
description: Open AIRI's data folder in your file manager.
|
||||
open: Open folder
|
||||
desktop:
|
||||
title: 重置桌面设置和状态
|
||||
description: 清除 AIRI 桌面设置和运行状态。
|
||||
@@ -406,7 +386,7 @@ pages:
|
||||
stop:
|
||||
label: 停止
|
||||
select-voice:
|
||||
loading: 正在加载模型……
|
||||
loading: 正在加载模型...
|
||||
required: 请选择声线
|
||||
provider-voice-selection:
|
||||
custom_model_placeholder: 输入指定模型名称...
|
||||
@@ -478,6 +458,19 @@ pages:
|
||||
success: 支付成功!Flux 已充值。
|
||||
canceled: 支付已取消。
|
||||
error: 出了点问题,请稍后再试。
|
||||
audit:
|
||||
title: 交易记录
|
||||
time: 时间
|
||||
type: 类型
|
||||
detail: 详情
|
||||
amount: 数量
|
||||
typeAddition: 充值
|
||||
typeConsumption: 消耗
|
||||
typeInitial: 初始赠送
|
||||
loading: 加载中...
|
||||
empty: 暂无交易记录
|
||||
loadMore: 加载更多
|
||||
delayHint: 消耗记录可能有最多 1 分钟的延迟
|
||||
packages:
|
||||
amount_500:
|
||||
label: 500 Flux
|
||||
@@ -782,9 +775,6 @@ pages:
|
||||
openrouter:
|
||||
description: OpenRouter.ai
|
||||
title: OpenRouter
|
||||
openrouter-audio-speech:
|
||||
description: openrouter.ai
|
||||
title: OpenRouter
|
||||
perplexity:
|
||||
description: perplexity.ai
|
||||
title: Perplexity
|
||||
@@ -820,9 +810,6 @@ pages:
|
||||
xai:
|
||||
description: X.AI
|
||||
title: xAI
|
||||
zai:
|
||||
description: z.ai
|
||||
title: Z.ai
|
||||
302-ai:
|
||||
description: 302.AI
|
||||
title: 302.AI
|
||||
|
||||
@@ -17,6 +17,52 @@ const loadingAmount = ref<number | null>(null)
|
||||
const message = ref<{ type: 'success' | 'error', text: string } | null>(null)
|
||||
const packages = ref<{ amount: number, label: string, price: string }[]>([])
|
||||
|
||||
interface AuditRecord {
|
||||
id: string
|
||||
type: 'consumption' | 'addition' | 'initial'
|
||||
amount: number
|
||||
description: string
|
||||
metadata: { promptTokens?: number, completionTokens?: number } | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const auditRecords = ref<AuditRecord[]>([])
|
||||
const auditLoading = ref(false)
|
||||
const auditHasMore = ref(false)
|
||||
const auditOffset = ref(0)
|
||||
const AUDIT_PAGE_SIZE = 20
|
||||
|
||||
async function fetchAuditHistory(loadMore = false) {
|
||||
auditLoading.value = true
|
||||
try {
|
||||
const offset = loadMore ? auditOffset.value : 0
|
||||
const res = await client.api.flux.history.$get({
|
||||
query: { limit: String(AUDIT_PAGE_SIZE), offset: String(offset) },
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { records: AuditRecord[], hasMore: boolean }
|
||||
if (loadMore) {
|
||||
auditRecords.value.push(...data.records)
|
||||
}
|
||||
else {
|
||||
auditRecords.value = data.records
|
||||
}
|
||||
auditHasMore.value = data.hasMore
|
||||
auditOffset.value = offset + data.records.length
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// silently fail
|
||||
}
|
||||
finally {
|
||||
auditLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
|
||||
async function fetchPackages() {
|
||||
try {
|
||||
const res = await client.api.stripe.packages.$get()
|
||||
@@ -29,7 +75,7 @@ async function fetchPackages() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
Promise.allSettled([fetchPackages(), authStore.updateCredits()])
|
||||
Promise.allSettled([fetchPackages(), authStore.updateCredits(), fetchAuditHistory()])
|
||||
|
||||
if (route.query.success === 'true') {
|
||||
message.value = { type: 'success', text: t('settings.pages.flux.checkout.success') }
|
||||
@@ -107,6 +153,134 @@ async function handleBuy(amount: number) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit History -->
|
||||
<div flex="~ col gap-3">
|
||||
<div flex="~ col sm:flex-row sm:items-baseline gap-1 sm:gap-2">
|
||||
<h3 text-lg font-semibold>
|
||||
{{ t('settings.pages.flux.audit.title') }}
|
||||
</h3>
|
||||
<span text="xs neutral-400">
|
||||
{{ t('settings.pages.flux.audit.delayHint') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="auditLoading && auditRecords.length === 0" text="sm neutral-500" py-4 text-center>
|
||||
{{ t('settings.pages.flux.audit.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="auditRecords.length === 0" text="sm neutral-500" py-4 text-center>
|
||||
{{ t('settings.pages.flux.audit.empty') }}
|
||||
</div>
|
||||
|
||||
<!-- Desktop: table -->
|
||||
<div v-else border="1 neutral-200 dark:neutral-800" overflow-x-auto rounded-xl hidden sm:block>
|
||||
<table w-full text-sm>
|
||||
<thead border="b neutral-200 dark:neutral-800">
|
||||
<tr>
|
||||
<th px-4 py-3 text-left font-medium>
|
||||
{{ t('settings.pages.flux.audit.time') }}
|
||||
</th>
|
||||
<th px-4 py-3 text-left font-medium>
|
||||
{{ t('settings.pages.flux.audit.type') }}
|
||||
</th>
|
||||
<th px-4 py-3 text-left font-medium>
|
||||
{{ t('settings.pages.flux.audit.detail') }}
|
||||
</th>
|
||||
<th px-4 py-3 text-right font-medium>
|
||||
{{ t('settings.pages.flux.audit.amount') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="record in auditRecords"
|
||||
:key="record.id"
|
||||
border="b neutral-100 dark:neutral-800/50 last:none"
|
||||
>
|
||||
<td whitespace-nowrap px-4 py-3 text="neutral-500">
|
||||
{{ formatDate(record.createdAt) }}
|
||||
</td>
|
||||
<td px-4 py-3>
|
||||
<span
|
||||
inline-block rounded-full px-2 py-0.5 text-xs font-medium
|
||||
:class="record.type === 'consumption'
|
||||
? 'bg-orange-500/10 text-orange-600 dark:text-orange-400'
|
||||
: 'bg-green-500/10 text-green-600 dark:text-green-400'"
|
||||
>
|
||||
{{ record.type === 'consumption'
|
||||
? t('settings.pages.flux.audit.typeConsumption')
|
||||
: record.type === 'addition'
|
||||
? t('settings.pages.flux.audit.typeAddition')
|
||||
: t('settings.pages.flux.audit.typeInitial') }}
|
||||
</span>
|
||||
</td>
|
||||
<td px-4 py-3>
|
||||
<span>{{ record.description }}</span>
|
||||
<span
|
||||
v-if="record.metadata?.promptTokens != null"
|
||||
ml-1 text="xs neutral-400"
|
||||
>
|
||||
({{ record.metadata.promptTokens }}+{{ record.metadata.completionTokens }} tokens)
|
||||
</span>
|
||||
</td>
|
||||
<td px-4 py-3 text-right font-mono>
|
||||
<span :class="record.amount >= 0 ? 'text-green-600 dark:text-green-400' : 'text-orange-600 dark:text-orange-400'">
|
||||
{{ record.amount >= 0 ? `+${record.amount}` : record.amount }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: card list -->
|
||||
<div v-if="auditRecords.length > 0" flex="~ col gap-2" sm:hidden>
|
||||
<div
|
||||
v-for="record in auditRecords"
|
||||
:key="record.id"
|
||||
border="1 neutral-200 dark:neutral-800" flex="~ col gap-1.5" rounded-lg px-3 py-2.5
|
||||
>
|
||||
<div flex="~ items-center justify-between">
|
||||
<span
|
||||
inline-block rounded-full px-2 py-0.5 text-xs font-medium
|
||||
:class="record.type === 'consumption'
|
||||
? 'bg-orange-500/10 text-orange-600 dark:text-orange-400'
|
||||
: 'bg-green-500/10 text-green-600 dark:text-green-400'"
|
||||
>
|
||||
{{ record.type === 'consumption'
|
||||
? t('settings.pages.flux.audit.typeConsumption')
|
||||
: record.type === 'addition'
|
||||
? t('settings.pages.flux.audit.typeAddition')
|
||||
: t('settings.pages.flux.audit.typeInitial') }}
|
||||
</span>
|
||||
<span text-sm font-semibold font-mono :class="record.amount >= 0 ? 'text-green-600 dark:text-green-400' : 'text-orange-600 dark:text-orange-400'">
|
||||
{{ record.amount >= 0 ? `+${record.amount}` : record.amount }}
|
||||
</span>
|
||||
</div>
|
||||
<div text="sm neutral-600 dark:neutral-300" truncate>
|
||||
{{ record.description }}
|
||||
<span
|
||||
v-if="record.metadata?.promptTokens != null"
|
||||
ml-1 text="xs neutral-400"
|
||||
>
|
||||
({{ record.metadata.promptTokens }}+{{ record.metadata.completionTokens }} tokens)
|
||||
</span>
|
||||
</div>
|
||||
<div text="xs neutral-400">
|
||||
{{ formatDate(record.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="auditHasMore" text-center>
|
||||
<Button
|
||||
:label="t('settings.pages.flux.audit.loadMore')"
|
||||
:loading="auditLoading"
|
||||
@click="fetchAuditHistory(true)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ function handleLocalSetup() {
|
||||
:enter="{ opacity: 1, y: 0 }"
|
||||
:duration="500"
|
||||
:delay="150"
|
||||
:class="['mx-auto', 'mt-6', 'w-full', 'max-w-sm', 'rounded-2xl', 'bg-neutral-100/80', 'backdrop-blur-sm', 'dark:bg-neutral-800/80', 'p-4']"
|
||||
:class="['mx-auto', 'mt-6', 'w-full', 'max-w-sm', 'rounded-2xl', 'bg-neutral-100/80', 'p-4', 'backdrop-blur-sm', 'dark:bg-neutral-800/80']"
|
||||
>
|
||||
<FieldCombobox
|
||||
v-model="language"
|
||||
|
||||
@@ -70,4 +70,10 @@ export function useAuthProviderSync() {
|
||||
console.error('error loading models for official providers', err)
|
||||
}
|
||||
})
|
||||
|
||||
authStore.onLogout(() => {
|
||||
for (const { id } of AUTH_ACTIVATED_PROVIDERS) {
|
||||
providersStore.setProviderUnconfigured(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1946,6 +1946,14 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
markProviderAdded(providerId)
|
||||
}
|
||||
|
||||
function setProviderUnconfigured(providerId: string) {
|
||||
if (providerRuntimeState.value[providerId]) {
|
||||
providerRuntimeState.value[providerId].isConfigured = false
|
||||
providerRuntimeState.value[providerId].validatedCredentialHash = undefined
|
||||
}
|
||||
unmarkProviderAdded(providerId)
|
||||
}
|
||||
|
||||
async function resetProviderSettings() {
|
||||
providerCredentials.value = {}
|
||||
addedProviders.value = {}
|
||||
@@ -2237,6 +2245,7 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
disposeProviderInstance,
|
||||
resetProviderSettings,
|
||||
forceProviderConfigured,
|
||||
setProviderUnconfigured,
|
||||
availableProvidersMetadata,
|
||||
allChatProvidersMetadata,
|
||||
allAudioSpeechProvidersMetadata,
|
||||
|
||||
Reference in New Issue
Block a user