feat: complete TASK-WP2-04 credit ledger

This commit is contained in:
suyx
2026-08-02 23:27:10 +08:00
parent 9b4860cfc2
commit 4551d73e76
24 changed files with 3002 additions and 6 deletions
+237
View File
@@ -10,6 +10,7 @@ import {
AccountProfileUpdateResponseSchema,
AccountSettingsResponseSchema,
AdminAuthenticatedUserSchema,
AdminCreditParamsSchema,
AdminLoginCompleteRequestSchema,
AdminLoginCompleteResponseSchema,
AdminLoginSendRequestSchema,
@@ -21,6 +22,16 @@ import {
CorrelationIdSchema,
AuthenticatedUserSchema,
CreditSummarySchema,
CreditAdjustmentHeadersSchema,
CreditAdjustmentRequestSchema,
CreditAdjustmentResponseSchema,
CreditBalanceResponseSchema,
CreditEntryStatusSchema,
CreditEntryTypeSchema,
CreditLedgerEntrySchema,
CreditLedgerQuerySchema,
CreditLedgerResponseSchema,
CreditReferenceTypeSchema,
CsrfHeadersSchema,
ErrorDetailsSchema,
ErrorEnvelopeSchema,
@@ -70,6 +81,10 @@ import {
type AdminLoginSendRequest,
type AccountDeletionCompleteRequest,
type AccountProfileUpdateRequest,
type AdminCreditParams,
type CreditAdjustmentHeaders,
type CreditAdjustmentRequest,
type CreditLedgerQuery,
type LoginCompleteRequest,
type LoginSendRequest,
type FailedEmptyTrashRequest,
@@ -98,6 +113,8 @@ import {
type BrowserUnsupportedReason,
} from "./browser-support.js";
import { EventHub } from "./event-hub.js";
import { CreditError } from "./credit-errors.js";
import type { CreditService } from "./credits.js";
import type { PublicAssetResolver } from "./local-data-root.js";
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
import { ProjectError } from "./project-errors.js";
@@ -125,6 +142,7 @@ export interface CreateAppOptions {
browserGate?: boolean;
browserSupportRelease?: BrowserSupportRelease;
browserSupportSecret?: Buffer;
credits?: CreditService;
eventHub?: EventHub;
networkBoundary?: NetworkBoundaryOptions;
publicAssets?: PublicAssetResolver;
@@ -215,6 +233,21 @@ function projectFailure(reply: FastifyReply, correlationId: string, error: unkno
return reply.code(mapping[error.code]).send(null);
}
function creditFailure(reply: FastifyReply, correlationId: string, error: unknown) {
if (!(error instanceof CreditError)) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
}
if (error.code === "credit_operation_conflict") {
return reply.code(409).send(createErrorEnvelope({ code: "IDEMPOTENCY_KEY_CONFLICT", correlationId }));
}
const status = error.code === "credit_request_invalid"
? 400
: error.code === "credit_insufficient" || error.code === "credit_invariant_failed"
? 409
: 404;
return reply.code(status).send(null);
}
type ProjectSummaryView = ReturnType<ProjectService["listProjects"]>[number];
type ProjectDetailView = ReturnType<ProjectService["getProject"]>;
@@ -331,6 +364,17 @@ export async function createApp(options: CreateAppOptions = {}) {
AdminLoginCompleteResponseSchema,
AdminSessionResponseSchema,
CreditSummarySchema,
CreditEntryTypeSchema,
CreditEntryStatusSchema,
CreditReferenceTypeSchema,
CreditBalanceResponseSchema,
CreditLedgerEntrySchema,
CreditLedgerQuerySchema,
CreditLedgerResponseSchema,
AdminCreditParamsSchema,
CreditAdjustmentHeadersSchema,
CreditAdjustmentRequestSchema,
CreditAdjustmentResponseSchema,
CsrfHeadersSchema,
AccountSettingsResponseSchema,
AccountProfileUpdateRequestSchema,
@@ -952,6 +996,199 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
app.get(
"/api/v1/me/credits",
{
schema: {
operationId: "getMyCredits",
response: {
200: Type.Ref(CreditBalanceResponseSchema),
401: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Credits"],
},
},
async (request, reply) => {
if (!options.registration || !options.credits) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const session = token ? options.registration.readUserSession(token) : undefined;
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const account = options.credits.readAccount(session.userId);
return {
available_balance: account.availableBalance,
reserved_balance: account.reservedBalance,
updated_at: account.updatedAt,
};
} catch (error) {
return creditFailure(reply, request.id, error);
}
},
);
app.get(
"/api/v1/me/credit-ledger",
{
attachValidation: true,
schema: {
operationId: "getMyCreditLedger",
querystring: Type.Ref(CreditLedgerQuerySchema),
response: {
200: Type.Ref(CreditLedgerResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Credits"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(null);
if (!options.registration || !options.credits) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const session = token ? options.registration.readUserSession(token) : undefined;
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const query = request.query as CreditLedgerQuery;
const ledger = options.credits.listLedger({
...(query.cursor ? { cursor: query.cursor } : {}),
...(query.event_type ? { eventType: query.event_type } : {}),
...(query.from ? { from: query.from } : {}),
...(query.limit ? { limit: query.limit } : {}),
...(query.to ? { to: query.to } : {}),
userId: session.userId,
});
return {
credits: {
available_balance: ledger.account.availableBalance,
reserved_balance: ledger.account.reservedBalance,
},
entries: ledger.entries.map((entry) => ({
amount: entry.amount,
available_after: entry.availableAfter,
available_before: entry.availableBefore,
created_at: entry.createdAt,
entry_id: entry.entryId,
entry_type: entry.entryType,
model_id: entry.modelId,
reason: entry.reason,
reference_id: entry.referenceId,
reference_type: entry.referenceType,
reserved_after: entry.reservedAfter,
reserved_before: entry.reservedBefore,
status: entry.status,
})),
next_cursor: ledger.nextCursor,
updated_at: ledger.account.updatedAt,
};
} catch (error) {
return creditFailure(reply, request.id, error);
}
},
);
app.get(
"/api/v1/admin/users/:userId/credits",
{
attachValidation: true,
schema: {
operationId: "getAdminUserCredits",
params: Type.Ref(AdminCreditParamsSchema),
response: {
200: Type.Ref(CreditBalanceResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Admin Credits"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(null);
if (!options.registration || !options.credits) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
const session = token ? options.registration.readAdminSession(token) : undefined;
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const account = options.credits.readAccount((request.params as AdminCreditParams).userId);
return {
available_balance: account.availableBalance,
reserved_balance: account.reservedBalance,
updated_at: account.updatedAt,
};
} catch (error) {
return creditFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/admin/users/:userId/credit-adjustments",
{
attachValidation: true,
schema: {
body: Type.Ref(CreditAdjustmentRequestSchema),
headers: Type.Ref(CreditAdjustmentHeadersSchema),
operationId: "adjustAdminUserCredits",
params: Type.Ref(AdminCreditParamsSchema),
response: {
200: Type.Ref(CreditAdjustmentResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
409: Type.Union([Type.Ref(ErrorEnvelopeSchema), Type.Null()]),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Admin Credits"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(null);
if (!options.registration || !options.credits) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
const headers = request.headers as CreditAdjustmentHeaders;
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const admin = options.registration.authorizeAdminMutation({
csrfToken: headers["x-csrf-token"],
sessionToken: token,
});
const body = request.body as CreditAdjustmentRequest;
const adjusted = options.credits.adjustAvailable({
adjustmentId: body.adjustment_id,
adminId: admin.userId,
amount: body.amount,
idempotencyKey: headers["idempotency-key"],
reason: body.reason,
userId: (request.params as AdminCreditParams).userId,
});
return {
adjustment_id: adjusted.adjustmentId,
available_balance: adjusted.availableBalance,
reserved_balance: adjusted.reservedBalance,
status: adjusted.status,
};
} catch (error) {
return error instanceof RegistrationError
? registrationFailure(reply, request.id, error)
: creditFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/account/deletion/send",
{
+16
View File
@@ -0,0 +1,16 @@
export type CreditErrorCode =
| "credit_account_not_found"
| "credit_generation_not_found"
| "credit_insufficient"
| "credit_invariant_failed"
| "credit_operation_conflict"
| "credit_request_invalid";
export class CreditError extends Error {
readonly code: CreditErrorCode;
constructor(code: CreditErrorCode) {
super(code);
this.code = code;
}
}
+548
View File
@@ -0,0 +1,548 @@
import { createHash, randomUUID } from "node:crypto";
import { createRequire } from "node:module";
import type BetterSqlite3 from "better-sqlite3";
import {
auditRetentionMilliseconds,
ensureAdminOperationAuditSchema,
isSafeAuditRef,
isSafeAuditSummaryJson,
serializeAuditSummary,
} from "./audit-policy.js";
import { CreditError } from "./credit-errors.js";
export { CreditError } from "./credit-errors.js";
export type { CreditErrorCode } from "./credit-errors.js";
const require = createRequire(import.meta.url);
const Database = require("better-sqlite3") as typeof BetterSqlite3;
interface CreditAccountRow {
available_balance: number;
reserved_balance: number;
updated_at: number;
user_id: string;
}
interface LedgerRow {
amount: number;
available_after: number;
available_before: number;
created_at: number;
entry_status: "succeeded" | "frozen" | "committed" | "released";
entry_type: "registration_grant" | "generation_reserve" | "generation_commit" | "generation_release" | "admin_adjustment";
ledger_id: string;
model_id: string | null;
reason: string | null;
reference_id: string | null;
reference_type: "registration" | "generation" | "admin_adjustment" | null;
reserved_after: number;
reserved_before: number;
user_id: string;
}
interface ReservationRow {
amount: number;
generation_id: string;
model_id: string;
status: "reserved" | "committed" | "released";
user_id: string;
}
function iso(timestamp: number) {
return new Date(timestamp).toISOString();
}
function encodeCursor(row: Pick<LedgerRow, "created_at" | "ledger_id">) {
return Buffer.from(JSON.stringify([row.created_at, row.ledger_id]), "utf8").toString("base64url");
}
function decodeCursor(cursor: string | undefined) {
if (!cursor) return undefined;
try {
const value: unknown = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
if (!Array.isArray(value) || value.length !== 2 || !Number.isSafeInteger(value[0]) || typeof value[1] !== "string") {
throw new Error("cursor_invalid");
}
return { createdAt: value[0] as number, ledgerId: value[1] };
} catch {
throw new CreditError("credit_request_invalid");
}
}
export class CreditService {
readonly database: BetterSqlite3.Database;
private readonly clock: () => number;
constructor(input: { clock?: () => number; databasePath: string }) {
this.clock = input.clock ?? Date.now;
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined);
this.database.pragma("journal_mode = WAL");
this.database.pragma("foreign_keys = ON");
this.database.pragma("synchronous = FULL");
this.database.pragma("busy_timeout = 5000");
this.database.function("dada_audit_ref_is_safe", { deterministic: true }, isSafeAuditRef);
this.database.function("dada_audit_summary_is_safe", { deterministic: true }, isSafeAuditSummaryJson);
this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0);
this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0);
this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0);
this.migrate();
}
close() {
this.database.close();
}
readAccount(userId: string) {
const row = this.database.prepare("SELECT * FROM credit_accounts WHERE user_id = ?").get(userId) as CreditAccountRow | undefined;
if (!row) throw new CreditError("credit_account_not_found");
return {
availableBalance: row.available_balance,
reservedBalance: row.reserved_balance,
updatedAt: iso(row.updated_at),
};
}
listLedger(input: {
cursor?: string;
eventType?: LedgerRow["entry_type"];
from?: string;
limit?: number;
to?: string;
userId: string;
}) {
const account = this.readAccount(input.userId);
const limit = input.limit ?? 20;
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new CreditError("credit_request_invalid");
const cursor = decodeCursor(input.cursor);
const conditions = ["user_id = ?"];
const values: Array<number | string> = [input.userId];
if (input.eventType) {
conditions.push("entry_type = ?");
values.push(input.eventType);
}
if (input.from) {
const from = Date.parse(input.from);
if (!Number.isFinite(from)) throw new CreditError("credit_request_invalid");
conditions.push("created_at >= ?");
values.push(from);
}
if (input.to) {
const to = Date.parse(input.to);
if (!Number.isFinite(to)) throw new CreditError("credit_request_invalid");
conditions.push("created_at <= ?");
values.push(to);
}
if (cursor) {
conditions.push("(created_at < ? OR (created_at = ? AND ledger_id < ?))");
values.push(cursor.createdAt, cursor.createdAt, cursor.ledgerId);
}
const rows = this.database.prepare(`
SELECT * FROM credit_ledger WHERE ${conditions.join(" AND ")}
ORDER BY created_at DESC, ledger_id DESC LIMIT ?
`).all(...values, limit + 1) as LedgerRow[];
const hasMore = rows.length > limit;
const page = rows.slice(0, limit);
return {
account,
entries: page.map((row) => ({
amount: row.amount,
availableAfter: row.available_after,
availableBefore: row.available_before,
createdAt: iso(row.created_at),
entryId: row.ledger_id,
entryType: row.entry_type,
modelId: row.model_id,
reason: row.reason,
referenceId: row.reference_id,
referenceType: row.reference_type,
reservedAfter: row.reserved_after,
reservedBefore: row.reserved_before,
status: row.entry_status,
})),
nextCursor: hasMore && page.length > 0 ? encodeCursor(page.at(-1)!) : null,
};
}
reserveGeneration(input: {
creditCost: number;
generationId: string;
modelId: string;
operationKey: string;
userId: string;
}) {
if (!Number.isSafeInteger(input.creditCost) || input.creditCost <= 0 || !input.modelId || !input.operationKey) {
throw new CreditError("credit_request_invalid");
}
return this.immediate(() => {
const replay = this.database.prepare("SELECT * FROM credit_ledger WHERE operation_key = ?")
.get(input.operationKey) as LedgerRow | undefined;
if (replay) {
if (replay.user_id !== input.userId || replay.reference_id !== input.generationId
|| replay.entry_type !== "generation_reserve" || replay.amount !== -input.creditCost
|| replay.model_id !== input.modelId) {
throw new CreditError("credit_operation_conflict");
}
return { availableBalance: replay.available_after, reservedBalance: replay.reserved_after, status: "reserved" as const };
}
const account = this.database.prepare("SELECT * FROM credit_accounts WHERE user_id = ?").get(input.userId) as CreditAccountRow | undefined;
if (!account) throw new CreditError("credit_account_not_found");
if (account.available_balance < input.creditCost) throw new CreditError("credit_insufficient");
if (!this.tableExists("generation_jobs")) throw new CreditError("credit_generation_not_found");
const generation = this.database.prepare(`
SELECT generation_id FROM generation_jobs
WHERE generation_id = ? AND owner_id = ? AND status IN ('queued', 'running')
`).get(input.generationId, input.userId);
if (!generation) throw new CreditError("credit_generation_not_found");
const existing = this.database.prepare("SELECT * FROM credit_reservations WHERE generation_id = ?")
.get(input.generationId) as ReservationRow | undefined;
if (existing) throw new CreditError("credit_operation_conflict");
const availableAfter = account.available_balance - input.creditCost;
const reservedAfter = account.reserved_balance + input.creditCost;
const now = this.clock();
const changed = this.database.prepare(`
UPDATE credit_accounts SET available_balance = ?, reserved_balance = ?, updated_at = ?
WHERE user_id = ? AND available_balance = ? AND reserved_balance = ?
`).run(availableAfter, reservedAfter, now, input.userId, account.available_balance, account.reserved_balance);
if (changed.changes !== 1) throw new CreditError("credit_invariant_failed");
this.database.prepare(`
INSERT INTO credit_reservations (generation_id, user_id, model_id, amount, status, created_at, finalized_at)
VALUES (?, ?, ?, ?, 'reserved', ?, NULL)
`).run(input.generationId, input.userId, input.modelId, input.creditCost, now);
this.database.prepare(`
UPDATE generation_jobs SET model_id = ?, confirmed_credit_cost = ?, reserved_credits = ?, final_credit_state = NULL
WHERE generation_id = ?
`).run(input.modelId, input.creditCost, input.creditCost, input.generationId);
this.insertLedger({
amount: -input.creditCost,
availableAfter,
availableBefore: account.available_balance,
createdAt: now,
entryStatus: "frozen",
entryType: "generation_reserve",
modelId: input.modelId,
operationKey: input.operationKey,
reason: null,
referenceId: input.generationId,
referenceType: "generation",
reservedAfter,
reservedBefore: account.reserved_balance,
userId: input.userId,
});
this.insertOutbox("generation_credit_reserved", input.generationId, input.operationKey, { amount: input.creditCost, user_id: input.userId }, now);
return { availableBalance: availableAfter, reservedBalance: reservedAfter, status: "reserved" as const };
});
}
finalizeGeneration(input: {
generationId: string;
operationKey: string;
outcome: "succeeded" | "failed" | "rejected";
}) {
if (!input.operationKey) throw new CreditError("credit_request_invalid");
return this.immediate(() => {
const reservation = this.database.prepare("SELECT * FROM credit_reservations WHERE generation_id = ?")
.get(input.generationId) as ReservationRow | undefined;
if (!reservation) throw new CreditError("credit_generation_not_found");
if (reservation.status !== "reserved") {
const replay = this.database.prepare(`
SELECT * FROM credit_ledger WHERE reference_id = ? AND entry_type IN ('generation_commit', 'generation_release')
ORDER BY created_at DESC, ledger_id DESC LIMIT 1
`).get(input.generationId) as LedgerRow | undefined;
if (!replay) throw new CreditError("credit_invariant_failed");
return {
availableBalance: replay.available_after,
creditState: reservation.status,
reservedBalance: replay.reserved_after,
status: "finalized" as const,
};
}
const operation = this.database.prepare("SELECT * FROM credit_ledger WHERE operation_key = ?").get(input.operationKey) as LedgerRow | undefined;
if (operation) throw new CreditError("credit_operation_conflict");
const account = this.database.prepare("SELECT * FROM credit_accounts WHERE user_id = ?").get(reservation.user_id) as CreditAccountRow | undefined;
if (!account || account.reserved_balance < reservation.amount) throw new CreditError("credit_invariant_failed");
const committed = input.outcome === "succeeded";
const creditState = committed ? "committed" as const : "released" as const;
const availableAfter = committed ? account.available_balance : account.available_balance + reservation.amount;
const reservedAfter = account.reserved_balance - reservation.amount;
if (!Number.isSafeInteger(availableAfter)) throw new CreditError("credit_invariant_failed");
const now = this.clock();
this.database.prepare(`
UPDATE credit_accounts SET available_balance = ?, reserved_balance = ?, updated_at = ? WHERE user_id = ?
`).run(availableAfter, reservedAfter, now, reservation.user_id);
this.database.prepare(`
UPDATE credit_reservations SET status = ?, finalized_at = ? WHERE generation_id = ? AND status = 'reserved'
`).run(creditState, now, input.generationId);
this.database.prepare("UPDATE generation_jobs SET final_credit_state = ? WHERE generation_id = ?")
.run(creditState, input.generationId);
this.insertLedger({
amount: committed ? -reservation.amount : reservation.amount,
availableAfter,
availableBefore: account.available_balance,
createdAt: now,
entryStatus: committed ? "committed" : "released",
entryType: committed ? "generation_commit" : "generation_release",
modelId: reservation.model_id,
operationKey: input.operationKey,
reason: null,
referenceId: input.generationId,
referenceType: "generation",
reservedAfter,
reservedBefore: account.reserved_balance,
userId: reservation.user_id,
});
this.insertOutbox(committed ? "generation_credit_committed" : "generation_credit_released", input.generationId, input.operationKey, { outcome: input.outcome }, now);
return { availableBalance: availableAfter, creditState, reservedBalance: reservedAfter, status: "finalized" as const };
});
}
adjustAvailable(input: {
adjustmentId: string;
adminId: string;
amount: number;
idempotencyKey: string;
reason: string;
userId: string;
}) {
const reason = input.reason.trim();
if (!Number.isSafeInteger(input.amount) || input.amount === 0 || !reason || reason.length > 500
|| input.idempotencyKey.length < 32 || input.idempotencyKey.length > 200
|| !/^[A-Za-z0-9_-]+$/.test(input.idempotencyKey)) {
throw new CreditError("credit_request_invalid");
}
const idempotencyKeyDigest = createHash("sha256").update(input.idempotencyKey, "utf8").digest("hex");
const requestHash = createHash("sha256")
.update(JSON.stringify([input.adjustmentId, input.adminId, input.userId, input.amount, reason]), "utf8")
.digest("hex");
return this.immediate(() => {
type Receipt = {
adjustment_id: string;
admin_id: string | null;
available_after: number;
idempotency_key_digest: string | null;
request_hash: string;
reserved_after: number;
user_id: string | null;
};
const receiptByKey = this.database.prepare(`
SELECT * FROM credit_adjustment_receipts
WHERE admin_id = ? AND user_id = ? AND idempotency_key_digest = ?
`).get(input.adminId, input.userId, idempotencyKeyDigest) as Receipt | undefined;
if (receiptByKey) {
if (receiptByKey.request_hash !== requestHash) throw new CreditError("credit_operation_conflict");
return {
adjustmentId: receiptByKey.adjustment_id,
availableBalance: receiptByKey.available_after,
reservedBalance: receiptByKey.reserved_after,
status: "adjusted" as const,
};
}
const receipt = this.database.prepare("SELECT * FROM credit_adjustment_receipts WHERE adjustment_id = ?")
.get(input.adjustmentId) as Receipt | undefined;
if (receipt) {
if (receipt.request_hash !== requestHash || receipt.admin_id !== input.adminId || receipt.user_id !== input.userId
|| receipt.idempotency_key_digest !== idempotencyKeyDigest) {
throw new CreditError("credit_operation_conflict");
}
return {
adjustmentId: input.adjustmentId,
availableBalance: receipt.available_after,
reservedBalance: receipt.reserved_after,
status: "adjusted" as const,
};
}
const admin = this.database.prepare(`
SELECT u.user_id FROM users u JOIN admin_access a ON a.user_id = u.user_id
WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1
`).get(input.adminId);
if (!admin) throw new CreditError("credit_account_not_found");
const target = this.database.prepare(`
SELECT c.* FROM credit_accounts c JOIN users u ON u.user_id = c.user_id
WHERE c.user_id = ? AND u.role = 'user' AND u.status <> 'deleted'
`).get(input.userId) as CreditAccountRow | undefined;
if (!target) throw new CreditError("credit_account_not_found");
const availableAfter = target.available_balance + input.amount;
if (!Number.isSafeInteger(availableAfter)) throw new CreditError("credit_request_invalid");
const now = this.clock();
this.database.prepare("UPDATE credit_accounts SET available_balance = ?, updated_at = ? WHERE user_id = ?")
.run(availableAfter, now, input.userId);
const ledgerId = this.insertLedger({
amount: input.amount,
availableAfter,
availableBefore: target.available_balance,
createdAt: now,
entryStatus: "succeeded",
entryType: "admin_adjustment",
modelId: null,
operationKey: `admin_adjustment:${createHash("sha256").update(`${input.adminId}\0${input.userId}\0${idempotencyKeyDigest}`, "utf8").digest("hex")}`,
reason,
referenceId: input.adjustmentId,
referenceType: "admin_adjustment",
reservedAfter: target.reserved_balance,
reservedBefore: target.reserved_balance,
userId: input.userId,
});
this.database.prepare(`
INSERT INTO admin_operation_logs (
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
result, before_summary, after_summary, occurred_at, expires_at
) VALUES (?, 'super_admin', ?, 'credit_adjustment', 'user_credit_account', ?, 'succeeded', ?, ?, ?, ?)
`).run(
randomUUID(), input.adminId, input.userId,
serializeAuditSummary({ available_balance: target.available_balance, reserved_balance: target.reserved_balance }),
serializeAuditSummary({ adjustment_amount: input.amount, available_balance: availableAfter, reserved_balance: target.reserved_balance }),
now, now + auditRetentionMilliseconds,
);
this.database.prepare(`
INSERT INTO credit_adjustment_receipts (
adjustment_id, admin_id, user_id, idempotency_key_digest, request_hash,
ledger_id, available_after, reserved_after, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
input.adjustmentId, input.adminId, input.userId, idempotencyKeyDigest, requestHash,
ledgerId, availableAfter, target.reserved_balance, now,
);
return {
adjustmentId: input.adjustmentId,
availableBalance: availableAfter,
reservedBalance: target.reserved_balance,
status: "adjusted" as const,
};
});
}
private immediate<T>(action: () => T): T {
this.database.exec("BEGIN IMMEDIATE");
try {
const result = action();
this.database.exec("COMMIT");
return result;
} catch (error) {
if (this.database.inTransaction) this.database.exec("ROLLBACK");
throw error;
}
}
private insertLedger(input: {
amount: number;
availableAfter: number;
availableBefore: number;
createdAt: number;
entryStatus: LedgerRow["entry_status"];
entryType: LedgerRow["entry_type"];
modelId: string | null;
operationKey: string;
reason: string | null;
referenceId: string | null;
referenceType: LedgerRow["reference_type"];
reservedAfter: number;
reservedBefore: number;
userId: string;
}) {
const ledgerId = randomUUID();
this.database.prepare(`
INSERT INTO credit_ledger (
ledger_id, user_id, operation_key, entry_type, amount,
available_before, available_after, reserved_before, reserved_after, created_at,
reference_type, reference_id, model_id, reason, entry_status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
ledgerId, input.userId, input.operationKey, input.entryType, input.amount,
input.availableBefore, input.availableAfter, input.reservedBefore, input.reservedAfter, input.createdAt,
input.referenceType, input.referenceId, input.modelId, input.reason, input.entryStatus,
);
return ledgerId;
}
private insertOutbox(topic: string, aggregateId: string, operationKey: string, payload: Record<string, unknown>, now: number) {
this.database.prepare(`
INSERT INTO outbox_events (
event_id, operation_key, topic, aggregate_type, aggregate_id, payload_json, status, created_at, published_at
) VALUES (?, ?, ?, 'generation', ?, ?, 'pending', ?, NULL)
`).run(randomUUID(), operationKey, topic, aggregateId, JSON.stringify(payload), now);
}
private tableExists(name: string) {
return Boolean(this.database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
}
private ensureColumn(table: string, column: string, definition: string) {
const columns = this.database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
if (!columns.some((value) => value.name === column)) this.database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
private migrate() {
if (!this.tableExists("credit_accounts") || !this.tableExists("credit_ledger")) {
throw new Error("credit_schema_unavailable");
}
this.ensureColumn("credit_ledger", "reference_type", "TEXT");
this.ensureColumn("credit_ledger", "reference_id", "TEXT");
this.ensureColumn("credit_ledger", "model_id", "TEXT");
this.ensureColumn("credit_ledger", "reason", "TEXT");
this.ensureColumn("credit_ledger", "entry_status", "TEXT NOT NULL DEFAULT 'succeeded'");
if (this.tableExists("generation_jobs")) {
this.ensureColumn("generation_jobs", "model_id", "TEXT");
this.ensureColumn("generation_jobs", "model_config_version", "INTEGER");
this.ensureColumn("generation_jobs", "confirmed_credit_cost", "INTEGER");
this.ensureColumn("generation_jobs", "reserved_credits", "INTEGER NOT NULL DEFAULT 0");
this.ensureColumn("generation_jobs", "final_credit_state", "TEXT");
this.ensureColumn("generation_jobs", "finished_at", "INTEGER");
}
this.database.exec(`
CREATE TABLE IF NOT EXISTS credit_reservations (
generation_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(user_id),
model_id TEXT NOT NULL,
amount INTEGER NOT NULL CHECK (amount > 0),
status TEXT NOT NULL CHECK (status IN ('reserved', 'committed', 'released')),
created_at INTEGER NOT NULL,
finalized_at INTEGER
);
CREATE INDEX IF NOT EXISTS credit_reservations_user_status ON credit_reservations(user_id, status, created_at);
CREATE TABLE IF NOT EXISTS credit_adjustment_receipts (
adjustment_id TEXT PRIMARY KEY,
admin_id TEXT REFERENCES users(user_id),
user_id TEXT REFERENCES users(user_id),
idempotency_key_digest TEXT CHECK (idempotency_key_digest IS NULL OR length(idempotency_key_digest) = 64),
request_hash TEXT NOT NULL CHECK (length(request_hash) = 64),
ledger_id TEXT NOT NULL UNIQUE REFERENCES credit_ledger(ledger_id),
available_after INTEGER NOT NULL,
reserved_after INTEGER NOT NULL CHECK (reserved_after >= 0),
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS outbox_events (
event_id TEXT PRIMARY KEY,
operation_key TEXT NOT NULL UNIQUE,
topic TEXT NOT NULL,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
payload_json TEXT NOT NULL CHECK (json_valid(payload_json)),
status TEXT NOT NULL CHECK (status IN ('pending', 'published')),
created_at INTEGER NOT NULL,
published_at INTEGER
);
DROP TRIGGER IF EXISTS credit_ledger_shape_guard;
CREATE TRIGGER credit_ledger_shape_guard BEFORE INSERT ON credit_ledger
WHEN
NEW.entry_status NOT IN ('succeeded', 'frozen', 'committed', 'released')
OR (NEW.entry_type = 'admin_adjustment' AND (
NEW.reference_type <> 'admin_adjustment' OR NEW.reference_id IS NULL OR trim(COALESCE(NEW.reason, '')) = ''
))
OR (NEW.entry_type IN ('generation_reserve', 'generation_commit', 'generation_release') AND (
NEW.reference_type <> 'generation' OR NEW.reference_id IS NULL OR NEW.model_id IS NULL
))
BEGIN SELECT RAISE(ABORT, 'credit_ledger_shape_invalid'); END;
`);
this.ensureColumn("credit_adjustment_receipts", "admin_id", "TEXT REFERENCES users(user_id)");
this.ensureColumn("credit_adjustment_receipts", "user_id", "TEXT REFERENCES users(user_id)");
this.ensureColumn("credit_adjustment_receipts", "idempotency_key_digest", "TEXT");
this.database.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS credit_adjustment_idempotency
ON credit_adjustment_receipts(admin_id, user_id, idempotency_key_digest)
WHERE admin_id IS NOT NULL AND user_id IS NOT NULL AND idempotency_key_digest IS NOT NULL;
`);
ensureAdminOperationAuditSchema(this.database, this.clock());
}
}
+7
View File
@@ -7,6 +7,7 @@ import { createApp } from "./app.js";
import { readBrowserSupportRelease } from "./browser-support.js";
import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.js";
import { ManagedStorage } from "./managed-storage.js";
import { CreditService } from "./credits.js";
import { ProjectService } from "./projects.js";
import { RegistrationService } from "./registration.js";
import { MockResendAdapter } from "./resend-adapter.js";
@@ -17,6 +18,7 @@ import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiC
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
let registration: RegistrationService | undefined;
let projects: ProjectService | undefined;
let credits: CreditService | undefined;
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
if (credentialChannelEnabled) {
const clients = initializeApiCredentialClients(await receiveApiCredentials());
@@ -36,8 +38,11 @@ if (credentialChannelEnabled) {
sessionPepper: derivePepper("session-pepper"),
});
projects = new ProjectService({ databasePath });
credits = new CreditService({ databasePath });
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
} catch (error) {
credits?.close();
credits = undefined;
projects?.close();
projects = undefined;
registration?.close();
@@ -51,6 +56,7 @@ if (credentialChannelEnabled) {
const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"));
const app = await createApp({
...(browserSupportRelease ? { browserSupportRelease } : {}),
...(credits ? { credits } : {}),
...(projects ? { projects } : {}),
...(registration ? { registration } : {}),
});
@@ -67,6 +73,7 @@ if (controlPipeIndex >= 0) {
let storage: ManagedStorage | undefined;
const control = attachApiSupervisorControl(controlPipe, async () => {
await app.close();
credits?.close();
projects?.close();
registration?.close();
storage?.close();
+6
View File
@@ -783,6 +783,12 @@ export class ProjectService {
prompt TEXT NOT NULL CHECK (length(prompt) BETWEEN 1 AND 4000),
ratio TEXT NOT NULL CHECK (ratio IN ('3:4', '1:1', '4:3', '9:16')),
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'rejected')),
model_id TEXT,
model_config_version INTEGER,
confirmed_credit_cost INTEGER CHECK (confirmed_credit_cost IS NULL OR confirmed_credit_cost > 0),
reserved_credits INTEGER NOT NULL DEFAULT 0 CHECK (reserved_credits >= 0),
final_credit_state TEXT CHECK (final_credit_state IS NULL OR final_credit_state IN ('committed', 'released')),
finished_at INTEGER,
error_category TEXT CHECK (error_category IS NULL OR error_category IN (
'upstream_timeout', 'upstream_failed', 'safety_rejected', 'model_disabled',
'gateway_balance_insufficient', 'gateway_contract_invalid', 'reference_invalid',
+25
View File
@@ -990,6 +990,11 @@ export class RegistrationService {
return { userId: session.user_id };
}
authorizeAdminMutation(input: { csrfToken: string; sessionToken: string }) {
const session = this.authenticatedAdminMutationSession(input.sessionToken, input.csrfToken, this.options.clock());
return { userId: session.user_id };
}
logoutUser(input: { csrfToken: string; sessionToken: string }) {
const now = this.options.clock();
this.runImmediate("session_revoke", () => {
@@ -1531,6 +1536,26 @@ export class RegistrationService {
return session;
}
private authenticatedAdminMutationSession(sessionToken: string, csrfToken: string, now: number) {
const session = this.database.prepare(`
SELECT s.session_id, s.user_id, s.csrf_token_digest
FROM sessions s
JOIN users u ON u.user_id = s.user_id
JOIN admin_access a ON a.user_id = u.user_id
WHERE s.token_digest = ? AND s.audience = 'admin' AND s.revoked_at IS NULL
AND s.expires_at > ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1
`).get(digest(sessionToken), now) as {
csrf_token_digest: string | null;
session_id: string;
user_id: string;
} | undefined;
if (!session) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid");
if (!session.csrf_token_digest || !constantTimeTextEqual(session.csrf_token_digest, digest(csrfToken))) {
throw new RegistrationError("AUTH_CSRF_INVALID", "csrf_invalid");
}
return session;
}
private queueOwnedManagedFiles(userId: string, now: number) {
const table = this.database.prepare(`
SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'managed_files'
+250
View File
@@ -0,0 +1,250 @@
.admin-users-page {
min-height: 100vh;
color: #111111;
background: #f6f6f4;
}
.admin-product-header {
display: flex;
min-height: 64px;
align-items: stretch;
justify-content: space-between;
padding: 0 max(3vw, 32px);
border-bottom: 1px solid #8c8c86;
background: #111111;
}
.admin-product-header > a,
.admin-product-header nav a {
display: flex;
align-items: center;
color: #ffffff;
font-weight: 900;
text-decoration: none;
}
.admin-product-header nav {
display: flex;
}
.admin-product-header nav a {
min-width: 84px;
justify-content: center;
}
.admin-product-header nav a[aria-current="page"] {
color: #111111;
background: #f2f500;
}
.admin-users-page > main {
width: min(1180px, calc(100% - 64px));
margin: 0 auto;
padding: 42px 0 80px;
}
.admin-users-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 24px;
padding-bottom: 22px;
border-bottom: 1px solid #999993;
}
.admin-users-heading p {
margin: 0 0 4px;
font-family: Consolas, monospace;
font-size: 11px;
font-weight: 700;
}
.admin-users-heading h1 {
margin: 0;
font-size: 36px;
}
.admin-users-heading button,
.adjustment-actions button {
min-height: 44px;
padding: 9px 16px;
border: 1px solid #111111;
border-radius: 0;
background: #f2f500;
font-weight: 800;
}
.admin-credit-summary {
display: grid;
grid-template-columns: 1.6fr 1fr 1fr;
margin-top: 28px;
border-block: 1px solid #85857f;
background: #ffffff;
}
.admin-credit-summary > div {
display: grid;
min-width: 0;
min-height: 140px;
align-content: center;
gap: 8px;
padding: 22px;
border-right: 1px solid #b3b3ad;
}
.admin-credit-summary > div:last-child {
border-right: 0;
}
.admin-credit-summary span {
color: #65655f;
font-size: 12px;
}
.admin-credit-summary strong {
font-family: Arial Black, "Segoe UI", sans-serif;
font-size: 38px;
}
.admin-credit-summary code {
overflow-wrap: anywhere;
}
.admin-users-empty,
.admin-users-error,
.admin-users-notice {
padding: 18px;
border-left: 5px solid #85857f;
background: #ffffff;
}
.admin-users-error {
border-color: #d14a3b;
background: #fff1ef;
}
.admin-users-notice {
border-color: #287b45;
background: #edf8f0;
font-weight: 700;
}
.credit-adjustment-overlay {
position: fixed;
z-index: 50;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
background: rgb(17 17 17 / 62%);
}
.credit-adjustment-dialog {
width: min(560px, 100%);
padding: 28px;
border: 2px solid #111111;
background: #ffffff;
box-shadow: 10px 10px 0 #f2f500;
}
.credit-adjustment-dialog > p {
margin: 0 0 6px;
font-family: Consolas, monospace;
font-size: 11px;
font-weight: 700;
}
.credit-adjustment-dialog h2 {
margin: 0 0 18px;
}
.adjustment-direction {
display: grid;
grid-template-columns: 1fr 1fr;
margin-bottom: 16px;
}
.adjustment-direction label {
display: flex;
min-height: 44px;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid #85857f;
font-weight: 800;
}
.adjustment-direction label + label {
border-left: 0;
}
.credit-adjustment-dialog form {
display: grid;
gap: 8px;
}
.credit-adjustment-dialog form > label {
margin-top: 6px;
font-weight: 800;
}
.credit-adjustment-dialog input[type="number"],
.credit-adjustment-dialog textarea {
min-height: 44px;
padding: 10px;
border: 1px solid #85857f;
border-radius: 0;
}
.credit-adjustment-dialog textarea {
min-height: 92px;
resize: vertical;
}
.adjustment-preview {
display: grid;
gap: 4px;
margin-top: 10px;
padding: 14px;
border-left: 5px solid #111111;
background: #e7e7e2;
}
.adjustment-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 12px;
}
.adjustment-actions button:last-child {
background: #ffffff;
}
.adjustment-actions button:disabled {
color: #777770;
background: #dfdfda;
cursor: not-allowed;
}
.is-negative {
color: #a52e24;
}
@media (max-width: 680px) {
.admin-users-page > main {
width: 100%;
padding-right: 16px;
padding-left: 16px;
}
.admin-credit-summary,
.adjustment-actions {
grid-template-columns: 1fr;
}
.admin-credit-summary > div {
border-right: 0;
border-bottom: 1px solid #b3b3ad;
}
}
+153
View File
@@ -0,0 +1,153 @@
import { useEffect, useId, useMemo, useState, type FormEvent } from "react";
import "./admin-users.css";
interface AdminSession {
csrf_token: string;
}
interface CreditBalance {
available_balance: number;
reserved_balance: number;
updated_at: string;
}
async function readJson<T>(url: string, init?: RequestInit) {
const response = await fetch(url, { credentials: "same-origin", ...init });
if (response.status === 401) throw new Error("admin_session_invalid");
if (!response.ok) throw new Error("request_failed");
return response.json() as Promise<T>;
}
function idempotencyKey() {
return crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "");
}
export function AdminUsersPage() {
const amountId = useId();
const reasonId = useId();
const userId = new URLSearchParams(window.location.search).get("userId") ?? "";
const [session, setSession] = useState<AdminSession>();
const [balance, setBalance] = useState<CreditBalance>();
const [failed, setFailed] = useState(false);
const [adjusting, setAdjusting] = useState(false);
const [direction, setDirection] = useState<"increase" | "decrease">("increase");
const [amount, setAmount] = useState("");
const [reason, setReason] = useState("");
const [adjustmentId, setAdjustmentId] = useState("");
const [adjustmentKey, setAdjustmentKey] = useState("");
const [submitting, setSubmitting] = useState(false);
const [notice, setNotice] = useState("");
useEffect(() => {
if (!/^[0-9a-f-]{36}$/i.test(userId)) return;
Promise.all([
readJson<AdminSession>("/api/v1/admin-auth/session"),
readJson<CreditBalance>(`/api/v1/admin/users/${userId}/credits`),
]).then(([nextSession, nextBalance]) => {
setSession(nextSession);
setBalance(nextBalance);
}).catch(() => setFailed(true));
}, [userId]);
const signedAmount = useMemo(() => {
const parsed = Number(amount);
if (!Number.isSafeInteger(parsed) || parsed <= 0) return 0;
return direction === "increase" ? parsed : -parsed;
}, [amount, direction]);
const projected = balance ? balance.available_balance + signedAmount : 0;
function openAdjustment() {
setDirection("increase");
setAmount("");
setReason("");
setAdjustmentId(crypto.randomUUID());
setAdjustmentKey(idempotencyKey());
setAdjusting(true);
setNotice("");
}
async function submitAdjustment(event: FormEvent) {
event.preventDefault();
if (!session || !balance || !adjustmentId || !adjustmentKey || signedAmount === 0 || !reason.trim() || submitting) return;
setSubmitting(true);
try {
const result = await readJson<CreditBalance & { adjustment_id: string; status: "adjusted" }>(
`/api/v1/admin/users/${userId}/credit-adjustments`,
{
body: JSON.stringify({ adjustment_id: adjustmentId, amount: signedAmount, reason: reason.trim() }),
headers: {
"Content-Type": "application/json",
"Idempotency-Key": adjustmentKey,
"X-CSRF-Token": session.csrf_token,
},
method: "POST",
},
);
setBalance({
available_balance: result.available_balance,
reserved_balance: result.reserved_balance,
updated_at: new Date().toISOString(),
});
setAdjusting(false);
setNotice("点数调整已完成并记录审计");
} catch {
setNotice("点数调整未完成,请检查输入后重试");
} finally {
setSubmitting(false);
}
}
return (
<div className="admin-users-page">
<header className="admin-product-header">
<a href="/admin">DADA ADMIN</a>
<nav aria-label="后台导航"><a aria-current="page" href="/admin/users"></a><a href="/admin/audit"></a></nav>
</header>
<main>
<header className="admin-users-heading">
<div><p>USER OPERATIONS</p><h1></h1></div>
{balance ? <button onClick={openAdjustment} type="button"></button> : null}
</header>
{!userId ? <p className="admin-users-empty"></p> : null}
{failed ? <p className="admin-users-error" role="alert"></p> : null}
{userId && !balance && !failed ? <p className="admin-users-empty" aria-live="polite"></p> : null}
{balance ? (
<section className="admin-credit-summary" aria-label="用户点数摘要">
<div><span></span><code>{userId}</code></div>
<div><span></span><strong className={balance.available_balance < 0 ? "is-negative" : undefined}>{balance.available_balance}</strong></div>
<div><span></span><strong>{balance.reserved_balance}</strong></div>
</section>
) : null}
{notice ? <p className="admin-users-notice" role="status">{notice}</p> : null}
</main>
{adjusting && balance ? (
<div className="credit-adjustment-overlay">
<section aria-labelledby="adjustment-title" aria-modal="true" className="credit-adjustment-dialog" role="dialog">
<p>CREDIT ADJUSTMENT</p>
<h2 id="adjustment-title"></h2>
<div className="adjustment-direction" role="radiogroup" aria-label="调整方向">
<label><input checked={direction === "increase"} name="direction" onChange={() => setDirection("increase")} type="radio" /></label>
<label><input checked={direction === "decrease"} name="direction" onChange={() => setDirection("decrease")} type="radio" /></label>
</div>
<form onSubmit={submitAdjustment}>
<label htmlFor={amountId}></label>
<input id={amountId} inputMode="numeric" min="1" onChange={(event) => setAmount(event.target.value.replace(/\D/g, ""))} type="number" value={amount} />
<label htmlFor={reasonId}></label>
<textarea id={reasonId} maxLength={500} onChange={(event) => setReason(event.target.value)} value={reason} />
<div className="adjustment-preview">
<span> {balance.available_balance}</span>
<strong> {projected}</strong>
<span> {balance.reserved_balance}</span>
</div>
<div className="adjustment-actions">
<button disabled={signedAmount === 0 || !reason.trim() || submitting} type="submit">{submitting ? "处理中" : "确认调整"}</button>
<button disabled={submitting} onClick={() => setAdjusting(false)} type="button"></button>
</div>
</form>
</section>
</div>
) : null}
</div>
);
}
+200
View File
@@ -0,0 +1,200 @@
.credits-page {
width: min(1280px, calc(100% - 64px));
margin: 0 auto;
padding: 44px 0 80px;
}
.credits-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 24px;
padding-bottom: 24px;
border-bottom: 1px solid #9c9c96;
}
.credits-heading p {
margin: 0 0 4px;
font-family: Consolas, monospace;
font-size: 11px;
font-weight: 700;
}
.credits-heading h1 {
margin: 0;
font-size: 40px;
}
.credits-heading time {
color: #676761;
font-size: 12px;
}
.credit-summary {
display: grid;
grid-template-columns: 1fr 1fr minmax(260px, 1.2fr);
align-items: stretch;
margin: 28px 0;
border-block: 1px solid #85857f;
background: #ffffff;
}
.credit-summary > div {
display: grid;
min-height: 142px;
align-content: center;
gap: 4px;
padding: 22px;
border-right: 1px solid #b3b3ad;
}
.credit-summary span,
.credit-summary small {
color: #65655f;
font-size: 12px;
}
.credit-summary strong {
font-family: Arial Black, "Segoe UI", sans-serif;
font-size: 42px;
}
.credit-summary > p {
display: grid;
margin: 0;
align-content: center;
padding: 22px;
background: #f2f500;
font-weight: 800;
}
.is-negative {
color: #a52e24;
}
.is-positive {
color: #287b45;
}
.credits-error {
padding: 12px 14px;
border-left: 5px solid #d14a3b;
background: #fff1ef;
font-weight: 700;
}
.credit-ledger > header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
margin-bottom: 14px;
}
.credit-ledger h2 {
margin: 0;
}
.credit-filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.credit-filters label {
display: grid;
gap: 4px;
color: #5d5d57;
font-size: 11px;
font-weight: 700;
}
.credit-filters select,
.credit-filters input {
min-height: 40px;
padding: 7px 10px;
border: 1px solid #85857f;
border-radius: 0;
background: #ffffff;
}
.credit-table-wrap {
overflow-x: auto;
border: 1px solid #85857f;
background: #ffffff;
}
.credit-table-wrap table {
width: 100%;
min-width: 880px;
border-collapse: collapse;
}
.credit-table-wrap th,
.credit-table-wrap td {
padding: 13px 14px;
border-bottom: 1px solid #d0d0ca;
text-align: left;
}
.credit-table-wrap th {
background: #e7e7e2;
font-size: 12px;
}
.credit-table-wrap td {
font-size: 13px;
}
.credit-table-wrap td small {
display: block;
max-width: 260px;
margin-top: 4px;
overflow-wrap: anywhere;
color: #666660;
}
.ledger-empty {
display: grid;
min-height: 280px;
place-items: center;
border: 1px dashed #969690;
}
.ledger-more,
.ledger-retry {
min-height: 44px;
margin-top: 14px;
padding: 9px 18px;
border: 1px solid #111111;
border-radius: 0;
background: #ffffff;
font-weight: 800;
}
@media (max-width: 760px) {
.credits-page {
width: 100%;
padding-right: 16px;
padding-left: 16px;
}
.credits-heading,
.credit-ledger > header {
display: grid;
}
.credit-summary {
grid-template-columns: 1fr;
}
.credit-summary > div {
border-right: 0;
border-bottom: 1px solid #b3b3ad;
}
.credit-filters {
display: grid;
grid-template-columns: 1fr;
}
}
+162
View File
@@ -0,0 +1,162 @@
import { useCallback, useEffect, useState } from "react";
import { LocalOnlyFooter, ProductHeader } from "./project-pages.js";
import "./credits-page.css";
type EntryType = "registration_grant" | "generation_reserve" | "generation_commit" | "generation_release" | "admin_adjustment";
interface CreditBalance {
available_balance: number;
reserved_balance: number;
updated_at: string;
}
interface LedgerEntry {
amount: number;
available_after: number;
available_before: number;
created_at: string;
entry_id: string;
entry_type: EntryType;
model_id: string | null;
reason: string | null;
reference_id: string | null;
reference_type: "registration" | "generation" | "admin_adjustment" | null;
reserved_after: number;
reserved_before: number;
status: "succeeded" | "frozen" | "committed" | "released";
}
interface LedgerResponse {
credits: Pick<CreditBalance, "available_balance" | "reserved_balance">;
entries: LedgerEntry[];
next_cursor: string | null;
updated_at: string;
}
const entryLabels: Record<EntryType, string> = {
admin_adjustment: "管理员调整",
generation_commit: "生成结算",
generation_release: "生成释放",
generation_reserve: "生成冻结",
registration_grant: "注册赠点",
};
const statusLabels = {
committed: "已结算",
frozen: "已冻结",
released: "已释放",
succeeded: "已完成",
} as const;
async function readJson<T>(url: string) {
const response = await fetch(url, { credentials: "same-origin" });
if (response.status === 401) {
window.dispatchEvent(new Event("dada:session-invalid"));
throw new Error("session_invalid");
}
if (!response.ok) throw new Error("request_failed");
return response.json() as Promise<T>;
}
function localTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
export function CreditsPage() {
const [balance, setBalance] = useState<CreditBalance>();
const [entries, setEntries] = useState<LedgerEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [eventType, setEventType] = useState<EntryType | "">("");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [failed, setFailed] = useState(false);
const load = useCallback(async (cursor?: string) => {
const query = new URLSearchParams({ limit: "20" });
if (cursor) query.set("cursor", cursor);
if (eventType) query.set("event_type", eventType);
if (from) query.set("from", new Date(`${from}T00:00:00`).toISOString());
if (to) query.set("to", new Date(`${to}T23:59:59.999`).toISOString());
if (cursor) setLoadingMore(true); else setLoading(true);
setFailed(false);
try {
const [nextBalance, ledger] = await Promise.all([
readJson<CreditBalance>("/api/v1/me/credits"),
readJson<LedgerResponse>(`/api/v1/me/credit-ledger?${query}`),
]);
setBalance(nextBalance);
setEntries((current) => cursor ? [...current, ...ledger.entries] : ledger.entries);
setNextCursor(ledger.next_cursor);
} catch (error) {
if (!(error instanceof Error) || error.message !== "session_invalid") setFailed(true);
} finally {
setLoading(false);
setLoadingMore(false);
}
}, [eventType, from, to]);
useEffect(() => { void load(); }, [load]);
return (
<div className="product-page">
<ProductHeader current="credits" />
<main className="credits-page">
<header className="credits-heading">
<div><p>ACCOUNT CREDIT</p><h1></h1></div>
{balance ? <time dateTime={balance.updated_at}> {localTime(balance.updated_at)}</time> : null}
</header>
{balance ? (
<section className="credit-summary" aria-label="点数余额">
<div><span></span><strong className={balance.available_balance < 0 ? "is-negative" : undefined}>{balance.available_balance}</strong></div>
<div><span></span><strong>{balance.reserved_balance}</strong><small> {balance.reserved_balance} </small></div>
<p></p>
</section>
) : null}
{failed ? <p className="credits-error" role="alert">{balance ? "当前保留上次余额,提交时仍以服务端实时校验为准。" : "请重新读取。"}</p> : null}
<section className="credit-ledger" aria-labelledby="ledger-title">
<header>
<h2 id="ledger-title"></h2>
<div className="credit-filters">
<label>
<select value={eventType} onChange={(event) => setEventType(event.target.value as EntryType | "")}>
<option value=""></option>
{Object.entries(entryLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</label>
<label><input type="date" value={from} onChange={(event) => setFrom(event.target.value)} /></label>
<label><input type="date" value={to} onChange={(event) => setTo(event.target.value)} /></label>
</div>
</header>
{loading && entries.length === 0 ? <p className="ledger-empty" aria-live="polite"></p> : null}
{!loading && entries.length === 0 && !failed ? <p className="ledger-empty"></p> : null}
{entries.length > 0 ? (
<div className="credit-table-wrap">
<table>
<thead><tr><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.entry_id}>
<td><time dateTime={entry.created_at}>{localTime(entry.created_at)}</time></td>
<td><strong>{entryLabels[entry.entry_type]}</strong>{entry.reason ? <small>{entry.reason}</small> : null}</td>
<td>{statusLabels[entry.status]}</td>
<td className={entry.amount < 0 ? "is-negative" : "is-positive"}> {entry.amount > 0 ? `+${entry.amount}` : entry.amount}</td>
<td>{entry.reference_type === "generation" ? "生成任务" : entry.reference_type === "admin_adjustment" ? "管理操作" : "注册"}</td>
<td> {entry.available_after} / {entry.reserved_after}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{nextCursor ? <button className="ledger-more" disabled={loadingMore} onClick={() => void load(nextCursor)} type="button">{loadingMore ? "加载中" : "加载更多"}</button> : null}
{failed ? <button className="ledger-retry" onClick={() => void load()} type="button"></button> : null}
</section>
</main>
<LocalOnlyFooter />
</div>
);
}
+31 -1
View File
@@ -1,9 +1,18 @@
// Generated from openapi/openapi.json. Do not edit by hand.
import type { AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectPurgeResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectRestoreResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectPurgeResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectRestoreResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
export async function adjustAdminUserCredits(body: CreditAdjustmentRequest, options: ClientOptions = {}): Promise<CreditAdjustmentResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/users/{userId}/credit-adjustments`, { body: JSON.stringify(body), method: "POST", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<CreditAdjustmentResponse>;
}
export async function checkBrowserSupport(body: {
"brands": Array<{
"brand": string;
@@ -95,6 +104,13 @@ export async function getAdminSession(options: ClientOptions = {}): Promise<Admi
return response.json() as Promise<AdminSessionResponse>;
}
export async function getAdminUserCredits(options: ClientOptions = {}): Promise<CreditBalanceResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/users/{userId}/credits`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<CreditBalanceResponse>;
}
export async function getBootstrap(options: ClientOptions = {}): Promise<{
"app_version": string;
"dependencies": Array<{
@@ -138,6 +154,20 @@ export function getEvents(options: Pick<ClientOptions, "baseUrl"> = {}): string
return `${options.baseUrl ?? ""}/api/v1/events`;
}
export async function getMyCreditLedger(options: ClientOptions = {}): Promise<CreditLedgerResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/me/credit-ledger`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<CreditLedgerResponse>;
}
export async function getMyCredits(options: ClientOptions = {}): Promise<CreditBalanceResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/me/credits`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<CreditBalanceResponse>;
}
export async function getProject(options: ClientOptions = {}): Promise<ProjectDetailResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}`, { method: "GET", headers: options.headers ?? {} });
+65
View File
@@ -56,6 +56,10 @@ export type AdminAuthenticatedUser = {
"user_id": string;
};
export type AdminCreditParams = {
"userId": string;
};
export type AdminLoginCompleteRequest = {
"registration_id": string;
"verification_code": string;
@@ -197,6 +201,67 @@ export type CanvasState = {
export type CorrelationId = string;
export type CreditAdjustmentHeaders = {
"idempotency-key": string;
"x-csrf-token": string;
};
export type CreditAdjustmentRequest = {
"adjustment_id": string;
"amount": number | number;
"reason": string;
};
export type CreditAdjustmentResponse = {
"adjustment_id": string;
"available_balance": number;
"reserved_balance": number;
"status": "adjusted";
};
export type CreditBalanceResponse = {
"available_balance": number;
"reserved_balance": number;
"updated_at": string;
};
export type CreditEntryStatus = "succeeded" | "frozen" | "committed" | "released";
export type CreditEntryType = "registration_grant" | "generation_reserve" | "generation_commit" | "generation_release" | "admin_adjustment";
export type CreditLedgerEntry = {
"amount": number;
"available_after": number;
"available_before": number;
"created_at": string;
"entry_id": string;
"entry_type": CreditEntryType;
"model_id": string | null;
"reason": string | null;
"reference_id": string | null;
"reference_type": CreditReferenceType | null;
"reserved_after": number;
"reserved_before": number;
"status": CreditEntryStatus;
};
export type CreditLedgerQuery = {
"cursor"?: string;
"event_type"?: CreditEntryType;
"from"?: string;
"limit"?: number;
"to"?: string;
};
export type CreditLedgerResponse = {
"credits": CreditSummary;
"entries": Array<CreditLedgerEntry>;
"next_cursor": string | null;
"updated_at": string;
};
export type CreditReferenceType = "registration" | "generation" | "admin_adjustment";
export type CreditSummary = {
"available_balance": number;
"reserved_balance": number;
+4
View File
@@ -5,6 +5,8 @@ import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
import { AdminAuthPage } from "./admin-auth.js";
import { UserAuthPage } from "./user-auth.js";
import { AccountSettingsPage } from "./account-settings.js";
import { AdminUsersPage } from "./admin-users.js";
import { CreditsPage } from "./credits-page.js";
import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js";
const root = document.getElementById("root");
@@ -24,9 +26,11 @@ function renderAuthenticationEntry() {
const projectDetail = window.location.pathname.match(/^\/app\/projects\/([0-9a-f-]{36})$/i);
let authenticationPage;
if (window.location.pathname === "/app/settings") authenticationPage = <AccountSettingsPage key={authRevision} />;
else if (window.location.pathname === "/app/credits") authenticationPage = <CreditsPage key={authRevision} />;
else if (projectDetail?.[1]) authenticationPage = <ProjectDetailPage key={authRevision} projectId={projectDetail[1]} />;
else if (window.location.pathname === "/app/projects") authenticationPage = <ProjectsPage key={authRevision} />;
else if (window.location.pathname === "/app") authenticationPage = <WorkspacePage key={authRevision} />;
else if (window.location.pathname === "/admin/users") authenticationPage = <AdminUsersPage key={authRevision} />;
else if (window.location.pathname.startsWith("/admin")) authenticationPage = <AdminAuthPage key={authRevision} />;
else authenticationPage = <UserAuthPage key={authRevision} />;
appRoot.render(
+3 -3
View File
@@ -117,21 +117,21 @@ async function downloadConflictPng(canvasState: CanvasState, projectName: string
setTimeout(() => URL.revokeObjectURL(url), 0);
}
function ProductHeader({ current }: { current: "workspace" | "projects" }) {
export function ProductHeader({ current }: { current: "workspace" | "projects" | "credits" }) {
return (
<header className="product-header">
<a className="product-brand" href="/app">DADA</a>
<nav aria-label="主导航">
<a aria-current={current === "workspace" ? "page" : undefined} href="/app"></a>
<a aria-current={current === "projects" ? "page" : undefined} href="/app/projects"></a>
<a href="/app/credits"></a>
<a aria-current={current === "credits" ? "page" : undefined} href="/app/credits"></a>
<a href="/app/settings"></a>
</nav>
</header>
);
}
function LocalOnlyFooter() {
export function LocalOnlyFooter() {
return <footer className="local-only-footer"></footer>;
}
+18
View File
@@ -0,0 +1,18 @@
export interface CreditSettlementTarget {
finalizeGeneration(input: {
generationId: string;
operationKey: string;
outcome: "succeeded" | "failed" | "rejected";
}): unknown;
}
export function settleGenerationCredits(
target: CreditSettlementTarget,
input: {
generationId: string;
operationKey: string;
outcome: "succeeded" | "failed" | "rejected";
},
) {
return target.finalizeGeneration(input);
}