feat: 添加后台登录验证码、记住密码功能,优化媒体资源与前端规范

- 新增后台登录图形验证码功能,完善登录安全防护
- 新增登录rememberMe参数,控制Refresh Token的会话持久化策略
- 实现OSS私有桶媒体URL自动签名,统一处理图片资源的临时访问签名
- 新增素材库数据库表与上传API,规范媒体资源管理流程
- 统一前端UI图标使用@element-plus/icons-vue,重构布局图标组件
- 登录页新增验证码输入、刷新功能,添加账号记忆与记住密码逻辑
- 更新全套文档,补充API契约、技术决策记录与集成流程说明
- 修复多个业务页面的图标展示问题,新增认证流程相关测试用例
This commit is contained in:
duanshuwen
2026-08-27 07:50:59 +08:00
parent 2c8c327de7
commit 6245159e7c
35 changed files with 914 additions and 79 deletions

View File

@@ -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 图标,不在图标迁移范围内。
## 验证

View File

@@ -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",

View 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 }) };
}

View File

@@ -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");

View File

@@ -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>

View File

@@ -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/);
});
});

View File

@@ -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="顶部轮播" />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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();

View File

@@ -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; } }

View File

@@ -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[];

View 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")

View File

@@ -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),

View 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)

View File

@@ -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="*")

View 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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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):

View File

@@ -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],
}

View File

@@ -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:

View File

@@ -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"

View File

@@ -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()

View 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 == []

View 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",
}

View File

@@ -31,6 +31,7 @@ MiniAPP 或 Public API 联调:
| `detail-api.md` | 路线详情管理 API | 后端、管理前端 |
| `concierge-api.md` | 管家顾问管理 API | 后端、管理前端 |
| `public-api.md` | MiniAPP 使用的 Public API 唯一契约 | 后端、MiniAPP |
| `decisions/` | 当前重要技术决策记录 | 全部 |
## 文档边界

View File

@@ -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`,管理端应重新获取验证码。验证码接口和登录接口都依赖 RedisRedis 不可用时返回 `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`,不降级为无会话校验。

View File

@@ -0,0 +1,24 @@
# 0001 后台登录增加图形验证码
## 状态
已采纳。
## 决策
后台登录沿用 RuoYi 的交互模式:登录页先获取图形验证码,提交账号、密码、验证码 ID 和验证码内容;验证码错误、过期或重复使用时,前端重新获取验证码。
WonderQ 使用 `GET /api/admin/auth/captcha`,返回验证码 ID、短时 SVG 图片 data URL 和有效秒数。验证码答案不进入响应、不写日志,只以哈希形式保存到 Redis并在校验时单次消费。验证码接口、登录限流和管理员会话都依赖 RedisRedis 不可用时返回 `503`,不降级绕过安全校验。
## 原因
- 保留 RuoYi 用户熟悉的登录防护和刷新交互。
- 不新增 Pillow 等图片依赖,使用后端生成的受控 SVG减少 Docker 镜像和部署复杂度。
- 让验证码与现有 Redis 会话、登录限流处于同一安全边界。
## 影响范围
- `WonderQ-Admin` 新增验证码接口、Redis 单次消费存储和登录请求字段。
- `WonderQ-Admin-UI-Vue` 登录页新增验证码图片、刷新和失败重试。
- `WonderQ-MiniAPP` 不使用后台登录,不需要改动。
- 现有 `token/accessToken/user` 登录响应和 Refresh Token Cookie 保持兼容。

View 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` 不使用后台登录,不需要改动。

View File

@@ -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 或排序结果。

View File

@@ -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 迁移删除,不能在新代码中重新声明或调用。

View File

@@ -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` 提交实时线索,不再读取已删除的站点配置表。
## 登录接口