feat: add tenant and user management with migrations, handlers, and tests
- Implemented tenant and user management features including: - Tenant creation and management with associated migrations. - User creation and management with associated migrations. - Tenant membership management with associated migrations. - Platform user roles management with associated migrations. - Quota management with associated migrations. - Article and template management with associated migrations. - Added HTTP handlers for templates and workspaces. - Created tests for protected and public routes. - Introduced a script to check tenant scope in SQL queries. - Documented task plan for backend completion and frontend foundation.
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
import { createApiClient } from "@geo/http-client";
|
||||
import type {
|
||||
ArticleDetail,
|
||||
ArticleListParams,
|
||||
ArticleListResponse,
|
||||
ArticleVersion,
|
||||
AuthTokens,
|
||||
Brand,
|
||||
BrandRequest,
|
||||
Competitor,
|
||||
CompetitorRequest,
|
||||
JsonValue,
|
||||
Keyword,
|
||||
KeywordRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
QuotaSummary,
|
||||
Question,
|
||||
QuestionRequest,
|
||||
QuestionVersion,
|
||||
RecentArticle,
|
||||
RefreshResponse,
|
||||
TemplateDetail,
|
||||
TemplateGenerateRequest,
|
||||
TemplateGenerateResponse,
|
||||
TemplateListItem,
|
||||
TemplateSchema,
|
||||
TemplateCard,
|
||||
UpdateQuestionRequest,
|
||||
UserInfo,
|
||||
WorkspaceOverview,
|
||||
} from "@geo/shared-types";
|
||||
|
||||
import {
|
||||
clearStoredSession,
|
||||
getStoredAccessToken,
|
||||
getStoredRefreshToken,
|
||||
setStoredTokens,
|
||||
} from "./session";
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
const generationStreamEnabled = import.meta.env.VITE_GENERATION_STREAM_ENABLED === "true";
|
||||
|
||||
const publicClient = createApiClient({ baseURL });
|
||||
|
||||
export const apiClient = createApiClient({
|
||||
baseURL,
|
||||
auth: {
|
||||
getAccessToken: getStoredAccessToken,
|
||||
getRefreshToken: getStoredRefreshToken,
|
||||
setTokens: setStoredTokens,
|
||||
clearTokens: clearStoredSession,
|
||||
async refresh(refreshToken: string): Promise<AuthTokens> {
|
||||
const data = await publicClient.post<RefreshResponse, { refresh_token: string }>(
|
||||
"/api/auth/refresh",
|
||||
{ refresh_token: refreshToken },
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const authApi = {
|
||||
login(payload: LoginRequest) {
|
||||
return publicClient.post<LoginResponse, LoginRequest>("/api/auth/login", payload);
|
||||
},
|
||||
me() {
|
||||
return apiClient.get<UserInfo>("/api/auth/me");
|
||||
},
|
||||
logout() {
|
||||
return apiClient.post<null>("/api/auth/logout");
|
||||
},
|
||||
};
|
||||
|
||||
export const workspaceApi = {
|
||||
overview() {
|
||||
return apiClient.get<WorkspaceOverview>("/api/tenant/workspace/overview");
|
||||
},
|
||||
recentArticles() {
|
||||
return apiClient.get<RecentArticle[]>("/api/tenant/workspace/recent-articles");
|
||||
},
|
||||
quotaSummary() {
|
||||
return apiClient.get<QuotaSummary>("/api/tenant/workspace/quota-summary");
|
||||
},
|
||||
templateCards() {
|
||||
return apiClient.get<TemplateCard[]>("/api/tenant/workspace/template-cards");
|
||||
},
|
||||
};
|
||||
|
||||
export const templatesApi = {
|
||||
list() {
|
||||
return apiClient.get<TemplateListItem[]>("/api/tenant/templates");
|
||||
},
|
||||
detail(id: number) {
|
||||
return apiClient.get<TemplateDetail>(`/api/tenant/templates/${id}`);
|
||||
},
|
||||
schema(id: number) {
|
||||
return apiClient.get<TemplateSchema>(`/api/tenant/templates/${id}/schema`);
|
||||
},
|
||||
generate(id: number, payload: TemplateGenerateRequest) {
|
||||
return apiClient.post<TemplateGenerateResponse, TemplateGenerateRequest>(
|
||||
`/api/tenant/templates/${id}/generate`,
|
||||
payload,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const articlesApi = {
|
||||
list(params: ArticleListParams) {
|
||||
return apiClient.get<ArticleListResponse>("/api/tenant/articles", { params });
|
||||
},
|
||||
detail(id: number) {
|
||||
return apiClient.get<ArticleDetail>(`/api/tenant/articles/${id}`);
|
||||
},
|
||||
versions(id: number) {
|
||||
return apiClient.get<ArticleVersion[]>(`/api/tenant/articles/${id}/versions`);
|
||||
},
|
||||
remove(id: number) {
|
||||
return apiClient.remove<null>(`/api/tenant/articles/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
export interface ArticleGenerationStreamEvent {
|
||||
type: string;
|
||||
article_id: number;
|
||||
task_id?: number;
|
||||
title?: string;
|
||||
status?: string;
|
||||
delta?: string;
|
||||
content?: string;
|
||||
error?: string;
|
||||
done?: boolean;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export function isGenerationStreamEnabled(): boolean {
|
||||
return generationStreamEnabled;
|
||||
}
|
||||
|
||||
export async function subscribeArticleGeneration(
|
||||
articleId: number,
|
||||
handlers: {
|
||||
onEvent: (event: ArticleGenerationStreamEvent) => void;
|
||||
onClose?: () => void;
|
||||
},
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const accessToken = getStoredAccessToken();
|
||||
if (!accessToken) {
|
||||
throw new Error("missing access token");
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseURL}/api/tenant/articles/${articleId}/stream`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`stream request failed with status ${response.status}`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("stream response body is empty");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
buffer = consumeSSEBuffer(buffer, handlers.onEvent);
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
consumeSSEBuffer(buffer, handlers.onEvent);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
handlers.onClose?.();
|
||||
}
|
||||
}
|
||||
|
||||
export const brandsApi = {
|
||||
list() {
|
||||
return apiClient.get<Brand[]>("/api/tenant/brands");
|
||||
},
|
||||
create(payload: BrandRequest) {
|
||||
return apiClient.post<Brand, BrandRequest>("/api/tenant/brands", payload);
|
||||
},
|
||||
detail(id: number) {
|
||||
return apiClient.get<Brand>(`/api/tenant/brands/${id}`);
|
||||
},
|
||||
update(id: number, payload: BrandRequest) {
|
||||
return apiClient.put<null, BrandRequest>(`/api/tenant/brands/${id}`, payload);
|
||||
},
|
||||
remove(id: number) {
|
||||
return apiClient.remove<null>(`/api/tenant/brands/${id}`);
|
||||
},
|
||||
listKeywords(brandId: number) {
|
||||
return apiClient.get<Keyword[]>(`/api/tenant/brands/${brandId}/keywords`);
|
||||
},
|
||||
createKeyword(brandId: number, payload: KeywordRequest) {
|
||||
return apiClient.post<Keyword, KeywordRequest>(
|
||||
`/api/tenant/brands/${brandId}/keywords`,
|
||||
payload,
|
||||
);
|
||||
},
|
||||
updateKeyword(brandId: number, keywordId: number, payload: KeywordRequest) {
|
||||
return apiClient.put<null, KeywordRequest>(
|
||||
`/api/tenant/brands/${brandId}/keywords/${keywordId}`,
|
||||
payload,
|
||||
);
|
||||
},
|
||||
removeKeyword(brandId: number, keywordId: number) {
|
||||
return apiClient.remove<null>(`/api/tenant/brands/${brandId}/keywords/${keywordId}`);
|
||||
},
|
||||
listQuestions(brandId: number, keywordId?: number | null) {
|
||||
return apiClient.get<Question[]>(`/api/tenant/brands/${brandId}/questions`, {
|
||||
params: keywordId ? { keyword_id: keywordId } : undefined,
|
||||
});
|
||||
},
|
||||
createQuestion(brandId: number, payload: QuestionRequest) {
|
||||
return apiClient.post<Question, QuestionRequest>(
|
||||
`/api/tenant/brands/${brandId}/questions`,
|
||||
payload,
|
||||
);
|
||||
},
|
||||
updateQuestion(brandId: number, questionId: number, payload: UpdateQuestionRequest) {
|
||||
return apiClient.put<null, UpdateQuestionRequest>(
|
||||
`/api/tenant/brands/${brandId}/questions/${questionId}`,
|
||||
payload,
|
||||
);
|
||||
},
|
||||
removeQuestion(brandId: number, questionId: number) {
|
||||
return apiClient.remove<null>(`/api/tenant/brands/${brandId}/questions/${questionId}`);
|
||||
},
|
||||
listQuestionVersions(brandId: number) {
|
||||
return apiClient.get<QuestionVersion[]>(`/api/tenant/brands/${brandId}/question-versions`);
|
||||
},
|
||||
listCompetitors(brandId: number) {
|
||||
return apiClient.get<Competitor[]>(`/api/tenant/brands/${brandId}/competitors`);
|
||||
},
|
||||
createCompetitor(brandId: number, payload: CompetitorRequest) {
|
||||
return apiClient.post<Competitor, CompetitorRequest>(
|
||||
`/api/tenant/brands/${brandId}/competitors`,
|
||||
payload,
|
||||
);
|
||||
},
|
||||
updateCompetitor(brandId: number, competitorId: number, payload: CompetitorRequest) {
|
||||
return apiClient.put<null, CompetitorRequest>(
|
||||
`/api/tenant/brands/${brandId}/competitors/${competitorId}`,
|
||||
payload,
|
||||
);
|
||||
},
|
||||
removeCompetitor(brandId: number, competitorId: number) {
|
||||
return apiClient.remove<null>(`/api/tenant/brands/${brandId}/competitors/${competitorId}`);
|
||||
},
|
||||
};
|
||||
|
||||
export function normalizeInputParams(
|
||||
payload: Record<string, JsonValue>,
|
||||
): TemplateGenerateRequest {
|
||||
return { input_params: payload };
|
||||
}
|
||||
|
||||
function consumeSSEBuffer(
|
||||
buffer: string,
|
||||
onEvent: (event: ArticleGenerationStreamEvent) => void,
|
||||
): string {
|
||||
let next = buffer;
|
||||
|
||||
for (;;) {
|
||||
const delimiterIndex = next.indexOf("\n\n");
|
||||
if (delimiterIndex === -1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const rawEvent = next.slice(0, delimiterIndex);
|
||||
next = next.slice(delimiterIndex + 2);
|
||||
|
||||
const parsed = parseSSEEvent(rawEvent);
|
||||
if (parsed) {
|
||||
onEvent(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function parseSSEEvent(raw: string): ArticleGenerationStreamEvent | null {
|
||||
const lines = raw.split(/\r?\n/);
|
||||
let event = "message";
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event:")) {
|
||||
event = line.slice(6).trim();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice(5).trimStart());
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = JSON.parse(dataLines.join("\n")) as unknown as ArticleGenerationStreamEvent;
|
||||
return {
|
||||
...data,
|
||||
type: event,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
const generateStatusMap: Record<string, { label: string; color: string }> = {
|
||||
draft: { label: "status.generate.draft", color: "default" },
|
||||
generating: { label: "status.generate.generating", color: "processing" },
|
||||
running: { label: "status.generate.running", color: "processing" },
|
||||
completed: { label: "status.generate.completed", color: "success" },
|
||||
failed: { label: "status.generate.failed", color: "error" },
|
||||
};
|
||||
|
||||
const publishStatusMap: Record<string, { label: string; color: string }> = {
|
||||
unpublished: { label: "status.publish.unpublished", color: "default" },
|
||||
publishing: { label: "status.publish.publishing", color: "processing" },
|
||||
published: { label: "status.publish.published", color: "success" },
|
||||
publish_success: { label: "status.publish.publish_success", color: "success" },
|
||||
publish_failed: { label: "status.publish.publish_failed", color: "error" },
|
||||
pending_review: { label: "status.publish.pending_review", color: "warning" },
|
||||
};
|
||||
|
||||
export function formatDateTime(value?: string | null, pattern = "YYYY-MM-DD HH:mm"): string {
|
||||
if (!value) {
|
||||
return "--";
|
||||
}
|
||||
const parsed = dayjs(value);
|
||||
return parsed.isValid() ? parsed.format(pattern) : value;
|
||||
}
|
||||
|
||||
export function getGenerateStatusMeta(status?: string | null): { label: string; color: string } {
|
||||
if (!status) {
|
||||
return { label: "--", color: "default" };
|
||||
}
|
||||
const meta = generateStatusMap[status];
|
||||
return meta ? { label: i18n.global.t(meta.label), color: meta.color } : { label: status, color: "default" };
|
||||
}
|
||||
|
||||
export function getPublishStatusMeta(status?: string | null): { label: string; color: string } {
|
||||
if (!status) {
|
||||
return { label: "--", color: "default" };
|
||||
}
|
||||
const meta = publishStatusMap[status];
|
||||
return meta ? { label: i18n.global.t(meta.label), color: meta.color } : { label: status, color: "default" };
|
||||
}
|
||||
|
||||
export function getSourceTypeLabel(sourceType?: string | null): string {
|
||||
if (!sourceType) {
|
||||
return "--";
|
||||
}
|
||||
if (sourceType === "template") {
|
||||
return i18n.global.t("status.sourceType.template");
|
||||
}
|
||||
return sourceType;
|
||||
}
|
||||
|
||||
export function getTemplateMeta(templateKey: string): {
|
||||
eyebrow: string;
|
||||
helper: string;
|
||||
accent: string;
|
||||
action: string;
|
||||
} {
|
||||
const key = [
|
||||
"top_x_article",
|
||||
"product_review",
|
||||
"research_report",
|
||||
"brand_search_expansion",
|
||||
].includes(templateKey)
|
||||
? templateKey
|
||||
: "default";
|
||||
|
||||
return {
|
||||
eyebrow: i18n.global.t(`templateMeta.${key}.eyebrow`),
|
||||
helper: i18n.global.t(`templateMeta.${key}.helper`),
|
||||
accent: i18n.global.t(`templateMeta.${key}.accent`),
|
||||
action: i18n.global.t(`templateMeta.${key}.action`),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ApiClientError } from "@geo/http-client";
|
||||
|
||||
const errorMessageMap: Record<string, string> = {
|
||||
invalid_credentials: "邮箱或密码错误",
|
||||
no_tenant: "当前账号未关联租户",
|
||||
not_authenticated: "请先登录",
|
||||
invalid_access_token: "登录状态已失效",
|
||||
invalid_refresh_token: "刷新令牌已失效",
|
||||
refresh_session_expired: "登录已过期,请重新登录",
|
||||
refresh_token_mismatch: "刷新令牌校验失败,请重新登录",
|
||||
token_revoked: "当前会话已经退出",
|
||||
tenant_scope_missing: "租户上下文缺失",
|
||||
query_failed: "数据读取失败",
|
||||
quota_insufficient: "生成额度不足",
|
||||
llm_unavailable: "生成服务不可用",
|
||||
network_error: "网络连接失败,请稍后重试",
|
||||
unknown_error: "发生未知错误",
|
||||
};
|
||||
|
||||
export function formatError(error: unknown): string {
|
||||
if (error instanceof ApiClientError) {
|
||||
const message = errorMessageMap[error.message] ?? error.message.replaceAll("_", " ");
|
||||
return error.detail ? `${message}: ${error.detail}` : message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return "发生未知错误";
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { AuthTokens, UserInfo } from "@geo/shared-types";
|
||||
|
||||
export interface SessionSnapshot {
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
user: UserInfo | null;
|
||||
}
|
||||
|
||||
type SessionListener = (snapshot: SessionSnapshot) => void;
|
||||
|
||||
const STORAGE_KEY = "geo.admin-web.session";
|
||||
const listeners = new Set<SessionListener>();
|
||||
|
||||
function emptySession(): SessionSnapshot {
|
||||
return {
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
function hasStorage(): boolean {
|
||||
return typeof window !== "undefined" && "localStorage" in window;
|
||||
}
|
||||
|
||||
function sanitizeSession(candidate: unknown): SessionSnapshot {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
return emptySession();
|
||||
}
|
||||
|
||||
const value = candidate as Partial<SessionSnapshot>;
|
||||
return {
|
||||
accessToken: typeof value.accessToken === "string" ? value.accessToken : null,
|
||||
refreshToken: typeof value.refreshToken === "string" ? value.refreshToken : null,
|
||||
user: value.user && typeof value.user === "object" ? (value.user as UserInfo) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function notify(snapshot: SessionSnapshot): void {
|
||||
listeners.forEach((listener) => listener(snapshot));
|
||||
}
|
||||
|
||||
export function readStoredSession(): SessionSnapshot {
|
||||
if (!hasStorage()) {
|
||||
return emptySession();
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return emptySession();
|
||||
}
|
||||
return sanitizeSession(JSON.parse(raw));
|
||||
} catch {
|
||||
return emptySession();
|
||||
}
|
||||
}
|
||||
|
||||
function persistSession(snapshot: SessionSnapshot): SessionSnapshot {
|
||||
if (hasStorage()) {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
}
|
||||
notify(snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function updateStoredSession(partial: Partial<SessionSnapshot>): SessionSnapshot {
|
||||
return persistSession({
|
||||
...readStoredSession(),
|
||||
...partial,
|
||||
});
|
||||
}
|
||||
|
||||
export function setStoredTokens(tokens: AuthTokens): SessionSnapshot {
|
||||
return updateStoredSession({
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
export function setStoredUser(user: UserInfo | null): SessionSnapshot {
|
||||
return updateStoredSession({ user });
|
||||
}
|
||||
|
||||
export function clearStoredSession(): SessionSnapshot {
|
||||
if (hasStorage()) {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
const next = emptySession();
|
||||
notify(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function getStoredAccessToken(): string | null {
|
||||
return readStoredSession().accessToken;
|
||||
}
|
||||
|
||||
export function getStoredRefreshToken(): string | null {
|
||||
return readStoredSession().refreshToken;
|
||||
}
|
||||
|
||||
export function subscribeStoredSession(listener: SessionListener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user