feat: 添加后台登录验证码、记住密码功能,优化媒体资源与前端规范
- 新增后台登录图形验证码功能,完善登录安全防护 - 新增登录rememberMe参数,控制Refresh Token的会话持久化策略 - 实现OSS私有桶媒体URL自动签名,统一处理图片资源的临时访问签名 - 新增素材库数据库表与上传API,规范媒体资源管理流程 - 统一前端UI图标使用@element-plus/icons-vue,重构布局图标组件 - 登录页新增验证码输入、刷新功能,添加账号记忆与记住密码逻辑 - 更新全套文档,补充API契约、技术决策记录与集成流程说明 - 修复多个业务页面的图标展示问题,新增认证流程相关测试用例
This commit is contained in:
@@ -19,6 +19,8 @@ yarn dev
|
||||
- Refresh Token 由后端以 HttpOnly Cookie 保存。
|
||||
- 401 会自动调用 `/api/admin/auth/refresh` 并重试一次;刷新失败后返回登录页。
|
||||
- 菜单和按钮权限来自 `/api/admin/system/profile`,前端只加载预注册组件。
|
||||
- 管理端界面图标统一使用 `@element-plus/icons-vue`;菜单图标通过 `src/components/layout/LayoutIcon.vue` 的白名单适配器映射,未知图标回退为默认图标。
|
||||
- `ConciergeDetail.icon` 等接口字段是业务内容数据,不属于 UI 图标,不在图标迁移范围内。
|
||||
|
||||
## 验证
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"element-plus": "^2.11.3",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.18",
|
||||
|
||||
43
WonderQ-Admin-UI-Vue/src/api/client-auth.test.ts
Normal file
43
WonderQ-Admin-UI-Vue/src/api/client-auth.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getCaptcha, login } from "@/api/client";
|
||||
|
||||
describe("admin auth client", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("loads a captcha challenge without an access token", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse(200, {
|
||||
captchaEnabled: true,
|
||||
captchaId: "captcha-1",
|
||||
image: "data:image/svg+xml;base64,PHN2Zy8+",
|
||||
expiresIn: 120,
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(getCaptcha()).resolves.toMatchObject({ captchaId: "captcha-1", captchaEnabled: true });
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/admin/auth/captcha", expect.objectContaining({ credentials: "include" }));
|
||||
});
|
||||
|
||||
it("sends the captcha fields with login", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse(200, {
|
||||
token: "access-token",
|
||||
accessToken: "access-token",
|
||||
expiresIn: 900,
|
||||
user: { id: "admin-1", email: "admin@example.com", name: "Admin", role: "admin" },
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await login("admin@example.com", "password", "captcha-1", "ABCD", true);
|
||||
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
|
||||
email: "admin@example.com",
|
||||
password: "password",
|
||||
captchaId: "captcha-1",
|
||||
captchaCode: "ABCD",
|
||||
rememberMe: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(code: number, data: unknown) {
|
||||
return { status: code, json: async () => ({ code, msg: "success", data }) };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
ApiErrorResponse,
|
||||
ApiResponse,
|
||||
AuthPayload,
|
||||
CaptchaPayload,
|
||||
AdminMenu,
|
||||
AdminProfile,
|
||||
ConciergeAdvisorCreate,
|
||||
@@ -139,7 +140,8 @@ export async function request<T>(path: string, options: RequestInit = {}, retry
|
||||
return unwrap<T>(await parseJson(response), response.status);
|
||||
}
|
||||
|
||||
export const login = (email: string, password: string) => request<AuthPayload>("/api/admin/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }, false);
|
||||
export const getCaptcha = () => request<CaptchaPayload>("/api/admin/auth/captcha", {}, false);
|
||||
export const login = (email: string, password: string, captchaId: string, captchaCode: string, rememberMe = false) => request<AuthPayload>("/api/admin/auth/login", { method: "POST", body: JSON.stringify({ email, password, captchaId, captchaCode, rememberMe }) }, false);
|
||||
export const getProfile = () => request<AdminProfile>("/api/admin/system/profile");
|
||||
/** Load the authenticated user's visible menu tree for dynamic route registration. */
|
||||
export const getRouters = () => request<AdminMenu[]>("/api/admin/system/routers");
|
||||
|
||||
@@ -1,46 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, type Component } from "vue";
|
||||
import {
|
||||
ArrowDown,
|
||||
Back as BackIcon,
|
||||
Close as CloseIcon,
|
||||
Collection as CollectionIcon,
|
||||
FullScreen as FullScreenIcon,
|
||||
Grid as GridIcon,
|
||||
Guide,
|
||||
Menu as MenuIcon,
|
||||
Moon as MoonIcon,
|
||||
More as MoreIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Right as RightIcon,
|
||||
Search as SearchIcon,
|
||||
Setting as SettingIcon,
|
||||
Sunny as SunnyIcon,
|
||||
User as UserIcon,
|
||||
} from "@element-plus/icons-vue";
|
||||
import type { LayoutIconName } from "@/lib/menu-display";
|
||||
|
||||
withDefaults(defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
name: LayoutIconName;
|
||||
size?: number | string;
|
||||
title?: string;
|
||||
}>(), {
|
||||
size: 24,
|
||||
});
|
||||
|
||||
const iconRegistry: Record<LayoutIconName, Component> = {
|
||||
Menu: MenuIcon,
|
||||
Grid: GridIcon,
|
||||
// Element Plus does not expose a Route icon; Guide is the closest semantic match.
|
||||
Route: Guide,
|
||||
User: UserIcon,
|
||||
Collection: CollectionIcon,
|
||||
Setting: SettingIcon,
|
||||
Search: SearchIcon,
|
||||
FullScreen: FullScreenIcon,
|
||||
Moon: MoonIcon,
|
||||
Sunny: SunnyIcon,
|
||||
Refresh: RefreshIcon,
|
||||
Close: CloseIcon,
|
||||
More: MoreIcon,
|
||||
Back: BackIcon,
|
||||
Right: RightIcon,
|
||||
Chevron: ArrowDown,
|
||||
};
|
||||
|
||||
const iconComponent = computed(() => iconRegistry[props.name] ?? MenuIcon);
|
||||
const iconSize = computed(() => typeof props.size === "number"
|
||||
? `${props.size}px`
|
||||
: /^\d+$/.test(props.size)
|
||||
? `${props.size}px`
|
||||
: props.size);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
<component
|
||||
:is="iconComponent"
|
||||
class="layout-icon"
|
||||
:style="{ width: iconSize, height: iconSize }"
|
||||
focusable="false"
|
||||
:aria-hidden="title ? undefined : true"
|
||||
:aria-label="title"
|
||||
role="img"
|
||||
>
|
||||
<title v-if="title">{{ title }}</title>
|
||||
<path v-if="name === 'Menu'" d="M4 6h16M4 12h16M4 18h16" />
|
||||
<path v-else-if="name === 'Grid'" d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z" />
|
||||
<path v-else-if="name === 'Route'" d="M5 5h.01M5 19h.01M19 5h.01M7 5h5a3 3 0 0 1 3 3v8a3 3 0 0 0 3 3h1M5 7v10" />
|
||||
<path v-else-if="name === 'User'" d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4 21a8 8 0 0 1 16 0" />
|
||||
<path v-else-if="name === 'Collection'" d="M4 5h16v14H4zM8 5v14M8 9h12M8 14h12" />
|
||||
<path v-else-if="name === 'Setting'" d="m12 3 1.2 2.4 2.6.4.9 2.5 2.2 1.5-.9 2.5.9 2.5-2.2 1.5-.9 2.5-2.6.4L12 21l-1.2-2.4-2.6-.4-.9-2.5-2.2-1.5.9-2.5-.9-2.5 2.2-1.5.9-2.5 2.6-.4zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
|
||||
<path v-else-if="name === 'Search'" d="m20 20-4.5-4.5M10.5 17a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Z" />
|
||||
<path v-else-if="name === 'FullScreen'" d="M8 4H4v4M16 4h4v4M20 16v4h-4M4 16v4h4" />
|
||||
<path v-else-if="name === 'Moon'" d="M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5Z" />
|
||||
<path v-else-if="name === 'Sunny'" d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z" />
|
||||
<path v-else-if="name === 'Refresh'" d="M20 11a8 8 0 0 0-14.8-3L3 11M3 5v6h6M4 13a8 8 0 0 0 14.8 3L21 13M21 19v-6h-6" />
|
||||
<path v-else-if="name === 'Close'" d="m6 6 12 12M18 6 6 18" />
|
||||
<path v-else-if="name === 'More'" d="M5 12h.01M12 12h.01M19 12h.01" stroke-width="3" />
|
||||
<path v-else-if="name === 'Back'" d="m15 18-6-6 6-6M9 12h10" />
|
||||
<path v-else-if="name === 'Right'" d="m9 6 6 6-6 6M5 12h10" />
|
||||
<path v-else-if="name === 'Chevron'" d="m8 9 4 4 4-4" />
|
||||
</svg>
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -74,14 +74,15 @@ describe("menu display primitives", () => {
|
||||
expect(getMenuIconName(menu({ id: "unknown", name: "未知", type: "page", icon: "NotAnIcon" }))).toBe("Menu");
|
||||
});
|
||||
|
||||
it("keeps the LayoutIcon SFC contract static and dependency-free", () => {
|
||||
it("renders every layout icon through the Element Plus icon registry", () => {
|
||||
const source = readFileSync(new URL("../components/layout/LayoutIcon.vue", import.meta.url), "utf8");
|
||||
expect(source).toContain('viewBox="0 0 24 24"');
|
||||
expect(source).toContain('from "@element-plus/icons-vue"');
|
||||
for (const name of ["Menu", "Grid", "Route", "User", "Collection", "Setting", "Search", "FullScreen", "Moon", "Sunny", "Refresh", "Close", "More", "Back", "Right", "Chevron"]) {
|
||||
expect(source).toContain(`name === '${name}'`);
|
||||
expect(source).toContain(`${name}:`);
|
||||
}
|
||||
expect(source).toContain('<title v-if="title">');
|
||||
expect(source).toContain(':aria-hidden="title ? undefined : true"');
|
||||
expect(source).not.toMatch(/emoji|external|lucide|element-plus/i);
|
||||
expect(source).toContain("Route: Guide");
|
||||
expect(source).toContain("Chevron: ArrowDown");
|
||||
expect(source).not.toMatch(/<svg|<path|emoji|external|lucide/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { CircleCloseFilled } from "@element-plus/icons-vue";
|
||||
import {
|
||||
createHomePlayRecommendation,
|
||||
createHomeTeamBuilding,
|
||||
@@ -226,7 +227,7 @@ onMounted(loadConfig);
|
||||
<div class="page-stack">
|
||||
<div class="page-intro site-config-intro"><div><p class="eyebrow">CONTENT / HOME</p><h2>首页配置</h2><p>统一维护顶部轮播、玩法推荐、万趣用车、团队共创和极境视界;保存后直接同步首页展示。</p></div></div>
|
||||
<el-skeleton v-if="loading" :rows="10" animated />
|
||||
<el-result v-else-if="loadError" icon="error" title="首页配置加载失败" :sub-title="loadError"><template #extra><el-button type="primary" @click="loadConfig">重试</el-button></template></el-result>
|
||||
<el-result v-else-if="loadError" title="首页配置加载失败" :sub-title="loadError"><template #icon><CircleCloseFilled /></template><template #extra><el-button type="primary" @click="loadConfig">重试</el-button></template></el-result>
|
||||
<template v-else>
|
||||
<el-tabs v-model="activeModule" class="site-config-tabs" @tab-change="closeEditor">
|
||||
<el-tab-pane name="heroSlides" label="顶部轮播" />
|
||||
|
||||
@@ -1,25 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from "vue";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import * as api from "@/api/client";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const form = reactive({ email: "admin@example.com", password: "ChangeMe123!" });
|
||||
const rememberedEmailKey = "wonderq-admin-remembered-email";
|
||||
const rememberedEmail = typeof window === "undefined" ? "" : window.localStorage.getItem(rememberedEmailKey) ?? "";
|
||||
const form = reactive({ email: rememberedEmail || "admin@example.com", password: "ChangeMe123!", captchaId: "", captchaCode: "", rememberMe: Boolean(rememberedEmail) });
|
||||
const error = ref("");
|
||||
const captchaImage = ref("");
|
||||
const captchaLoading = ref(false);
|
||||
|
||||
async function loadCaptcha() {
|
||||
captchaLoading.value = true;
|
||||
try {
|
||||
const captcha = await api.getCaptcha();
|
||||
form.captchaId = captcha.captchaId;
|
||||
form.captchaCode = "";
|
||||
captchaImage.value = captcha.image;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "验证码加载失败,请稍后重试";
|
||||
} finally {
|
||||
captchaLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error.value = "";
|
||||
try {
|
||||
await auth.login(form.email, form.password);
|
||||
await auth.login(form.email, form.password, form.captchaId, form.captchaCode, form.rememberMe);
|
||||
if (form.rememberMe) window.localStorage.setItem(rememberedEmailKey, form.email.trim());
|
||||
else window.localStorage.removeItem(rememberedEmailKey);
|
||||
await router.replace(typeof route.query.redirect === "string" ? route.query.redirect : "/dashboard");
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "登录失败,请稍后重试";
|
||||
ElMessage.error(error.value);
|
||||
await loadCaptcha();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadCaptcha);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,6 +58,16 @@ async function submit() {
|
||||
<el-form :model="form" label-position="top" @submit.prevent="submit">
|
||||
<el-form-item label="账号"><el-input v-model="form.email" type="email" autocomplete="username" /></el-form-item>
|
||||
<el-form-item label="密码"><el-input v-model="form.password" type="password" show-password autocomplete="current-password" @keyup.enter="submit" /></el-form-item>
|
||||
<el-form-item label="图形验证码">
|
||||
<div class="login-captcha-row">
|
||||
<el-input v-model="form.captchaCode" maxlength="8" autocomplete="off" placeholder="请输入验证码" @keyup.enter="submit" />
|
||||
<button type="button" class="login-captcha-image" :disabled="captchaLoading" aria-label="刷新图形验证码" @click="loadCaptcha">
|
||||
<img v-if="captchaImage" :src="captchaImage" alt="图形验证码" />
|
||||
<span v-else>{{ captchaLoading ? "加载中" : "点击刷新" }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-checkbox v-model="form.rememberMe" class="login-remember">记住密码</el-checkbox>
|
||||
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" class="login-error" />
|
||||
<el-button type="primary" native-type="submit" :loading="auth.loading" class="login-button">{{ auth.loading ? "登录中" : "进入后台" }}</el-button>
|
||||
</el-form>
|
||||
|
||||
@@ -1 +1,10 @@
|
||||
<template><el-result icon="warning" title="页面不存在" sub-title="请从左侧菜单重新进入"><template #extra><el-button type="primary" @click="$router.push('/dashboard')">返回仪表盘</el-button></template></el-result></template>
|
||||
<script setup lang="ts">
|
||||
import { WarningFilled } from "@element-plus/icons-vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-result title="页面不存在" sub-title="请从左侧菜单重新进入">
|
||||
<template #icon><WarningFilled /></template>
|
||||
<template #extra><el-button type="primary" @click="$router.push('/dashboard')">返回仪表盘</el-button></template>
|
||||
</el-result>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { CircleCloseFilled } from "@element-plus/icons-vue";
|
||||
import {
|
||||
createDetail,
|
||||
createWanfaCategory,
|
||||
@@ -243,7 +244,7 @@ onMounted(loadData);
|
||||
</div>
|
||||
|
||||
<el-skeleton v-if="loading" :rows="10" animated />
|
||||
<el-result v-else-if="error" icon="error" title="玩法与详情加载失败" :sub-title="error"><template #extra><el-button type="primary" @click="loadData">重试</el-button></template></el-result>
|
||||
<el-result v-else-if="error" title="玩法与详情加载失败" :sub-title="error"><template #icon><CircleCloseFilled /></template><template #extra><el-button type="primary" @click="loadData">重试</el-button></template></el-result>
|
||||
<el-empty v-else-if="!categories.length" description="暂无玩法分类"><el-button v-permission="'admin:wanfa:read'" type="primary" @click="openCategoryCreate">创建第一条分类</el-button></el-empty>
|
||||
|
||||
<div v-else class="wanfa-workspace">
|
||||
|
||||
@@ -41,10 +41,10 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
async function login(email: string, password: string, captchaId: string, captchaCode: string, rememberMe = false) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const session = await api.login(email, password);
|
||||
const session = await api.login(email, password, captchaId, captchaCode, rememberMe);
|
||||
api.setAccessToken(session.accessToken || session.token);
|
||||
user.value = session.user;
|
||||
await loadProfile();
|
||||
|
||||
@@ -262,6 +262,7 @@ button, input { font: inherit; }
|
||||
.sidebar-menu .el-menu--inline { background: transparent !important; }
|
||||
.sidebar-item__content { display: inline-flex; width: 100%; align-items: center; gap: 12px; }
|
||||
.sidebar-item__content svg { flex: 0 0 auto; }
|
||||
.layout-icon { display: block; flex: 0 0 auto; vertical-align: middle; }
|
||||
.sidebar-item__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.el-menu--collapse .sidebar-item__content { justify-content: center; }
|
||||
|
||||
@@ -349,6 +350,12 @@ button, input { font: inherit; }
|
||||
.login-card-heading h2 { margin: 0; font-size: 22px; }
|
||||
.login-card-heading p { margin: 5px 0 0; color: var(--muted); font-size: 13px; }
|
||||
.login-error { margin: 4px 0 17px; }
|
||||
.login-remember { margin: -4px 0 12px; }
|
||||
.login-captcha-row { display: grid; grid-template-columns: minmax(0, 1fr) 160px; gap: 10px; align-items: center; width: 100%; }
|
||||
.login-captcha-image { display: flex; width: 160px; height: 32px; align-items: center; justify-content: center; padding: 0; overflow: hidden; border: 1px solid #dcdfe6; border-radius: 4px; color: #909399; background: #f5f7fa; cursor: pointer; }
|
||||
.login-captcha-image:hover:not(:disabled), .login-captcha-image:focus-visible { border-color: var(--primary-color); }
|
||||
.login-captcha-image:disabled { cursor: wait; opacity: .7; }
|
||||
.login-captcha-image img { display: block; width: 160px; height: 48px; object-fit: cover; }
|
||||
.login-button { width: 100%; height: 42px; }
|
||||
@media (max-width: 900px) { .login-page { grid-template-columns: 1fr; padding: 48px 24px; gap: 24px; } .login-art { max-width: none; } .login-art h1 { font-size: 38px; } .login-card { max-width: 520px; width: 100%; } .advisor-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 640px) { .user-name { display: none; } .metric-grid, .module-grid, .loading-grid, .advisor-grid { grid-template-columns: 1fr; } .page-intro { align-items: flex-start; flex-direction: column; } }
|
||||
|
||||
@@ -117,6 +117,13 @@ export type AuthPayload = {
|
||||
user: AdminUser;
|
||||
};
|
||||
|
||||
export type CaptchaPayload = {
|
||||
captchaEnabled: boolean;
|
||||
captchaId: string;
|
||||
image: string;
|
||||
expiresIn: number;
|
||||
};
|
||||
|
||||
export type Dashboard = {
|
||||
stats: { newLeadCount: number; leadCount: number };
|
||||
recentLeads: Lead[];
|
||||
|
||||
65
WonderQ-Admin/alembic/versions/0034_media_assets.py
Normal file
65
WonderQ-Admin/alembic/versions/0034_media_assets.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Create the media asset table required by admin uploads."""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "0034_media_assets"
|
||||
down_revision = "0033_remove_site_versions"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
DEFAULT_DEPT_ID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
|
||||
def _table_names() -> set[str]:
|
||||
return set(inspect(op.get_bind()).get_table_names())
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if "MediaAsset" in _table_names():
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"MediaAsset",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("url", sa.String(), nullable=False, unique=True),
|
||||
sa.Column("name", sa.String(), nullable=True),
|
||||
sa.Column("mimeType", sa.String(), nullable=True),
|
||||
sa.Column("sizeBytes", sa.Integer(), nullable=True),
|
||||
sa.Column("group", sa.String(), nullable=True),
|
||||
sa.Column(
|
||||
"deptId",
|
||||
sa.String(),
|
||||
nullable=False,
|
||||
server_default=sa.text(f"'{DEFAULT_DEPT_ID}'"),
|
||||
),
|
||||
sa.Column("createdById", sa.String(), nullable=True),
|
||||
sa.Column("createdAt", sa.DateTime(), nullable=False),
|
||||
sa.Column("updatedAt", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["deptId"],
|
||||
["AdminDepartment.id"],
|
||||
name="fk_MediaAsset_deptId",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["createdById"],
|
||||
["AdminUser.id"],
|
||||
name="fk_MediaAsset_createdById",
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_MediaAsset_deptId", "MediaAsset", ["deptId"])
|
||||
op.create_index("ix_MediaAsset_createdById", "MediaAsset", ["createdById"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if "MediaAsset" not in _table_names():
|
||||
return
|
||||
|
||||
op.drop_index("ix_MediaAsset_createdById", table_name="MediaAsset")
|
||||
op.drop_index("ix_MediaAsset_deptId", table_name="MediaAsset")
|
||||
op.drop_table("MediaAsset")
|
||||
@@ -64,7 +64,7 @@ def decode_admin_access_token(token: str) -> dict:
|
||||
return jwt.decode(token, get_settings().jwt_secret, algorithms=["HS256"], options={"verify_aud": False})
|
||||
|
||||
|
||||
def issue_admin_session(user: AdminUser, store: AdminSessionStore) -> tuple[str, str, int]:
|
||||
def issue_admin_session(user: AdminUser, store: AdminSessionStore, *, remember_me: bool = False) -> tuple[str, str, int]:
|
||||
settings = get_settings()
|
||||
now = datetime.now(timezone.utc)
|
||||
session_id = str(uuid4())
|
||||
@@ -79,6 +79,7 @@ def issue_admin_session(user: AdminUser, store: AdminSessionStore) -> tuple[str,
|
||||
refresh_hash=hash_refresh_token(refresh_token),
|
||||
access_expires_at=access_expires_at,
|
||||
refresh_expires_at=refresh_expires_at,
|
||||
remember_me=remember_me,
|
||||
)
|
||||
return (
|
||||
create_access_token(user, session_id=session_id, access_jti=access_jti),
|
||||
|
||||
49
WonderQ-Admin/app/captcha.py
Normal file
49
WonderQ-Admin/app/captcha.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import html
|
||||
import secrets
|
||||
from uuid import uuid4
|
||||
|
||||
from .redis_session import AdminSessionStore
|
||||
|
||||
CAPTCHA_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
|
||||
|
||||
def _captcha_code(length: int = 4) -> str:
|
||||
return "".join(secrets.choice(CAPTCHA_ALPHABET) for _ in range(length))
|
||||
|
||||
|
||||
def _captcha_image(code: str) -> str:
|
||||
lines = "".join(
|
||||
f'<path d="M{secrets.randbelow(150) + 5},{secrets.randbelow(42) + 3} '
|
||||
f'L{secrets.randbelow(150) + 5},{secrets.randbelow(42) + 3}" />'
|
||||
for _ in range(5)
|
||||
)
|
||||
safe_code = html.escape(code)
|
||||
svg = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="48" viewBox="0 0 160 48">'
|
||||
'<rect width="160" height="48" rx="4" fill="#f5f7fa"/>'
|
||||
f'<g stroke="#c0c4cc" stroke-width="1" opacity=".8">{lines}</g>'
|
||||
f'<text x="80" y="32" text-anchor="middle" fill="#303133" '
|
||||
'font-family="Arial,sans-serif" font-size="24" font-weight="700" letter-spacing="5">'
|
||||
f"{safe_code}</text></svg>"
|
||||
)
|
||||
encoded = base64.b64encode(svg.encode("utf-8")).decode("ascii")
|
||||
return f"data:image/svg+xml;base64,{encoded}"
|
||||
|
||||
|
||||
def create_captcha(store: AdminSessionStore, ttl_seconds: int) -> dict[str, object]:
|
||||
captcha_id = uuid4().hex
|
||||
code = _captcha_code()
|
||||
store.create_captcha(captcha_id, code, ttl_seconds)
|
||||
return {
|
||||
"captchaEnabled": True,
|
||||
"captchaId": captcha_id,
|
||||
"image": _captcha_image(code),
|
||||
"expiresIn": ttl_seconds,
|
||||
}
|
||||
|
||||
|
||||
def verify_captcha(store: AdminSessionStore, captcha_id: str, code: str) -> bool:
|
||||
return store.consume_captcha(captcha_id, code)
|
||||
@@ -17,6 +17,7 @@ class Settings(BaseSettings):
|
||||
admin_permission_cache_seconds: int = Field(default=300)
|
||||
admin_login_rate_limit: int = Field(default=5)
|
||||
admin_login_rate_window_seconds: int = Field(default=60)
|
||||
admin_captcha_expires_seconds: int = Field(default=120)
|
||||
log_level: str = Field(default="info")
|
||||
port: int = Field(default=4000)
|
||||
cors_origins: str = Field(default="*")
|
||||
|
||||
90
WonderQ-Admin/app/media_urls.py
Normal file
90
WonderQ-Admin/app/media_urls.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
from time import time
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
SIGNED_QUERY_KEYS = {"OSSAccessKeyId", "Expires", "Signature"}
|
||||
MEDIA_URL_EXPIRES_SECONDS = 3600
|
||||
|
||||
|
||||
def normalized_oss_host(endpoint: str, bucket: str) -> tuple[str, str]:
|
||||
raw = endpoint.strip().rstrip("/")
|
||||
if "://" not in raw:
|
||||
raw = f"https://{raw}"
|
||||
parsed = urlsplit(raw)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise ValueError("invalid OSS endpoint")
|
||||
host = parsed.netloc
|
||||
if not host.lower().startswith(f"{bucket.lower()}."):
|
||||
host = f"{bucket}.{host}"
|
||||
return parsed.scheme, host
|
||||
|
||||
|
||||
def sign_oss_get_url(
|
||||
url: str,
|
||||
*,
|
||||
access_key_id: str,
|
||||
access_key_secret: str,
|
||||
endpoint: str,
|
||||
bucket: str,
|
||||
expires_at: int,
|
||||
) -> str:
|
||||
scheme, expected_host = normalized_oss_host(endpoint, bucket)
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme != scheme or parsed.netloc.lower() != expected_host.lower():
|
||||
return url
|
||||
|
||||
path = parsed.path or "/"
|
||||
canonical_resource = f"/{bucket}{path}"
|
||||
string_to_sign = f"GET\n\n\n{expires_at}\n{canonical_resource}"
|
||||
signature = base64.b64encode(
|
||||
hmac.new(
|
||||
access_key_secret.strip().encode("utf-8"),
|
||||
string_to_sign.encode("utf-8"),
|
||||
hashlib.sha1,
|
||||
).digest()
|
||||
).decode("ascii")
|
||||
query = [
|
||||
(key, value)
|
||||
for key, value in parse_qsl(parsed.query, keep_blank_values=True)
|
||||
if key not in SIGNED_QUERY_KEYS
|
||||
]
|
||||
query.extend(
|
||||
[
|
||||
("OSSAccessKeyId", access_key_id.strip()),
|
||||
("Expires", str(expires_at)),
|
||||
("Signature", signature),
|
||||
]
|
||||
)
|
||||
return urlunsplit((scheme, expected_host, path, urlencode(query), ""))
|
||||
|
||||
|
||||
def resolve_media_url(url: str | None) -> str | None:
|
||||
if not url or not isinstance(url, str):
|
||||
return url
|
||||
|
||||
settings = get_settings()
|
||||
values = (
|
||||
settings.oss_access_key_id,
|
||||
settings.oss_access_key_secret,
|
||||
settings.oss_endpoint,
|
||||
settings.oss_bucket_name,
|
||||
)
|
||||
if not all(value and value.strip() for value in values):
|
||||
return url
|
||||
|
||||
try:
|
||||
return sign_oss_get_url(
|
||||
url,
|
||||
access_key_id=settings.oss_access_key_id,
|
||||
access_key_secret=settings.oss_access_key_secret,
|
||||
endpoint=settings.oss_endpoint,
|
||||
bucket=settings.oss_bucket_name,
|
||||
expires_at=int(time()) + MEDIA_URL_EXPIRES_SECONDS,
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return url
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
@@ -22,6 +23,11 @@ def new_refresh_token() -> str:
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
|
||||
def hash_captcha_answer(captcha_id: str, answer: str) -> str:
|
||||
normalized = answer.strip().upper()
|
||||
return hashlib.sha256(f"{captcha_id}:{normalized}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionRecord:
|
||||
session_id: str
|
||||
@@ -30,6 +36,7 @@ class SessionRecord:
|
||||
refresh_hash: str
|
||||
access_expires_at: datetime
|
||||
refresh_expires_at: datetime
|
||||
remember_me: bool = False
|
||||
|
||||
|
||||
class AdminSessionStore(Protocol):
|
||||
@@ -69,6 +76,10 @@ class AdminSessionStore(Protocol):
|
||||
|
||||
def allow_login_attempt(self, identity: str, limit: int, window_seconds: int) -> bool: ...
|
||||
|
||||
def create_captcha(self, captcha_id: str, answer: str, ttl_seconds: int) -> None: ...
|
||||
|
||||
def consume_captcha(self, captcha_id: str, answer: str) -> bool: ...
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
@@ -80,6 +91,7 @@ class InMemoryAdminSessionStore:
|
||||
self._refresh_index: dict[str, str] = {}
|
||||
self._permission_cache: dict[str, tuple[dict, datetime]] = {}
|
||||
self._login_attempts: dict[str, tuple[int, datetime]] = {}
|
||||
self._captchas: dict[str, tuple[str, datetime]] = {}
|
||||
|
||||
def create(self, **kwargs) -> None:
|
||||
record = SessionRecord(**kwargs)
|
||||
@@ -118,6 +130,7 @@ class InMemoryAdminSessionStore:
|
||||
refresh_hash=refresh_hash_next,
|
||||
access_expires_at=access_expires_at,
|
||||
refresh_expires_at=refresh_expires_at,
|
||||
remember_me=record.remember_me,
|
||||
)
|
||||
self._sessions[session_id] = next_record
|
||||
self._refresh_index[refresh_hash_next] = session_id
|
||||
@@ -157,6 +170,15 @@ class InMemoryAdminSessionStore:
|
||||
self._login_attempts[identity] = (attempts, expires_at)
|
||||
return attempts <= limit
|
||||
|
||||
def create_captcha(self, captcha_id: str, answer: str, ttl_seconds: int) -> None:
|
||||
self._captchas[captcha_id] = (hash_captcha_answer(captcha_id, answer), _now() + timedelta(seconds=max(1, ttl_seconds)))
|
||||
|
||||
def consume_captcha(self, captcha_id: str, answer: str) -> bool:
|
||||
record = self._captchas.pop(captcha_id, None)
|
||||
if not record or record[1] <= _now():
|
||||
return False
|
||||
return hmac.compare_digest(record[0], hash_captcha_answer(captcha_id, answer))
|
||||
|
||||
|
||||
class RedisAdminSessionStore:
|
||||
prefix = "wonderq:admin"
|
||||
@@ -183,6 +205,9 @@ class RedisAdminSessionStore:
|
||||
def _login_limit_key(self, identity: str) -> str:
|
||||
return f"{self.prefix}:login-limit:{hashlib.sha256(identity.encode('utf-8')).hexdigest()}"
|
||||
|
||||
def _captcha_key(self, captcha_id: str) -> str:
|
||||
return f"{self.prefix}:captcha:{captcha_id}"
|
||||
|
||||
@staticmethod
|
||||
def _serialize(record: SessionRecord) -> str:
|
||||
return json.dumps(
|
||||
@@ -193,6 +218,7 @@ class RedisAdminSessionStore:
|
||||
"refreshHash": record.refresh_hash,
|
||||
"accessExpiresAt": record.access_expires_at.isoformat(),
|
||||
"refreshExpiresAt": record.refresh_expires_at.isoformat(),
|
||||
"rememberMe": record.remember_me,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -209,6 +235,7 @@ class RedisAdminSessionStore:
|
||||
refresh_hash=payload["refreshHash"],
|
||||
access_expires_at=datetime.fromisoformat(payload["accessExpiresAt"]),
|
||||
refresh_expires_at=datetime.fromisoformat(payload["refreshExpiresAt"]),
|
||||
remember_me=bool(payload.get("rememberMe", False)),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise RedisUnavailableError("Redis 会话数据无效") from exc
|
||||
@@ -273,6 +300,7 @@ class RedisAdminSessionStore:
|
||||
refresh_hash=refresh_hash_next,
|
||||
access_expires_at=access_expires_at,
|
||||
refresh_expires_at=refresh_expires_at,
|
||||
remember_me=record.remember_me,
|
||||
)
|
||||
pipe.multi()
|
||||
pipe.delete(refresh_key)
|
||||
@@ -337,6 +365,28 @@ class RedisAdminSessionStore:
|
||||
except Exception as exc:
|
||||
raise RedisUnavailableError("Redis 登录限流不可用") from exc
|
||||
|
||||
def create_captcha(self, captcha_id: str, answer: str, ttl_seconds: int) -> None:
|
||||
self._ensure_available()
|
||||
try:
|
||||
self.client.set(self._captcha_key(captcha_id), hash_captcha_answer(captcha_id, answer), ex=max(1, ttl_seconds))
|
||||
except Exception as exc:
|
||||
raise RedisUnavailableError("Redis 验证码写入失败") from exc
|
||||
|
||||
def consume_captcha(self, captcha_id: str, answer: str) -> bool:
|
||||
self._ensure_available()
|
||||
script = """
|
||||
local value = redis.call('GET', KEYS[1])
|
||||
if value then redis.call('DEL', KEYS[1]) end
|
||||
return value
|
||||
"""
|
||||
try:
|
||||
stored = self.client.eval(script, 1, self._captcha_key(captcha_id))
|
||||
except Exception as exc:
|
||||
raise RedisUnavailableError("Redis 验证码校验不可用") from exc
|
||||
if not stored:
|
||||
return False
|
||||
return hmac.compare_digest(str(stored), hash_captcha_answer(captcha_id, answer))
|
||||
|
||||
|
||||
def get_admin_session_store() -> AdminSessionStore:
|
||||
return RedisAdminSessionStore()
|
||||
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from ..auth import get_actor_id, issue_admin_session, require_admin, rotate_admin_session, verify_password
|
||||
from ..api_response import success_response
|
||||
from ..captcha import create_captcha, verify_captcha
|
||||
from ..config import get_settings
|
||||
from ..database import get_db
|
||||
from ..models import (
|
||||
@@ -57,7 +58,8 @@ from ..schemas import (
|
||||
DetailPatch,
|
||||
)
|
||||
from ..seed import create_media
|
||||
from ..serializers import concierge_advisor_dict, detail_record_dict, encode_value, lead_dict, model_dict
|
||||
from ..media_urls import resolve_media_url
|
||||
from ..serializers import concierge_advisor_dict, detail_record_dict, encode_value, lead_dict, media_model_dict, model_dict
|
||||
from .shared import site_config
|
||||
|
||||
|
||||
@@ -291,7 +293,7 @@ def hero_slide_admin_dict(item: HeroSlide) -> dict:
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
"kicker": item.kicker or None,
|
||||
"image": item.image or None,
|
||||
"image": resolve_media_url(item.image) or None,
|
||||
"isActive": item.isActive,
|
||||
"sortOrder": item.sortOrder,
|
||||
"createdAt": encode_value(item.createdAt),
|
||||
@@ -302,7 +304,7 @@ def hero_slide_admin_dict(item: HeroSlide) -> dict:
|
||||
def site_item_dict(module: str, item) -> dict:
|
||||
if module == "heroSlides":
|
||||
return hero_slide_admin_dict(item)
|
||||
return model_dict(item)
|
||||
return media_model_dict(item)
|
||||
|
||||
|
||||
def module_items(db: Session, config: dict) -> list:
|
||||
@@ -353,7 +355,7 @@ def wanfa_route_dict(route: WanfaRoute) -> dict:
|
||||
"id": route.id,
|
||||
"title": route.title,
|
||||
"subtitle": route.subtitle,
|
||||
"image": route.image,
|
||||
"image": resolve_media_url(route.image),
|
||||
"routeCount": route.routeCount,
|
||||
"demandKeyword": route.demandKeyword,
|
||||
}
|
||||
@@ -464,7 +466,7 @@ def create_home_item(db: Session, request: Request, body, model, entity: str):
|
||||
after = model_dict(item)
|
||||
audit(db, get_actor_id(request), "create", entity, item.id, after=after)
|
||||
db.commit()
|
||||
return success_response(after, status_code=status.HTTP_201_CREATED)
|
||||
return success_response(media_model_dict(item), status_code=status.HTTP_201_CREATED)
|
||||
def update_home_item(db: Session, request: Request, item_id: str, body, model, entity: str, label: str, code: str):
|
||||
item = home_item_or_error(db, model, item_id, label, code)
|
||||
before = model_dict(item)
|
||||
@@ -474,7 +476,7 @@ def update_home_item(db: Session, request: Request, item_id: str, body, model, e
|
||||
after = model_dict(item)
|
||||
audit(db, get_actor_id(request), "update", entity, item.id, after=after, before=before)
|
||||
db.commit()
|
||||
return success_response(after)
|
||||
return success_response(media_model_dict(item))
|
||||
|
||||
|
||||
def delete_home_item(db: Session, request: Request, item_id: str, model, entity: str, label: str, code: str):
|
||||
@@ -498,12 +500,12 @@ def reorder_home_items(db: Session, request: Request, body: HomeReorderIn, model
|
||||
after = [model_dict(item) for item in ordered]
|
||||
audit(db, get_actor_id(request), "reorder", entity, after=after)
|
||||
db.commit()
|
||||
return success_response({"items": after})
|
||||
return success_response({"items": [media_model_dict(item) for item in ordered]})
|
||||
|
||||
|
||||
@router.get("/home/team-buildings")
|
||||
def list_home_team_buildings(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
|
||||
return success_response({"items": [model_dict(item) for item in home_items(db, HomeTeamBuilding)]})
|
||||
return success_response({"items": [media_model_dict(item) for item in home_items(db, HomeTeamBuilding)]})
|
||||
|
||||
|
||||
@router.post("/home/team-buildings", status_code=status.HTTP_201_CREATED)
|
||||
@@ -549,7 +551,7 @@ def delete_home_team_building(
|
||||
|
||||
@router.get("/home/wild-archives")
|
||||
def list_home_wild_archives(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
|
||||
return success_response({"items": [model_dict(item) for item in home_items(db, HomeWildArchive)]})
|
||||
return success_response({"items": [media_model_dict(item) for item in home_items(db, HomeWildArchive)]})
|
||||
|
||||
|
||||
@router.post("/home/wild-archives", status_code=status.HTTP_201_CREATED)
|
||||
@@ -1145,6 +1147,17 @@ def delete_concierge_advisor(
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.get("/auth/captcha")
|
||||
def get_login_captcha(
|
||||
store: AdminSessionStore = Depends(get_admin_session_store),
|
||||
):
|
||||
settings = get_settings()
|
||||
try:
|
||||
return success_response(create_captcha(store, settings.admin_captcha_expires_seconds))
|
||||
except RedisUnavailableError as exc:
|
||||
raise HTTPException(status_code=503, detail="后台会话服务暂时不可用") from exc
|
||||
|
||||
|
||||
@router.post("/auth/login")
|
||||
def login(
|
||||
body: LoginIn,
|
||||
@@ -1160,11 +1173,16 @@ def login(
|
||||
raise HTTPException(status_code=429, detail="登录尝试过于频繁,请稍后再试", headers={"Retry-After": str(settings.admin_login_rate_window_seconds)})
|
||||
except RedisUnavailableError as exc:
|
||||
raise HTTPException(status_code=503, detail="后台会话服务暂时不可用") from exc
|
||||
try:
|
||||
if not verify_captcha(store, body.captchaId, body.captchaCode):
|
||||
raise HTTPException(status_code=401, detail="图形验证码错误或已过期")
|
||||
except RedisUnavailableError as exc:
|
||||
raise HTTPException(status_code=503, detail="后台会话服务暂时不可用") from exc
|
||||
user = db.scalar(select(AdminUser).where(AdminUser.email == body.email))
|
||||
if not user or not user.isActive or not verify_password(body.password, user.passwordHash):
|
||||
raise HTTPException(status_code=401, detail="账号或密码错误")
|
||||
try:
|
||||
access_token, refresh_token, expires_in = issue_admin_session(user, store)
|
||||
access_token, refresh_token, expires_in = issue_admin_session(user, store, remember_me=body.rememberMe)
|
||||
except RedisUnavailableError as exc:
|
||||
raise HTTPException(status_code=503, detail="后台会话服务暂时不可用") from exc
|
||||
result = success_response(
|
||||
@@ -1182,7 +1200,7 @@ def login(
|
||||
secure=settings.admin_refresh_cookie_secure,
|
||||
samesite="lax",
|
||||
path=settings.admin_refresh_cookie_path,
|
||||
max_age=settings.admin_refresh_expires_days * 24 * 60 * 60,
|
||||
max_age=settings.admin_refresh_expires_days * 24 * 60 * 60 if body.rememberMe else None,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -1226,7 +1244,7 @@ def refresh_admin_session(
|
||||
secure=settings.admin_refresh_cookie_secure,
|
||||
samesite="lax",
|
||||
path=settings.admin_refresh_cookie_path,
|
||||
max_age=settings.admin_refresh_expires_days * 24 * 60 * 60,
|
||||
max_age=settings.admin_refresh_expires_days * 24 * 60 * 60 if record.remember_me else None,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -1292,7 +1310,7 @@ def admin_site_config(_user: AdminUser = Depends(require_admin_permission("admin
|
||||
for item in scoped_items(db, HeroSlide, _user, HeroSlide.sortOrder.asc())
|
||||
],
|
||||
"vehicleOptions": [
|
||||
model_dict(item)
|
||||
media_model_dict(item)
|
||||
for item in scoped_items(db, VehicleOption, _user, VehicleOption.sortOrder.asc())
|
||||
],
|
||||
}
|
||||
@@ -1484,7 +1502,7 @@ def update_lead_status(lead_id: str, body: LeadStatusIn, request: Request, _user
|
||||
@router.get("/media-assets")
|
||||
def list_media_assets(_user: AdminUser = Depends(require_admin_permission("admin:media:read")), db: Session = Depends(get_db)):
|
||||
assets = db.scalars(apply_data_scope(select(MediaAsset), MediaAsset, _user, db).order_by(MediaAsset.createdAt.desc()).limit(200)).all()
|
||||
return success_response({"items": [model_dict(asset) for asset in assets]})
|
||||
return success_response({"items": [media_model_dict(asset) for asset in assets]})
|
||||
|
||||
|
||||
@router.post("/media-assets/upload", status_code=status.HTTP_201_CREATED)
|
||||
@@ -1512,6 +1530,6 @@ def upload_media_asset(
|
||||
after = model_dict(asset)
|
||||
audit(db, get_actor_id(request), "upload", "media_asset", asset.id, after)
|
||||
db.commit()
|
||||
return success_response(after, status_code=status.HTTP_201_CREATED)
|
||||
return success_response(media_model_dict(asset), status_code=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from ..models import HeroSlide, VehicleOption
|
||||
from ..serializers import model_dict
|
||||
from ..serializers import media_model_dict
|
||||
|
||||
|
||||
def public_model_dict(item) -> dict:
|
||||
result = model_dict(item)
|
||||
result = media_model_dict(item)
|
||||
result.pop("deptId", None)
|
||||
result.pop("createdById", None)
|
||||
return result
|
||||
|
||||
@@ -13,6 +13,25 @@ CharterDuration = Literal["halfDay", "fullDay"]
|
||||
class LoginIn(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=6)
|
||||
captchaId: str = Field(min_length=1, max_length=64)
|
||||
captchaCode: str = Field(min_length=4, max_length=8)
|
||||
rememberMe: bool = False
|
||||
|
||||
@field_validator("captchaId")
|
||||
@classmethod
|
||||
def normalize_captcha_id(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("验证码不能为空")
|
||||
return normalized
|
||||
|
||||
@field_validator("captchaCode")
|
||||
@classmethod
|
||||
def normalize_captcha_code(cls, value: str) -> str:
|
||||
normalized = value.strip().upper()
|
||||
if not normalized:
|
||||
raise ValueError("验证码不能为空")
|
||||
return normalized
|
||||
|
||||
|
||||
class PhoneLoginIn(BaseModel):
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy.inspection import inspect
|
||||
|
||||
from .media_urls import resolve_media_url
|
||||
|
||||
|
||||
def encode_value(value):
|
||||
if isinstance(value, datetime):
|
||||
@@ -20,6 +22,21 @@ def model_dict(instance, include: dict[str, object] | None = None) -> dict:
|
||||
return data
|
||||
|
||||
|
||||
def media_model_dict(instance) -> dict:
|
||||
return resolve_media_fields(model_dict(instance))
|
||||
|
||||
|
||||
def resolve_media_fields(data: dict) -> dict:
|
||||
result = dict(data)
|
||||
for field in ("image", "avatar", "qrImage"):
|
||||
if field in result:
|
||||
result[field] = resolve_media_url(result[field])
|
||||
for field in ("gallery", "images"):
|
||||
if field in result and isinstance(result[field], list):
|
||||
result[field] = [resolve_media_url(value) for value in result[field]]
|
||||
return result
|
||||
|
||||
|
||||
def lead_dict(lead) -> dict:
|
||||
return model_dict(
|
||||
lead,
|
||||
@@ -32,7 +49,7 @@ def public_wanfa_route_dict(route) -> dict:
|
||||
"id": route.id,
|
||||
"title": route.title,
|
||||
"subtitle": route.subtitle,
|
||||
"image": route.image,
|
||||
"image": resolve_media_url(route.image),
|
||||
"routeCount": route.routeCount,
|
||||
"demandKeyword": route.demandKeyword,
|
||||
}
|
||||
@@ -47,7 +64,7 @@ def public_wanfa_category_dict(category) -> dict:
|
||||
|
||||
|
||||
def detail_record_dict(detail) -> dict:
|
||||
return model_dict(detail)
|
||||
return media_model_dict(detail)
|
||||
|
||||
|
||||
def public_detail_dict(detail, concierge_advisor=None) -> dict:
|
||||
@@ -62,7 +79,7 @@ def public_detail_dict(detail, concierge_advisor=None) -> dict:
|
||||
"included": detail.included or [],
|
||||
"excluded": detail.excluded or [],
|
||||
"notes": detail.notes or [],
|
||||
"gallery": detail.gallery or [],
|
||||
"gallery": [resolve_media_url(image) for image in (detail.gallery or [])],
|
||||
"conciergeAdvisor": public_concierge_advisor_dict(concierge_advisor) if concierge_advisor else None,
|
||||
}
|
||||
|
||||
@@ -77,16 +94,16 @@ def public_home_wanfa_recommendation_dict(item) -> dict:
|
||||
|
||||
|
||||
def concierge_advisor_dict(advisor) -> dict:
|
||||
return model_dict(advisor)
|
||||
return media_model_dict(advisor)
|
||||
|
||||
|
||||
def public_concierge_advisor_dict(advisor) -> dict:
|
||||
return {
|
||||
"avatar": advisor.avatar,
|
||||
"avatar": resolve_media_url(advisor.avatar),
|
||||
"name": advisor.name,
|
||||
"role": advisor.role,
|
||||
"details": advisor.details or [],
|
||||
"qrImage": advisor.qrImage,
|
||||
"qrImage": resolve_media_url(advisor.qrImage),
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +114,7 @@ def public_home_experience_dict(item) -> dict:
|
||||
"category": item.category,
|
||||
"title": item.title,
|
||||
"englishTitle": item.englishTitle,
|
||||
"image": item.image,
|
||||
"image": resolve_media_url(item.image),
|
||||
"demandKeyword": item.demandKeyword,
|
||||
}
|
||||
|
||||
@@ -108,7 +125,7 @@ def public_home_team_building_dict(item) -> dict:
|
||||
"tag": item.tag,
|
||||
"title": item.title,
|
||||
"description": item.description,
|
||||
"image": item.image,
|
||||
"image": resolve_media_url(item.image),
|
||||
"demandKeyword": item.demandKeyword,
|
||||
}
|
||||
|
||||
@@ -132,7 +149,7 @@ def public_home_wild_archive_dict(item) -> dict:
|
||||
return {
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
"image": item.image,
|
||||
"image": resolve_media_url(item.image),
|
||||
"demandKeyword": item.demandKeyword,
|
||||
"photoCount": len(item.images or [item.image]),
|
||||
}
|
||||
@@ -144,5 +161,5 @@ def public_home_wild_archive_detail_dict(item) -> dict:
|
||||
images = [item.image]
|
||||
return {
|
||||
**public_home_wild_archive_dict(item),
|
||||
"images": images,
|
||||
"images": [resolve_media_url(image) for image in images],
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ services:
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
PORT: 4000
|
||||
LOG_LEVEL: info
|
||||
OSS_ACCESS_KEY_ID: ${OSS_ACCESS_KEY_ID:-}
|
||||
OSS_ACCESS_KEY_SECRET: ${OSS_ACCESS_KEY_SECRET:-}
|
||||
OSS_ENDPOINT: ${OSS_ENDPOINT:-}
|
||||
OSS_BUCKET_NAME: ${OSS_BUCKET_NAME:-}
|
||||
ports:
|
||||
- "4000:4000"
|
||||
depends_on:
|
||||
|
||||
@@ -76,6 +76,32 @@ def test_in_memory_store_supports_login_limit_and_permission_cache():
|
||||
assert store.get_permission_context("admin-1") == {"permissions": ["admin:read"]}
|
||||
|
||||
|
||||
def test_in_memory_captcha_is_single_use():
|
||||
store = InMemoryAdminSessionStore()
|
||||
store.create_captcha("captcha-1", "ABCD", 120)
|
||||
|
||||
assert store.consume_captcha("captcha-1", "ABCD") is True
|
||||
assert store.consume_captcha("captcha-1", "ABCD") is False
|
||||
|
||||
|
||||
def test_admin_captcha_endpoint_returns_graphical_challenge():
|
||||
store = InMemoryAdminSessionStore()
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_admin_session_store] = lambda: store
|
||||
|
||||
try:
|
||||
response = TestClient(app).get("/api/admin/auth/captcha")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]
|
||||
assert data["captchaEnabled"] is True
|
||||
assert data["captchaId"]
|
||||
assert data["image"].startswith("data:image/svg+xml;base64,")
|
||||
assert data["expiresIn"] == 120
|
||||
|
||||
|
||||
def test_admin_access_token_contains_scoped_session_claims():
|
||||
user = AdminUser(
|
||||
id="admin-1",
|
||||
@@ -109,12 +135,20 @@ def test_admin_login_keeps_legacy_fields_and_sets_http_only_refresh_cookie():
|
||||
)
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_db] = lambda: LoginDb(user)
|
||||
app.dependency_overrides[get_admin_session_store] = lambda: InMemoryAdminSessionStore()
|
||||
store = InMemoryAdminSessionStore()
|
||||
store.create_captcha("captcha-login", "ABCD", 120)
|
||||
app.dependency_overrides[get_admin_session_store] = lambda: store
|
||||
|
||||
try:
|
||||
response = TestClient(app).post(
|
||||
"/api/admin/auth/login",
|
||||
json={"email": "admin@example.com", "password": "ChangeMe123!"},
|
||||
json={
|
||||
"email": "admin@example.com",
|
||||
"password": "ChangeMe123!",
|
||||
"captchaId": "captcha-login",
|
||||
"captchaCode": "ABCD",
|
||||
"rememberMe": False,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
@@ -127,6 +161,7 @@ def test_admin_login_keeps_legacy_fields_and_sets_http_only_refresh_cookie():
|
||||
cookie = response.headers["set-cookie"]
|
||||
assert "HttpOnly" in cookie
|
||||
assert "Path=/api/admin/auth" in cookie
|
||||
assert "Max-Age=" not in cookie
|
||||
|
||||
|
||||
def test_refresh_rotates_access_token_and_logout_revokes_it():
|
||||
@@ -145,9 +180,16 @@ def test_refresh_rotates_access_token_and_logout_revokes_it():
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
store.create_captcha("captcha-refresh", "ABCD", 120)
|
||||
login_response = client.post(
|
||||
"/api/admin/auth/login",
|
||||
json={"email": "admin@example.com", "password": "ChangeMe123!"},
|
||||
json={
|
||||
"email": "admin@example.com",
|
||||
"password": "ChangeMe123!",
|
||||
"captchaId": "captcha-refresh",
|
||||
"captchaCode": "ABCD",
|
||||
"rememberMe": False,
|
||||
},
|
||||
)
|
||||
old_token = login_response.json()["data"]["accessToken"]
|
||||
|
||||
@@ -155,6 +197,7 @@ def test_refresh_rotates_access_token_and_logout_revokes_it():
|
||||
new_token = refresh_response.json()["data"]["accessToken"]
|
||||
|
||||
assert refresh_response.status_code == 200
|
||||
assert "Max-Age=" not in refresh_response.headers["set-cookie"]
|
||||
assert new_token != old_token
|
||||
assert client.get("/api/admin/me", headers={"Authorization": f"Bearer {old_token}"}).status_code == 401
|
||||
assert client.get("/api/admin/me", headers={"Authorization": f"Bearer {new_token}"}).status_code == 200
|
||||
@@ -166,6 +209,45 @@ def test_refresh_rotates_access_token_and_logout_revokes_it():
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_remember_me_keeps_persistent_cookie_after_refresh_rotation():
|
||||
user = AdminUser(
|
||||
id="admin-1",
|
||||
email="admin@example.com",
|
||||
name="Admin",
|
||||
role="admin",
|
||||
passwordHash=hash_password("ChangeMe123!", rounds=4),
|
||||
isActive=True,
|
||||
)
|
||||
store = InMemoryAdminSessionStore()
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_db] = lambda: LoginDb(user)
|
||||
app.dependency_overrides[get_admin_session_store] = lambda: store
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
store.create_captcha("captcha-remember", "ABCD", 120)
|
||||
login_response = client.post(
|
||||
"/api/admin/auth/login",
|
||||
json={
|
||||
"email": "admin@example.com",
|
||||
"password": "ChangeMe123!",
|
||||
"captchaId": "captcha-remember",
|
||||
"captchaCode": "ABCD",
|
||||
"rememberMe": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert login_response.status_code == 200
|
||||
assert "Max-Age=" in login_response.headers["set-cookie"]
|
||||
|
||||
refresh_response = client.post("/api/admin/auth/refresh")
|
||||
|
||||
assert refresh_response.status_code == 200
|
||||
assert "Max-Age=" in refresh_response.headers["set-cookie"]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_rbac_models_define_normalized_association_tables():
|
||||
assert AdminRole.__tablename__ == "AdminRole"
|
||||
assert AdminMenu.__tablename__ == "AdminMenu"
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.models import AdminUser, Customer, HeroSlide, Lead, MediaAsset, Vehicle
|
||||
from app.routers import admin as admin_router
|
||||
from app.routers import public as public_router
|
||||
from app.routers.shared import site_config
|
||||
from app.redis_session import InMemoryAdminSessionStore, get_admin_session_store
|
||||
from app.schemas import LeadCreateIn, LeadQuery
|
||||
|
||||
|
||||
@@ -264,6 +265,39 @@ def test_admin_dynamic_routers_requires_admin_authentication():
|
||||
assert response.json() == {"code": 401, "msg": "请先登录后台", "data": None}
|
||||
|
||||
|
||||
def test_admin_login_requires_valid_captcha():
|
||||
admin_user = AdminUser(
|
||||
id="admin-test",
|
||||
email="admin@example.com",
|
||||
name="Admin",
|
||||
role="admin",
|
||||
passwordHash=hash_password("ChangeMe123!", rounds=4),
|
||||
isActive=True,
|
||||
)
|
||||
fake_db = FakeDb(scalar_values=[admin_user])
|
||||
store = InMemoryAdminSessionStore()
|
||||
store.create_captcha("captcha-test", "ABCD", 120)
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_db] = lambda: fake_db
|
||||
app.dependency_overrides[get_admin_session_store] = lambda: store
|
||||
|
||||
try:
|
||||
response = TestClient(app).post(
|
||||
"/api/admin/auth/login",
|
||||
json={
|
||||
"email": "admin@example.com",
|
||||
"password": "ChangeMe123!",
|
||||
"captchaId": "captcha-test",
|
||||
"captchaCode": "WRONG",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["data"] is None
|
||||
|
||||
|
||||
def test_admin_login_returns_token_and_user():
|
||||
admin_user = AdminUser(
|
||||
id="admin-test",
|
||||
@@ -274,13 +308,22 @@ def test_admin_login_returns_token_and_user():
|
||||
isActive=True,
|
||||
)
|
||||
fake_db = FakeDb(scalar_values=[admin_user])
|
||||
store = InMemoryAdminSessionStore()
|
||||
store.create_captcha("captcha-contract", "ABCD", 120)
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_db] = lambda: fake_db
|
||||
app.dependency_overrides[get_admin_session_store] = lambda: store
|
||||
|
||||
try:
|
||||
response = TestClient(app).post(
|
||||
"/api/admin/auth/login",
|
||||
json={"email": "admin@example.com", "password": "ChangeMe123!"},
|
||||
json={
|
||||
"email": "admin@example.com",
|
||||
"password": "ChangeMe123!",
|
||||
"captchaId": "captcha-contract",
|
||||
"captchaCode": "ABCD",
|
||||
"rememberMe": True,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
93
WonderQ-Admin/tests/test_media_asset_migration.py
Normal file
93
WonderQ-Admin/tests/test_media_asset_migration.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
VERSIONS_DIR = Path(__file__).parents[1] / "alembic" / "versions"
|
||||
|
||||
|
||||
def load_migration():
|
||||
path = VERSIONS_DIR / "0034_media_assets.py"
|
||||
spec = spec_from_file_location("media_asset_migration", path)
|
||||
assert spec and spec.loader
|
||||
migration = module_from_spec(spec)
|
||||
spec.loader.exec_module(migration)
|
||||
return migration
|
||||
|
||||
|
||||
class Inspector:
|
||||
def __init__(self, tables: set[str]):
|
||||
self.tables = tables
|
||||
|
||||
def get_table_names(self) -> list[str]:
|
||||
return sorted(self.tables)
|
||||
|
||||
|
||||
def test_media_asset_migration_creates_the_upload_table_with_scope_columns(monkeypatch):
|
||||
migration = load_migration()
|
||||
created: dict[str, object] = {}
|
||||
indexes: list[tuple[object, ...]] = []
|
||||
|
||||
monkeypatch.setattr(migration, "inspect", lambda _bind: Inspector({"AdminUser", "AdminDepartment"}))
|
||||
monkeypatch.setattr(migration.op, "get_bind", lambda: object())
|
||||
monkeypatch.setattr(
|
||||
migration.op,
|
||||
"create_table",
|
||||
lambda *args, **kwargs: created.update(args=args, kwargs=kwargs),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
migration.op,
|
||||
"create_index",
|
||||
lambda *args, **kwargs: indexes.append((*args, *kwargs.values())),
|
||||
)
|
||||
|
||||
migration.upgrade()
|
||||
|
||||
assert migration.down_revision == "0033_remove_site_versions"
|
||||
assert created["args"][0] == "MediaAsset"
|
||||
columns = {column.name: column for column in created["args"][1:] if isinstance(column, sa.Column)}
|
||||
assert set(columns) == {
|
||||
"id",
|
||||
"url",
|
||||
"name",
|
||||
"mimeType",
|
||||
"sizeBytes",
|
||||
"group",
|
||||
"deptId",
|
||||
"createdById",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
}
|
||||
assert columns["url"].unique
|
||||
assert columns["deptId"].nullable is False
|
||||
assert columns["deptId"].server_default.arg.text.strip("'") == migration.DEFAULT_DEPT_ID
|
||||
assert columns["createdById"].nullable is True
|
||||
assert {index[0] for index in indexes} == {
|
||||
"ix_MediaAsset_deptId",
|
||||
"ix_MediaAsset_createdById",
|
||||
}
|
||||
constraints = [constraint for constraint in created["args"][1:] if isinstance(constraint, sa.ForeignKeyConstraint)]
|
||||
primary_keys = [constraint for constraint in created["args"][1:] if isinstance(constraint, sa.PrimaryKeyConstraint)]
|
||||
assert primary_keys[0]._pending_colargs == ["id"]
|
||||
assert {
|
||||
(constraint.name, tuple(constraint.column_keys), tuple(constraint.elements[0].target_fullname.split(".")))
|
||||
for constraint in constraints
|
||||
} == {
|
||||
("fk_MediaAsset_deptId", ("deptId",), ("AdminDepartment", "id")),
|
||||
("fk_MediaAsset_createdById", ("createdById",), ("AdminUser", "id")),
|
||||
}
|
||||
|
||||
|
||||
def test_media_asset_migration_is_safe_when_table_already_exists(monkeypatch):
|
||||
migration = load_migration()
|
||||
calls: list[tuple[object, ...]] = []
|
||||
|
||||
monkeypatch.setattr(migration, "inspect", lambda _bind: Inspector({"MediaAsset"}))
|
||||
monkeypatch.setattr(migration.op, "get_bind", lambda: object())
|
||||
monkeypatch.setattr(migration.op, "create_table", lambda *args, **_kwargs: calls.append(args))
|
||||
monkeypatch.setattr(migration.op, "create_index", lambda *args, **_kwargs: calls.append(args))
|
||||
|
||||
migration.upgrade()
|
||||
|
||||
assert calls == []
|
||||
82
WonderQ-Admin/tests/test_media_urls.py
Normal file
82
WonderQ-Admin/tests/test_media_urls.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from base64 import b64encode
|
||||
from hashlib import sha1
|
||||
from hmac import new as hmac_new
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from app import media_urls
|
||||
|
||||
|
||||
def test_sign_oss_get_url_uses_oss_v1_query_signature():
|
||||
url = "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/admin/site-config/image.webp"
|
||||
|
||||
signed = media_urls.sign_oss_get_url(
|
||||
url,
|
||||
access_key_id="test-access-key",
|
||||
access_key_secret="test-access-secret",
|
||||
endpoint="oss-cn-guangzhou.aliyuncs.com",
|
||||
bucket="one-feel-ota-data",
|
||||
expires_at=1_800_000_000,
|
||||
)
|
||||
|
||||
parsed = urlsplit(signed)
|
||||
query = parse_qs(parsed.query)
|
||||
string_to_sign = "GET\n\n\n1800000000\n/one-feel-ota-data/admin/site-config/image.webp"
|
||||
expected_signature = b64encode(
|
||||
hmac_new(b"test-access-secret", string_to_sign.encode("utf-8"), sha1).digest()
|
||||
).decode("ascii")
|
||||
|
||||
assert parsed.path == "/admin/site-config/image.webp"
|
||||
assert query["OSSAccessKeyId"] == ["test-access-key"]
|
||||
assert query["Expires"] == ["1800000000"]
|
||||
assert query["Signature"] == [expected_signature]
|
||||
|
||||
|
||||
def test_resolve_media_url_only_signs_the_configured_oss_host(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
media_urls,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(
|
||||
oss_access_key_id="test-access-key",
|
||||
oss_access_key_secret="test-access-secret",
|
||||
oss_endpoint="oss-cn-guangzhou.aliyuncs.com",
|
||||
oss_bucket_name="one-feel-ota-data",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(media_urls, "time", lambda: 1_800_000_000)
|
||||
|
||||
oss_url = "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/admin/image.webp"
|
||||
external_url = "https://cdn.example.test/image.webp"
|
||||
|
||||
signed = media_urls.resolve_media_url(oss_url)
|
||||
|
||||
assert signed != oss_url
|
||||
assert "OSSAccessKeyId=test-access-key" in signed
|
||||
assert media_urls.resolve_media_url(external_url) == external_url
|
||||
|
||||
|
||||
def test_resolve_media_fields_signs_common_image_fields(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"app.serializers.resolve_media_url",
|
||||
lambda value: f"signed:{value}" if value else value,
|
||||
)
|
||||
|
||||
from app.serializers import resolve_media_fields
|
||||
|
||||
result = resolve_media_fields(
|
||||
{
|
||||
"image": "oss-image",
|
||||
"avatar": "oss-avatar",
|
||||
"qrImage": "oss-qr",
|
||||
"gallery": ["oss-gallery-1", "oss-gallery-2"],
|
||||
"label": "unchanged",
|
||||
}
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"image": "signed:oss-image",
|
||||
"avatar": "signed:oss-avatar",
|
||||
"qrImage": "signed:oss-qr",
|
||||
"gallery": ["signed:oss-gallery-1", "signed:oss-gallery-2"],
|
||||
"label": "unchanged",
|
||||
}
|
||||
@@ -31,6 +31,7 @@ MiniAPP 或 Public API 联调:
|
||||
| `detail-api.md` | 路线详情管理 API | 后端、管理前端 |
|
||||
| `concierge-api.md` | 管家顾问管理 API | 后端、管理前端 |
|
||||
| `public-api.md` | MiniAPP 使用的 Public API 唯一契约 | 后端、MiniAPP |
|
||||
| `decisions/` | 当前重要技术决策记录 | 全部 |
|
||||
|
||||
## 文档边界
|
||||
|
||||
|
||||
@@ -15,17 +15,25 @@
|
||||
## 通用约定
|
||||
|
||||
- API 前缀:`/api/admin`。
|
||||
- 除登录接口外均需 `Authorization: Bearer <admin-jwt>`。
|
||||
- 验证码和登录接口不要求 `Authorization`;其余受保护 Admin API 使用 `Authorization: Bearer <admin-jwt>` 或认证 Cookie 约定。
|
||||
- JSON 请求统一使用 camelCase 字段。
|
||||
- 变更接口写入审计日志后再提交事务。
|
||||
- 成功业务结果统一放在 `data`;创建成功为 HTTP/code `201`。
|
||||
- 失败统一返回数字 `code`、用户可读 `msg`、`data: null`,业务错误码放在可选的 `errorCode`。
|
||||
- 所有持久化资源的 `id` 由后端生成稳定 UUID 字符串。Admin UI 必须保存并复用接口返回的 ID,不能根据标题、文案或数组下标自行拼接,也不能假设 ID 是可读 slug。
|
||||
|
||||
## 管理端图标约定
|
||||
|
||||
- `WonderQ-Admin-UI-Vue` 的界面图标统一使用 `@element-plus/icons-vue`,不新增手写 SVG、Emoji 或其他图标库。
|
||||
- `src/components/layout/LayoutIcon.vue` 是后端菜单图标名与 Element Plus 图标组件之间的受控白名单适配器;未知图标必须回退为默认菜单图标,不能动态执行组件路径。
|
||||
- 为兼容既有菜单数据,`Route`、`Chevron` 等 WonderQ 图标名继续保留为前端语义别名,分别映射到 Element Plus 的 `Guide`、`ArrowDown`。
|
||||
- `ConciergeDetail.icon` 等业务字段属于内容数据,不是管理端界面图标;其值和接口契约不因本规范改变。
|
||||
|
||||
## 接口清单
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| -------- | ----------------------------------------- | -------------------- |
|
||||
| `GET` | `/api/admin/auth/captcha` | 获取后台登录图形验证码 |
|
||||
| `POST` | `/api/admin/auth/login` | 后台登录 |
|
||||
| `POST` | `/api/admin/auth/refresh` | 使用 HttpOnly Cookie 刷新后台访问令牌 |
|
||||
| `POST` | `/api/admin/auth/logout` | 撤销当前后台会话 |
|
||||
@@ -70,6 +78,8 @@
|
||||
| `GET` | `/api/admin/media-assets` | 素材列表 |
|
||||
| `POST` | `/api/admin/media-assets/upload` | 上传图片 |
|
||||
|
||||
媒体上传成功响应中的 `data.url` 是可直接用于图片回显的 HTTP(S) URL。OSS 私有读场景下,该 URL 会包含短时 GET 签名;管理端应直接使用返回值,后续 Admin/Public API 响应会重新生成签名。该接口只写入素材库,不会自动绑定首页配置;绑定首页轮播或用车卡片后,仍需提交对应的站点配置保存接口。
|
||||
|
||||
## 站点模块
|
||||
|
||||
`SiteModule` 只允许以下值:
|
||||
@@ -102,9 +112,34 @@ type SiteModule =
|
||||
`POST /api/admin/auth/login` 请求:
|
||||
|
||||
```json
|
||||
{ "email": "admin@example.test", "password": "<password>" }
|
||||
{
|
||||
"email": "admin@example.test",
|
||||
"password": "<password>",
|
||||
"captchaId": "<captcha-id>",
|
||||
"captchaCode": "ABCD",
|
||||
"rememberMe": false
|
||||
}
|
||||
```
|
||||
|
||||
登录前先调用 `GET /api/admin/auth/captcha`,接口返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"captchaEnabled": true,
|
||||
"captchaId": "<captcha-id>",
|
||||
"image": "data:image/svg+xml;base64,<image-data>",
|
||||
"expiresIn": 120
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
验证码只允许消费一次,默认 120 秒过期。验证码答案只以哈希形式保存在 Redis 中;验证码错误、过期或重复使用时登录返回 `401`,管理端应重新获取验证码。验证码接口和登录接口都依赖 Redis,Redis 不可用时返回 `503`,不得绕过验证码或会话校验。
|
||||
|
||||
`rememberMe` 默认为 `false`。勾选后,Refresh Token Cookie 按现有 7 天有效期持久化,刷新令牌轮换时继续保持持久化;未勾选时使用会话级 HttpOnly Cookie,刷新时不延长为持久化 Cookie。该字段只控制登录会话生命周期,不表示服务端或前端保存密码。
|
||||
|
||||
成功响应包裹为 `data: { token, accessToken, expiresIn, user: { id, email, name, role } }`。`accessToken` 是短时访问令牌,`token` 是当前响应中的同值兼容字段;Refresh Token 只通过同域 HttpOnly Cookie 返回,不进入 JSON。
|
||||
|
||||
管理员登录、刷新和退出依赖 Redis 会话存储。Refresh Token 轮换后旧令牌立即失效;Redis 不可用时认证接口返回 `503`,不降级为无会话校验。
|
||||
|
||||
24
docs/decisions/0001-admin-login-captcha.md
Normal file
24
docs/decisions/0001-admin-login-captcha.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# 0001 后台登录增加图形验证码
|
||||
|
||||
## 状态
|
||||
|
||||
已采纳。
|
||||
|
||||
## 决策
|
||||
|
||||
后台登录沿用 RuoYi 的交互模式:登录页先获取图形验证码,提交账号、密码、验证码 ID 和验证码内容;验证码错误、过期或重复使用时,前端重新获取验证码。
|
||||
|
||||
WonderQ 使用 `GET /api/admin/auth/captcha`,返回验证码 ID、短时 SVG 图片 data URL 和有效秒数。验证码答案不进入响应、不写日志,只以哈希形式保存到 Redis,并在校验时单次消费。验证码接口、登录限流和管理员会话都依赖 Redis;Redis 不可用时返回 `503`,不降级绕过安全校验。
|
||||
|
||||
## 原因
|
||||
|
||||
- 保留 RuoYi 用户熟悉的登录防护和刷新交互。
|
||||
- 不新增 Pillow 等图片依赖,使用后端生成的受控 SVG,减少 Docker 镜像和部署复杂度。
|
||||
- 让验证码与现有 Redis 会话、登录限流处于同一安全边界。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `WonderQ-Admin` 新增验证码接口、Redis 单次消费存储和登录请求字段。
|
||||
- `WonderQ-Admin-UI-Vue` 登录页新增验证码图片、刷新和失败重试。
|
||||
- `WonderQ-MiniAPP` 不使用后台登录,不需要改动。
|
||||
- 现有 `token/accessToken/user` 登录响应和 Refresh Token Cookie 保持兼容。
|
||||
23
docs/decisions/0002-admin-remember-me.md
Normal file
23
docs/decisions/0002-admin-remember-me.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# 0002 后台登录记住登录状态
|
||||
|
||||
## 状态
|
||||
|
||||
已采纳。
|
||||
|
||||
## 决策
|
||||
|
||||
登录页提供 RuoYi 风格的“记住密码”勾选项,但 WonderQ 不保存明文密码。`rememberMe` 作为登录请求字段传给后端:勾选时 Refresh Token Cookie 使用现有 7 天持久化策略;未勾选时使用会话级 HttpOnly Cookie。Redis 会话记录保存该状态,Refresh Token 轮换时继承原状态。
|
||||
|
||||
Vue 管理端只在勾选后保存账号和勾选偏好,用于下次填充账号;密码交由浏览器密码管理器处理,不能写入 localStorage、Cookie 或业务 API。
|
||||
|
||||
## 原因
|
||||
|
||||
- 保留 RuoYi 用户熟悉的登录体验。
|
||||
- 避免为了“记住密码”在前端持久化可复用的密码凭据。
|
||||
- 让“记住登录状态”与现有 HttpOnly Refresh Token 会话模型一致。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `WonderQ-Admin` 的登录请求增加可选 `rememberMe`,Redis 会话记录兼容旧记录,旧记录默认按未勾选处理。
|
||||
- `WonderQ-Admin-UI-Vue` 增加复选框和账号记忆逻辑。
|
||||
- `WonderQ-MiniAPP` 不使用后台登录,不需要改动。
|
||||
@@ -70,14 +70,18 @@ yarn dev:mp-weixin
|
||||
|
||||
- `GET /health` 返回健康状态。
|
||||
- `GET /api/public/site-config` 返回前台站点配置。
|
||||
- `POST /api/admin/auth/login` 返回统一包裹的登录结果。
|
||||
- `GET /api/admin/auth/captcha` 返回图形验证码和短时 `captchaId`。
|
||||
- `POST /api/admin/auth/login` 携带 `captchaId`、`captchaCode` 和可选 `rememberMe` 后返回统一包裹的登录结果;验证码错误或过期后重新获取。
|
||||
- 管理端登录后依次读取 `/api/admin/system/profile` 和 `/api/admin/system/routers`;后者按当前管理员权限提供动态导航树。
|
||||
- 所有成功响应包含数字 `code`、`msg: "success"` 和 `data`;失败响应的 `data` 必须为 `null`。
|
||||
|
||||
管理端重点检查:
|
||||
|
||||
- 登录后请求带 `Authorization: Bearer <access-token>`,Refresh Token 只通过 HttpOnly Cookie 传递。
|
||||
- 登录页应展示验证码图片,点击图片可刷新;验证码失败后自动刷新,不能在前端缓存或记录验证码答案。
|
||||
- “记住密码”只控制 Refresh Token Cookie 是否持久化;前端最多记住账号和勾选偏好,不得保存明文密码。
|
||||
- 动态路由只注册 `/api/admin/system/routers` 返回的页面菜单;目录用于组织层级,按钮只用于按钮权限,不注册为页面。
|
||||
- 管理端界面图标统一由 `@element-plus/icons-vue` 提供;菜单返回的图标名经 `LayoutIcon` 白名单映射,未知值显示默认图标。顾问服务详情中的 `icon` 字段仍按业务内容数据处理。
|
||||
- 变更角色、菜单或用户关联后,权限缓存失效时要重新登录或重新加载菜单,确认侧栏、路由和按钮权限同步变化。
|
||||
- 站点模块、玩法、详情、管家、线索和媒体接口按当前契约返回。
|
||||
- 新增、更新、删除和排序成功后,页面使用接口返回的数据更新状态,不自行生成 ID 或排序结果。
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
|
||||
- `GET /api/admin/site-config` 返回启用和停用的完整记录,前端负责显示状态。
|
||||
- `sortOrder` 为从 `0` 开始的非负整数,后端负责重新规范化。
|
||||
- 图片字段保存最终 HTTP(S) URL,不接受 base64;管理端上传组件通过媒体上传接口先取得 URL,再提交配置。
|
||||
- 图片字段保存最终 HTTP(S) URL,不接受 base64;管理端上传组件通过媒体上传接口先取得 URL,再提交配置。OSS 私有读场景下,接口响应会为 OSS 图片 URL 临时追加短时 GET 签名,供管理端和 Public API 回显;后端会在每次响应时重新签名,签名参数不应由客户端自行拼接或长期缓存。
|
||||
- `POST /api/admin/media-assets/upload` 只创建素材库记录,不会自动修改 `heroSlides` 或 `vehicleOptions`。将图片用于首页配置时,必须把返回的 `data.url` 写入对应编辑表单,并继续提交对应的 `POST` 或 `PATCH /api/admin/site-config/{module}/{id}`;成功后才会在 `GET /api/admin/site-config` 中返回该图片。
|
||||
- 首页玩法推荐、团队共创和极境视界保留独立的新增、编辑、删除、启停和排序能力,保存后由 Public API 直接提供给 MiniAPP。
|
||||
- 旧需求页主视觉、特色卡片、需求表单、体验推荐、用车服务说明等表结构已由后续 Alembic 迁移删除,不能在新代码中重新声明或调用。
|
||||
|
||||
@@ -238,6 +238,8 @@ type HomeWildArchive = {
|
||||
|
||||
站点模块通常包含 `id`、`createdAt`、`updatedAt`、`isActive` 和 `sortOrder`。客户端按 `sortOrder` 消费排序模块,不依赖固定 ID。
|
||||
|
||||
图片字段(如 `image`、`gallery`、`avatar`、`qrImage`)始终返回可直接请求的 HTTP(S) URL。OSS 配置为私有读时,WonderQ-Admin 会在响应中生成短时 GET 签名 URL;客户端应直接使用返回值,不应持久化或自行修改签名参数。
|
||||
|
||||
旧需求页主视觉、特色卡片和需求表单已移除;需求页面只通过 `POST /api/public/leads` 提交实时线索,不再读取已删除的站点配置表。
|
||||
|
||||
## 登录接口
|
||||
|
||||
Reference in New Issue
Block a user