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:
2026-04-01 00:58:42 +08:00
commit de30497f59
210 changed files with 23733 additions and 0 deletions
+327
View File
@@ -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,
};
}
+77
View File
@@ -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`),
};
}
+31
View File
@@ -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 "发生未知错误";
}
+108
View File
@@ -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);
};
}