feat: complete TASK-WP4-03 text templates

This commit is contained in:
suyx
2026-08-03 11:40:45 +08:00
parent d16802c164
commit dd7a23a281
24 changed files with 1934 additions and 20 deletions
+86
View File
@@ -87,6 +87,12 @@ import {
ProjectStateSaveResponseSchema,
ProjectSummarySchema,
ProjectViewStatusSchema,
RecentAssetItemSchema,
RecentAssetKindSchema,
RecentAssetListResponseSchema,
RecentAssetQuerySchema,
RecentAssetRecordRequestSchema,
RecentAssetRecordResponseSchema,
RegistrationCompleteHeadersSchema,
RegistrationCompleteRequestSchema,
RegistrationCompleteResponseSchema,
@@ -120,6 +126,8 @@ import {
type ProjectRenameRequest,
type ProjectEditableState,
type ProjectStateSaveHeaders,
type RecentAssetQuery,
type RecentAssetRecordRequest,
type RegistrationCompleteRequest,
type RegistrationSendRequest,
} from "@dada/shared-contracts";
@@ -162,6 +170,7 @@ import {
registrationFieldError,
} from "./registration-errors.js";
import type { RegistrationService } from "./registration.js";
import type { RecentAssetService } from "./recent-assets.js";
import { ModelConfigurationError } from "./model-configuration.js";
import type { ModelConfigurationService } from "./model-configuration.js";
@@ -189,6 +198,7 @@ export interface CreateAppOptions {
models?: ModelConfigurationService;
networkBoundary?: NetworkBoundaryOptions;
publicAssets?: PublicAssetResolver;
recentAssets?: RecentAssetService;
projects?: ProjectService;
registration?: RegistrationService;
}
@@ -726,6 +736,12 @@ export async function createApp(options: CreateAppOptions = {}) {
ProjectStateSaveHeadersSchema,
ProjectStateSaveResponseSchema,
ProjectStateConflictResponseSchema,
RecentAssetKindSchema,
RecentAssetItemSchema,
RecentAssetQuerySchema,
RecentAssetListResponseSchema,
RecentAssetRecordRequestSchema,
RecentAssetRecordResponseSchema,
FailedEmptyTrashRequestSchema,
FailedEmptyTrashResponseSchema,
ModelIdSchema,
@@ -810,6 +826,76 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
app.get(
"/api/v1/assets/recent",
{
attachValidation: true,
schema: {
operationId: "listRecentAssets",
querystring: Type.Ref(RecentAssetQuerySchema),
response: {
200: Type.Ref(RecentAssetListResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Assets"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(null);
if (!options.registration || !options.recentAssets) {
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 }));
const query = request.query as RecentAssetQuery;
return { items: options.recentAssets.list(session.userId, query.asset_kind) };
},
);
app.post(
"/api/v1/assets/recent",
{
attachValidation: true,
schema: {
body: Type.Ref(RecentAssetRecordRequestSchema),
headers: Type.Ref(CsrfHeadersSchema),
operationId: "recordRecentAsset",
response: {
200: Type.Ref(RecentAssetRecordResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Assets"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(null);
if (!options.registration || !options.recentAssets) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const csrfToken = headerValue(request.headers["x-csrf-token"]);
if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const owner = options.registration.authorizeUserMutation({ csrfToken, sessionToken: token });
const body = request.body as RecentAssetRecordRequest;
options.recentAssets.recordSuccessfulUse({
assetId: body.asset_id,
assetKind: body.asset_kind,
resourceVersion: body.resource_version,
userId: owner.userId,
});
return { status: "recorded" as const };
} catch (error) {
return registrationFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/admin-auth/login/send",
{
+4
View File
@@ -11,6 +11,7 @@ import { LatestExportService } from "./latest-exports.js";
import { CreditService } from "./credits.js";
import { ProjectService } from "./projects.js";
import { RegistrationService } from "./registration.js";
import { RecentAssetService } from "./recent-assets.js";
import { MockResendAdapter } from "./resend-adapter.js";
import { readSecureConfigCandidate } from "./secure-config.js";
import { StructuredJsonlLogger } from "./structured-log.js";
@@ -24,6 +25,7 @@ let credits: CreditService | undefined;
let storage: ManagedStorage | undefined;
let latestExports: LatestExportService | undefined;
let models: ModelConfigurationService | undefined;
let recentAssets: RecentAssetService | undefined;
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
if (credentialChannelEnabled) {
const clients = initializeApiCredentialClients(await receiveApiCredentials());
@@ -47,6 +49,7 @@ if (credentialChannelEnabled) {
storage = new ManagedStorage({ dataRoot, databasePath });
latestExports = new LatestExportService({ databasePath, storage });
models = new ModelConfigurationService({ database: registration.database });
recentAssets = new RecentAssetService({ database: registration.database });
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
} catch (error) {
latestExports?.close();
@@ -73,6 +76,7 @@ const app = await createApp({
...(models ? { models } : {}),
...(projects ? { projects } : {}),
...(registration ? { registration } : {}),
...(recentAssets ? { recentAssets } : {}),
});
await app.listen({
+67
View File
@@ -0,0 +1,67 @@
import { createRequire } from "node:module";
import type BetterSqlite3 from "better-sqlite3";
const require = createRequire(import.meta.url);
const Database = require("better-sqlite3") as typeof BetterSqlite3;
export type RecentAssetKind = "static_sticker" | "text_template";
export interface RecentAssetItem {
asset_id: string;
asset_kind: RecentAssetKind;
resource_version: string;
}
export class RecentAssetService {
readonly database: BetterSqlite3.Database;
private readonly ownsDatabase: boolean;
private readonly clock: () => number;
constructor(input: { clock?: () => number; database?: BetterSqlite3.Database; databasePath?: string }) {
if (!input.database && !input.databasePath) throw new Error("recent_asset_database_required");
this.database = input.database ?? new Database(input.databasePath!);
this.ownsDatabase = !input.database;
this.clock = input.clock ?? Date.now;
this.database.exec(`
CREATE TABLE IF NOT EXISTS recent_assets (
user_id TEXT NOT NULL,
asset_kind TEXT NOT NULL CHECK (asset_kind IN ('text_template', 'static_sticker')),
asset_id TEXT NOT NULL,
resource_version TEXT NOT NULL,
used_at INTEGER NOT NULL,
PRIMARY KEY (user_id, asset_kind, asset_id)
);
CREATE INDEX IF NOT EXISTS recent_assets_user_kind_used
ON recent_assets (user_id, asset_kind, used_at DESC, asset_id ASC);
`);
}
close() {
if (this.ownsDatabase) this.database.close();
}
list(userId: string, assetKind: RecentAssetKind, limit = 12): RecentAssetItem[] {
if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new Error("recent_asset_limit_invalid");
return this.database.prepare(`
SELECT asset_id, asset_kind, resource_version
FROM recent_assets
WHERE user_id = ? AND asset_kind = ?
ORDER BY used_at DESC, asset_id ASC
LIMIT ?
`).all(userId, assetKind, limit) as RecentAssetItem[];
}
recordSuccessfulUse(input: { assetId: string; assetKind: RecentAssetKind; resourceVersion: string; userId: string }) {
if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/.test(input.assetId)
|| !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/.test(input.resourceVersion)) {
throw new Error("recent_asset_reference_invalid");
}
this.database.prepare(`
INSERT INTO recent_assets (user_id, asset_kind, asset_id, resource_version, used_at)
VALUES (@userId, @assetKind, @assetId, @resourceVersion, @usedAt)
ON CONFLICT (user_id, asset_kind, asset_id) DO UPDATE SET
resource_version = excluded.resource_version,
used_at = excluded.used_at
`).run({ ...input, usedAt: this.clock() });
}
}