feat: add admin accounts and image templates

This commit is contained in:
inman
2026-07-03 11:25:25 +08:00
parent d98e58adfa
commit c3ea9f0eb1
61 changed files with 7016 additions and 199 deletions

View File

@@ -21,7 +21,7 @@ export type AuthRuntimeConfig = {
export function getAuthRuntimeConfig(): AuthRuntimeConfig {
const authBaseUrl = trimTrailingSlash(envValue("ZHINIAN_AUTH_BASE_URL", "AUTH_BASE"));
const clientId = envValue("ZHINIAN_AUTH_CLIENT_ID", "AUTH_CLIENT_ID") || "customPC";
const clientId = envValue("ZHINIAN_AUTH_CLIENT_ID", "AUTH_CLIENT_ID") || "app";
const clientSecret = envValue("ZHINIAN_AUTH_CLIENT_SECRET", "AUTH_CLIENT_SECRET");
const scope = envValue("ZHINIAN_AUTH_SCOPE", "AUTH_SCOPE") || "server";
const issuer = envValue("ZHINIAN_AUTH_ISSUER", "AUTH_ISSUER") || "https://pig4cloud.com";

77
lib/auth/permissions.ts Normal file
View File

@@ -0,0 +1,77 @@
import type { AuthUser } from "@/lib/auth/session";
const DEFAULT_ADMIN_AUTHORITIES = [
"ROLE_ADMIN",
"ROLE_1",
"1",
"ADMIN",
"SUPER_ADMIN",
"SYS_ADMIN",
"ZHINIAN_ADMIN",
"sys_user_view",
"sys_user_add",
"sys_user_edit",
"sys_role_view",
"sys_log_view",
"sys_config_view",
"sys_client_view"
];
const DEFAULT_ADMIN_USERS = [
"ceshiop"
];
const ADMIN_PREFIXES = [
"SYS_USER_",
"SYS_ROLE_",
"SYS_MENU_",
"SYS_LOG_",
"SYS_CONFIG_",
"SYS_CLIENT_",
"ADMIN:"
];
export function configuredAdminAuthorities(): string[] {
const configured = process.env.ZHINIAN_ADMIN_AUTHORITIES?.trim();
if (!configured) return DEFAULT_ADMIN_AUTHORITIES;
return configured
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
}
export function configuredAdminUsers(): string[] {
const configured = process.env.ZHINIAN_ADMIN_USERS?.trim();
if (!configured) return DEFAULT_ADMIN_USERS;
return configured
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
}
export function hasAdminAccess(
user: AuthUser | null | undefined,
adminAuthorities = configuredAdminAuthorities(),
adminUsers = configuredAdminUsers()
): boolean {
if (!user) return false;
const allowedUsers = new Set(adminUsers.map(normalizeAccountName));
const identities = [user.username, user.subject, user.displayName, user.id]
.map((item) => item ? normalizeAccountName(item) : "")
.filter(Boolean);
if (identities.some((identity) => allowedUsers.has(identity))) return true;
const allowed = new Set(adminAuthorities.map(normalizeAuthority));
return user.authorities.some((authority) => {
const normalized = normalizeAuthority(authority);
return allowed.has(normalized) || ADMIN_PREFIXES.some((prefix) => normalized.startsWith(prefix));
});
}
export function normalizeAuthority(value: string): string {
return value.trim().replace(/[-\s]+/g, "_").toUpperCase();
}
function normalizeAccountName(value: string): string {
return value.trim().toLowerCase();
}

View File

@@ -14,10 +14,14 @@ export type AuthSession = {
user: AuthUser;
issuedAt: number;
expiresAt: number;
accessToken?: string;
tokenType?: string;
};
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
const DEFAULT_COOKIE_CHUNK_SIZE = 3000;
const DEFAULT_COOKIE_MAX_CHUNKS = 20;
export async function createSignedJsonValue(value: unknown, secret: string): Promise<string> {
const payload = bytesToBase64Url(textEncoder.encode(JSON.stringify(value)));
@@ -42,6 +46,39 @@ export async function createSessionCookieValue(session: AuthSession, secret: str
return createSignedJsonValue(session, secret);
}
export function chunkCookieValue(value: string, chunkSize = DEFAULT_COOKIE_CHUNK_SIZE): string[] {
if (value.length <= chunkSize) return [value];
const chunks: string[] = [];
for (let index = 0; index < value.length; index += chunkSize) {
chunks.push(value.slice(index, index + chunkSize));
}
return chunks;
}
export function chunkedCookieName(baseName: string, index: number): string {
return index === 0 ? baseName : `${baseName}.${index}`;
}
export function chunkedCookieNames(baseName: string, maxChunks = DEFAULT_COOKIE_MAX_CHUNKS): string[] {
return Array.from({ length: maxChunks }, (_, index) => chunkedCookieName(baseName, index));
}
export function readChunkedCookieValue(
baseName: string,
getValue: (name: string) => string | undefined,
maxChunks = DEFAULT_COOKIE_MAX_CHUNKS
): string | undefined {
const first = getValue(baseName);
if (!first) return undefined;
let value = first;
for (let index = 1; index < maxChunks; index += 1) {
const chunk = getValue(chunkedCookieName(baseName, index));
if (!chunk) break;
value += chunk;
}
return value;
}
export async function parseSessionCookieValue(
value: string | undefined,
secret: string,
@@ -53,6 +90,8 @@ export async function parseSessionCookieValue(
if (session.expiresAt <= nowSeconds) return null;
return {
...session,
accessToken: typeof session.accessToken === "string" ? session.accessToken : undefined,
tokenType: typeof session.tokenType === "string" ? session.tokenType : undefined,
user: {
...session.user,
authorities: Array.isArray(session.user.authorities) ? session.user.authorities : [],

View File

@@ -32,13 +32,22 @@ export function getSelectedImageEngine(): ImageCreationEngine {
return value === "evolink" ? "evolink" : "jimeng";
}
export function getEffectiveImageEngine(capability: EnabledImageCapability): ImageCreationEngine {
export function getEffectiveImageEngine(capability: EnabledImageCapability, override?: unknown): ImageCreationEngine {
if (capability === "image.upscale") return "jimeng";
const overrideEngine = normalizeImageEngine(override);
if (overrideEngine) return overrideEngine;
if (capability === "image.generate") return selectedEngineFrom(process.env.IMAGE_GENERATE_ENGINE);
if (capability === "image.inpaint") return selectedEngineFrom(process.env.IMAGE_INPAINT_ENGINE);
return getSelectedImageEngine();
}
export function normalizeImageEngine(value: unknown): ImageCreationEngine | undefined {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (normalized === "evolink") return "evolink";
if (normalized === "jimeng") return "jimeng";
return undefined;
}
function selectedEngineFrom(value: string | undefined): ImageCreationEngine {
const normalized = (value || "").trim().toLowerCase();
if (normalized === "evolink") return "evolink";

View File

@@ -1,4 +1,5 @@
import { getTemplateById, type VideoTemplate } from "@/lib/content/video-templates";
import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders";
export type StoryboardScene = {
id: string;
@@ -112,12 +113,8 @@ export function assemblePrompt(input: PromptAssemblyInput): PromptAssemblyResult
export function extractMaterialRequirements(prompt: string) {
const requirements = { image: 0, video: 0, audio: 0 };
for (const match of prompt.matchAll(/@(参考视频|图片|图|视频|音频)(\d*)/g)) {
const kind = match[1];
const index = match[2] ? Number(match[2]) : 1;
if (kind === "图片" || kind === "图") requirements.image = Math.max(requirements.image, index);
if (kind === "视频" || kind === "参考视频") requirements.video = Math.max(requirements.video, index);
if (kind === "音频") requirements.audio = Math.max(requirements.audio, index);
for (const placeholder of extractMaterialPlaceholders(prompt)) {
requirements[placeholder.type] = Math.max(requirements[placeholder.type], placeholder.index);
}
return requirements;
}

View File

@@ -0,0 +1,63 @@
import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders";
export type MaterialDraftKind = "image" | "video" | "audio";
const tokenBoundaryBeforePattern = /[\s(【\[]$/;
const tokenBoundaryAfterPattern = /^[\s)】\]]/;
export function detectMaterialDraftStart(previous: string, next: string, cursor: number): { text: string; index: number } | null {
const index = cursor - 1;
if (index < 0 || next[index] !== "@") return null;
if (next.length !== previous.length + 1) return null;
if (`${next.slice(0, index)}${next.slice(cursor)}` !== previous) return null;
return { text: previous, index };
}
export function normalizeMaterialDraftToken(input: string, prompt: string, defaultKind: MaterialDraftKind = "image"): string | null {
const query = input.replace(/^@+/, "").trim().replace(/\s+/g, "");
if (!query) return nextMaterialDraftToken(defaultKind, prompt);
const explicit = query.match(/^(参考视频|图片|图|视频|音频)(\d+)$/);
if (explicit) {
const kind = kindFromText(explicit[1]);
const index = Number(explicit[2]);
if (Number.isInteger(index) && index > 0) return labelForDraftKind(kind, index);
return null;
}
const kindOnly = query.match(/^(参考视频|图片|图|视频|音频)$/);
if (kindOnly) return nextMaterialDraftToken(kindFromText(kindOnly[1]), prompt);
return null;
}
export function nextMaterialDraftToken(kind: MaterialDraftKind, prompt: string): string {
const placeholders = extractMaterialPlaceholders(prompt).filter((placeholder) => placeholder.type === kind);
const nextIndex = placeholders.reduce((max, placeholder) => Math.max(max, placeholder.index), 0) + 1;
return labelForDraftKind(kind, nextIndex);
}
export function insertMaterialDraftToken(text: string, index: number, token: string): { text: string; cursor: number } {
const safeIndex = Math.max(0, Math.min(index, text.length));
const before = text.slice(0, safeIndex);
const after = text.slice(safeIndex);
const needsLeadingSpace = before.length > 0 && !tokenBoundaryBeforePattern.test(before);
const needsTrailingSpace = after.length === 0 || !tokenBoundaryAfterPattern.test(after);
const insertion = `${needsLeadingSpace ? " " : ""}${token}${needsTrailingSpace ? " " : ""}`;
return {
text: `${before}${insertion}${after}`,
cursor: before.length + insertion.length
};
}
export function labelForDraftKind(kind: MaterialDraftKind, index: number): string {
if (kind === "video") return `@视频${index}`;
if (kind === "audio") return `@音频${index}`;
return `@图片${index}`;
}
function kindFromText(value: string): MaterialDraftKind {
if (value === "视频" || value === "参考视频") return "video";
if (value === "音频") return "audio";
return "image";
}

View File

@@ -0,0 +1,35 @@
export type MaterialPlaceholder = {
token: string;
type: "image" | "video" | "audio";
index: number;
};
const materialTokenPattern = /@(参考视频|图片|图|视频|音频)(\d+)/g;
export function extractMaterialPlaceholders(prompt: string): MaterialPlaceholder[] {
const placeholders: MaterialPlaceholder[] = [];
const seen = new Set<string>();
for (const match of prompt.matchAll(materialTokenPattern)) {
const kind = match[1];
const index = Number(match[2]);
if (!Number.isInteger(index) || index < 1) continue;
const type = typeForKind(kind);
const token = labelForPlaceholder(type, index);
if (seen.has(token)) continue;
seen.add(token);
placeholders.push({ token, type, index });
}
return placeholders;
}
function typeForKind(kind: string): MaterialPlaceholder["type"] {
if (kind === "视频" || kind === "参考视频") return "video";
if (kind === "音频") return "audio";
return "image";
}
function labelForPlaceholder(type: MaterialPlaceholder["type"], index: number) {
if (type === "video") return `@视频${index}`;
if (type === "audio") return `@音频${index}`;
return `@图片${index}`;
}

View File

@@ -4,6 +4,7 @@ import { getEvolinkImageSettings, getSelectedImageEngine, shouldMockEvolinkApi,
import { authConfigSummary, getAuthRuntimeConfig } from "@/lib/auth/config";
import { getJimengCapabilities } from "@/lib/jimeng/capabilities";
import { getSeedanceConfig, shouldMockSeedance } from "@/lib/seedance/client";
import { accountManagementConfigured } from "@/lib/server/organization-client";
import { rootDir } from "@/lib/server/runtime";
import { shouldMockVisualApi } from "@/lib/volcengine/visual-client";
import type { EnabledImageCapability } from "@/lib/types";
@@ -65,7 +66,7 @@ const settingDefinitions: Array<{
]
},
{ key: "ZHINIAN_AUTH_BASE_URL", label: "Auth Base URL" },
{ key: "ZHINIAN_AUTH_CLIENT_ID", label: "客户端 ID", defaultValue: "customPC" },
{ key: "ZHINIAN_AUTH_CLIENT_ID", label: "客户端 ID", defaultValue: "app" },
{ key: "ZHINIAN_AUTH_CLIENT_SECRET", label: "客户端密钥", secret: true, type: "password" },
{ key: "ZHINIAN_AUTH_SCOPE", label: "Scope", defaultValue: "server" },
{ key: "ZHINIAN_AUTH_ISSUER", label: "Issuer", defaultValue: "https://pig4cloud.com" },
@@ -73,6 +74,21 @@ const settingDefinitions: Array<{
{ key: "ZHINIAN_AUTH_SESSION_SECRET", label: "会话签名密钥", secret: true, type: "password" }
]
},
{
id: "organization",
title: "组织账号接口",
description: "用于后台账号管理的组织、成员、角色和部门接口;服务端代调用组织服务。",
fields: [
{ key: "ZHINIAN_ADMIN_AUTHORITIES", label: "管理员权限码", description: "逗号分隔,如 ROLE_ADMIN,sys_user_view" },
{ key: "ZHINIAN_ADMIN_USERS", label: "管理员账号", description: "逗号分隔,默认 ceshiop" },
{ key: "ZHINIAN_ORG_API_BASE_URL", label: "组织服务 Base URL", description: "如 https://gateway.example.com/hotelStaff" },
{ key: "ZHINIAN_ORG_API_TOKEN", label: "组织服务备用 Token", secret: true, type: "password" },
{ key: "ZHINIAN_STAFF_API_BASE_URL", label: "企业端用户服务 Base URL", description: "如 https://gateway.example.com/hotel-staff-server-biz" },
{ key: "ZHINIAN_STAFF_API_TOKEN", label: "企业端用户服务备用 Token", secret: true, type: "password" },
{ key: "ZHINIAN_ORG_TENANT_ID", label: "租户 ID" },
{ key: "ZHINIAN_ORG_ID", label: "默认组织 ID" }
]
},
{
id: "visual",
title: "即梦图片 API",
@@ -178,6 +194,7 @@ export async function getApiSettings() {
evolink: shouldMockEvolinkApi() ? "mock" : "real",
seedance: shouldMockSeedance() ? "mock" : "real",
auth: authConfigSummary(auth),
organization: accountManagementConfigured() ? "configured" : "missing",
data: process.env.SUPABASE_SERVICE_ROLE_KEY ? "supabase" : "local"
},
capabilities: [

View File

@@ -1,6 +1,7 @@
import { cookies } from "next/headers";
import { SESSION_COOKIE_NAME, getAuthRuntimeConfig } from "@/lib/auth/config";
import { parseSessionCookieValue, type AuthSession, type AuthUser } from "@/lib/auth/session";
import { hasAdminAccess } from "@/lib/auth/permissions";
import { parseSessionCookieValue, readChunkedCookieValue, type AuthSession, type AuthUser } from "@/lib/auth/session";
import { DEFAULT_OWNER_ID } from "@/lib/server/runtime";
export class AuthRequiredError extends Error {
@@ -27,38 +28,77 @@ const localUser: AuthUser = {
username: "demo",
displayName: "智念演示用户",
clientId: "local-dev",
authorities: [],
authorities: ["zhinian_admin"],
scope: []
};
function localSession(): AuthSession {
const now = Math.floor(Date.now() / 1000);
return {
version: 1,
user: localUser,
issuedAt: now,
expiresAt: now + 24 * 60 * 60
};
}
export async function getOptionalAuthSession(): Promise<AuthSession | null> {
const config = getAuthRuntimeConfig();
if (!config.sessionSecret) return null;
const cookieStore = await cookies();
return parseSessionCookieValue(cookieStore.get(SESSION_COOKIE_NAME)?.value, config.sessionSecret);
return parseSessionCookieValue(
readChunkedCookieValue(SESSION_COOKIE_NAME, (name) => cookieStore.get(name)?.value),
config.sessionSecret
);
}
export async function requireAppUser(): Promise<AuthUser> {
export async function requireAppSession(): Promise<AuthSession> {
const session = await getOptionalAuthSession();
if (session) return session.user;
if (session) return session;
const config = getAuthRuntimeConfig();
if (!config.required) return localUser;
if (!config.required) return localSession();
if (!config.configured) {
throw new AuthConfigurationError(`认证配置不完整:${config.missing.join(", ") || "未知配置"}`);
}
throw new AuthRequiredError();
}
export async function requireAppUser(): Promise<AuthUser> {
return (await requireAppSession()).user;
}
export class AdminRequiredError extends Error {
status = 403;
constructor(message = "需要管理员权限。") {
super(message);
this.name = "AdminRequiredError";
}
}
export async function requireAdminUser(): Promise<AuthUser> {
return (await requireAdminSession()).user;
}
export async function requireAdminSession(): Promise<AuthSession> {
const session = await requireAppSession();
if (!hasAdminAccess(session.user)) throw new AdminRequiredError();
return session;
}
export async function getShellAuthState(): Promise<{
user: AuthUser | null;
authRequired: boolean;
authConfigured: boolean;
isAdmin: boolean;
}> {
const config = getAuthRuntimeConfig();
const session = await getOptionalAuthSession();
const user = session?.user || (!config.required ? localUser : null);
return {
user: session?.user || null,
user,
authRequired: config.required,
authConfigured: config.configured
authConfigured: config.configured,
isAdmin: hasAdminAccess(user)
};
}

View File

@@ -75,7 +75,8 @@ export async function verifyAuthJwt(token: string, config = getAuthRuntimeConfig
export function createSessionFromClaims(
claims: AuthTokenClaims,
config: AuthRuntimeConfig,
tokenResponseExpiresIn?: number
tokenResponseExpiresIn?: number,
token?: { accessToken?: string; tokenType?: string }
): AuthSession {
const now = Math.floor(Date.now() / 1000);
const jwtExpiry = numberClaim(claims.exp);
@@ -85,7 +86,9 @@ export function createSessionFromClaims(
version: 1,
user: userFromClaims(claims, config),
issuedAt: now,
expiresAt
expiresAt,
accessToken: token?.accessToken,
tokenType: token?.tokenType
};
}

View File

@@ -1,7 +1,6 @@
import { NextResponse } from "next/server";
import {
AUTH_STATE_COOKIE_NAME,
SESSION_COOKIE_NAME,
getAuthRuntimeConfig,
safeNextPath,
shouldUseSecureAuthCookie,
@@ -13,6 +12,7 @@ import {
parseSignedJsonValue
} from "@/lib/auth/session";
import { createSessionFromClaims, verifyAuthJwt } from "@/lib/server/auth/jwt";
import { clearSessionCookieValues, setSessionCookieValue } from "@/lib/server/auth/session-cookie";
import { requestOrigin } from "@/lib/server/runtime";
export type AuthStateCookie = {
@@ -90,18 +90,16 @@ export async function completeAuthorizationCallback(request: Request): Promise<N
const token = await exchangeAuthorizationCode(code, authRedirectUri(request), config);
if (!token.access_token) throw new OAuthLoginError("认证中心没有返回 access_token。");
const claims = await verifyAuthJwt(token.access_token, config);
const session = createSessionFromClaims(claims, config, token.expires_in);
const session = createSessionFromClaims(claims, config, token.expires_in, {
accessToken: token.access_token,
tokenType: token.token_type
});
const response = NextResponse.redirect(new URL(stateCookie.next, request.url));
response.cookies.set(
SESSION_COOKIE_NAME,
setSessionCookieValue(
response,
request.url,
await createSessionCookieValue(session, config.sessionSecret || ""),
{
httpOnly: true,
sameSite: "lax",
secure: shouldUseSecureAuthCookie(request.url),
path: "/",
expires: new Date(session.expiresAt * 1000)
}
new Date(session.expiresAt * 1000)
);
response.cookies.set(AUTH_STATE_COOKIE_NAME, "", clearCookieOptions(request.url));
return response;
@@ -109,7 +107,7 @@ export async function completeAuthorizationCallback(request: Request): Promise<N
export function clearAuthCookies(request: Request, redirectTo = "/auth/login?loggedOut=1"): NextResponse {
const response = NextResponse.redirect(new URL(redirectTo, request.url));
response.cookies.set(SESSION_COOKIE_NAME, "", clearCookieOptions(request.url));
clearSessionCookieValues(response, request.url);
response.cookies.set(AUTH_STATE_COOKIE_NAME, "", clearCookieOptions(request.url));
return response;
}

View File

@@ -0,0 +1,45 @@
import type { NextResponse } from "next/server";
import { SESSION_COOKIE_NAME, shouldUseSecureAuthCookie } from "@/lib/auth/config";
import { chunkCookieValue, chunkedCookieName, chunkedCookieNames } from "@/lib/auth/session";
const MAX_SESSION_COOKIE_CHUNKS = 20;
type CookieResponse = Pick<NextResponse, "cookies">;
export function setSessionCookieValue(
response: CookieResponse,
requestUrl: string,
value: string,
expires: Date
) {
const chunks = chunkCookieValue(value);
const options = {
httpOnly: true,
sameSite: "lax" as const,
secure: shouldUseSecureAuthCookie(requestUrl),
path: "/",
expires
};
chunks.forEach((chunk, index) => {
response.cookies.set(chunkedCookieName(SESSION_COOKIE_NAME, index), chunk, options);
});
for (let index = chunks.length; index < MAX_SESSION_COOKIE_CHUNKS; index += 1) {
response.cookies.set(chunkedCookieName(SESSION_COOKIE_NAME, index), "", clearSessionCookieOptions(requestUrl));
}
}
export function clearSessionCookieValues(response: CookieResponse, requestUrl: string) {
for (const name of chunkedCookieNames(SESSION_COOKIE_NAME, MAX_SESSION_COOKIE_CHUNKS)) {
response.cookies.set(name, "", clearSessionCookieOptions(requestUrl));
}
}
function clearSessionCookieOptions(requestUrl: string) {
return {
httpOnly: true,
sameSite: "lax" as const,
secure: shouldUseSecureAuthCookie(requestUrl),
path: "/",
maxAge: 0
};
}

View File

@@ -1,7 +1,7 @@
import { readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
import type { AppState, Asset, GenerationCapability, GenerationJob, GenerationStatus, Project, UsageEvent } from "@/lib/types";
import type { AppState, Asset, GenerationCapability, GenerationJob, GenerationStatus, ImageTemplate, Project, UsageEvent } from "@/lib/types";
import { createId } from "@/lib/server/ids";
import { dataDir, DEFAULT_OWNER_ID, ensureRuntimeDirs } from "@/lib/server/runtime";
@@ -11,6 +11,7 @@ let localWriteQueue: Promise<unknown> = Promise.resolve();
type AssetInput = Omit<Asset, "id" | "createdAt" | "updatedAt"> & Partial<Pick<Asset, "id" | "createdAt" | "updatedAt">>;
type JobInput = Omit<GenerationJob, "id" | "createdAt" | "updatedAt"> & Partial<Pick<GenerationJob, "id" | "createdAt" | "updatedAt">>;
type UsageInput = Omit<UsageEvent, "id" | "createdAt"> & Partial<Pick<UsageEvent, "id" | "createdAt">>;
type ImageTemplateInput = Omit<ImageTemplate, "id" | "createdAt" | "updatedAt"> & Partial<Pick<ImageTemplate, "id" | "createdAt" | "updatedAt">>;
export type GenerationJobListFilters = {
ownerId?: string;
@@ -339,6 +340,108 @@ export async function listProjects(ownerId = DEFAULT_OWNER_ID): Promise<Project[
return state.projects.filter((project) => project.ownerId === ownerId).sort(sortNewest);
}
export async function listImageTemplates(ownerId = DEFAULT_OWNER_ID): Promise<ImageTemplate[]> {
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase
.from("image_templates")
.select("*")
.eq("owner_id", ownerId)
.order("sort_order", { ascending: true })
.order("updated_at", { ascending: false });
if (error) throw new Error(error.message);
return (data || []).map(imageTemplateFromRow);
}
const state = await readState();
return state.imageTemplates
.filter((template) => template.ownerId === ownerId)
.sort(sortTemplates);
}
export async function getImageTemplate(id: string): Promise<ImageTemplate | null> {
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase.from("image_templates").select("*").eq("id", id).maybeSingle();
if (error) throw new Error(error.message);
return data ? imageTemplateFromRow(data) : null;
}
const state = await readState();
return state.imageTemplates.find((template) => template.id === id) || null;
}
export async function createImageTemplate(input: ImageTemplateInput): Promise<ImageTemplate> {
const now = new Date().toISOString();
const template: ImageTemplate = {
...input,
id: input.id || createId("tmpl"),
ownerId: input.ownerId || DEFAULT_OWNER_ID,
settings: input.settings || {},
sortOrder: input.sortOrder ?? 0,
createdAt: input.createdAt || now,
updatedAt: input.updatedAt || now
};
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase.from("image_templates").insert(imageTemplateToRow(template)).select("*").single();
if (error) throw new Error(error.message);
return imageTemplateFromRow(data);
}
return mutateLocalState((state) => {
state.imageTemplates.unshift(template);
state.imageTemplates.sort(sortTemplates);
return template;
});
}
export async function updateImageTemplate(
id: string,
ownerId: string,
patch: Partial<Omit<ImageTemplate, "id" | "ownerId" | "createdAt" | "updatedAt">>
): Promise<ImageTemplate | null> {
const existing = await getImageTemplate(id);
if (!existing || existing.ownerId !== ownerId) return null;
const updated: ImageTemplate = {
...existing,
...patch,
settings: patch.settings || existing.settings,
updatedAt: new Date().toISOString()
};
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase
.from("image_templates")
.update(imageTemplateToRow(updated))
.eq("id", id)
.eq("owner_id", ownerId)
.select("*")
.maybeSingle();
if (error) throw new Error(error.message);
return data ? imageTemplateFromRow(data) : null;
}
return mutateLocalState((state) => {
const index = state.imageTemplates.findIndex((template) => template.id === id && template.ownerId === ownerId);
if (index === -1) return null;
state.imageTemplates[index] = updated;
state.imageTemplates.sort(sortTemplates);
return updated;
});
}
export async function deleteImageTemplate(id: string, ownerId: string): Promise<ImageTemplate | null> {
const existing = await getImageTemplate(id);
if (!existing || existing.ownerId !== ownerId) return null;
const supabase = getSupabaseAdmin();
if (supabase) {
const { error } = await supabase.from("image_templates").delete().eq("id", id).eq("owner_id", ownerId);
if (error) throw new Error(error.message);
return existing;
}
return mutateLocalState((state) => {
state.imageTemplates = state.imageTemplates.filter((template) => !(template.id === id && template.ownerId === ownerId));
return existing;
});
}
async function readState(): Promise<AppState> {
await ensureRuntimeDirs();
const path = join(dataDir(), STORE_FILE);
@@ -378,7 +481,8 @@ function normalizeState(raw: Partial<AppState>): AppState {
assets: raw.assets || [],
generationJobs: raw.generationJobs || [],
usageEvents: raw.usageEvents || [],
projects: raw.projects || []
projects: raw.projects || [],
imageTemplates: raw.imageTemplates || []
};
}
@@ -395,6 +499,12 @@ function sortNewest<T extends { createdAt: string }>(a: T, b: T): number {
return b.createdAt.localeCompare(a.createdAt);
}
function sortTemplates(a: ImageTemplate, b: ImageTemplate): number {
const sortOrder = (a.sortOrder || 0) - (b.sortOrder || 0);
if (sortOrder !== 0) return sortOrder;
return b.updatedAt.localeCompare(a.updatedAt);
}
function isClaimableJob(job: GenerationJob, nowIso: string, staleBefore: string): boolean {
if (["succeeded", "failed", "expired", "cancelled"].includes(job.status)) return false;
if ((job.scheduledAt || job.createdAt) > nowIso) return false;
@@ -545,6 +655,43 @@ function usageFromRow(row: Record<string, unknown>): UsageEvent {
};
}
function imageTemplateToRow(template: Partial<ImageTemplate>) {
const row: Record<string, unknown> = {};
if (template.id !== undefined) row.id = template.id;
if (template.ownerId !== undefined) row.owner_id = template.ownerId;
if (template.name !== undefined) row.name = template.name;
if (template.description !== undefined) row.description = template.description || null;
if (template.prompt !== undefined) row.prompt = template.prompt;
if (template.previewImageUrl !== undefined) row.preview_image_url = template.previewImageUrl || null;
if (template.settings !== undefined) row.settings = template.settings;
if (template.sortOrder !== undefined) row.sort_order = template.sortOrder;
if (template.createdAt !== undefined) row.created_at = template.createdAt;
if (template.updatedAt !== undefined) row.updated_at = template.updatedAt;
return row;
}
function imageTemplateFromRow(row: Record<string, unknown>): ImageTemplate {
return {
id: String(row.id),
ownerId: String(row.owner_id),
name: String(row.name || ""),
description: optionalString(row.description),
prompt: String(row.prompt || ""),
previewImageUrl: optionalString(row.preview_image_url),
settings: isRecord(row.settings) ? {
engine: row.settings.engine === "jimeng" || row.settings.engine === "evolink" ? row.settings.engine : undefined,
width: optionalNumber(row.settings.width),
height: optionalNumber(row.settings.height),
forceSingle: typeof row.settings.forceSingle === "boolean" ? row.settings.forceSingle : undefined,
scale: optionalNumber(row.settings.scale),
quality: row.settings.quality === "low" || row.settings.quality === "medium" || row.settings.quality === "high" ? row.settings.quality : undefined
} : {},
sortOrder: optionalNumber(row.sort_order) ?? 0,
createdAt: String(row.created_at),
updatedAt: String(row.updated_at)
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View File

@@ -14,6 +14,7 @@ import {
queryEvolinkTask,
shouldMockEvolinkApi,
submitEvolinkImageTask,
type ImageCreationEngine,
type EvolinkTaskResponse
} from "@/lib/evolink/image-client";
import {
@@ -32,6 +33,7 @@ export type SubmitImageJobInput = {
ownerId?: string;
externalClientId?: string;
capability: EnabledImageCapability;
engine?: ImageCreationEngine;
prompt?: string;
imageUrls?: string[];
inputAssetIds?: string[];
@@ -56,7 +58,7 @@ export async function submitImageJob(input: SubmitImageJobInput, origin: string)
const ownerId = input.ownerId || DEFAULT_OWNER_ID;
const capability = getEnabledImageCapability(input.capability);
const normalizedUrls = (input.imageUrls || []).map((url) => toAbsoluteUrl(url, origin));
const engine = getEffectiveImageEngine(input.capability);
const engine = getEffectiveImageEngine(input.capability, input.engine);
const providerPayload = engine === "evolink"
? buildEvolinkImagePayload(input.capability, { ...input, imageUrls: normalizedUrls })
: buildJimengPayload(input.capability, capability.reqKey, { ...input, imageUrls: normalizedUrls });

View File

@@ -0,0 +1,104 @@
import type { ImageTemplate } from "@/lib/types";
type ImageTemplateBody = {
name?: unknown;
description?: unknown;
prompt?: unknown;
previewImageUrl?: unknown;
settings?: unknown;
sortOrder?: unknown;
};
type ImageTemplatePatch = Partial<Omit<ImageTemplate, "id" | "ownerId" | "createdAt" | "updatedAt">>;
export function normalizeImageTemplateCreate(body: ImageTemplateBody): Omit<ImageTemplate, "id" | "ownerId" | "createdAt" | "updatedAt"> {
return {
name: requiredText(body.name, "模板名称", 80),
description: optionalText(body.description, 240),
prompt: requiredText(body.prompt, "预设提示词", 4000),
previewImageUrl: optionalPreviewUrl(body.previewImageUrl),
settings: normalizeTemplateSettings(body.settings),
sortOrder: optionalNumber(body.sortOrder) ?? 0
};
}
export function normalizeImageTemplateUpdate(body: ImageTemplateBody): ImageTemplatePatch {
const patch: ImageTemplatePatch = {};
if ("name" in body) patch.name = requiredText(body.name, "模板名称", 80);
if ("description" in body) patch.description = optionalText(body.description, 240);
if ("prompt" in body) patch.prompt = requiredText(body.prompt, "预设提示词", 4000);
if ("previewImageUrl" in body) patch.previewImageUrl = optionalPreviewUrl(body.previewImageUrl);
if ("settings" in body) patch.settings = normalizeTemplateSettings(body.settings);
if ("sortOrder" in body) patch.sortOrder = optionalNumber(body.sortOrder) ?? 0;
return patch;
}
function requiredText(value: unknown, label: string, maxLength: number): string {
const text = optionalText(value, maxLength);
if (!text) throw new Error(`${label}不能为空`);
return text;
}
function optionalText(value: unknown, maxLength: number): string | undefined {
if (value === undefined || value === null) return undefined;
const text = String(value).trim();
if (!text) return undefined;
return text.slice(0, maxLength);
}
function optionalPreviewUrl(value: unknown): string | undefined {
const url = optionalText(value, 1000);
if (!url) return undefined;
if (url.startsWith("/") || /^https?:\/\//i.test(url)) return url;
throw new Error("效果预览图地址必须是 http(s) 或站内路径");
}
function normalizeTemplateSettings(value: unknown): ImageTemplate["settings"] {
if (!isRecord(value)) return {};
const width = optionalPositiveInteger(value.width);
const height = optionalPositiveInteger(value.height);
const engine = normalizeImageEngine(value.engine);
const scale = optionalScale(value.scale);
const quality = normalizeQuality(value.quality);
return {
...(engine ? { engine } : {}),
...(width && height ? { width, height } : {}),
...(typeof value.forceSingle === "boolean" ? { forceSingle: value.forceSingle } : {}),
...(scale !== undefined ? { scale } : {}),
...(quality ? { quality } : {})
};
}
function optionalNumber(value: unknown): number | undefined {
if (value === undefined || value === null || value === "") return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.trunc(parsed) : undefined;
}
function optionalPositiveInteger(value: unknown): number | undefined {
const parsed = optionalNumber(value);
if (!parsed || parsed < 1) return undefined;
return Math.min(parsed, 8192);
}
function optionalScale(value: unknown): number | undefined {
const parsed = optionalNumber(value);
if (parsed === undefined) return undefined;
return Math.max(1, Math.min(100, parsed));
}
function normalizeImageEngine(value: unknown): ImageTemplate["settings"]["engine"] | undefined {
const text = optionalText(value, 20);
if (text === "jimeng" || text === "evolink") return text;
return undefined;
}
function normalizeQuality(value: unknown): ImageTemplate["settings"]["quality"] | undefined {
const text = optionalText(value, 20);
if (text === "low" || text === "medium" || text === "high") return text;
return undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View File

@@ -0,0 +1,411 @@
export type OrganizationInfo = {
organizationId: string;
organizationName: string;
organizationType?: string;
organizationBindTenantId?: number;
};
export type OrganizationGroupInfo = {
groupId: string;
groupName: string;
groupDesc?: string;
};
export type OrganizationRoleInfo = {
roleId: string;
roleName: string;
};
export type OrganizationMemberInfo = {
memberId: string;
memberName: string;
memberStatus: "0" | "1" | "2" | string;
memberPhone: string;
organizationId: string;
memberRoleId?: string;
memberRoleName?: string;
memberGroupId?: string;
memberGroupName?: string;
memberUserId?: string;
};
export type PlatformUserInfo = {
userId: string;
username?: string;
phone: string;
tenantId: number;
name?: string;
nickname?: string;
mustChangePassword?: boolean;
lockFlag?: string;
initialPassword?: string | null;
};
export type PageResult<T> = {
records: T[];
total: number;
size: number;
current: number;
pages: number;
};
export type OrganizationApiConfig = {
organizationBaseUrl: string;
staffBaseUrl: string;
organizationToken: string;
staffToken: string;
tenantId: string;
defaultOrganizationId: string;
configured: boolean;
missing: string[];
staffConfigured: boolean;
staffMissing: string[];
};
export type OrganizationRequestContext = {
accessToken?: string;
};
type OrganizationApiResponse<T> = {
code?: number | string;
msg?: string | null;
data?: T;
};
type RequestOptions = {
method?: "GET" | "POST";
body?: Record<string, unknown>;
inner?: boolean;
service?: "organization" | "staff";
accessToken?: string;
};
export class OrganizationApiError extends Error {
status: number;
details?: unknown;
path?: string;
constructor(message: string, status = 502, details?: unknown, path?: string) {
super(message);
this.name = "OrganizationApiError";
this.status = status;
this.details = details;
this.path = path;
}
}
export function getOrganizationApiConfig(accessToken?: string): OrganizationApiConfig {
const organizationBaseUrl = cleanEnv(process.env.ZHINIAN_ORG_API_BASE_URL);
const staffBaseUrl = cleanEnv(process.env.ZHINIAN_STAFF_API_BASE_URL);
const loginToken = cleanEnv(accessToken);
const organizationToken = loginToken || cleanEnv(process.env.ZHINIAN_ORG_API_TOKEN);
const staffToken = cleanEnv(process.env.ZHINIAN_STAFF_API_TOKEN) || organizationToken;
const tenantId = cleanEnv(process.env.ZHINIAN_ORG_TENANT_ID);
const defaultOrganizationId = cleanEnv(process.env.ZHINIAN_ORG_ID);
const missing: string[] = [];
if (!organizationBaseUrl) missing.push("ZHINIAN_ORG_API_BASE_URL");
if (!organizationToken) missing.push("当前登录token或ZHINIAN_ORG_API_TOKEN");
const staffMissing: string[] = [];
if (!staffBaseUrl) staffMissing.push("ZHINIAN_STAFF_API_BASE_URL");
if (!staffToken) staffMissing.push("当前登录token或ZHINIAN_STAFF_API_TOKEN");
if (!tenantId) staffMissing.push("ZHINIAN_ORG_TENANT_ID");
return {
organizationBaseUrl,
staffBaseUrl,
organizationToken,
staffToken,
tenantId,
defaultOrganizationId,
configured: missing.length === 0,
missing,
staffConfigured: staffMissing.length === 0,
staffMissing
};
}
export function organizationApiConfigured(): boolean {
return getOrganizationApiConfig().configured;
}
export function accountManagementConfigured(): boolean {
const config = getOrganizationApiConfig("login-token");
return config.configured && config.staffConfigured;
}
export async function listOrganizations(context: OrganizationRequestContext = {}): Promise<OrganizationInfo[]> {
return organizationRequest<OrganizationInfo[]>(organizationPath("ZHINIAN_ORG_LIST_PATH", "/organization/organizationList"), {
method: "GET",
accessToken: context.accessToken
});
}
export async function listOrganizationGroups(organizationId: string, context: OrganizationRequestContext = {}): Promise<OrganizationGroupInfo[]> {
return organizationRequest<OrganizationGroupInfo[]>(organizationPath("ZHINIAN_ORG_GROUP_LIST_PATH", "/organizationGroup/organizationGroupList"), {
method: "POST",
body: compactBody({ organizationId }),
accessToken: context.accessToken
});
}
export async function createOrganizationGroup(input: {
organizationId: string;
groupName: string;
groupDesc?: string;
parentId?: string;
}, context: OrganizationRequestContext = {}): Promise<boolean> {
return organizationRequest<boolean>(organizationPath("ZHINIAN_ORG_GROUP_CREATE_PATH", "/organizationGroup/createOrganizationGroup"), {
method: "POST",
body: compactBody(input),
accessToken: context.accessToken
});
}
export async function listOrganizationRoles(organizationId: string, context: OrganizationRequestContext = {}): Promise<OrganizationRoleInfo[]> {
return organizationRequest<OrganizationRoleInfo[]>(organizationPath("ZHINIAN_ORG_ROLE_LIST_PATH", "/organizationRole/organizationRoleList"), {
method: "POST",
body: compactBody({ organizationId }),
accessToken: context.accessToken
});
}
export async function listOrganizationMembers(input: {
organizationId: string;
pageNum: number;
pageSize: number;
groupIdList?: string[];
memberName?: string;
memberStatus?: string;
}, context: OrganizationRequestContext = {}): Promise<PageResult<OrganizationMemberInfo>> {
const path = organizationPath("ZHINIAN_ORG_MEMBER_LIST_PATH", "/adminOrganization/organizationMember/organizationMemberList");
return organizationRequest<PageResult<OrganizationMemberInfo>>(path, {
method: "POST",
inner: organizationMemberListPathNeedsInner(path),
body: compactBody(input),
accessToken: context.accessToken
});
}
export async function addOrganizationMember(input: {
memberName: string;
memberPhone: string;
roleId: string;
organizationId: string;
groupId: string;
}, context: OrganizationRequestContext = {}): Promise<boolean> {
return organizationRequest<boolean>(organizationPath("ZHINIAN_ORG_MEMBER_ADD_PATH", "/organizationMember/addOrganizationMember"), {
method: "POST",
body: compactBody(input),
accessToken: context.accessToken
});
}
export async function createPlatformUser(input: {
tenantId: number;
username?: string;
phone: string;
name?: string;
nickname?: string;
initialPassword?: string;
mustChangePassword?: boolean;
}, context: OrganizationRequestContext = {}): Promise<PlatformUserInfo> {
return organizationRequest<PlatformUserInfo>(organizationPath("ZHINIAN_STAFF_USER_CREATE_PATH", "/adminPcUser/createPlatformUser"), {
method: "POST",
service: "staff",
body: compactBody(input),
accessToken: context.accessToken
});
}
export async function getPlatformUserByPhone(input: {
tenantId: number;
phone: string;
}, context: OrganizationRequestContext = {}): Promise<PlatformUserInfo | null> {
return organizationRequest<PlatformUserInfo | null>(organizationPath("ZHINIAN_STAFF_USER_BY_PHONE_PATH", "/adminPcUser/getPlatformUserByPhone"), {
method: "POST",
service: "staff",
body: compactBody(input),
accessToken: context.accessToken
});
}
export async function resetPlatformUserPassword(input: {
tenantId: number;
userId: number;
newPassword: string;
mustChangePassword?: boolean;
}, context: OrganizationRequestContext = {}): Promise<boolean> {
return organizationRequest<boolean>(organizationPath("ZHINIAN_STAFF_PASSWORD_RESET_PATH", "/adminPcUser/resetPlatformUserPassword"), {
method: "POST",
service: "staff",
body: compactBody(input),
accessToken: context.accessToken
});
}
export async function addOrganizationMemberAndCreatePlatformUser(input: {
tenantId: number;
username?: string;
phone?: string;
name?: string;
nickname?: string;
initialPassword?: string;
mustChangePassword?: boolean;
memberName?: string;
memberPhone?: string;
roleId: string;
organizationId: string;
groupId: string;
}, context: OrganizationRequestContext = {}): Promise<PlatformUserInfo> {
return organizationRequest<PlatformUserInfo>(organizationPath(
"ZHINIAN_STAFF_MEMBER_CREATE_PATH",
"/adminOrganization/organizationMember/addOrganizationMemberAndCreatePlatformUser"
), {
method: "POST",
service: "staff",
body: compactBody(input),
accessToken: context.accessToken
});
}
export async function updateOrganizationMember(input: {
memberId: string;
memberName?: string;
roleId?: string;
groupId?: string;
}, context: OrganizationRequestContext = {}): Promise<boolean> {
return organizationRequest<boolean>(organizationPath("ZHINIAN_ORG_MEMBER_UPDATE_PATH", "/organizationMember/updateOrganizationMember"), {
method: "POST",
body: compactBody(input),
accessToken: context.accessToken
});
}
export async function modifyOrganizationMemberStatus(input: {
memberId: string;
memberStatus: "0" | "1" | "2";
}, context: OrganizationRequestContext = {}): Promise<boolean> {
return organizationRequest<boolean>(organizationPath("ZHINIAN_ORG_MEMBER_STATUS_PATH", "/organizationMember/modifyOrganizationMemberStatus"), {
method: "POST",
body: compactBody(input),
accessToken: context.accessToken
});
}
export async function removeOrganizationMember(memberId: string, context: OrganizationRequestContext = {}): Promise<boolean> {
return organizationRequest<boolean>(organizationPath("ZHINIAN_ORG_MEMBER_REMOVE_PATH", "/organizationMember/removeOrganizationMember"), {
method: "POST",
body: compactBody({ memberId }),
accessToken: context.accessToken
});
}
export function emptyPage<T>(pageNum = 1, pageSize = 10): PageResult<T> {
return {
records: [],
total: 0,
size: pageSize,
current: pageNum,
pages: 0
};
}
export function isOrganizationRouteNotFound(error: unknown): error is OrganizationApiError {
if (!(error instanceof OrganizationApiError) || error.status !== 404) return false;
const detailMessage = typeof (error.details as { msg?: unknown } | undefined)?.msg === "string"
? String((error.details as { msg?: string }).msg)
: "";
return /No static resource/i.test(detailMessage) ||
/请求路径不匹配|没有暴露|未在当前组织服务暴露/i.test(error.message);
}
export function isOrganizationPermissionDenied(error: unknown): error is OrganizationApiError {
if (!(error instanceof OrganizationApiError)) return false;
const detailMessage = typeof (error.details as { msg?: unknown } | undefined)?.msg === "string"
? String((error.details as { msg?: string }).msg)
: "";
return /仅管理员角色允许调用|缺少.*管理员|管理员角色/.test(`${error.message} ${detailMessage}`);
}
async function organizationRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const config = getOrganizationApiConfig(options.accessToken);
const missing = options.service === "staff" ? config.staffMissing : config.missing;
const configured = options.service === "staff" ? config.staffConfigured : config.configured;
if (!configured) {
throw new OrganizationApiError(`组织账号接口配置不完整:${missing.join(", ")}`, 503);
}
const method = options.method || "POST";
const baseUrl = options.service === "staff" ? config.staffBaseUrl : config.organizationBaseUrl;
const token = options.service === "staff" ? config.staffToken : config.organizationToken;
const response = await fetch(buildOrganizationUrl(baseUrl, path), {
method,
headers: requestHeaders(config, token, method, options.inner),
body: method === "GET" ? undefined : JSON.stringify(options.body || {}),
cache: "no-store"
});
const payload = await parsePayload<OrganizationApiResponse<T>>(response);
if (!response.ok) {
throw new OrganizationApiError(organizationErrorMessage(payload?.msg, path, response.status), response.status, payload, path);
}
if (payload && payload.code !== undefined && String(payload.code) !== "0") {
throw new OrganizationApiError(organizationErrorMessage(payload.msg, path, 502), 502, payload, path);
}
return payload?.data as T;
}
function organizationErrorMessage(message: string | null | undefined, path: string, status: number): string {
const text = message || `组织接口请求失败:${status}`;
if (/No static resource/i.test(text) && path.includes("/organizationMember/organizationMemberList")) {
return `成员列表查询暂未开放。当前 hotelStaff 没有暴露 ${path},账号创建和密码维护仍可用;请让运维提供成员查询外部路径后配置 ZHINIAN_ORG_MEMBER_LIST_PATH。`;
}
if (/No static resource/i.test(text) && path.startsWith("/organization")) {
return `组织服务地址或请求路径不匹配:当前地址没有暴露 ${path},请确认 ZHINIAN_ORG_API_BASE_URL、路径覆盖配置和统一服务路由。`;
}
return text;
}
function requestHeaders(config: OrganizationApiConfig, token: string, method: string, inner?: boolean): HeadersInit {
const headers: Record<string, string> = {
Accept: "application/json"
};
if (method !== "GET") headers["Content-Type"] = "application/json; charset=utf-8";
headers.Authorization = /^Bearer\s+/i.test(token) ? token : `Bearer ${token}`;
if (config.tenantId) headers.tenantId = config.tenantId;
if (inner) headers.from = "Y";
return headers;
}
function buildOrganizationUrl(baseUrl: string, path: string): string {
return `${baseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
}
function organizationPath(envName: string, fallback: string): string {
return cleanEnv(process.env[envName]) || fallback;
}
function organizationMemberListPathNeedsInner(path: string): boolean {
return /^\/?organizationMember\//.test(path);
}
async function parsePayload<T>(response: Response): Promise<T | null> {
const text = await response.text();
if (!text) return null;
try {
return JSON.parse(text) as T;
} catch {
throw new OrganizationApiError("组织接口返回了非 JSON 响应。", response.ok ? 502 : response.status, text.slice(0, 500));
}
}
function compactBody(input: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(input).filter(([, value]) => {
if (value === null || value === undefined) return false;
if (typeof value === "string") return value.trim().length > 0;
if (Array.isArray(value)) return value.length > 0;
return true;
}));
}
function cleanEnv(value: string | undefined): string {
return value?.trim() || "";
}

View File

@@ -1,14 +1,15 @@
import { getAsset, listAssets, listGenerationJobsFiltered } from "@/lib/server/data-store";
import { DEFAULT_OWNER_ID } from "@/lib/server/runtime";
import { publicApiOwnerId } from "@/lib/server/public-api-auth";
import type { Asset, GenerationJob } from "@/lib/types";
const JOB_LOOKUP_LIMIT = 200;
export async function listPublicApiAssets(clientId: string): Promise<Asset[]> {
const ownerId = publicApiOwnerId(clientId);
const [assets, jobs] = await Promise.all([
listAssets(DEFAULT_OWNER_ID),
listAssets(ownerId),
listGenerationJobsFiltered({
ownerId: DEFAULT_OWNER_ID,
ownerId,
externalClientId: clientId,
limit: JOB_LOOKUP_LIMIT
})
@@ -18,11 +19,12 @@ export async function listPublicApiAssets(clientId: string): Promise<Asset[]> {
}
export async function getPublicApiAsset(clientId: string, assetId: string): Promise<Asset | null> {
const ownerId = publicApiOwnerId(clientId);
const asset = await getAsset(assetId);
if (!asset || asset.ownerId !== DEFAULT_OWNER_ID) return null;
if (!asset || asset.ownerId !== ownerId) return null;
if (asset.tags.includes(apiClientTag(clientId))) return asset;
const jobs = await listGenerationJobsFiltered({
ownerId: DEFAULT_OWNER_ID,
ownerId,
externalClientId: clientId,
limit: JOB_LOOKUP_LIMIT
});
@@ -30,10 +32,7 @@ export async function getPublicApiAsset(clientId: string, assetId: string): Prom
}
function canAccessAsset(clientId: string, asset: Asset, accessibleIds: Set<string>): boolean {
return asset.ownerId === DEFAULT_OWNER_ID && (
asset.tags.includes(apiClientTag(clientId)) ||
accessibleIds.has(asset.id)
);
return asset.tags.includes(apiClientTag(clientId)) || accessibleIds.has(asset.id);
}
function assetIdsFromJobs(jobs: GenerationJob[]): Set<string> {

View File

@@ -41,6 +41,11 @@ export function authenticatePublicApiRequest(request: Request): PublicApiClient
return client;
}
export function publicApiOwnerId(client: PublicApiClient | string): string {
const id = typeof client === "string" ? client : client.id;
return `api:${sanitizeOwnerPart(id)}`;
}
export function assertInternalWorkerToken(request: Request) {
const expected = process.env.ZHINIAN_INTERNAL_WORKER_TOKEN?.trim();
if (!expected && process.env.NODE_ENV !== "production") return;
@@ -67,3 +72,7 @@ function safeEqual(expected: string, presented: string): boolean {
if (left.length !== right.length) return false;
return timingSafeEqual(left, right);
}
function sanitizeOwnerPart(value: string): string {
return value.replace(/[^A-Za-z0-9_.:@-]+/g, "_").slice(0, 96) || "unknown";
}

View File

@@ -2,9 +2,8 @@ import { createHash } from "node:crypto";
import { assemblePrompt, type PromptAssemblyInput, type PromptMaterial } from "@/lib/prompt/assembler";
import { findGenerationJobByIdempotency } from "@/lib/server/data-store";
import { submitImageJob, type SubmitImageJobInput } from "@/lib/server/generation-service";
import { DEFAULT_OWNER_ID } from "@/lib/server/runtime";
import { submitVideoJob, type SubmitVideoJobInput } from "@/lib/server/video-generation-service";
import type { PublicApiClient } from "@/lib/server/public-api-auth";
import { publicApiOwnerId, type PublicApiClient } from "@/lib/server/public-api-auth";
import type { EnabledImageCapability, GenerationCapability, GenerationJob } from "@/lib/types";
export class PublicApiConflictError extends Error {
@@ -46,10 +45,11 @@ export async function createPublicGenerationJob(input: {
origin: string;
}): Promise<{ job: GenerationJob; reused: boolean }> {
const capability = input.body.capability || "image.generate";
const ownerId = publicApiOwnerId(input.client);
const idempotencyKey = input.request.headers.get("idempotency-key") || input.body.idempotencyKey;
const fingerprint = idempotencyKey ? fingerprintBody(input.body) : undefined;
if (idempotencyKey && fingerprint) {
const existing = await findGenerationJobByIdempotency(input.client.id, idempotencyKey);
const existing = await findGenerationJobByIdempotency(input.client.id, idempotencyKey, ownerId);
if (existing) {
if (existing.idempotencyFingerprint !== fingerprint) {
throw new PublicApiConflictError("Idempotency key was already used with a different request body.");
@@ -59,7 +59,7 @@ export async function createPublicGenerationJob(input: {
}
const common = {
ownerId: DEFAULT_OWNER_ID,
ownerId,
externalClientId: input.client.id,
idempotencyKey,
idempotencyFingerprint: fingerprint,

View File

@@ -100,6 +100,28 @@ export type Project = {
updatedAt: string;
};
export type ImageTemplateSettings = {
engine?: "jimeng" | "evolink";
width?: number;
height?: number;
forceSingle?: boolean;
scale?: number;
quality?: "low" | "medium" | "high";
};
export type ImageTemplate = {
id: string;
ownerId: string;
name: string;
description?: string;
prompt: string;
previewImageUrl?: string;
settings: ImageTemplateSettings;
sortOrder: number;
createdAt: string;
updatedAt: string;
};
export type AppState = {
users: Array<{
id: string;
@@ -110,6 +132,7 @@ export type AppState = {
generationJobs: GenerationJob[];
usageEvents: UsageEvent[];
projects: Project[];
imageTemplates: ImageTemplate[];
};
export type VisualTaskSubmitResponse = {