feat(P0-A): 整合第一版并冻结最终发布 #1
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? {} });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user