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, AccountProfileUpdateResponseSchema,
AccountSettingsResponseSchema, AccountSettingsResponseSchema,
AdminAuthenticatedUserSchema, AdminAuthenticatedUserSchema,
AdminCreditParamsSchema,
AdminLoginCompleteRequestSchema, AdminLoginCompleteRequestSchema,
AdminLoginCompleteResponseSchema, AdminLoginCompleteResponseSchema,
AdminLoginSendRequestSchema, AdminLoginSendRequestSchema,
@@ -21,6 +22,16 @@ import {
CorrelationIdSchema, CorrelationIdSchema,
AuthenticatedUserSchema, AuthenticatedUserSchema,
CreditSummarySchema, CreditSummarySchema,
CreditAdjustmentHeadersSchema,
CreditAdjustmentRequestSchema,
CreditAdjustmentResponseSchema,
CreditBalanceResponseSchema,
CreditEntryStatusSchema,
CreditEntryTypeSchema,
CreditLedgerEntrySchema,
CreditLedgerQuerySchema,
CreditLedgerResponseSchema,
CreditReferenceTypeSchema,
CsrfHeadersSchema, CsrfHeadersSchema,
ErrorDetailsSchema, ErrorDetailsSchema,
ErrorEnvelopeSchema, ErrorEnvelopeSchema,
@@ -70,6 +81,10 @@ import {
type AdminLoginSendRequest, type AdminLoginSendRequest,
type AccountDeletionCompleteRequest, type AccountDeletionCompleteRequest,
type AccountProfileUpdateRequest, type AccountProfileUpdateRequest,
type AdminCreditParams,
type CreditAdjustmentHeaders,
type CreditAdjustmentRequest,
type CreditLedgerQuery,
type LoginCompleteRequest, type LoginCompleteRequest,
type LoginSendRequest, type LoginSendRequest,
type FailedEmptyTrashRequest, type FailedEmptyTrashRequest,
@@ -98,6 +113,8 @@ import {
type BrowserUnsupportedReason, type BrowserUnsupportedReason,
} from "./browser-support.js"; } from "./browser-support.js";
import { EventHub } from "./event-hub.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 type { PublicAssetResolver } from "./local-data-root.js";
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js"; import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
import { ProjectError } from "./project-errors.js"; import { ProjectError } from "./project-errors.js";
@@ -125,6 +142,7 @@ export interface CreateAppOptions {
browserGate?: boolean; browserGate?: boolean;
browserSupportRelease?: BrowserSupportRelease; browserSupportRelease?: BrowserSupportRelease;
browserSupportSecret?: Buffer; browserSupportSecret?: Buffer;
credits?: CreditService;
eventHub?: EventHub; eventHub?: EventHub;
networkBoundary?: NetworkBoundaryOptions; networkBoundary?: NetworkBoundaryOptions;
publicAssets?: PublicAssetResolver; publicAssets?: PublicAssetResolver;
@@ -215,6 +233,21 @@ function projectFailure(reply: FastifyReply, correlationId: string, error: unkno
return reply.code(mapping[error.code]).send(null); 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 ProjectSummaryView = ReturnType<ProjectService["listProjects"]>[number];
type ProjectDetailView = ReturnType<ProjectService["getProject"]>; type ProjectDetailView = ReturnType<ProjectService["getProject"]>;
@@ -331,6 +364,17 @@ export async function createApp(options: CreateAppOptions = {}) {
AdminLoginCompleteResponseSchema, AdminLoginCompleteResponseSchema,
AdminSessionResponseSchema, AdminSessionResponseSchema,
CreditSummarySchema, CreditSummarySchema,
CreditEntryTypeSchema,
CreditEntryStatusSchema,
CreditReferenceTypeSchema,
CreditBalanceResponseSchema,
CreditLedgerEntrySchema,
CreditLedgerQuerySchema,
CreditLedgerResponseSchema,
AdminCreditParamsSchema,
CreditAdjustmentHeadersSchema,
CreditAdjustmentRequestSchema,
CreditAdjustmentResponseSchema,
CsrfHeadersSchema, CsrfHeadersSchema,
AccountSettingsResponseSchema, AccountSettingsResponseSchema,
AccountProfileUpdateRequestSchema, 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( app.post(
"/api/v1/account/deletion/send", "/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 { readBrowserSupportRelease } from "./browser-support.js";
import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.js"; import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.js";
import { ManagedStorage } from "./managed-storage.js"; import { ManagedStorage } from "./managed-storage.js";
import { CreditService } from "./credits.js";
import { ProjectService } from "./projects.js"; import { ProjectService } from "./projects.js";
import { RegistrationService } from "./registration.js"; import { RegistrationService } from "./registration.js";
import { MockResendAdapter } from "./resend-adapter.js"; import { MockResendAdapter } from "./resend-adapter.js";
@@ -17,6 +18,7 @@ import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiC
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin"); const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
let registration: RegistrationService | undefined; let registration: RegistrationService | undefined;
let projects: ProjectService | undefined; let projects: ProjectService | undefined;
let credits: CreditService | undefined;
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath(); const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
if (credentialChannelEnabled) { if (credentialChannelEnabled) {
const clients = initializeApiCredentialClients(await receiveApiCredentials()); const clients = initializeApiCredentialClients(await receiveApiCredentials());
@@ -36,8 +38,11 @@ if (credentialChannelEnabled) {
sessionPepper: derivePepper("session-pepper"), sessionPepper: derivePepper("session-pepper"),
}); });
projects = new ProjectService({ databasePath }); projects = new ProjectService({ databasePath });
credits = new CreditService({ databasePath });
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath)); registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
} catch (error) { } catch (error) {
credits?.close();
credits = undefined;
projects?.close(); projects?.close();
projects = undefined; projects = undefined;
registration?.close(); registration?.close();
@@ -51,6 +56,7 @@ if (credentialChannelEnabled) {
const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json")); const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"));
const app = await createApp({ const app = await createApp({
...(browserSupportRelease ? { browserSupportRelease } : {}), ...(browserSupportRelease ? { browserSupportRelease } : {}),
...(credits ? { credits } : {}),
...(projects ? { projects } : {}), ...(projects ? { projects } : {}),
...(registration ? { registration } : {}), ...(registration ? { registration } : {}),
}); });
@@ -67,6 +73,7 @@ if (controlPipeIndex >= 0) {
let storage: ManagedStorage | undefined; let storage: ManagedStorage | undefined;
const control = attachApiSupervisorControl(controlPipe, async () => { const control = attachApiSupervisorControl(controlPipe, async () => {
await app.close(); await app.close();
credits?.close();
projects?.close(); projects?.close();
registration?.close(); registration?.close();
storage?.close(); storage?.close();
+6
View File
@@ -783,6 +783,12 @@ export class ProjectService {
prompt TEXT NOT NULL CHECK (length(prompt) BETWEEN 1 AND 4000), 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')), 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')), 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 ( error_category TEXT CHECK (error_category IS NULL OR error_category IN (
'upstream_timeout', 'upstream_failed', 'safety_rejected', 'model_disabled', 'upstream_timeout', 'upstream_failed', 'safety_rejected', 'model_disabled',
'gateway_balance_insufficient', 'gateway_contract_invalid', 'reference_invalid', 'gateway_balance_insufficient', 'gateway_contract_invalid', 'reference_invalid',
+25
View File
@@ -990,6 +990,11 @@ export class RegistrationService {
return { userId: session.user_id }; 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 }) { logoutUser(input: { csrfToken: string; sessionToken: string }) {
const now = this.options.clock(); const now = this.options.clock();
this.runImmediate("session_revoke", () => { this.runImmediate("session_revoke", () => {
@@ -1531,6 +1536,26 @@ export class RegistrationService {
return session; 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) { private queueOwnedManagedFiles(userId: string, now: number) {
const table = this.database.prepare(` const table = this.database.prepare(`
SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'managed_files' 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. // 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 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: { export async function checkBrowserSupport(body: {
"brands": Array<{ "brands": Array<{
"brand": string; "brand": string;
@@ -95,6 +104,13 @@ export async function getAdminSession(options: ClientOptions = {}): Promise<Admi
return response.json() as Promise<AdminSessionResponse>; 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<{ export async function getBootstrap(options: ClientOptions = {}): Promise<{
"app_version": string; "app_version": string;
"dependencies": Array<{ "dependencies": Array<{
@@ -138,6 +154,20 @@ export function getEvents(options: Pick<ClientOptions, "baseUrl"> = {}): string
return `${options.baseUrl ?? ""}/api/v1/events`; 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> { export async function getProject(options: ClientOptions = {}): Promise<ProjectDetailResponse> {
const request = options.fetch ?? globalThis.fetch; const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}`, { method: "GET", headers: options.headers ?? {} }); 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; "user_id": string;
}; };
export type AdminCreditParams = {
"userId": string;
};
export type AdminLoginCompleteRequest = { export type AdminLoginCompleteRequest = {
"registration_id": string; "registration_id": string;
"verification_code": string; "verification_code": string;
@@ -197,6 +201,67 @@ export type CanvasState = {
export type CorrelationId = string; 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 = { export type CreditSummary = {
"available_balance": number; "available_balance": number;
"reserved_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 { AdminAuthPage } from "./admin-auth.js";
import { UserAuthPage } from "./user-auth.js"; import { UserAuthPage } from "./user-auth.js";
import { AccountSettingsPage } from "./account-settings.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"; import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js";
const root = document.getElementById("root"); const root = document.getElementById("root");
@@ -24,9 +26,11 @@ function renderAuthenticationEntry() {
const projectDetail = window.location.pathname.match(/^\/app\/projects\/([0-9a-f-]{36})$/i); const projectDetail = window.location.pathname.match(/^\/app\/projects\/([0-9a-f-]{36})$/i);
let authenticationPage; let authenticationPage;
if (window.location.pathname === "/app/settings") authenticationPage = <AccountSettingsPage key={authRevision} />; 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 (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/projects") authenticationPage = <ProjectsPage key={authRevision} />;
else if (window.location.pathname === "/app") authenticationPage = <WorkspacePage 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 if (window.location.pathname.startsWith("/admin")) authenticationPage = <AdminAuthPage key={authRevision} />;
else authenticationPage = <UserAuthPage key={authRevision} />; else authenticationPage = <UserAuthPage key={authRevision} />;
appRoot.render( appRoot.render(
+3 -3
View File
@@ -117,21 +117,21 @@ async function downloadConflictPng(canvasState: CanvasState, projectName: string
setTimeout(() => URL.revokeObjectURL(url), 0); setTimeout(() => URL.revokeObjectURL(url), 0);
} }
function ProductHeader({ current }: { current: "workspace" | "projects" }) { export function ProductHeader({ current }: { current: "workspace" | "projects" | "credits" }) {
return ( return (
<header className="product-header"> <header className="product-header">
<a className="product-brand" href="/app">DADA</a> <a className="product-brand" href="/app">DADA</a>
<nav aria-label="主导航"> <nav aria-label="主导航">
<a aria-current={current === "workspace" ? "page" : undefined} href="/app"></a> <a aria-current={current === "workspace" ? "page" : undefined} href="/app"></a>
<a aria-current={current === "projects" ? "page" : undefined} href="/app/projects"></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> <a href="/app/settings"></a>
</nav> </nav>
</header> </header>
); );
} }
function LocalOnlyFooter() { export function LocalOnlyFooter() {
return <footer className="local-only-footer"></footer>; 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);
}
+676
View File
@@ -287,6 +287,19 @@
], ],
"type": "object" "type": "object"
}, },
"AdminCreditParams": {
"additionalProperties": false,
"properties": {
"userId": {
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
"type": "string"
}
},
"required": [
"userId"
],
"type": "object"
},
"AdminLoginCompleteRequest": { "AdminLoginCompleteRequest": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -1192,6 +1205,358 @@
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
"type": "string" "type": "string"
}, },
"CreditAdjustmentHeaders": {
"additionalProperties": true,
"properties": {
"idempotency-key": {
"maxLength": 200,
"minLength": 32,
"pattern": "^[A-Za-z0-9_-]+$",
"type": "string"
},
"x-csrf-token": {
"maxLength": 64,
"minLength": 43,
"pattern": "^[A-Za-z0-9_-]+$",
"type": "string"
}
},
"required": [
"idempotency-key",
"x-csrf-token"
],
"type": "object"
},
"CreditAdjustmentRequest": {
"additionalProperties": false,
"properties": {
"adjustment_id": {
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
"type": "string"
},
"amount": {
"anyOf": [
{
"maximum": -1,
"type": "integer"
},
{
"minimum": 1,
"type": "integer"
}
]
},
"reason": {
"maxLength": 500,
"minLength": 1,
"type": "string"
}
},
"required": [
"adjustment_id",
"amount",
"reason"
],
"type": "object"
},
"CreditAdjustmentResponse": {
"additionalProperties": false,
"properties": {
"adjustment_id": {
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
"type": "string"
},
"available_balance": {
"type": "integer"
},
"reserved_balance": {
"minimum": 0,
"type": "integer"
},
"status": {
"enum": [
"adjusted"
],
"type": "string"
}
},
"required": [
"adjustment_id",
"available_balance",
"reserved_balance",
"status"
],
"type": "object"
},
"CreditBalanceResponse": {
"additionalProperties": false,
"properties": {
"available_balance": {
"type": "integer"
},
"reserved_balance": {
"minimum": 0,
"type": "integer"
},
"updated_at": {
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
"type": "string"
}
},
"required": [
"available_balance",
"reserved_balance",
"updated_at"
],
"type": "object"
},
"CreditEntryStatus": {
"anyOf": [
{
"enum": [
"succeeded"
],
"type": "string"
},
{
"enum": [
"frozen"
],
"type": "string"
},
{
"enum": [
"committed"
],
"type": "string"
},
{
"enum": [
"released"
],
"type": "string"
}
]
},
"CreditEntryType": {
"anyOf": [
{
"enum": [
"registration_grant"
],
"type": "string"
},
{
"enum": [
"generation_reserve"
],
"type": "string"
},
{
"enum": [
"generation_commit"
],
"type": "string"
},
{
"enum": [
"generation_release"
],
"type": "string"
},
{
"enum": [
"admin_adjustment"
],
"type": "string"
}
]
},
"CreditLedgerEntry": {
"additionalProperties": false,
"properties": {
"amount": {
"type": "integer"
},
"available_after": {
"type": "integer"
},
"available_before": {
"type": "integer"
},
"created_at": {
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
"type": "string"
},
"entry_id": {
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
"type": "string"
},
"entry_type": {
"$ref": "#/components/schemas/CreditEntryType"
},
"model_id": {
"anyOf": [
{
"maxLength": 160,
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
]
},
"reason": {
"anyOf": [
{
"maxLength": 500,
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
]
},
"reference_id": {
"anyOf": [
{
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
"type": "string"
},
{
"type": "null"
}
]
},
"reference_type": {
"anyOf": [
{
"$ref": "#/components/schemas/CreditReferenceType"
},
{
"type": "null"
}
]
},
"reserved_after": {
"minimum": 0,
"type": "integer"
},
"reserved_before": {
"minimum": 0,
"type": "integer"
},
"status": {
"$ref": "#/components/schemas/CreditEntryStatus"
}
},
"required": [
"amount",
"available_after",
"available_before",
"created_at",
"entry_id",
"entry_type",
"model_id",
"reason",
"reference_id",
"reference_type",
"reserved_after",
"reserved_before",
"status"
],
"type": "object"
},
"CreditLedgerQuery": {
"additionalProperties": false,
"properties": {
"cursor": {
"maxLength": 300,
"minLength": 1,
"pattern": "^[A-Za-z0-9_-]+$",
"type": "string"
},
"event_type": {
"$ref": "#/components/schemas/CreditEntryType"
},
"from": {
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
"type": "string"
},
"limit": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"to": {
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
"type": "string"
}
},
"type": "object"
},
"CreditLedgerResponse": {
"additionalProperties": false,
"properties": {
"credits": {
"$ref": "#/components/schemas/CreditSummary"
},
"entries": {
"items": {
"$ref": "#/components/schemas/CreditLedgerEntry"
},
"maxItems": 100,
"type": "array"
},
"next_cursor": {
"anyOf": [
{
"maxLength": 300,
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
]
},
"updated_at": {
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
"type": "string"
}
},
"required": [
"credits",
"entries",
"next_cursor",
"updated_at"
],
"type": "object"
},
"CreditReferenceType": {
"anyOf": [
{
"enum": [
"registration"
],
"type": "string"
},
{
"enum": [
"generation"
],
"type": "string"
},
{
"enum": [
"admin_adjustment"
],
"type": "string"
}
]
},
"CreditSummary": { "CreditSummary": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -3558,6 +3923,179 @@
] ]
} }
}, },
"/api/v1/admin/users/{userId}/credit-adjustments": {
"post": {
"operationId": "adjustAdminUserCredits",
"parameters": [
{
"in": "path",
"name": "userId",
"required": true,
"schema": {
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
"type": "string"
}
},
{
"in": "header",
"name": "idempotency-key",
"required": true,
"schema": {
"maxLength": 200,
"minLength": 32,
"pattern": "^[A-Za-z0-9_-]+$",
"type": "string"
}
},
{
"in": "header",
"name": "x-csrf-token",
"required": true,
"schema": {
"maxLength": 64,
"minLength": 43,
"pattern": "^[A-Za-z0-9_-]+$",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreditAdjustmentRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreditAdjustmentResponse"
}
}
},
"description": "Default Response"
},
"400": {
"description": "Default Response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
},
"404": {
"description": "Default Response"
},
"409": {
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/ErrorEnvelope"
},
{
"type": "null"
}
]
}
}
},
"description": "Default Response"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
}
},
"tags": [
"Admin Credits"
]
}
},
"/api/v1/admin/users/{userId}/credits": {
"get": {
"operationId": "getAdminUserCredits",
"parameters": [
{
"in": "path",
"name": "userId",
"required": true,
"schema": {
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreditBalanceResponse"
}
}
},
"description": "Default Response"
},
"400": {
"description": "Default Response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
},
"404": {
"description": "Default Response"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
}
},
"tags": [
"Admin Credits"
]
}
},
"/api/v1/auth/login/complete": { "/api/v1/auth/login/complete": {
"post": { "post": {
"operationId": "completeLogin", "operationId": "completeLogin",
@@ -4992,6 +5530,144 @@
] ]
} }
}, },
"/api/v1/me/credit-ledger": {
"get": {
"operationId": "getMyCreditLedger",
"parameters": [
{
"in": "query",
"name": "cursor",
"required": false,
"schema": {
"maxLength": 300,
"minLength": 1,
"pattern": "^[A-Za-z0-9_-]+$",
"type": "string"
}
},
{
"in": "query",
"name": "event_type",
"required": false,
"schema": {
"$ref": "#/components/schemas/CreditEntryType"
}
},
{
"in": "query",
"name": "from",
"required": false,
"schema": {
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
"type": "string"
}
},
{
"in": "query",
"name": "limit",
"required": false,
"schema": {
"maximum": 100,
"minimum": 1,
"type": "integer"
}
},
{
"in": "query",
"name": "to",
"required": false,
"schema": {
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreditLedgerResponse"
}
}
},
"description": "Default Response"
},
"400": {
"description": "Default Response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
},
"404": {
"description": "Default Response"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
}
},
"tags": [
"Credits"
]
}
},
"/api/v1/me/credits": {
"get": {
"operationId": "getMyCredits",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreditBalanceResponse"
}
}
},
"description": "Default Response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
},
"404": {
"description": "Default Response"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
}
},
"tags": [
"Credits"
]
}
},
"/api/v1/projects": { "/api/v1/projects": {
"get": { "get": {
"operationId": "listProjects", "operationId": "listProjects",
+4 -2
View File
@@ -14,7 +14,7 @@
"test:integration": "vitest run tests/integration", "test:integration": "vitest run tests/integration",
"test:api": "pnpm check:openapi && vitest run tests/api", "test:api": "pnpm check:openapi && vitest run tests/api",
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker", "test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts --config playwright.config.ts", "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts --config playwright.config.ts",
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
@@ -57,7 +57,9 @@
"test:wp2-02": "node scripts/run-wp2-02-validation.mjs", "test:wp2-02": "node scripts/run-wp2-02-validation.mjs",
"test:wp2-02:red": "node scripts/run-wp2-02-validation.mjs --phase red", "test:wp2-02:red": "node scripts/run-wp2-02-validation.mjs --phase red",
"test:wp2-03": "node scripts/run-wp2-03-validation.mjs", "test:wp2-03": "node scripts/run-wp2-03-validation.mjs",
"test:wp2-03:red": "node scripts/run-wp2-03-validation.mjs --phase red" "test:wp2-03:red": "node scripts/run-wp2-03-validation.mjs --phase red",
"test:wp2-04": "node scripts/run-wp2-04-validation.mjs",
"test:wp2-04:red": "node scripts/run-wp2-04-validation.mjs --phase red"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.62.0", "@playwright/test": "1.62.0",
+72
View File
@@ -0,0 +1,72 @@
import { Type, type Static } from "@sinclair/typebox";
import { CreditSummarySchema } from "./auth.js";
const uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$";
const isoTimestampPattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$";
export const CreditEntryTypeSchema = Type.Union([
Type.Literal("registration_grant"), Type.Literal("generation_reserve"), Type.Literal("generation_commit"),
Type.Literal("generation_release"), Type.Literal("admin_adjustment"),
], { $id: "CreditEntryType" });
export const CreditEntryStatusSchema = Type.Union([
Type.Literal("succeeded"), Type.Literal("frozen"), Type.Literal("committed"), Type.Literal("released"),
], { $id: "CreditEntryStatus" });
export const CreditReferenceTypeSchema = Type.Union([
Type.Literal("registration"), Type.Literal("generation"), Type.Literal("admin_adjustment"),
], { $id: "CreditReferenceType" });
export const CreditBalanceResponseSchema = Type.Object({
...CreditSummarySchema.properties,
updated_at: Type.String({ pattern: isoTimestampPattern }),
}, { additionalProperties: false, $id: "CreditBalanceResponse" });
export const CreditLedgerEntrySchema = Type.Object({
amount: Type.Integer(),
available_after: Type.Integer(),
available_before: Type.Integer(),
created_at: Type.String({ pattern: isoTimestampPattern }),
entry_id: Type.String({ pattern: uuidPattern }),
entry_type: Type.Ref(CreditEntryTypeSchema),
model_id: Type.Union([Type.String({ maxLength: 160, minLength: 1 }), Type.Null()]),
reason: Type.Union([Type.String({ maxLength: 500, minLength: 1 }), Type.Null()]),
reference_id: Type.Union([Type.String({ pattern: uuidPattern }), Type.Null()]),
reference_type: Type.Union([Type.Ref(CreditReferenceTypeSchema), Type.Null()]),
reserved_after: Type.Integer({ minimum: 0 }),
reserved_before: Type.Integer({ minimum: 0 }),
status: Type.Ref(CreditEntryStatusSchema),
}, { additionalProperties: false, $id: "CreditLedgerEntry" });
export const CreditLedgerQuerySchema = Type.Object({
cursor: Type.Optional(Type.String({ maxLength: 300, minLength: 1, pattern: "^[A-Za-z0-9_-]+$" })),
event_type: Type.Optional(Type.Ref(CreditEntryTypeSchema)),
from: Type.Optional(Type.String({ pattern: isoTimestampPattern })),
limit: Type.Optional(Type.Integer({ maximum: 100, minimum: 1 })),
to: Type.Optional(Type.String({ pattern: isoTimestampPattern })),
}, { additionalProperties: false, $id: "CreditLedgerQuery" });
export const CreditLedgerResponseSchema = Type.Object({
credits: Type.Ref(CreditSummarySchema),
entries: Type.Array(Type.Ref(CreditLedgerEntrySchema), { maxItems: 100 }),
next_cursor: Type.Union([Type.String({ maxLength: 300, minLength: 1 }), Type.Null()]),
updated_at: Type.String({ pattern: isoTimestampPattern }),
}, { additionalProperties: false, $id: "CreditLedgerResponse" });
export const AdminCreditParamsSchema = Type.Object({
userId: Type.String({ pattern: uuidPattern }),
}, { additionalProperties: false, $id: "AdminCreditParams" });
export const CreditAdjustmentHeadersSchema = Type.Object({
"idempotency-key": Type.String({ maxLength: 200, minLength: 32, pattern: "^[A-Za-z0-9_-]+$" }),
"x-csrf-token": Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }),
}, { additionalProperties: true, $id: "CreditAdjustmentHeaders" });
export const CreditAdjustmentRequestSchema = Type.Object({
adjustment_id: Type.String({ pattern: uuidPattern }),
amount: Type.Union([Type.Integer({ maximum: -1 }), Type.Integer({ minimum: 1 })]),
reason: Type.String({ maxLength: 500, minLength: 1 }),
}, { additionalProperties: false, $id: "CreditAdjustmentRequest" });
export const CreditAdjustmentResponseSchema = Type.Object({
adjustment_id: Type.String({ pattern: uuidPattern }),
available_balance: Type.Integer(),
reserved_balance: Type.Integer({ minimum: 0 }),
status: Type.Literal("adjusted"),
}, { additionalProperties: false, $id: "CreditAdjustmentResponse" });
export type CreditLedgerQuery = Static<typeof CreditLedgerQuerySchema>;
export type AdminCreditParams = Static<typeof AdminCreditParamsSchema>;
export type CreditAdjustmentHeaders = Static<typeof CreditAdjustmentHeadersSchema>;
export type CreditAdjustmentRequest = Static<typeof CreditAdjustmentRequestSchema>;
+1
View File
@@ -3,6 +3,7 @@ export * from "./api.js";
export * from "./auth.js"; export * from "./auth.js";
export * from "./bootstrap.js"; export * from "./bootstrap.js";
export * from "./canvas.js"; export * from "./canvas.js";
export * from "./credits.js";
export * from "./events.js"; export * from "./events.js";
export * from "./projects.js"; export * from "./projects.js";
export * from "./registration-notice.js"; export * from "./registration-notice.js";
+111
View File
@@ -0,0 +1,111 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
const phaseIndex = process.argv.indexOf("--phase");
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
if (!['red', 'green'].includes(phase)) throw new Error(`Unsupported phase: ${phase}`);
const runId = process.env.DADA_TDD_RUN_ID ?? `wp2-04-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
const runDirectory = resolve("artifacts", "tdd", runId);
const casesDirectory = resolve(runDirectory, "cases");
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
mkdirSync(casesDirectory, { recursive: true });
const cases = [
{
acceptance: ["AC-03"],
evidence: ["request.json", "response.json", "db-diff.json"],
id: "TDD-WP2-CRED-001-reserve",
requirements: ["CREDIT-01", "CREDIT-02", "CREDIT-03", "CREDIT-05"],
},
{
acceptance: ["AC-28"],
evidence: ["response.json", "db-diff.json", "external-calls.json", "screenshots/insufficient.png"],
id: "TDD-WP2-CRED-001-insufficient",
requirements: ["CREDIT-04"],
},
{
acceptance: ["AC-03", "AC-04", "AC-29"],
evidence: ["worker-events.json", "db-diff.json", "fs-before.json", "fs-after.json"],
id: "TDD-WP2-CRED-001-finalize-once",
requirements: ["CREDIT-03", "CREDIT-05", "GEN-09", "GEN-10"],
},
{
acceptance: ["AC-29", "AC-50"],
evidence: ["response.json", "db-diff.json", "screenshots/negative-balance.png"],
id: "TDD-WP2-CRED-002-admin-adjustment",
requirements: ["CREDIT-05", "CREDIT-06", "ADMIN-01", "ADMIN-09"],
},
];
for (const item of cases) mkdirSync(resolve(casesDirectory, item.id), { recursive: true });
const commands = phase === "red"
? [
["integration", ["exec", "vitest", "run", "tests/integration/wp2-04-credits.test.ts"]],
["api", ["exec", "vitest", "run", "tests/api/wp2-04-credits.test.ts"]],
["worker", ["exec", "vitest", "run", "tests/worker/wp2-04-credit-settlement.test.ts"]],
["e2e", ["exec", "playwright", "test", "tests/e2e/credits.spec.ts", "--config", "playwright.config.ts"]],
]
: [
["integration", ["test:integration"]], ["api", ["test:api"]],
["worker", ["test:worker"]], ["e2e", ["test:e2e"]], ["tdd-trace", ["validate:tdd-trace"]],
];
const environment = { ...process.env, DADA_EVIDENCE_DIR_CREDITS: casesDirectory };
const commandResults = [];
for (const [name, args] of commands) {
const command = `pnpm ${args.join(" ")}`;
const started_at = new Date().toISOString();
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], { encoding: "utf8", env: environment });
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
commandResults.push({ command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at });
}
const commandState = phase === "red"
? commandResults.every((result) => result.exit_code !== 0)
: commandResults.every((result) => result.exit_code === 0);
const manifest = {
path: "tasks.manifest.json",
sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase(),
};
const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim();
const dirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0;
const summaries = [];
for (const item of cases) {
const directory = resolve(casesDirectory, item.id);
writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
if (phase === "red") {
writeFileSync(resolve(directory, "red-observation.json"), `${JSON.stringify({
expected_failure: "Credit service, immutable detailed ledger, settlement worker, credit APIs and credit/admin UI are absent",
status: commandState ? "red_confirmed" : "failed",
}, null, 2)}\n`);
}
const evidenceRefs = phase === "red" ? ["red-observation.json"] : item.evidence;
const missingEvidence = evidenceRefs.filter((file) => !existsSync(resolve(directory, file)));
const targetStatus = phase === "red" ? "red_confirmed" : "passed";
const status = commandState && missingEvidence.length === 0 ? targetStatus : "failed";
writeFileSync(resolve(directory, "result.json"), `${JSON.stringify({
acceptance_criteria: item.acceptance,
automation: ["automated"],
commit,
evidence_refs: evidenceRefs,
layer: ["DB", "API", "WRK", "E2E"],
manifest,
missing_evidence: missingEvidence,
phase,
requirements: item.requirements,
run_id: runId,
status,
task_id: "TASK-WP2-04",
test_id: item.id,
work_package: "WP-2",
worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation",
}, null, 2)}\n`);
summaries.push({ missing_evidence: missingEvidence, status, test_id: item.id });
}
const targetStatus = phase === "red" ? "red_confirmed" : "passed";
const status = summaries.every((item) => item.status === targetStatus) ? targetStatus : "failed";
const summary = { cases: summaries, phase, run_id: runId, status };
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`);
console.log(JSON.stringify(summary, null, 2));
if (status !== targetStatus) process.exit(1);
+120
View File
@@ -0,0 +1,120 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createApp } from "../../apps/api/src/app.js";
import { CreditService } from "../../apps/api/src/credits.js";
import { RegistrationService } from "../../apps/api/src/registration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
const now = Date.parse("2026-08-02T12:00:00.000Z");
const baseHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
const roots: string[] = [];
const closeables: Array<{ close(): void }> = [];
function evidence(caseId: string, file: string, value: unknown) {
const root = process.env.DADA_EVIDENCE_DIR_CREDITS;
if (!root) return;
const directory = resolve(root, caseId);
mkdirSync(directory, { recursive: true });
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
}
afterEach(() => {
for (const value of closeables.splice(0).reverse()) value.close();
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
});
describe("TASK-WP2-04 credit APIs", () => {
it("returns the user's balance and immutable paginated ledger", async () => {
const root = mkdtempSync(join(tmpdir(), "dada-wp2-04-credit-api-"));
roots.push(root);
const databasePath = join(root, "dada.sqlite3");
const registration = new RegistrationService({
challengePepper: Buffer.alloc(32, 0x51), clock: () => now,
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath,
invitePepper: Buffer.alloc(32, 0x52), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x53),
});
const credits = new CreditService({ clock: () => now, databasePath });
closeables.push(credits, registration);
const userId = randomUUID();
registration.database.prepare(`INSERT INTO users (
user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at
) VALUES (?, 'ledger@example.invalid', 'user', 'active', 1, ?, ?)`).run(userId, randomUUID(), now);
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Ledger User', '@ledger')").run(userId);
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(userId, now);
registration.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
) VALUES (?, ?, ?, 'registration_grant', 10, 0, 10, 0, 0, ?)`).run(randomUUID(), userId, `registration:${randomUUID()}`, now);
const session = registration.issueAuthenticatedSession(userId, "user");
const app = await createApp({ browserGate: false, credits, networkBoundary: { allowTestPort: true }, registration });
const cookie = `dada_session=${session.sessionToken}`;
const balance = await app.inject({ headers: { ...baseHeaders, cookie }, method: "GET", url: "/api/v1/me/credits" });
const ledger = await app.inject({ headers: { ...baseHeaders, cookie }, method: "GET", url: "/api/v1/me/credit-ledger?limit=20" });
expect(balance.statusCode).toBe(200);
expect(balance.json()).toMatchObject({ available_balance: 10, reserved_balance: 0 });
expect(ledger.statusCode).toBe(200);
expect(ledger.json()).toMatchObject({ credits: { available_balance: 10, reserved_balance: 0 }, next_cursor: null });
expect(ledger.json().entries).toHaveLength(1);
expect(ledger.json().entries[0]).toMatchObject({ amount: 10, entry_type: "registration_grant", status: "succeeded" });
evidence("TDD-WP2-CRED-001-reserve", "response.json", { balance: balance.json(), ledger: ledger.json() });
await app.close();
});
it("allows an active super admin to adjust available only and replay safely", async () => {
const root = mkdtempSync(join(tmpdir(), "dada-wp2-04-admin-api-"));
roots.push(root);
const databasePath = join(root, "dada.sqlite3");
const registration = new RegistrationService({
challengePepper: Buffer.alloc(32, 0x61), clock: () => now,
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath,
invitePepper: Buffer.alloc(32, 0x62), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x63),
});
const credits = new CreditService({ clock: () => now, databasePath });
closeables.push(credits, registration);
const userId = randomUUID();
const adminId = randomUUID();
registration.database.prepare(`INSERT INTO users (
user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at
) VALUES (?, 'adjusted@example.invalid', 'user', 'active', 1, ?, ?)`).run(userId, randomUUID(), now);
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Adjusted User', '@adjusted')").run(userId);
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 0, 1, ?)").run(userId, now);
registration.database.prepare(`INSERT INTO users (
user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at
) VALUES (?, 'adjuster@example.invalid', 'super_admin', 'active', 0, ?, ?)`).run(adminId, randomUUID(), now);
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(adminId);
const adminSession = registration.issueAuthenticatedSession(adminId, "admin");
const app = await createApp({ browserGate: false, credits, networkBoundary: { allowTestPort: true }, registration });
const cookie = `dada_admin_session=${adminSession.sessionToken}`;
const sessionResponse = await app.inject({ headers: { ...baseHeaders, cookie }, method: "GET", url: "/api/v1/admin-auth/session" });
const csrf = sessionResponse.json().csrf_token as string;
const adjustmentId = randomUUID();
const request = {
body: { adjustment_id: adjustmentId, amount: -5, reason: "人工测试额度校正" },
headers: { ...baseHeaders, cookie, "idempotency-key": randomUUID().replaceAll("-", "") + randomUUID().replaceAll("-", ""), "x-csrf-token": csrf },
method: "POST" as const,
url: `/api/v1/admin/users/${userId}/credit-adjustments`,
};
const before = await app.inject({ headers: { ...baseHeaders, cookie }, method: "GET", url: `/api/v1/admin/users/${userId}/credits` });
const first = await app.inject(request);
const replay = await app.inject(request);
const conflict = await app.inject({
...request,
body: { adjustment_id: randomUUID(), amount: 1, reason: "同键不同请求" },
});
const invalid = await app.inject({ ...request, body: { adjustment_id: randomUUID(), amount: 1, reason: "" } });
expect(before.json()).toMatchObject({ available_balance: 0, reserved_balance: 1 });
expect(first.statusCode).toBe(200);
expect(first.json()).toEqual({ adjustment_id: adjustmentId, available_balance: -5, reserved_balance: 1, status: "adjusted" });
expect(replay.json()).toEqual(first.json());
expect(conflict.statusCode).toBe(409);
expect(conflict.json()).toMatchObject({ error: { code: "IDEMPOTENCY_KEY_CONFLICT" } });
expect(invalid.statusCode).toBe(400);
evidence("TDD-WP2-CRED-002-admin-adjustment", "response.json", { before: before.json(), first: first.json(), invalid_status: invalid.statusCode, replay: replay.json() });
await app.close();
});
});
+92
View File
@@ -0,0 +1,92 @@
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { expect, test } from "@playwright/test";
import { createServer, type ViteDevServer } from "vite";
let vite: ViteDevServer;
let webUrl: string;
test.beforeAll(async () => {
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
await vite.listen();
const address = vite.httpServer?.address();
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
webUrl = `http://127.0.0.1:${address.port}`;
});
test.afterAll(async () => vite.close());
const userId = "00000000-0000-4000-8000-000000000601";
const adminId = "00000000-0000-4000-8000-000000000602";
const session = {
audience: "user", authenticated: true, credits: { available_balance: -5, reserved_balance: 1 },
csrf_token: "csrf-credit-fixture-0000000000000000000000000000000000",
expires_at: "2026-09-02T12:00:00.000Z",
user: { creator_name: "Credit User", role: "user", social_id: "@credit", status: "active", user_id: userId },
};
test("TDD-WP2-CRED-001-insufficient shows negative available, reserved and no purchase entry", async ({ page }) => {
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json", status: 200 }));
await page.route("**/api/v1/me/credits", (route) => route.fulfill({
body: JSON.stringify({ available_balance: -5, reserved_balance: 1, updated_at: "2026-08-02T12:00:00.000Z" }), contentType: "application/json", status: 200,
}));
await page.route("**/api/v1/me/credit-ledger**", (route) => route.fulfill({
body: JSON.stringify({
credits: { available_balance: -5, reserved_balance: 1 },
entries: [{
amount: -5, available_after: -5, available_before: 0, created_at: "2026-08-02T12:00:00.000Z",
entry_id: "00000000-0000-4000-8000-000000000603", entry_type: "admin_adjustment",
model_id: null, reason: "人工测试额度校正", reference_id: "00000000-0000-4000-8000-000000000604",
reference_type: "admin_adjustment", reserved_after: 1, reserved_before: 1, status: "succeeded",
}], next_cursor: null, updated_at: "2026-08-02T12:00:00.000Z",
}), contentType: "application/json", status: 200,
}));
await page.goto(`${webUrl}/app/credits`);
await expect(page.getByRole("heading", { name: "点数明细" })).toBeVisible();
await expect(page.getByText("-5", { exact: true })).toBeVisible();
await expect(page.getByText("已冻结 1 点")).toBeVisible();
await expect(page.getByText("可用点数不足时,请联系管理员调整点数。" )).toBeVisible();
await expect(page.getByText(/购买|充值/)).toHaveCount(0);
const root = process.env.DADA_EVIDENCE_DIR_CREDITS;
if (root) {
const directory = resolve(root, "TDD-WP2-CRED-001-insufficient", "screenshots");
mkdirSync(directory, { recursive: true });
await page.screenshot({ fullPage: true, path: resolve(directory, "insufficient.png") });
}
});
test("TDD-WP2-CRED-002-admin-adjustment previews and submits available-only negative balance", async ({ page }) => {
let adjusted = false;
const adminSession = {
acknowledged_private_content_notice_version: null,
admin: { role: "super_admin", status: "active", user_id: adminId }, audience: "admin", authenticated: true,
csrf_token: "csrf-admin-credit-fixture-0000000000000000000000000000000",
current_private_content_notice_version: null, expires_at: "2026-09-02T12:00:00.000Z", notice_acknowledged: false,
};
await page.route("**/api/v1/admin-auth/session", (route) => route.fulfill({ body: JSON.stringify(adminSession), contentType: "application/json", status: 200 }));
await page.route(`**/api/v1/admin/users/${userId}/credits`, (route) => route.fulfill({
body: JSON.stringify({ available_balance: adjusted ? -5 : 0, reserved_balance: 1, updated_at: "2026-08-02T12:00:00.000Z" }), contentType: "application/json", status: 200,
}));
await page.route(`**/api/v1/admin/users/${userId}/credit-adjustments`, (route) => {
adjusted = true;
const body = route.request().postDataJSON() as { adjustment_id: string };
return route.fulfill({ body: JSON.stringify({ adjustment_id: body.adjustment_id, available_balance: -5, reserved_balance: 1, status: "adjusted" }), contentType: "application/json", status: 200 });
});
await page.goto(`${webUrl}/admin/users?userId=${userId}`);
await page.getByRole("button", { name: "调整点数" }).click();
await page.getByRole("radio", { name: "扣减" }).check();
await page.getByLabel("调整数量").fill("5");
await page.getByLabel("调整原因").fill("人工测试额度校正");
await expect(page.getByText("调整后可用点数 -5")).toBeVisible();
await expect(page.getByText("冻结点数保持 1")).toBeVisible();
const root = process.env.DADA_EVIDENCE_DIR_CREDITS;
if (root) {
const directory = resolve(root, "TDD-WP2-CRED-002-admin-adjustment", "screenshots");
mkdirSync(directory, { recursive: true });
await page.screenshot({ fullPage: true, path: resolve(directory, "negative-balance.png") });
}
await page.getByRole("button", { name: "确认调整" }).click();
await expect(page.getByText("点数调整已完成并记录审计")).toBeVisible();
expect(adjusted).toBe(true);
});
+130
View File
@@ -0,0 +1,130 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { CreditError, CreditService } from "../../apps/api/src/credits.js";
import { ProjectService } from "../../apps/api/src/projects.js";
import { RegistrationService } from "../../apps/api/src/registration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
const now = Date.parse("2026-08-02T12:00:00.000Z");
const roots: string[] = [];
const closeables: Array<{ close(): void }> = [];
function evidence(caseId: string, file: string, value: unknown) {
const root = process.env.DADA_EVIDENCE_DIR_CREDITS;
if (!root) return;
const directory = resolve(root, caseId);
mkdirSync(directory, { recursive: true });
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
}
function harness(available: number) {
const root = mkdtempSync(join(tmpdir(), "dada-wp2-04-credit-"));
roots.push(root);
const databasePath = join(root, "dada.sqlite3");
const registration = new RegistrationService({
challengePepper: Buffer.alloc(32, 0x31), clock: () => now,
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath,
invitePepper: Buffer.alloc(32, 0x32), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x33),
});
const projects = new ProjectService({ clock: () => now, databasePath });
const credits = new CreditService({ clock: () => now, databasePath });
closeables.push(credits, projects, registration);
const userId = randomUUID();
registration.database.prepare(`
INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at)
VALUES (?, ?, 'user', 'active', 1, ?, ?)
`).run(userId, `${userId}@example.invalid`, randomUUID(), now);
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Credit User', '@credit_user')").run(userId);
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, ?, 0, ?)")
.run(userId, available, now);
return { credits, databasePath, projects, registration, root, userId };
}
afterEach(() => {
for (const value of closeables.splice(0).reverse()) value.close();
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
});
describe("TDD-WP2-CRED-001", () => {
it("reserves available credit exactly once and appends an immutable ledger event", () => {
const { credits, projects, registration, userId } = harness(10);
const created = projects.createProjectForGeneration({ ownerId: userId, prompt: "点数冻结", ratio: "3:4", status: "queued" });
const generationId = created.generation.generationId;
const operationKey = `generation:${generationId}:reserve`;
const request = { creditCost: 1, generationId, modelId: "gemini-3.1-flash-image-preview", operationKey, userId };
const first = credits.reserveGeneration(request);
expect(() => credits.reserveGeneration({ ...request, modelId: "gemini-3-pro-image-preview" }))
.toThrowError(expect.objectContaining<CreditError>({ code: "credit_operation_conflict" }));
const replay = credits.reserveGeneration(request);
expect(first).toEqual({ availableBalance: 9, reservedBalance: 1, status: "reserved" });
expect(replay).toEqual(first);
expect(credits.readAccount(userId)).toMatchObject({ availableBalance: 9, reservedBalance: 1 });
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM credit_ledger WHERE entry_type = 'generation_reserve'").get()).toEqual({ count: 1 });
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM outbox_events WHERE aggregate_id = ?").get(generationId)).toEqual({ count: 1 });
expect(registration.database.prepare("SELECT reserved_credits, confirmed_credit_cost, final_credit_state FROM generation_jobs WHERE generation_id = ?").get(generationId))
.toEqual({ confirmed_credit_cost: 1, final_credit_state: null, reserved_credits: 1 });
expect(() => registration.database.prepare("UPDATE credit_ledger SET amount = 99 WHERE operation_key = ?").run(operationKey)).toThrow();
evidence("TDD-WP2-CRED-001-reserve", "request.json", request);
evidence("TDD-WP2-CRED-001-reserve", "response.json", first);
evidence("TDD-WP2-CRED-001-reserve", "db-diff.json", { account: credits.readAccount(userId), ledger_rows: 1, outbox_rows: 1 });
});
it("rejects insufficient available credit with no job, ledger, outbox or balance side effect", () => {
const { credits, registration, userId } = harness(0);
const generationId = randomUUID();
expect(() => credits.reserveGeneration({
creditCost: 1, generationId, modelId: "gemini-3.1-flash-image-preview",
operationKey: `generation:${generationId}:reserve`, userId,
})).toThrowError(expect.objectContaining<CreditError>({ code: "credit_insufficient" }));
const counts = {
jobs: (registration.database.prepare("SELECT COUNT(*) AS count FROM generation_jobs").get() as { count: number }).count,
ledger: (registration.database.prepare("SELECT COUNT(*) AS count FROM credit_ledger").get() as { count: number }).count,
outbox: (registration.database.prepare("SELECT COUNT(*) AS count FROM outbox_events").get() as { count: number }).count,
};
expect(counts).toEqual({ jobs: 0, ledger: 0, outbox: 0 });
expect(credits.readAccount(userId)).toMatchObject({ availableBalance: 0, reservedBalance: 0 });
evidence("TDD-WP2-CRED-001-insufficient", "response.json", { available_balance: 0, required_credits: 1, status: "insufficient" });
evidence("TDD-WP2-CRED-001-insufficient", "db-diff.json", { account: credits.readAccount(userId), ...counts });
evidence("TDD-WP2-CRED-001-insufficient", "external-calls.json", { calls: 0, staging_files: 0, storage_reservations: 0 });
});
});
describe("TDD-WP2-CRED-002-admin-adjustment", () => {
it("changes only available, replays by adjustment id, and rolls balance, ledger and audit back together", () => {
const { credits, projects, registration, userId } = harness(1);
const generation = projects.createProjectForGeneration({ ownerId: userId, prompt: "活动任务", ratio: "1:1", status: "queued" }).generation;
credits.reserveGeneration({ creditCost: 1, generationId: generation.generationId, modelId: "gemini-3.1-flash-image-preview", operationKey: `generation:${generation.generationId}:reserve`, userId });
const adminId = randomUUID();
registration.database.prepare(`
INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at)
VALUES (?, 'admin-credit@example.invalid', 'super_admin', 'active', 0, ?, ?)
`).run(adminId, randomUUID(), now);
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(adminId);
const adjustmentId = randomUUID();
const idempotencyKey = `admin-credit-${randomUUID()}-${randomUUID()}`;
const first = credits.adjustAvailable({ adjustmentId, adminId, amount: -5, idempotencyKey, reason: "测试额度校正", userId });
const replay = credits.adjustAvailable({ adjustmentId, adminId, amount: -5, idempotencyKey, reason: "测试额度校正", userId });
expect(first).toEqual({ adjustmentId, availableBalance: -5, reservedBalance: 1, status: "adjusted" });
expect(replay).toEqual(first);
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM credit_ledger WHERE entry_type = 'admin_adjustment'").get()).toEqual({ count: 1 });
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs WHERE operation_type = 'credit_adjustment'").get()).toEqual({ count: 1 });
expect(() => credits.adjustAvailable({ adjustmentId: randomUUID(), adminId, amount: 1, idempotencyKey: `admin-credit-${randomUUID()}-${randomUUID()}`, reason: " ", userId }))
.toThrowError(expect.objectContaining<CreditError>({ code: "credit_request_invalid" }));
registration.database.exec(`CREATE TRIGGER fail_credit_adjustment_audit BEFORE INSERT ON admin_operation_logs
WHEN NEW.operation_type = 'credit_adjustment' BEGIN SELECT RAISE(ABORT, 'forced_audit_failure'); END;`);
expect(() => credits.adjustAvailable({ adjustmentId: randomUUID(), adminId, amount: 2, idempotencyKey: `admin-credit-${randomUUID()}-${randomUUID()}`, reason: "审计失败回滚", userId })).toThrow();
registration.database.exec("DROP TRIGGER fail_credit_adjustment_audit");
expect(credits.readAccount(userId)).toMatchObject({ availableBalance: -5, reservedBalance: 1 });
const stored = registration.database.prepare(`
SELECT amount, reason, available_before, available_after, reserved_before, reserved_after
FROM credit_ledger WHERE entry_type = 'admin_adjustment'
`).get();
evidence("TDD-WP2-CRED-002-admin-adjustment", "db-diff.json", { account: credits.readAccount(userId), audit_rows: 1, ledger: stored });
});
});
@@ -0,0 +1,71 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { CreditService } from "../../apps/api/src/credits.js";
import { ProjectService } from "../../apps/api/src/projects.js";
import { RegistrationService } from "../../apps/api/src/registration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
import { settleGenerationCredits } from "../../apps/worker/src/credit-settlement.js";
const now = Date.parse("2026-08-02T12:00:00.000Z");
function evidence(file: string, value: unknown) {
const root = process.env.DADA_EVIDENCE_DIR_CREDITS;
if (!root) return;
const directory = resolve(root, "TDD-WP2-CRED-001-finalize-once");
mkdirSync(directory, { recursive: true });
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
}
describe("TDD-WP2-CRED-001-finalize-once", () => {
it("commits or releases each reservation exactly once across callback replay and restart", () => {
const root = mkdtempSync(join(tmpdir(), "dada-wp2-04-settle-"));
const databasePath = join(root, "dada.sqlite3");
const registration = new RegistrationService({
challengePepper: Buffer.alloc(32, 0x41), clock: () => now,
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath,
invitePepper: Buffer.alloc(32, 0x42), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x43),
});
const projects = new ProjectService({ clock: () => now, databasePath });
let credits = new CreditService({ clock: () => now, databasePath });
const rows = [];
for (const outcome of ["succeeded", "failed", "rejected"] as const) {
const userId = randomUUID();
registration.database.prepare(`INSERT INTO users (
user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at
) VALUES (?, ?, 'user', 'active', 1, ?, ?)`).run(userId, `${userId}@example.invalid`, randomUUID(), now);
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Settle User', '@settle')").run(userId);
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 1, 0, ?)").run(userId, now);
const generationId = projects.createProjectForGeneration({ ownerId: userId, prompt: outcome, ratio: "3:4", status: "queued" }).generation.generationId;
credits.reserveGeneration({ creditCost: 1, generationId, modelId: "gemini-3.1-flash-image-preview", operationKey: `generation:${generationId}:reserve`, userId });
const input = { generationId, operationKey: `generation:${generationId}:finalize`, outcome };
const first = settleGenerationCredits(credits, input);
const callbackReplay = settleGenerationCredits(credits, input);
credits.close();
credits = new CreditService({ clock: () => now, databasePath });
const restartReplay = settleGenerationCredits(credits, input);
expect(callbackReplay).toEqual(first);
expect(restartReplay).toEqual(first);
const account = credits.readAccount(userId);
expect(account).toMatchObject({ availableBalance: outcome === "succeeded" ? 0 : 1, reservedBalance: 0 });
const ledger = registration.database.prepare(`
SELECT entry_type, COUNT(*) AS count FROM credit_ledger
WHERE user_id = ? AND entry_type IN ('generation_commit', 'generation_release') GROUP BY entry_type
`).get(userId);
expect(ledger).toEqual({ count: 1, entry_type: outcome === "succeeded" ? "generation_commit" : "generation_release" });
rows.push({ account, generation_id: generationId, ledger, outcome });
}
evidence("worker-events.json", { replay_count: 2, settlements: rows });
evidence("db-diff.json", { invariant: "available_plus_reserved changes only on commit; release restores available", settlements: rows });
evidence("fs-before.json", { managed_files: 0, output_files: 0 });
evidence("fs-after.json", { managed_files: 0, output_files: 0 });
credits.close();
projects.close();
registration.close();
rmSync(root, { force: true, recursive: true });
});
});