diff --git a/WonderQ-Admin-UI-Vue/README.md b/WonderQ-Admin-UI-Vue/README.md index 85eaa4b..1c2082c 100644 --- a/WonderQ-Admin-UI-Vue/README.md +++ b/WonderQ-Admin-UI-Vue/README.md @@ -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 图标,不在图标迁移范围内。 ## 验证 diff --git a/WonderQ-Admin-UI-Vue/package.json b/WonderQ-Admin-UI-Vue/package.json index 557f72b..cdf8011 100644 --- a/WonderQ-Admin-UI-Vue/package.json +++ b/WonderQ-Admin-UI-Vue/package.json @@ -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", diff --git a/WonderQ-Admin-UI-Vue/src/api/client-auth.test.ts b/WonderQ-Admin-UI-Vue/src/api/client-auth.test.ts new file mode 100644 index 0000000..4bf91a9 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/api/client-auth.test.ts @@ -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 }) }; +} diff --git a/WonderQ-Admin-UI-Vue/src/api/client.ts b/WonderQ-Admin-UI-Vue/src/api/client.ts index 747ae81..6f8910f 100644 --- a/WonderQ-Admin-UI-Vue/src/api/client.ts +++ b/WonderQ-Admin-UI-Vue/src/api/client.ts @@ -2,6 +2,7 @@ import type { ApiErrorResponse, ApiResponse, AuthPayload, + CaptchaPayload, AdminMenu, AdminProfile, ConciergeAdvisorCreate, @@ -139,7 +140,8 @@ export async function request(path: string, options: RequestInit = {}, retry return unwrap(await parseJson(response), response.status); } -export const login = (email: string, password: string) => request("/api/admin/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }, false); +export const getCaptcha = () => request("/api/admin/auth/captcha", {}, false); +export const login = (email: string, password: string, captchaId: string, captchaCode: string, rememberMe = false) => request("/api/admin/auth/login", { method: "POST", body: JSON.stringify({ email, password, captchaId, captchaCode, rememberMe }) }, false); export const getProfile = () => request("/api/admin/system/profile"); /** Load the authenticated user's visible menu tree for dynamic route registration. */ export const getRouters = () => request("/api/admin/system/routers"); diff --git a/WonderQ-Admin-UI-Vue/src/components/layout/LayoutIcon.vue b/WonderQ-Admin-UI-Vue/src/components/layout/LayoutIcon.vue index b322b8e..a84f50d 100644 --- a/WonderQ-Admin-UI-Vue/src/components/layout/LayoutIcon.vue +++ b/WonderQ-Admin-UI-Vue/src/components/layout/LayoutIcon.vue @@ -1,46 +1,69 @@ diff --git a/WonderQ-Admin-UI-Vue/src/lib/menu-display.test.ts b/WonderQ-Admin-UI-Vue/src/lib/menu-display.test.ts index 1b55f44..b37c777 100644 --- a/WonderQ-Admin-UI-Vue/src/lib/menu-display.test.ts +++ b/WonderQ-Admin-UI-Vue/src/lib/menu-display.test.ts @@ -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(''); 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/); }); }); diff --git a/WonderQ-Admin-UI-Vue/src/pages/HomePage.vue b/WonderQ-Admin-UI-Vue/src/pages/HomePage.vue index a4279fa..5c5783b 100644 --- a/WonderQ-Admin-UI-Vue/src/pages/HomePage.vue +++ b/WonderQ-Admin-UI-Vue/src/pages/HomePage.vue @@ -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="顶部轮播" /> diff --git a/WonderQ-Admin-UI-Vue/src/pages/LoginPage.vue b/WonderQ-Admin-UI-Vue/src/pages/LoginPage.vue index e0b2fb8..fe465d1 100644 --- a/WonderQ-Admin-UI-Vue/src/pages/LoginPage.vue +++ b/WonderQ-Admin-UI-Vue/src/pages/LoginPage.vue @@ -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> diff --git a/WonderQ-Admin-UI-Vue/src/pages/NotFoundPage.vue b/WonderQ-Admin-UI-Vue/src/pages/NotFoundPage.vue index 7da7ac5..513a950 100644 --- a/WonderQ-Admin-UI-Vue/src/pages/NotFoundPage.vue +++ b/WonderQ-Admin-UI-Vue/src/pages/NotFoundPage.vue @@ -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> diff --git a/WonderQ-Admin-UI-Vue/src/pages/WanfaPage.vue b/WonderQ-Admin-UI-Vue/src/pages/WanfaPage.vue index de42c9d..4d145f4 100644 --- a/WonderQ-Admin-UI-Vue/src/pages/WanfaPage.vue +++ b/WonderQ-Admin-UI-Vue/src/pages/WanfaPage.vue @@ -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"> diff --git a/WonderQ-Admin-UI-Vue/src/stores/auth.ts b/WonderQ-Admin-UI-Vue/src/stores/auth.ts index 8a24cb0..481fefc 100644 --- a/WonderQ-Admin-UI-Vue/src/stores/auth.ts +++ b/WonderQ-Admin-UI-Vue/src/stores/auth.ts @@ -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(); diff --git a/WonderQ-Admin-UI-Vue/src/styles.css b/WonderQ-Admin-UI-Vue/src/styles.css index 567864b..12830d5 100644 --- a/WonderQ-Admin-UI-Vue/src/styles.css +++ b/WonderQ-Admin-UI-Vue/src/styles.css @@ -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; } } diff --git a/WonderQ-Admin-UI-Vue/src/types.ts b/WonderQ-Admin-UI-Vue/src/types.ts index d615b3a..b11c79c 100644 --- a/WonderQ-Admin-UI-Vue/src/types.ts +++ b/WonderQ-Admin-UI-Vue/src/types.ts @@ -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[]; diff --git a/WonderQ-Admin/alembic/versions/0034_media_assets.py b/WonderQ-Admin/alembic/versions/0034_media_assets.py new file mode 100644 index 0000000..18ff203 --- /dev/null +++ b/WonderQ-Admin/alembic/versions/0034_media_assets.py @@ -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") diff --git a/WonderQ-Admin/app/auth.py b/WonderQ-Admin/app/auth.py index 6425617..0405e45 100644 --- a/WonderQ-Admin/app/auth.py +++ b/WonderQ-Admin/app/auth.py @@ -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), diff --git a/WonderQ-Admin/app/captcha.py b/WonderQ-Admin/app/captcha.py new file mode 100644 index 0000000..0983f29 --- /dev/null +++ b/WonderQ-Admin/app/captcha.py @@ -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) diff --git a/WonderQ-Admin/app/config.py b/WonderQ-Admin/app/config.py index 630de8c..3c07cc6 100644 --- a/WonderQ-Admin/app/config.py +++ b/WonderQ-Admin/app/config.py @@ -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="*") diff --git a/WonderQ-Admin/app/media_urls.py b/WonderQ-Admin/app/media_urls.py new file mode 100644 index 0000000..3ddcd68 --- /dev/null +++ b/WonderQ-Admin/app/media_urls.py @@ -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 diff --git a/WonderQ-Admin/app/redis_session.py b/WonderQ-Admin/app/redis_session.py index ad03640..30b4bd5 100644 --- a/WonderQ-Admin/app/redis_session.py +++ b/WonderQ-Admin/app/redis_session.py @@ -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() diff --git a/WonderQ-Admin/app/routers/admin.py b/WonderQ-Admin/app/routers/admin.py index 7e18e04..b4ac2d4 100644 --- a/WonderQ-Admin/app/routers/admin.py +++ b/WonderQ-Admin/app/routers/admin.py @@ -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) diff --git a/WonderQ-Admin/app/routers/shared.py b/WonderQ-Admin/app/routers/shared.py index 43c5176..04fd693 100644 --- a/WonderQ-Admin/app/routers/shared.py +++ b/WonderQ-Admin/app/routers/shared.py @@ -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 diff --git a/WonderQ-Admin/app/schemas.py b/WonderQ-Admin/app/schemas.py index 1729c31..487b095 100644 --- a/WonderQ-Admin/app/schemas.py +++ b/WonderQ-Admin/app/schemas.py @@ -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): diff --git a/WonderQ-Admin/app/serializers.py b/WonderQ-Admin/app/serializers.py index a739ff5..2e6fea2 100644 --- a/WonderQ-Admin/app/serializers.py +++ b/WonderQ-Admin/app/serializers.py @@ -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], } diff --git a/WonderQ-Admin/docker-compose.yml b/WonderQ-Admin/docker-compose.yml index b09af10..7b81c4e 100644 --- a/WonderQ-Admin/docker-compose.yml +++ b/WonderQ-Admin/docker-compose.yml @@ -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: diff --git a/WonderQ-Admin/tests/test_admin_rbac.py b/WonderQ-Admin/tests/test_admin_rbac.py index 16f1cad..0c16ae3 100644 --- a/WonderQ-Admin/tests/test_admin_rbac.py +++ b/WonderQ-Admin/tests/test_admin_rbac.py @@ -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" diff --git a/WonderQ-Admin/tests/test_api_contracts.py b/WonderQ-Admin/tests/test_api_contracts.py index 24577a2..a32b35e 100644 --- a/WonderQ-Admin/tests/test_api_contracts.py +++ b/WonderQ-Admin/tests/test_api_contracts.py @@ -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() diff --git a/WonderQ-Admin/tests/test_media_asset_migration.py b/WonderQ-Admin/tests/test_media_asset_migration.py new file mode 100644 index 0000000..714c013 --- /dev/null +++ b/WonderQ-Admin/tests/test_media_asset_migration.py @@ -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 == [] diff --git a/WonderQ-Admin/tests/test_media_urls.py b/WonderQ-Admin/tests/test_media_urls.py new file mode 100644 index 0000000..8c30506 --- /dev/null +++ b/WonderQ-Admin/tests/test_media_urls.py @@ -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", + } diff --git a/docs/README.md b/docs/README.md index 3fbb4ad..ab8ee60 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,6 +31,7 @@ MiniAPP 或 Public API 联调: | `detail-api.md` | 路线详情管理 API | 后端、管理前端 | | `concierge-api.md` | 管家顾问管理 API | 后端、管理前端 | | `public-api.md` | MiniAPP 使用的 Public API 唯一契约 | 后端、MiniAPP | +| `decisions/` | 当前重要技术决策记录 | 全部 | ## 文档边界 diff --git a/docs/admin-api-requirements.md b/docs/admin-api-requirements.md index d85a469..efa2217 100644 --- a/docs/admin-api-requirements.md +++ b/docs/admin-api-requirements.md @@ -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`,不降级为无会话校验。 diff --git a/docs/decisions/0001-admin-login-captcha.md b/docs/decisions/0001-admin-login-captcha.md new file mode 100644 index 0000000..b8b9538 --- /dev/null +++ b/docs/decisions/0001-admin-login-captcha.md @@ -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 保持兼容。 diff --git a/docs/decisions/0002-admin-remember-me.md b/docs/decisions/0002-admin-remember-me.md new file mode 100644 index 0000000..6917539 --- /dev/null +++ b/docs/decisions/0002-admin-remember-me.md @@ -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` 不使用后台登录,不需要改动。 diff --git a/docs/integration-workflow.md b/docs/integration-workflow.md index 3c2a65e..e8eeb73 100644 --- a/docs/integration-workflow.md +++ b/docs/integration-workflow.md @@ -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 或排序结果。 diff --git a/docs/module-config-api.md b/docs/module-config-api.md index 3d76c5b..765032a 100644 --- a/docs/module-config-api.md +++ b/docs/module-config-api.md @@ -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 迁移删除,不能在新代码中重新声明或调用。 diff --git a/docs/public-api.md b/docs/public-api.md index 1807268..47c0bbb 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -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` 提交实时线索,不再读取已删除的站点配置表。 ## 登录接口