feat: complete TASK-WP2-04 credit ledger

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