feat: add admin accounts and image templates
This commit is contained in:
@@ -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
77
lib/auth/permissions.ts
Normal 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();
|
||||
}
|
||||
@@ -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 : [],
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
63
lib/prompt/material-draft.ts
Normal file
63
lib/prompt/material-draft.ts
Normal 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";
|
||||
}
|
||||
35
lib/prompt/material-placeholders.ts
Normal file
35
lib/prompt/material-placeholders.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
45
lib/server/auth/session-cookie.ts
Normal file
45
lib/server/auth/session-cookie.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
104
lib/server/image-template-input.ts
Normal file
104
lib/server/image-template-input.ts
Normal 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);
|
||||
}
|
||||
411
lib/server/organization-client.ts
Normal file
411
lib/server/organization-client.ts
Normal 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() || "";
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
23
lib/types.ts
23
lib/types.ts
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user