From fa281363fb461883c1e5fa23bfc0c770ae8f3520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=AB=E5=9C=B0=E5=83=A7?= <357099073@qq.com> Date: Sat, 19 Sep 2026 21:44:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=81=9A=E7=BB=9F=E4=B8=80=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + backend/src/router/index.js | 15 +- backend/src/stores/auth.js | 5 + backend/src/utils/authClient.js | 197 +++ backend/src/utils/request.js | 17 +- backend/src/views/auth/callback.vue | 97 ++ backend/src/views/login/index.vue | 9 +- docs/产品总结.md | 1006 +++++++++++++ docs/平台更新功能 - 问题修复.md | 160 ++ docs/平台更新功能说明.md | 207 +++ docs/开发总结.md | 217 +++ go/conf/app.conf | 24 + go/controllers/api_platform_upgrade.go | 125 ++ go/controllers/auth/login.go | 375 +++++ go/controllers/auth/oidc.go | 526 +++++++ go/controllers/backend_admin_user.go | 1315 +++++++++-------- go/controllers/platform_tenant_user.go | 798 +++++----- go/docs/sql/create_auth_tables.sql | 258 ++++ go/middleware/jwt.go | 273 +++- go/migrate_auth_conflict_20260919004551.csv | 2 + go/migrate_auth_conflict_20260919004725.csv | 2 + go/migrate_auth_map_20260919004727.csv | 4 + go/models/auth_client.go | 43 + go/models/auth_identity.go | 72 + go/models/auth_login_log.go | 26 + go/models/auth_session.go | 94 ++ go/models/auth_tenant_config.go | 66 + go/models/auth_tenant_user.go | 28 + go/models/init.go | 17 +- go/models/platform_upgrade.go | 23 + go/pkg/jwtutil/jwt.go | 384 ++++- go/pkg/jwtutil/jwt_test.go | 70 + go/pkg/passwordutil/password.go | 204 ++- go/pkg/passwordutil/password_test.go | 69 + go/routers/api/api.go | 6 + go/routers/auth/auth.go | 34 + go/routers/router.go | 170 ++- go/scripts/migrate_auth.go | 392 +++++ go/services/auth/identity.go | 148 ++ go/services/auth/login.go | 268 ++++ go/services/auth/session.go | 220 +++ go/services/auth/sync.go | 219 +++ go/services/auth/token.go | 217 +++ go/services/password_reset.go | 23 + go/services/platform_auth.go | 30 + go/views/auth/login.tpl | 165 +++ platform/src/views/login/index.vue | 11 +- .../views/platform/platformupgrade/index.vue | 236 +++ uniapp/api/auth.js | 48 +- uniapp/api/request.js | 71 +- uniapp/pages/login/login.vue | 102 +- uniapp/pages/tools/schedule/index.vue | 39 - uniapp/utils/auth.js | 54 +- website/src/config/nav.ts | 30 +- website/src/router/index.ts | 55 +- website/src/views/about.vue | 8 + website/src/views/careers.vue | 8 + website/src/views/cases/detail.vue | 8 + website/src/views/cases/index.vue | 8 + .../{detail.vue => common/DetailPage.vue} | 0 .../views/{list.vue => common/ListPage.vue} | 0 .../{single.vue => common/SinglePage.vue} | 0 website/src/views/contact.vue | 8 + website/src/views/events/detail.vue | 8 + website/src/views/events/index.vue | 8 + .../src/views/home/components/HeroBanner.vue | 16 +- .../views/home/components/ScheduleSection.vue | 141 +- website/src/views/news/detail.vue | 8 + website/src/views/news/index.vue | 8 + website/src/views/partners.vue | 8 + website/src/views/policy/detail.vue | 8 + website/src/views/policy/index.vue | 8 + website/src/views/products/detail.vue | 8 + website/src/views/products/index.vue | 8 + website/src/views/solutions/detail.vue | 8 + website/src/views/solutions/index.vue | 8 + website/src/views/support/detail.vue | 8 + website/src/views/support/index.vue | 8 + 78 files changed, 8127 insertions(+), 1442 deletions(-) create mode 100644 backend/src/utils/authClient.js create mode 100644 backend/src/views/auth/callback.vue create mode 100644 docs/产品总结.md create mode 100644 docs/平台更新功能 - 问题修复.md create mode 100644 docs/平台更新功能说明.md create mode 100644 docs/开发总结.md create mode 100644 go/controllers/api_platform_upgrade.go create mode 100644 go/controllers/auth/login.go create mode 100644 go/controllers/auth/oidc.go create mode 100644 go/docs/sql/create_auth_tables.sql create mode 100644 go/migrate_auth_conflict_20260919004551.csv create mode 100644 go/migrate_auth_conflict_20260919004725.csv create mode 100644 go/migrate_auth_map_20260919004727.csv create mode 100644 go/models/auth_client.go create mode 100644 go/models/auth_identity.go create mode 100644 go/models/auth_login_log.go create mode 100644 go/models/auth_session.go create mode 100644 go/models/auth_tenant_config.go create mode 100644 go/models/auth_tenant_user.go create mode 100644 go/models/platform_upgrade.go create mode 100644 go/pkg/jwtutil/jwt_test.go create mode 100644 go/pkg/passwordutil/password_test.go create mode 100644 go/routers/auth/auth.go create mode 100644 go/scripts/migrate_auth.go create mode 100644 go/services/auth/identity.go create mode 100644 go/services/auth/login.go create mode 100644 go/services/auth/session.go create mode 100644 go/services/auth/sync.go create mode 100644 go/services/auth/token.go create mode 100644 go/views/auth/login.tpl create mode 100644 platform/src/views/platform/platformupgrade/index.vue create mode 100644 website/src/views/about.vue create mode 100644 website/src/views/careers.vue create mode 100644 website/src/views/cases/detail.vue create mode 100644 website/src/views/cases/index.vue rename website/src/views/{detail.vue => common/DetailPage.vue} (100%) rename website/src/views/{list.vue => common/ListPage.vue} (100%) rename website/src/views/{single.vue => common/SinglePage.vue} (100%) create mode 100644 website/src/views/contact.vue create mode 100644 website/src/views/events/detail.vue create mode 100644 website/src/views/events/index.vue create mode 100644 website/src/views/news/detail.vue create mode 100644 website/src/views/news/index.vue create mode 100644 website/src/views/partners.vue create mode 100644 website/src/views/policy/detail.vue create mode 100644 website/src/views/policy/index.vue create mode 100644 website/src/views/products/detail.vue create mode 100644 website/src/views/products/index.vue create mode 100644 website/src/views/solutions/detail.vue create mode 100644 website/src/views/solutions/index.vue create mode 100644 website/src/views/support/detail.vue create mode 100644 website/src/views/support/index.vue diff --git a/.gitignore b/.gitignore index 66e6666..33eb9d1 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,5 @@ go/conf .mimocode/package.json .mimocode/package-lock.json .mimocode/bun.lock + +docs/sql/ \ No newline at end of file diff --git a/backend/src/router/index.js b/backend/src/router/index.js index b310339..d0706b8 100644 --- a/backend/src/router/index.js +++ b/backend/src/router/index.js @@ -1,5 +1,6 @@ import { createRouter, createWebHashHistory } from "vue-router"; import { convertMenusToRoutes } from "./dynamicRoutes"; +import { isSSOEnabled, redirectToAuthorize } from "@/utils/authClient"; // 静态子路由:需要在 Main 框架内显示的页面 const staticMainChildren = [ @@ -146,6 +147,13 @@ const staticRoutes = [ component: () => import("@/views/login/forget.vue"), meta: { requiresAuth: false } }, + // 统一认证中心回跳页(接收 code 换取令牌,页面自身不展示业务内容) + { + path: "/auth/callback", + name: "AuthCallback", + component: () => import("@/views/auth/callback.vue"), + meta: { requiresAuth: false } + }, { path: "/home", name: "Home", @@ -304,7 +312,7 @@ function findFirstValidRoute(routes) { router.beforeEach(async (to, from, next) => { const token = localStorage.getItem("token"); - const publicPaths = ["/login", "/register", "/forget"]; + const publicPaths = ["/login", "/register", "/forget", "/auth/callback"]; if (publicPaths.includes(to.path)) { if (token) { @@ -319,6 +327,11 @@ router.beforeEach(async (to, from, next) => { } if (!token) { + // 统一认证模式:跳认证中心登录(未开启时走原有本地登录页) + if (isSSOEnabled()) { + await redirectToAuthorize(); + return; + } next({ path: "/login", query: { redirect: to.path } }); return; } diff --git a/backend/src/stores/auth.js b/backend/src/stores/auth.js index 8880ba4..0ef57c2 100644 --- a/backend/src/stores/auth.js +++ b/backend/src/stores/auth.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref, reactive } from 'vue' import { getCurrentUser } from '@/api/login' +import { isSSOEnabled, logoutSSO } from '@/utils/authClient' // 用户信息类型 const defaultUser = { @@ -93,6 +94,10 @@ export const useAuthStore = defineStore('auth', () => { Object.assign(user, defaultUser) localStorage.removeItem('token') localStorage.removeItem('userInfo') + // 统一认证模式:吊销令牌并跳认证中心完成单点登出(所有调用点自动生效) + if (isSSOEnabled()) { + logoutSSO() + } } // 检查认证状态 diff --git a/backend/src/utils/authClient.js b/backend/src/utils/authClient.js new file mode 100644 index 0000000..56370f3 --- /dev/null +++ b/backend/src/utils/authClient.js @@ -0,0 +1,197 @@ +/** + * 统一认证中心(UAC)客户端 —— OIDC Authorization Code + PKCE + * + * 接入方式:在 .env 中配置 + * VITE_AUTH_MODE=sso 开启统一认证(不配置则走原有 /backend/login) + * VITE_AUTH_BASE=https://api.yunzer.cn/auth + * VITE_AUTH_CLIENT_ID=yz-backend + * VITE_AUTH_REDIRECT_URI=https://back.yunzer.cn/#/auth/callback + * + * 说明:token 仍存在 localStorage(沿用现有 utils/request.js 与 stores/auth.js), + * 因此接入后业务代码无需改动即可带上 Authorization 头。 + */ + +const AUTH_BASE = import.meta.env.VITE_AUTH_BASE || ""; +const CLIENT_ID = import.meta.env.VITE_AUTH_CLIENT_ID || "yz-backend"; +const REDIRECT_URI = + import.meta.env.VITE_AUTH_REDIRECT_URI || `${window.location.origin}/#/auth/callback`; +const POST_LOGOUT_URI = + import.meta.env.VITE_AUTH_POST_LOGOUT_URI || `${window.location.origin}/#/login`; + +const ACCESS_KEY = "token"; // 与现有代码保持一致 +const REFRESH_KEY = "auth_refresh_token"; +const SID_KEY = "auth_sid"; + +/** 是否启用统一认证(默认关闭,保持原有登录方式) */ +export function isSSOEnabled() { + return import.meta.env.VITE_AUTH_MODE === "sso" && !!AUTH_BASE; +} + +// ---------------------------------------------------------------- PKCE + +function randomString(len = 64) { + const arr = new Uint8Array(len); + crypto.getRandomValues(arr); + return Array.from(arr, (b) => ("0" + b.toString(16)).slice(-2)).join("").slice(0, len); +} + +async function sha256(plain) { + return crypto.subtle.digest("SHA-256", new TextEncoder().encode(plain)); +} + +function base64url(buf) { + return btoa(String.fromCharCode(...new Uint8Array(buf))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +async function generatePKCE() { + const verifier = randomString(64); + const challenge = base64url(await sha256(verifier)); + return { verifier, challenge }; +} + +// ---------------------------------------------------------------- 令牌存储 + +export function setTokens(data) { + if (data?.access_token) localStorage.setItem(ACCESS_KEY, data.access_token); + if (data?.refresh_token) localStorage.setItem(REFRESH_KEY, data.refresh_token); + if (data?.sid) localStorage.setItem(SID_KEY, data.sid); +} + +export function getRefreshToken() { + return localStorage.getItem(REFRESH_KEY) || ""; +} + +export function clearTokens() { + localStorage.removeItem(ACCESS_KEY); + localStorage.removeItem(REFRESH_KEY); + localStorage.removeItem(SID_KEY); +} + +export function getAccessToken() { + return localStorage.getItem(ACCESS_KEY) || ""; +} + +// ---------------------------------------------------------------- 主流程 + +/** 跳转认证中心登录(带 PKCE 与 state) */ +export async function redirectToAuthorize() { + const { verifier, challenge } = await generatePKCE(); + sessionStorage.setItem("pkce_verifier", verifier); + const state = randomString(24); + sessionStorage.setItem("oidc_state", state); + + const params = new URLSearchParams({ + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: "code", + scope: "openid profile tenant", + state, + code_challenge: challenge, + code_challenge_method: "S256", + }); + window.location.href = `${AUTH_BASE}/authorize?${params.toString()}`; +} + +/** 处理认证中心回跳:用 code 换令牌 */ +export async function handleAuthorizeCallback(query = {}) { + const code = query.code; + const state = query.state; + const savedState = sessionStorage.getItem("oidc_state"); + const verifier = sessionStorage.getItem("pkce_verifier"); + + if (!code) throw new Error("缺少授权码"); + if (savedState && state !== savedState) throw new Error("state 校验失败"); + if (!verifier) throw new Error("PKCE 校验信息丢失,请重新登录"); + + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: CLIENT_ID, + code_verifier: verifier, + redirect_uri: REDIRECT_URI, + }); + + const res = await fetch(`${AUTH_BASE}/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.access_token) { + throw new Error(data.error_description || data.error || "换取令牌失败"); + } + + sessionStorage.removeItem("pkce_verifier"); + sessionStorage.removeItem("oidc_state"); + setTokens(data); + return data; +} + +/** 静默刷新访问令牌 */ +export async function refreshAccessToken() { + const refresh = getRefreshToken(); + if (!refresh) throw new Error("无刷新令牌"); + + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refresh, + client_id: CLIENT_ID, + }); + const res = await fetch(`${AUTH_BASE}/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.access_token) throw new Error("刷新令牌失败"); + setTokens(data); + return data.access_token; +} + +/** 单点登出:吊销令牌后回认证中心登出页 */ +export async function logoutSSO() { + const token = getAccessToken(); + try { + await fetch(`${AUTH_BASE}/revoke`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token, refresh_token: getRefreshToken() }), + }); + } catch (e) { + // 吊销失败也要继续登出,避免卡在本端 + } + clearTokens(); + const params = new URLSearchParams({ + client_id: CLIENT_ID, + post_logout_redirect_uri: POST_LOGOUT_URI, + }); + window.location.href = `${AUTH_BASE}/logout?${params.toString()}`; +} + +/** 拉取当前登录用户(认证中心视角) */ +export async function fetchUserInfo() { + const res = await fetch(`${AUTH_BASE}/userinfo`, { + headers: { Authorization: `Bearer ${getAccessToken()}` }, + }); + if (!res.ok) throw new Error("获取用户信息失败"); + return res.json(); +} + +/** 切换企业(一人多企业场景) */ +export async function switchTenant(tid) { + const res = await fetch(`${AUTH_BASE}/switch-tenant`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${getAccessToken()}`, + }, + body: JSON.stringify({ tid, client_id: CLIENT_ID }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.code !== 200) throw new Error(data.msg || "切换企业失败"); + if (data.data?.tokens) setTokens(data.data.tokens); + return data.data; +} diff --git a/backend/src/utils/request.js b/backend/src/utils/request.js index c9c117e..985a2ed 100644 --- a/backend/src/utils/request.js +++ b/backend/src/utils/request.js @@ -1,4 +1,5 @@ import axios from 'axios'; +import { isSSOEnabled, refreshAccessToken, clearTokens } from '@/utils/authClient'; // 获取API基础URL,添加调试信息 const apiBaseURL = import.meta.env.VITE_API_BASE_URL; @@ -40,13 +41,27 @@ service.interceptors.response.use( response => { return response.data; }, - error => { + async error => { if (error.response) { // 后端统一返回 {code, msg},优先透出业务提示(如唯一性冲突、无权操作), // 避免页面只显示 "Request failed with status code xxx" const bizMsg = error.response.data?.msg || error.response.data?.message; switch (error.response.status) { case 401: + // 统一认证模式:先用 refresh_token 静默换取新令牌并重放原请求, + // 刷新失败(令牌过期/被吊销)才真正登出。 + if (isSSOEnabled()) { + try { + const newToken = await refreshAccessToken(); + error.config.headers['Authorization'] = `Bearer ${newToken}`; + return service.request(error.config); + } catch (e) { + clearTokens(); + localStorage.removeItem('userInfo'); + window.location.href = '#/login'; + return Promise.reject(new Error('token无效')); + } + } console.error('未授权,请重新登录'); localStorage.removeItem('token'); localStorage.removeItem('userInfo'); diff --git a/backend/src/views/auth/callback.vue b/backend/src/views/auth/callback.vue new file mode 100644 index 0000000..4bc166d --- /dev/null +++ b/backend/src/views/auth/callback.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/backend/src/views/login/index.vue b/backend/src/views/login/index.vue index 39ab93c..e325d28 100644 --- a/backend/src/views/login/index.vue +++ b/backend/src/views/login/index.vue @@ -196,17 +196,17 @@ const performLoginRequest = async () => { if (res && res.code === 200) { // 登录成功:处理"记住我" + // 安全:只记住账号与租户名,绝不明文存储密码 if (rememberMe.value) { localStorage.setItem("loginAccount", account.value); localStorage.setItem("loginTenantName", tenant_name.value); - localStorage.setItem("loginPassword", password.value); localStorage.setItem("loginRememberMe", "true"); } else { localStorage.removeItem("loginAccount"); localStorage.removeItem("loginTenantName"); - localStorage.removeItem("loginPassword"); localStorage.setItem("loginRememberMe", "false"); } + localStorage.removeItem("loginPassword"); authStore.setLoginInfo({ ...res.data, tenant_name: tenant_name.value }); @@ -327,17 +327,17 @@ const startGeetest4 = async () => { }); if (loginRes && loginRes.code === 200) { + // 安全:只记住账号与租户名,绝不明文存储密码 if (rememberMe.value) { localStorage.setItem("loginAccount", account.value); localStorage.setItem("loginTenantName", tenant_name.value); - localStorage.setItem("loginPassword", password.value); localStorage.setItem("loginRememberMe", "true"); } else { localStorage.removeItem("loginAccount"); localStorage.removeItem("loginTenantName"); - localStorage.removeItem("loginPassword"); localStorage.setItem("loginRememberMe", "false"); } + localStorage.removeItem("loginPassword"); authStore.setLoginInfo({ ...loginRes.data, tenant_name: tenant_name.value }); const { useTabsStore } = await import("@/stores"); @@ -471,7 +471,6 @@ onMounted(async () => { if (savedRemember === "true") { tenant_name.value = localStorage.getItem("loginTenantName") || ""; account.value = localStorage.getItem("loginAccount") || ""; - password.value = localStorage.getItem("loginPassword") || ""; rememberMe.value = true; } diff --git a/docs/产品总结.md b/docs/产品总结.md new file mode 100644 index 0000000..6503d1e --- /dev/null +++ b/docs/产品总结.md @@ -0,0 +1,1006 @@ +# 企业上云多租户平台产品总结分析 + +## 📋 一、产品概述 + +**产品定位**: 这是一款面向企业的 SaaS 多租户云端管理平台系统,集成了 **OA(办公自动化)**、**CRM(客户关系管理)**、**ERP(企业资源计划)**、**CMS(内容管理系统)** 四大核心模块,旨在帮助企业实现数字化转型,提升办公效率,优化业务流程。 + +**核心技术架构**: +- **后端**: Go + Beego 框架 + MySQL +- **前端**: Vue 3 + Element Plus + Vite +- **移动端**: uni-app (支持多端发布) +- **部署模式**: 多租户 SaaS 模式,支持租户隔离与独立配置 + +--- + +## 🗺️ 二、产品功能结构图 + +### 2.1 完整功能架构图 + +```mermaid +graph TB + ROOT["🏢 企业上云多租户平台"] + + ROOT --> OA["📑 OA 办公自动化"] + ROOT --> CRM["🤝 CRM 客户关系管理"] + ROOT --> ERP["💰 ERP 企业资源计划"] + ROOT --> CMS["📝 CMS 内容管理系统"] + ROOT --> PLATFORM["⚙️ 平台支撑功能"] + ROOT --> AI["🤖 AI 智能增强"] + ROOT --> MOBILE["📱 移动端应用"] + + %% OA 模块 + OA --> OA_NOTICE["📢 通知公告"] + OA --> OA_SCHEDULE["📅 日程安排"] + OA --> OA_DOC["📚 文档知识库"] + OA --> OA_COMPENSATION["💵 薪酬管理"] + + OA_NOTICE --> ON_TYPE["通知类型
通知/公告/活动"] + OA_NOTICE --> ON_LEVEL["优先级分级
普通/重要/紧急"] + OA_NOTICE --> ON_STATUS["状态管理
草稿/已发布/已下架"] + OA_NOTICE --> ON_READ["阅读统计"] + + OA_SCHEDULE --> OS_PERSONAL["个人日程"] + OA_SCHEDULE --> OS_TEAM["团队日程"] + OA_SCHEDULE --> OS_REMIND["日程提醒"] + OA_SCHEDULE --> OS_CALENDAR["日历视图"] + + OA_DOC --> OD_SPACE["双空间模型"] + OD_SPACE --> OD_SHARED["共享文档空间
租户级"] + OD_SPACE --> OD_PRIVATE["私密文档空间
用户级"] + + OA_DOC --> OD_CATEGORY["文档分类体系"] + OD_CATEGORY --> OD_TREE["树形分类结构"] + OD_CATEGORY --> OD_PROJECT["项目文档分类"] + + OA_DOC --> OD_TYPE["文档类型支持"] + OD_TYPE --> OT_DOC["文档类 doc/docx"] + OD_TYPE --> OT_SHEET["表格类 xls/xlsx"] + OD_TYPE --> OT_PPT["演示类 ppt/pptx"] + OD_TYPE --> OT_PDF["PDF 文件"] + OD_TYPE --> OT_IMG["图片类 jpg/png"] + OD_TYPE --> OT_ZIP["压缩包 zip/rar"] + OD_TYPE --> OT_VIDEO["视频/音频"] + + OA_DOC --> OD_GRAPH["文档图谱功能"] + OD_GRAPH --> OG_REF["引用关系 reference"] + OD_GRAPH --> OG_REL["相关关系 related"] + OD_GRAPH --> OG_VER["版本迭代 version"] + OD_GRAPH --> OG_BEL["从属关系 belong"] + + OA_DOC --> OD_PERM["权限管理"] + OD_PERM --> OP_VIS["可见性控制"] + OD_PERM --> OP_SHARE["分享机制"] + OD_PERM --> OP_STAT["查看/下载统计"] + + OA_COMPENSATION --> OC_PLAN["薪酬方案配置"] + OA_COMPENSATION --> OC_CALC["薪酬核算发放"] + OA_COMPENSATION --> OC_QUERY["薪酬记录查询"] + OA_COMPENSATION --> OC_TAX["税务计算"] + + %% CRM 模块 + CRM --> CR_LEAD["📌 线索管理 Leads"] + CRM --> CR_BUSI["💼 商机管理 Business"] + CRM --> CR_CUSTOMER["🏢 客户管理 Customer"] + CRM --> CR_SUPPLIER["🤝 供应商管理 Supplier"] + CRM --> CR_PROJECT["📊 项目管理 Project"] + CRM --> CR_CONTRACT["📄 合同管理 Contract"] + CRM --> CR_PAYBACK["💸 回款管理 Payback"] + CRM --> CR_CONTACT["👥 联系人管理 Contact"] + CRM --> CR_PRODUCT["📦 产品管理 Product"] + CRM --> CR_DASHBOARD["📈 数据看板 Dashboard"] + + CR_LEAD --> CL_ACTION["线索录入分配"] + CR_LEAD --> CL_FOLLOW["线索跟进记录"] + CR_LEAD --> CL_CONVERT["线索转化商机"] + CR_LEAD --> CL_POOL["公海池机制"] + + CR_BUSI --> CB_STAGE["商机阶段跟踪"] + CR_BUSI --> CB_AMOUNT["商机金额管理"] + CR_BUSI --> CB_WINLOSS["赢单/输单分析"] + CR_BUSI --> CB_RATE["转化率统计"] + + CR_CUSTOMER --> CUST_BASE["基本信息
名称/类型/行业"] + CR_CUSTOMER --> CUST_CONTACT["联系信息
联系人/电话/邮箱"] + CR_CUSTOMER --> CUST_QUAL["资质信息
注册资本/成立日期"] + CR_CUSTOMER --> CUST_TAX["税务信息
发票标题/税号/银行账户"] + CR_CUSTOMER --> CUST_LEVEL["客户等级 L1-L5"] + CR_CUSTOMER --> CUST_SOURCE["客户来源"] + CR_CUSTOMER --> CUST_POOL["公海池机制
回收/领取/退回"] + + CR_SUPPLIER --> SUPP_BASE["基础信息
名称/类型/行业"] + CR_SUPPLIER --> SUPP_CONTACT["联系信息
联系人/电话/邮箱"] + CR_SUPPLIER --> SUPP_QUAL["资质认证
注册资本/企业类型"] + CR_SUPPLIER --> SUPP_INVOICE["开票信息
发票标题/税号/银行账户"] + CR_SUPPLIER --> SUPP_LEVEL["供应商等级评估"] + CR_SUPPLIER --> SUPP_STATUS["合作状态管理"] + + CR_PROJECT --> PR_TYPE["项目类型"] + PR_TYPE --> PT_SINGLE["单一项目
只有上游客户"] + PR_TYPE --> PT_UPDOWN["上下游项目
上游 + 下游"] + PR_TYPE --> PT_UPMID["上中游项目
上游 A+ 中游 B"] + + CR_PROJECT --> PR_AMOUNT["金额拆分管理"] + PR_AMOUNT --> PA_UPSTREAM["上游金额
对客户收入"] + PR_AMOUNT --> PA_DOWNSTREAM["下游金额
对供应商成本"] + PR_AMOUNT --> PA_PROFIT["毛利计算
上游 - 下游"] + + CR_PROJECT --> PR_LIFECYCLE["全生命周期"] + PR_LIFECYCLE --> PL_STATUS["进度状态
未开始/进行中/已完成"] + PR_LIFECYCLE --> PL_TIME["起止时间自动汇总"] + PR_LIFECYCLE --> PL_CONTACT["对接人管理"] + PR_LIFECYCLE --> PL_DOC["项目文档集成"] + + CR_CONTRACT --> CT_TYPE["合同类型"] + CT_TYPE --> TT_DEV["开发合同"] + CT_TYPE --> TT_SERV["服务合同"] + CT_TYPE --> TT_SALES["销售合同"] + CT_TYPE --> TT_LEASE["租赁合同"] + CT_TYPE --> TT_PURCH["采购合同"] + CT_TYPE --> TT_OPS["运维合同"] + + CR_CONTRACT --> CT_FORM["合同形式"] + CT_FORM --> CF_ROLE["我方角色
甲/乙/丙/丁方"] + CT_FORM --> CF_PARTY["签约方数
双方/三方/四方"] + CT_FORM --> CF_HEAD["有头/无头合同"] + + CR_CONTRACT --> CT_AMOUNT["金额明细"] + CT_AMOUNT --> CA_HARD["硬件金额"] + CT_AMOUNT --> CA_SOFT["软件金额"] + CT_AMOUNT --> CA_OTHER["其他费用"] + CT_AMOUNT --> CA_TOTAL["总金额/总成本/总利润"] + + CR_CONTRACT --> CT_STATUS["合同状态流转"] + CT_STATUS --> CS_DRAFT["草稿"] + CT_STATUS --> CS_APPROVE["审批中"] + CT_STATUS --> CS_COMPLETE["已完成"] + CT_STATUS --> CS_EXEC["履约中"] + CT_STATUS --> CS_EXCEPTION["异常"] + + CR_PAYBACK --> PB_TYPE["回款周期类型"] + PB_TYPE --> PT_MONTH["月度回款"] + PB_TYPE --> PT_QUARTER["季度回款"] + PB_TYPE --> PT_YEAR["年度回款"] + PB_TYPE --> PT_PROGRESS["进度回款"] + PB_TYPE --> PT_CUSTOM["自定义回款"] + + CR_PAYBACK --> PB_METHOD["回款方式"] + PB_METHOD --> PM_TRANSFER["对公/网银转账"] + PB_METHOD --> PM_CASH["现金"] + PB_METHOD --> PM_CHECK["支票"] + PB_METHOD --> PM_ALIPAY["支付宝/微信"] + + CR_PAYBACK --> PB_PLAN["回款计划明细"] + PB_PLAN --> PP_SEQ["期数编号/计划名称"] + PB_PLAN --> PP_AMOUNT["回款金额"] + PB_PLAN --> PP_DATE["计划日期"] + PB_PLAN --> PP_PERCENT["进度百分比"] + PB_PLAN --> PP_RECEIVED["实收金额与日期"] + + CR_PAYBACK --> PB_TRACK["回款追踪"] + PB_TRACK --> PT_CONTRACT["合同金额 vs 回款总额"] + PB_TRACK --> PT_RECEIVED["已回款金额统计"] + PB_TRACK --> PT_OVERDUE["回款逾期预警"] + PB_TRACK --> PT_RECEIPT["电子回执上传"] + + CR_CONTACT --> CC_TREE["企业通讯录"] + CR_CONTACT --> CC_GROUP["联系人分组"] + CR_CONTACT --> CC_INFO["联系方式维护"] + CR_CONTACT --> CC_POS["职位信息管理"] + + CR_PRODUCT --> PC_CATEGORY["产品分类体系"] + CR_PRODUCT --> PC_INFO["产品信息维护"] + CR_PRODUCT --> PC_PRICE["价格管理"] + CR_PRODUCT --> PC_PROJECT["项目产品清单"] + + CR_DASHBOARD --> DB_FUNNEL["线索转化漏斗"] + CR_DASHBOARD --> DB_PAYBACK["回款进度统计"] + CR_DASHBOARD --> DB_PROJECT["项目概览"] + CR_DASHBOARD --> DB_RANK["业绩排行榜"] + + %% ERP 模块 + ERP --> ER_ACCOUNT["📚 账套管理"] + ER_ACCOUNT --> EA_MULTI["多账套支持"] + ER_ACCOUNT --> EA_PERIOD["会计期间设置"] + ER_ACCOUNT --> EA_STANDARD["会计准则选择"] + ER_ACCOUNT --> EA_TAX["税率配置"] + ER_ACCOUNT --> EA_CURRENCY["币种管理"] + + ERP --> ER_CUSTOMER["🏢 客户管理 ERP"] + ER_CUSTOMER --> EC_ARCHIVE["客户档案建立"] + ER_CUSTOMER --> EC_CREDIT["信用额度管理"] + ER_CUSTOMER --> EC_AGING["账龄分析"] + ER_CUSTOMER --> EC_RECONCILE["往来对账"] + + ERP --> ER_SUPPLIER["🤝 供应商管理 ERP"] + ER_SUPPLIER --> ES_ARCHIVE["供应商档案"] + ER_SUPPLIER --> ES_ORDER["采购订单管理"] + ER_SUPPLIER --> ES_AP["应付账款"] + ER_SUPPLIER --> ES_ANALYSIS["采购统计分析"] + + ERP --> ER_PARTY["往来单位"] + ER_PARTY --> EP_CONTACT["公司联系人管理"] + ER_PARTY --> EP_MULTIPLE["多方主体协同"] + ER_PARTY --> EP_RELATION["合作关系维护"] + + ERP --> ER_SETTING["基础设置"] + ER_SETTING --> SS_CODE["编码规则配置"] + ER_SETTING --> SS_APPROVE["审批流程设置"] + ER_SETTING --> SS_DATA["数据权限控制"] + + %% CMS 模块 + CMS --> CM_ARTICLE["📝 文章管理"] + CM_ARTICLE --> CA_EDITOR["富文本编辑器"] + CM_ARTICLE --> CA_CATEGORY["文章分类体系"] + CM_ARTICLE --> CA_TAG["标签管理"] + CM_ARTICLE --> CA_AUTHOR["作者管理"] + CM_ARTICLE --> CA_PUBLISH["发布时间控制"] + + CMS --> CM_SHOW["文章展示"] + CM_SHOW --> CS_HOME["首页推荐位"] + CM_SHOW --> CS_HOT["热门文章"] + CM_SHOW --> CS_SIMILAR["相关文章推荐"] + CM_SHOW --> CS_VIEWS["浏览量统计"] + CM_SHOW --> CS_LIKE["点赞功能"] + + CMS --> CM_OFFICE["多租户官网"] + CM_OFFICE --> CO_TEMPLATE["模板驱动建站"] + CM_OFFICE --> CO_DOMAIN["域名绑定"] + CM_OFFICE --> CO_THEME["独立主题配置"] + CM_OFFICE --> CO_SEO["SEO 优化支持"] + + CMS --> CM_MEDIA["媒体库管理"] + CM_MEDIA --> MM_UPLOAD["图片/视频上传"] + CM_MEDIA --> MM_CATEGORY["媒体分类"] + CM_MEDIA --> MM_CDN["CDN 集成"] + + %% 平台支撑 + PLATFORM --> P_TENANT["🏢 多租户管理"] + P_TENANT --> PT_REGISTER["租户注册开通"] + P_TENANT --> PT_PACKAGE["套餐管理"] + PT_PACKAGE --> PP_USER["用户数配额"] + PT_PACKAGE --> PP_FUNC["功能模块授权"] + PT_PACKAGE --> PP_EXPIRE["套餐到期提醒"] + P_TENANT --> PT_CONFIG["租户配置"] + PT_CONFIG --> TC_SITE["站点设置"] + PT_CONFIG --> TC个性["个性化配置"] + PT_CONFIG --> TC_DOMAIN["域名绑定"] + + PLATFORM --> P_PERMISSION["🔐 权限角色管理"] + P_PERMISSION --> PP_RBAC["RBAC 权限模型"] + P_PERMISSION --> PP_ROLE["角色自定义"] + P_PERMISSION --> PP_MENU["菜单权限控制"] + P_PERMISSION --> PP_DATA["数据权限隔离"] + P_PERMISSION --> PP_LOG["操作日志审计"] + + PLATFORM --> P_ORG["🏭 组织架构管理"] + P_ORG --> PO_TREE["树形部门结构"] + P_ORG --> PO_POSITION["岗位管理"] + P_ORG --> PO_EMPLOYEE["员工档案管理"] + P_ORG --> PO_DEPT["部门职责定义"] + + PLATFORM --> P_USER["👤 用户管理"] + P_USER --> PU_ACCOUNT["租户用户账号"] + P_USER --> PU_STATUS["用户状态管理"] + P_USER --> PU_PASSWORD["密码策略"] + P_USER --> PU_LOGIN_LOG["登录日志"] + + PLATFORM --> P_MONITOR["📊 系统监控"] + P_MONITOR --> PM_OPLOG["操作日志"] + P_MONITOR --> PM_LOGIN["登录日志"] + P_MONITOR --> PM_PERF["系统性能监控"] + P_MONITOR --> PM_ERROR["错误日志"] + + %% AI 智能 + AI --> AIC_CHAT["💬 AI 聊天助手"] + AIC_CHAT --> AIC_SESSION["会话管理"] + AIC_CHAT --> AIC_PRESET["预设提示词"] + AIC_CHAT --> AIC_HISTORY["对话历史记录"] + + AI --> AIS_GENERATE["🎨 AI 智能生成"] + AIS_GENERATE --> AG_CONTENT["内容自动生成"] + AIS_GENERATE --> AG_COPY["文案创作"] + AIS_GENERATE --> AG_RECOMMEND["智能推荐"] + + AI --> AIP_PROVIDER["🔌 AI 提供商管理"] + AIP_PROVIDER --> AP_MODEL["多模型支持"] + AIP_PROVIDER --> AP_APIKEY["API 密钥管理"] + AIP_PROVIDER --> AP_QUOTA["调用配额控制"] + + %% 移动端 + MOBILE --> M_OA["📱 移动 OA"] + M_OA --> MO_APPROVE["移动审批"] + M_OA --> MO_MESSAGE["消息推送"] + M_OA --> MO_SCHEDULE["日程查看"] + M_OA --> MO_DOC["文档预览"] + + MOBILE --> M_CR["📱 移动 CRM"] + M_CR --> MC_FOLLOW["客户跟进记录"] + M_CR --> MC_REPORT["商机汇报"] + M_CR --> MC_STATS["数据统计看板"] + + MOBILE --> M_NOTE["📝 笔记本应用"] + M_NOTE --> MN_NOTE["移动笔记"] + M_NOTE --> MN_TODO["待办事项"] + M_NOTE --> MN_REMIND["提醒 reminders"] + + MOBILE --> M_ACTIVITY["🎉 活动管理"] + M_ACTIVITY --> MA_SIGNUP["活动报名"] + M_ACTIVITY --> MA_NOTIFY["活动通知"] + + style ROOT fill:#2c3e50,stroke:#34495e,color:#fff,stroke-width:4px + style OA fill:#3498db,stroke:#2980b9,color:#fff,stroke-width:2px + style CRM fill:#e74c3c,stroke:#c0392b,color:#fff,stroke-width:2px + style ERP fill:#27ae60,stroke:#1e8449,color:#fff,stroke-width:2px + style CMS fill:#f39c12,stroke:#d68910,color:#fff,stroke-width:2px + style PLATFORM fill:#9b59b6,stroke:#8e44ad,color:#fff,stroke-width:2px + style AI fill:#1abc9c,stroke:#16a085,color:#fff,stroke-width:2px + style MOBILE fill:#34495e,stroke:#2c3e50,color:#fff,stroke-width:2px +``` + +### 2.2 业务全流程图 + +```mermaid +sequenceDiagram + participant 销售 as 销售人员 + participant CRM as CRM 系统 + participant 财务 as 财务人员 + participant OA as OA 系统 + participant ERP as ERP 系统 + + Note over 销售,ERP: 第一阶段:线索获取 + 销售->>CRM: 录入线索 + CRM->>CRM: 线索评分/分配 + CRM->>销售:分配跟进任务 + + Note over 销售,CRM: 第二阶段:商机转化 + 销售->>CRM: 跟进记录 + CRM->>CRM: 线索转商机 + 销售->>CRM: 创建商机 + CRM->>销售:商机阶段推进 + + Note over 销售,ERP: 第三阶段:客户/供应商管理 + 销售->>CRM: 创建客户档案 + CRM->>ERP: 同步客户信息 + 销售->>CRM: 添加供应商(如需) + CRM->>ERP: 同步供应商信息 + + Note over 销售,ERP: 第四阶段:项目管理 + 销售->>CRM: 立项申请 + CRM->>CRM: 创建项目 + alt 上下游项目 + CRM->>ERP: 关联供应商 + CRM->>ERP: 关联客户 + end + CRM->>OA: 创建项目文档空间 + + Note over 销售,ERP: 第五阶段:合同签订 + 销售->>CRM: 起草合同 + CRM->>OA: 审批流程 + OA->>销售:审批通过 + CRM->>CRM: 合同归档 + CRM->>ERP: 同步合同信息 + + Note over 财务,ERP: 第六阶段:回款管理 + CRM->>财务:生成回款计划 + 财务->>ERP: 确认回款计划 + 客户->>财务:支付款项 + 财务->>ERP: 登记回款 + ERP->>CRM: 更新回款状态 + CRM->>销售:回款提醒 + + Note over 财务,ERP: 第七阶段:对账结算 + 财务->>ERP: 生成对账单 + ERP->>ERP: 账龄分析 + 财务->>ERP: 核销应收 + ERP->>CRM: 更新客户状态 + + Note over 销售,OA: 持续跟进 + 销售->>CRM: 后续服务记录 + CRM->>OA: 上传项目文档 + OA->>OA: 知识沉淀 +``` + +### 2.3 数据关系图 + +```mermaid +erDiagram + TENANT ||--o{ USER : "拥有" + TENANT ||--o{ ORGANIZATION : "包含" + TENANT ||--o{ LEAD : "管理" + TENANT ||--o{ BUSINESS : "管理" + TENANT ||--o{ CUSTOMER : "管理" + TENANT ||--o{ SUPPLIER : "管理" + TENANT ||--o{ PROJECT : "管理" + TENANT ||--o{ CONTRACT : "管理" + TENANT ||--o{ PAYBACK : "管理" + TENANT ||--o{ ARTICLE : "发布" + TENANT ||--o{ NOTICE : "发布" + TENANT ||--o{ DOCUMENT : "存储" + + USER ||--o{ LEAD : "负责" + USER ||--o{ BUSINESS : "跟进" + USER ||--o{ PROJECT : "负责" + USER ||--o{ CONTRACT : "签订" + USER ||--o{ PAYBACK : "跟踪" + USER ||--o{ DOCUMENT : "创建" + + LEAD ||--|{ BUSINESS : "转化" + BUSINESS ||--o{ PROJECT : "发起" + CUSTOMER ||--o{ PROJECT : "参与" + SUPPLIER ||--o{ PROJECT : "参与" + PROJECT ||--o{ CONTRACT : "包含" + CONTRACT ||--o{ PAYBACK : "计划" + + ORGANIZATION ||--o{ USER : "归属" + ORGANIZATION ||--o{ ORGANIZATION : "父子" + ORGANIZATION ||--o{ DOCUMENT : "共享" + + DOCUMENT ||--o{ DOCUMENT_LINK : "引用" + DOCUMENT ||--o{ DOCUMENT_LINK : "被引用" + DOCUMENT ||--o{ DOCUMENT_SHARE : "共享给" + + ARTICLE ||--o{ ARTICLE_CATEGORY : "分类" + ARTICLE ||--o{ SYSTEM_FILE : "附件" + + CONTRACT ||--o{ SYSTEM_FILE : "附件" + PAYBACK ||--o{ SYSTEM_FILE : "回执" + + TENANT { + string tenant_code "租户编码" + string tenant_name "租户名称" + int user_quota "用户配额" + datetime package_expire_time "套餐到期时间" + } + + USER { + string uid "用户 ID" + string username "用户名" + string real_name "真实姓名" + uint64 org_id "所属部门" + } + + CUSTOMER { + uint64 id "客户 ID" + string customer_name "客户名称" + string customer_type "客户类型" + string customer_level "客户等级" + bool in_pool "是否在公海" + } + + PROJECT { + uint64 id "项目 ID" + string project_name "项目名称" + int project_type "项目类型" + float upstream_amount "上游金额" + float downstream_amount "下游金额" + } + + CONTRACT { + uint64 id "合同 ID" + string contract_no "合同编号" + float total_amount "合同总额" + float total_profit "总利润" + int status "合同状态" + } + + PAYBACK { + uint64 id "回款 ID" + float total_amount "计划总额" + float received_amount "已回款" + int plan_type "回款周期" + } +``` + +--- + +## 🎯 三、核心功能模块详解 + +### 2.1 OA 办公自动化模块 + +#### 2.1.1 通知公告系统 +- **通知类型**: 通知 / 公告 / 活动 +- **优先级分级**: 普通 / 重要 / 紧急 +- **功能特性**: + - 支持置顶展示 + - 阅读统计追踪 + - 按租户隔离,租户内共享 + - 状态管理:草稿/已发布/已下架 + +#### 2.1.2 日程安排管理 +- **功能范围**: + - 个人/团队日程创建与管理 + - 日程提醒设置 + - 日历视图展示 + - 重复日程支持 +- **协作特性**: 支持部门级日程共享 + +#### 2.1.3 OA 文档知识库 +**双空间模型设计**: +- **共享文档空间**: 租户级,全员共用一套文件夹结构 +- **私密文档空间**: 用户级别,每位用户独立文件夹 + +**文档分类体系**: +- 树形分类结构(支持多级) +- 系统内置"项目文档"分类 +- 支持虚拟"未分类" + +**文档类型支持**: +- 文档类:doc/docx/txt/md/rtf/wps/odt +- 表格类:xls/xlsx/csv/ods +- 演示类:ppt/pptx/odp +- PDF 文件 +- 图片类:jpg/png/gif/bmp/webp/svg +- 压缩包:zip/rar/7z/tar/gz +- 视频/音频文件 + +**文档图谱功能**: +- 引用关系(reference) +- 相关关系(related) +- 版本迭代(version) +- 从属关系(belong) + +**权限管理**: +- 可见性控制:租户公开 / 私密 +- 分享机制:按用户或部门共享 +- 在线编辑与版本管理 +- 查看/下载统计 + +#### 2.1.4 薪酬管理 +- 薪酬方案配置 +- 薪酬核算与发放 +- 薪酬记录查询 +- 税务计算 + +--- + +### 2.2 CRM 客户关系管理模块 + +#### 2.2.1 线索管理(Leads) +- 线索录入与分配 +- 线索跟进记录 +- 线索转化商机 +- 公海池机制 + +#### 2.2.2 商机管理(Business) +- 商机阶段跟踪 +- 商机金额管理 +- 赢单/输单分析 +- 转化率统计 + +#### 2.2.3 客户管理 +**客户信息维度**: +- 基本信息:名称/类型/行业/地址 +- 联系人信息:姓名/电话/邮箱 +- 资质信息:注册资本/实缴资本/成立日期 +- 税务信息:纳税人资格/税号/开户行 +- 客户等级:L1-L5分级体系 +- 客户来源:市场活动/网络推广/转介绍等 + +**公海池机制**: +- 客户回收规则 +- 领取/退回机制 +- 跟进期限提醒 +- 历史负责人追踪 + +#### 2.2.4 供应商管理 +**供应商信息维度**: +- 基础信息:名称/类型/行业/地址 +- 联系信息:联系人/电话/邮箱 +- 资质认证:注册资本/企业类型 +- 开票信息:发票标题/税号/银行账户 +- 供应商等级评估 +- 合作状态管理 + +#### 2.2.5 项目管理 +**项目类型**: +1. **单一项目**: 只有上游客户 +2. **上下游项目**: 上游客户 + 下游供应商 +3. **上中游项目**: 上游 A 公司 + 中游 B 公司 + +**金额拆分管理**: +- 上游金额:对客户的收入 +- 下游金额:对供应商的成本 +- 毛利计算:上下游项目 = 上游 - 下游 +- 上中游项目:上游合同额 vs 中游合同额 + +**项目全生命周期**: +- 项目立项 → 执行 → 监控 → 收尾 +- 进度状态:未开始/进行中/已完成/已暂停/异常 +- 起止时间自动汇总(基于关联合同) +- 对接人管理:上下游分别配置联系人 + +**项目文档集成**: +- 自动创建项目专属文档文件夹 +- 文档与项目关联 +- 团队协作共享 + +#### 2.2.6 合同管理 +**合同类型**: +- 开发合同 / 服务合同 / 销售合同 +- 租赁合同 / 采购合同 / 运维合同 +- 咨询合同 / 其他合同 + +**合同形式**: +- 我方角色:甲方/乙方/丙方/丁方 +- 签约方数:双方/三方/四方合同 +- 有头合同(关联项目)/ 无头合同 + +**金额明细**: +- 硬件金额 +- 软件金额 +- 其他费用 +- 总金额 / 总成本 / 总利润 + +**合同状态**: +- 草稿 → 审批中 → 已完成 → 履约中 → 异常 + +#### 2.2.7 回款管理 +**回款周期类型**: +- 月度回款 +- 季度回款 +- 年度回款 +- 进度回款 +- 自定义回款 + +**回款方式**: +- 对公转账 / 网银转账 / 现金 +- 支票 / 支付宝 / 微信 / 其他 + +**回款计划明细**: +- 期数编号 +- 计划名称 +- 回款金额 +- 计划日期 +- 进度百分比(进度式回款) +- 实收金额与日期登记 + +**回款追踪**: +- 合同金额 vs 回款总额 +- 已回款金额统计 +- 回款逾期预警 +- 电子回执上传 + +#### 2.2.8 联系人管理 +- 企业通讯录管理 +- 联系人分组 +- 联系方式维护 +- 职位信息管理 + +#### 2.2.9 产品管理 +- 产品分类体系 +- 产品信息维护 +- 价格管理 +- 项目产品清单 + +#### 2.2.10 数据看板 +- 线索转化漏斗 +- 回款进度统计 +- 项目概览 +- 业绩排行榜 + +--- + +### 2.3 ERP 企业资源计划模块 + +#### 2.3.1 账套管理 +- 多账套支持 +- 会计期间设置 +- 会计准则选择 +- 税率配置 +- 币种管理 + +#### 2.3.2 客户管理(ERP 视角) +- 客户档案建立 +- 信用额度管理 +- 账龄分析 +- 往来对账 + +#### 2.3.3 供应商管理(ERP 视角) +- 供应商档案 +- 采购订单管理 +- 应付账款 +- 采购统计分析 + +#### 2.3.4 往来单位 +- 公司联系人管理 +- 多方主体协同 +- 合作关系维护 + +#### 2.3.5 基础设置 +- 编码规则配置 +- 审批流程设置 +- 数据权限控制 + +--- + +### 2.4 CMS 内容管理系统 + +#### 2.4.1 文章管理 +- 富文本编辑器 +- 文章分类体系 +- 标签管理 +- 作者管理 +- 发布时间控制 + +#### 2.4.2 文章展示 +- 首页推荐位 +- 热门文章 +- 相关文章推荐 +- 浏览量统计 +- 点赞功能 + +#### 2.4.3 多租户官网 +- 模板驱动建站 +- 域名绑定 +- 独立主题配置 +- SEO 优化支持 + +#### 2.4.4 媒体库管理 +- 图片/视频上传 +- 媒体分类 +- CDN 集成 + +--- + +## 🔧 三、平台支撑功能 + +### 3.1 多租户管理 +- **租户注册与开通** +- **套餐管理**: + - 用户数配额限制 + - 功能模块授权 + - 套餐到期提醒 +- **租户配置**: + - 站点设置 + - 个性化配置 + - 域名绑定 + +### 3.2 权限与角色管理 +- RBAC 权限模型 +- 角色自定义 +- 菜单权限控制 +- 数据权限隔离 +- 操作日志审计 + +### 3.3 组织架构管理 +- 树形部门结构 +- 岗位管理 +- 员工档案管理 +- 部门职责定义 + +### 3.4 用户管理 +- 租户用户账号 +- 用户状态管理 +- 密码策略 +- 登录日志 + +### 3.5 系统监控 +- 操作日志 +- 登录日志 +- 系统性能监控 +- 错误日志 + +--- + +## 🤖 四、AI 智能增强功能 + +### 4.1 AI 聊天助手 +- 会话管理 +- 预设提示词 +- 对话历史记录 + +### 4.2 AI 智能生成 +- 内容自动生成 +- 文案创作 +- 智能推荐 + +### 4.3 AI 提供商管理 +- 多模型支持 +- API 密钥管理 +- 调用配额控制 + +--- + +## 📱 五、移动端功能 + +### 5.1 移动 OA +- 移动审批 +- 消息推送 +- 日程查看 +- 文档预览 + +### 5.2 移动 CRM +- 客户跟进记录 +- 商机汇报 +- 数据统计看板 + +### 5.3 笔记本应用 +- 移动笔记 +- 待办事项 +- reminders 提醒 + +### 5.4 活动管理 +- 活动报名 +- 活动通知 + +--- + +## 🎨 六、特色功能亮点 + +### 6.1 全流程业务闭环 +``` +线索 → 商机 → 项目 → 合同 → 回款 + ↓ + 客户/供应商管理 ←→ 联系人管理 +``` + +### 6.2 上下游一体化管理 +- 项目级别的上下游拆分 +- 收入/成本独立核算 +- 多方主体协同 +- 毛利实时统计 + +### 6.3 文档知识图谱 +- 文档关联关系可视化 +- 版本迭代追踪 +- 项目文档自动归类 +- 智能搜索与推荐 + +### 6.4 回款精细化管理 +- 多维度回款计划 +- 进度式回款支持 +- 逾期自动预警 +- 电子凭证管理 + +### 6.5 双空间文档管理 +- 共享与私密分离 +- 灵活的权限控制 +- 部门级共享 +- 租户级备份 + +### 6.6 公海池机制 +- 客户资源流转 +- 防止客户垄断 +- 公平分配原则 +- 跟进时效监控 + +--- + +## 🏆 七、产品价值主张 + +### 7.1 提升办公效率 +- **一站式平台**: OA+CRM+ERP+CMS 整合,避免多系统切换 +- **自动化流程**: 减少手工操作,降低出错率 +- **移动办公**: 随时随地处理业务 +- **智能辅助**: AI 赋能,提升决策效率 + +### 7.2 强化客户关系 +- **360°客户视图**: 完整客户信息与交互历史 +- **销售过程透明**: 线索到回款全程可追溯 +- **精细化运营**: 客户分级,精准营销 +- **提高转化率**: 公海池激活沉睡客户 + +### 7.3 优化资源配置 +- **项目精益管理**: 上下游成本收入拆分 +- **资金流可视**: 回款计划与实际对比 +- **库存优化**: ERP 集成,合理备货 +- **人力调配**: 组织架构清晰,责任明确 + +### 7.4 促进知识沉淀 +- **文档集中管理**: 企业知识资产统一存储 +- **知识复用**: 文档关联,经验传承 +- **版本控制**: 避免版本混乱 +- **安全可控**: 权限分级,防止泄露 + +### 7.5 支持业务扩展 +- **多租户架构**: SaaS 模式,快速部署 +- **模块化设计**: 按需选用功能 +- **灵活配置**: 适应不同行业场景 +- **开放 API**: 第三方系统集成 + +--- + +## 📊 八、典型应用场景 + +### 8.1 贸易/分销企业 +- **场景**: 客户开发 → 报价谈判 → 签订合同 → 发货收款 +- **价值**: 全流程线上化,业绩一目了然 + +### 8.2 工程项目公司 +- **场景**: 项目立项 → 上下游分包 → 进度管理 → 结算回款 +- **价值**: 项目成本精准核算,利润可控 + +### 8.3 咨询服务企业 +- **场景**: 线索获取 → 方案报价 → 项目交付 → 客户续费 +- **价值**: 服务质量可追溯,客户满意度提升 + +### 8.4 生产制造企业 +- **场景**: 销售订单 → 采购计划 → 生产排程 → 发货收款 +- **价值**: 产销协同,库存周转加快 + +### 8.5 互联网科技公司 +- **场景**: 需求分析 → 产品开发 → 上线运营 → 客户成功 +- **价值**: 敏捷开发,快速响应市场 + +--- + +## 🚀 九、技术优势 + +### 9.1 高性能架构 +- Go 语言后端,高并发处理能力 +- 数据库连接池优化 +- 缓存策略 +- 异步任务处理 + +### 9.2 数据安全 +- 多租户数据隔离 +- 操作日志审计 +- 敏感数据加密 +- 定期备份机制 + +### 9.3 可扩展性 +- 微服务友好架构 +- 插件化设计 +- API 网关 +- 支持容器化部署 + +### 9.4 用户体验 +- 响应式设计 +- 前后端分离 +- 组件化开发 +- 国际化支持 + +--- + +## 📈 十、未来发展方向 + +### 10.1 功能深化 +- BI 商业智能分析 +- RPA 流程自动化 +- 更强大的 AI 能力 +- 供应链金融 + +### 10.2 生态建设 +- 应用市场 +- 第三方插件 +- 开发者社区 +- API 开放平台 + +### 10.3 行业解决方案 +- 垂直行业模板 +- 最佳实践库 +- 实施方法论 +- 培训认证体系 + +### 10.4 全球化 +- 多语言支持 +- 多币种结算 +- 跨境合规 +- 全球部署 + +--- + +## 📝 十一、总结 + +本平台是一款**功能完善、架构先进、易于扩展**的企业级 SaaS 多租户管理系统。通过整合 OA、CRM、ERP、CMS 四大核心模块,为企业提供了一站式的数字化解决方案。 + +**核心价值**: +✅ **提效**: 自动化流程,移动办公,AI 赋能 +✅ **增收**: 销售全流程管理,提高转化率 +✅ **降本**: 精细化成本管理,资源优化配置 +✅ **控险**: 回款预警,权限控制,审计追踪 +✅ **沉淀**: 知识文档管理,数据资产积累 + +**适用对象**: +中小企业、贸易公司、工程公司、咨询公司、制造型企业等需要提升内部管理效率和客户管理能力的各类企业。 + +**竞争优势**: +- 多租户 SaaS 模式,开箱即用 +- 一体化平台,避免系统孤岛 +- 上下游一体化管理,特色鲜明 +- 文档知识图谱,创新体验 +- 灵活配置,适应性强 + +--- + +*文档最后更新时间:2026-09-18* diff --git a/docs/平台更新功能 - 问题修复.md b/docs/平台更新功能 - 问题修复.md new file mode 100644 index 0000000..1af2814 --- /dev/null +++ b/docs/平台更新功能 - 问题修复.md @@ -0,0 +1,160 @@ +# 平台更新功能 - 问题修复记录 + +## ❌ 原始错误 + +### 前端错误信息 +``` +获取数据失败:SyntaxError: Unexpected token '<', "🎉 核心功能更新...", + "create_time": "2026-09-18 10:00:00" + } + ] +} +``` + +## 📝 数据维护建议 + +### 添加新更新记录 + +1. 通过后台管理界面点击"新增更新内容" +2. 填写以下信息: + - **更新日期**: 选择发布日期 + - **标题**: 简明扼要地描述更新主题 + - **更新内容**: 详细描述更新内容,支持 HTML 格式 + - **排序**: 数值越大越靠前显示 + - **状态**: 选择显示或隐藏 + +### 更新内容格式建议 + +推荐使用 HTML 格式,示例: + +```html +

🎉 核心功能更新

+ + +

💡 体验优化

+ +``` + +### 删除记录 + +建议使用"隐藏"状态而非直接删除,保留历史记录便于追溯。如需彻底删除,请在后台管理中操作。 + +## 🔧 常见问题 + +### Q1: 官网首页没有显示更新内容? + +**检查项**: +1. 确认数据库表已创建且包含数据 +2. 确认 `status = 1`(显示状态) +3. 检查浏览器控制台是否有 API 请求错误 +4. 确认后端服务已重启并正确注册路由 + +### Q2: 如何自定义显示数量? + +修改 `ScheduleSection.vue` 中的 API 调用参数: + +```javascript +const response = await fetch('/api/platform/upgrade/list?limit=20'); +``` + +或在后端控制器的 `List()` 方法中调整默认值。 + +### Q3: 能否按租户隔离显示? + +当前设计为全局显示(`tenant_id` 为空)。如需按租户隔离,可: +1. 在查询时增加租户过滤条件 +2. 在前端传递租户 ID 参数 +3. 修改控制器逻辑 + +## 📊 扩展功能建议 + +1. **版本对比**: 增加版本号字段,支持版本对比 +2. **附件下载**: 支持上传更新相关的附件(如安装包) +3. **分类标签**: 增加功能分类(如 CRM/OA/ERP/CMS) +4. **统计功能**: 记录每条更新的阅读量、点赞数等 +5. **推送通知**: 更新时通过邮件/短信通知用户 + +## 📞 技术支持 + +如有问题,请联系开发团队或查看项目文档。 + +--- + +**最后更新**: 2026-09-18 diff --git a/docs/开发总结.md b/docs/开发总结.md new file mode 100644 index 0000000..bb5ab56 --- /dev/null +++ b/docs/开发总结.md @@ -0,0 +1,217 @@ +# 平台更新功能开发总结 + +## ✅ 已完成的工作 + +### 1. 前端修改 + +#### 官网首页组件 (ScheduleSection.vue) +- ✅ 将"近期活动与发布节奏"改为"近期平台更新内容" +- ✅ 数据结构从硬编码改为从 API 动态获取 +- ✅ 字段映射:`update_date`、`title`、`content` +- ✅ 添加加载状态和空数据提示 +- ✅ 管理入口链接指向 `/platform/platformupgrade` + +**关键改动**: +```vue +// 原数据结构 +interface ScheduleItem { + range: string; + period: string; + name: string; + desc: string; +} + +// 新数据结构 +interface UpgradeItem { + id: number; + update_date: string; + title: string; + content: string; + create_time: string; +} +``` + +### 2. 后端开发 + +#### 数据模型 (platform_upgrade.go) +```go +type PlatformUpgrade struct { + ID uint64 `orm:"column(id);pk;auto"` + TenantID string `orm:"column(tenant_id);size(64)"` + UpdateDate string `orm:"column(update_date);size(20)"` + Title string `orm:"column(title);size(255)"` + Content string `orm:"column(content);type(text)"` + Sort int `orm:"column(sort);default(0)"` + Status int8 `orm:"column(status);default(1)"` + CreateUserID string `orm:"column(create_user_id);size(64)"` + CreateTime time.Time `orm:"column(create_time);auto_now_add"` + UpdateTime *time.Time `orm:"column(update_time);auto_now"` + DeleteTime *time.Time `orm:"column(delete_time);null"` +} +``` + +#### API 控制器 (api_platform_upgrade.go) +- **List()**: GET `/api/platform/upgrade/list` + - 支持 `limit` 参数(默认 10,最大 50) + - 按 `sort` 降序、`create_time` 降序排列 + - 只返回 `status=1` 的记录 + +- **Detail()**: GET `/api/platform/upgrade/detail?id={id}` + - 获取单条更新详情 + - 权限验证和参数校验 + +#### 路由注册 (api.go) +```go +beego.Router("/api/platform/upgrade/list", &controllers.ApiPlatformUpgradeController{}, "get:List") +beego.Router("/api/platform/upgrade/detail", &controllers.ApiPlatformUpgradeController{}, "get:Detail") +``` + +### 3. 管理平台页面 (platformupgrade/index.vue) + +完整的管理界面,包含: +- ✅ 数据表格展示 +- ✅ 新增/编辑对话框 +- ✅ 删除确认 +- ✅ 表单验证 +- ✅ 排序和状态管理 + +**功能特性**: +- 日期选择器 +- HTML 内容输入 +- 数字输入(排序) +- 状态切换(显示/隐藏) + +### 4. 数据库脚本 (platform_upgrade.sql) + +提供完整的建表 SQL 和示例数据: +- ✅ 表结构定义 +- ✅ 索引优化 +- ✅ 3 条示例数据(V3.0 版本发布、OA 升级、CRM 优化) +- ✅ UTF8MB4 字符集支持 + +### 5. 文档 + +- ✅ 《平台更新功能说明.md》- 详细使用文档 +- ✅ 《开发总结.md》- 本文档 + +## 📁 文件清单 + +``` +e:\Demo\Go\yunzerwebsiteallinone\ +├── go/ +│ ├── models/ +│ │ └── platform_upgrade.go # 新增:数据模型 +│ ├── controllers/ +│ │ └── api_platform_upgrade.go # 新增:API 控制器 +│ └── routers/ +│ └── api/ +│ └── api.go # 修改:注册新路由 +│ +├── website/ +│ └── src/views/home/components/ +│ └── ScheduleSection.vue # 修改:官网首页组件 +│ +├── platform/ +│ └── src/views/platform/ +│ └── platformupgrade/ +│ └── index.vue # 新增:管理页面 +│ +└── docs/ + ├── sql/ + │ └── platform_upgrade.sql # 新增:SQL 脚本 + └── 平台更新功能说明.md # 新增:使用说明 + └── 开发总结.md # 新增:本文档 +``` + +## 🚀 部署步骤 + +### Step 1: 执行 SQL 脚本 +```bash +# 在 MySQL 中执行 +source e:\Demo\Go\yunzerwebsiteallinone\docs\sql\platform_upgrade.sql +``` + +### Step 2: 重启后端服务 +```bash +cd e:\Demo\Go\yunzerwebsiteallinone\go +# 重新编译运行 +bee run +# 或 +go build -o main.exe +./main.exe +``` + +### Step 3: 测试功能 +1. 访问官网首页,查看更新内容展示 +2. 访问后台管理页面,进行增删改查操作 +3. 调用 API 接口验证数据 + +## 🔍 技术要点 + +### 1. 前后端分离架构 +- 前端通过 AJAX 请求获取数据 +- 后端提供 RESTful API +- 数据格式统一为 JSON + +### 2. 软删除机制 +- 使用 `delete_time` 字段实现软删除 +- 查询时过滤 `delete_time__isnull` +- 保留历史记录便于追溯 + +### 3. 排序策略 +- `sort` 字段控制显示顺序 +- 数值越大越靠前 +- 支持管理员灵活调整 + +### 4. 租户隔离设计 +- `tenant_id` 字段预留租户标识 +- 当前实现为全局显示(空字符串) +- 未来可扩展为按租户隔离 + +### 5. HTML 内容支持 +- `content` 字段使用 TEXT 类型 +- 支持富文本 HTML 格式 +- 前端可渲染格式化内容 + +## 📊 数据流程 + +```mermaid +graph LR + A[管理员] -->|增删改查 | B(后台管理页面) + B -->|CRUD 操作 | C[后端 API] + C -->|读写 | D[(MySQL 数据库)] + E[官网首页] -->|GET /api/platform/upgrade/list| C + C -->|返回数据 | E + E -->|展示 | F[访客] +``` + +## ⚠️ 注意事项 + +1. **数据库连接**: 确保数据库连接正常 +2. **路由注册**: 后端必须重启才能生效 +3. **权限配置**: 如需后台管理,需配置菜单权限 +4. **数据安全**: 建议对 HTML 内容进行 XSS 过滤 +5. **性能优化**: 大数据量时需考虑分页和缓存 + +## 🔄 后续优化建议 + +1. **分页功能**: 当前列表不分页,数据量大时需优化 +2. **富文本编辑器**: 管理页面可使用 Quill/CKEditor 等编辑器 +3. **版本对比**: 增加版本号字段,支持版本对比功能 +4. **附件上传**: 支持上传更新相关的安装包、文档等 +5. **分类标签**: 增加功能分类(CRM/OA/ERP/CMS) +6. **统计功能**: 记录阅读量、点赞数等互动数据 +7. **推送通知**: 更新时自动通知用户 + +## 📞 联系方式 + +如有问题,请查阅: +- 《平台更新功能说明.md》 +- 项目 README +- 相关代码注释 + +--- + +**开发完成时间**: 2026-09-18 +**开发者**: AI Assistant +**版本**: v1.0.0 diff --git a/go/conf/app.conf b/go/conf/app.conf index 017fa30..2fc64e9 100644 --- a/go/conf/app.conf +++ b/go/conf/app.conf @@ -53,6 +53,30 @@ acme_directory = # Nginx 扑底站点按 SNI 读取该目录。Go 进程需要对此目录有写权限。 ssl_cert_dir = /www/wwwroot/ssl-certs +# ==================== 统一认证中心(UAC)基础配置 ==================== +# 全局 JWT 鉴权模式:off=不启用(等同改造前)/ warn=观察模式只记日志不拦截 / on=真正拦截401 +# 上线步骤:先 warn 跑一段时间核对日志,确认白名单无遗漏后再改 on +auth_enforce = warn +# 额外免鉴权路径前缀(在内置白名单之外追加),多个用英文逗号分隔 +auth_whitelist = + +# JWT 签发者(统一认证中心地址) +jwt_issuer = https://api.yunzer.cn/auth +# HS256 主密钥:留空则沿用内置默认密钥(兼容历史 token),生产环境务必配置 +# 注意:修改后所有已签发的旧 token 立即失效,需在业务低峰期操作 +jwt_secret = peaceandlove +# HS256 轮换密钥(用于平滑换密钥),格式:kid1:secret1,kid2:secret2 +jwt_secrets = + +# RS256 密钥对(P1 认证中心启用;未配置时自动回落 HS256,不影响启动) +# 生成命令: +# openssl genpkey -algorithm RSA -out jwt_rsa_private.pem -pkeyopt rsa_keygen_bits:2048 +# openssl rsa -pubout -in jwt_rsa_private.pem -out jwt_rsa_public.pem +# 私钥文件不要提交到代码仓库 +jwt_rsa_private_key_file = E:\Demo\ssh\jwt_rsa_private.pem +jwt_rsa_public_key_file = E:\Demo\ssh\jwt_rsa_public.pem +jwt_rsa_kid = rsa-1 + # ==================== 微信公众号(服务号) ==================== # 服务号 AppSecret / EncodingAESKey 入库加密密钥:任意随机串即可,代码内部做 SHA-256 派生。 # 注意:更换该值后,历史已保存的公众号密钥将无法解密,需在「通知设置 → 微信配置」重新保存一次。 diff --git a/go/controllers/api_platform_upgrade.go b/go/controllers/api_platform_upgrade.go new file mode 100644 index 0000000..42eaa6b --- /dev/null +++ b/go/controllers/api_platform_upgrade.go @@ -0,0 +1,125 @@ +package controllers + +import ( + "strconv" + "strings" + + "server/models" + + beego "github.com/beego/beego/v2/server/web" +) + +// ApiPlatformUpgradeController 平台更新内容接口 +type ApiPlatformUpgradeController struct { + beego.Controller +} + +// List GET /api/platform/upgrade/list 获取平台更新列表(公开接口) +func (c *ApiPlatformUpgradeController) List() { + // 可选参数:limit - 返回数量限制,默认 10 + limitStr := c.GetString("limit") + limit := 10 + if limitStr != "" { + val, err := strconv.Atoi(limitStr) + if err == nil { + limit = val + } + } + if limit <= 0 || limit > 50 { + limit = 10 + } + + // 查询显示状态的更新记录,按排序字段和创建时间倒序 + var upgradeList []models.PlatformUpgradeNotice + qs := models.Orm.QueryTable(new(models.PlatformUpgradeNotice)). + Filter("status", 1). + Filter("delete_time__isnull", true) + + _, err := qs.OrderBy("-sort", "-create_time").Limit(limit).All(&upgradeList) + if err != nil { + c.Data["json"] = map[string]interface{}{ + "code": 500, + "msg": "查询失败", + } + _ = c.ServeJSON() + return + } + + // 转换为前端需要的格式 + data := make([]map[string]interface{}, 0, len(upgradeList)) + for _, item := range upgradeList { + record := map[string]interface{}{ + "id": item.ID, + "update_date": item.UpdateDate, + "title": item.Title, + "content": item.Content, + "create_time": item.CreateTime.Format("2006-01-02 15:04:05"), + } + data = append(data, record) + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": data, + } + _ = c.ServeJSON() +} + +// Detail GET /api/platform/upgrade/detail?id={id} 获取单条更新详情 +func (c *ApiPlatformUpgradeController) Detail() { + idStr := strings.TrimSpace(c.GetString("id")) + if idStr == "" { + c.Data["json"] = map[string]interface{}{ + "code": 400, + "msg": "缺少参数 id", + } + _ = c.ServeJSON() + return + } + + var id uint64 + var parseErr error + id, parseErr = strconv.ParseUint(idStr, 10, 64) + if parseErr != nil { + c.Data["json"] = map[string]interface{}{ + "code": 400, + "msg": "参数错误", + } + _ = c.ServeJSON() + return + } + + var row models.PlatformUpgradeNotice + var queryErr error + queryErr = models.Orm.QueryTable(new(models.PlatformUpgradeNotice)). + Filter("id", id). + Filter("status", 1). + Filter("delete_time__isnull", true). + One(&row) + if queryErr != nil { + c.Data["json"] = map[string]interface{}{ + "code": 404, + "msg": "更新内容不存在", + } + _ = c.ServeJSON() + return + } + + data := map[string]interface{}{ + "id": row.ID, + "tenant_id": row.TenantID, + "update_date": row.UpdateDate, + "title": row.Title, + "content": row.Content, + "sort": row.Sort, + "create_time": row.CreateTime.Format("2006-01-02 15:04:05"), + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": data, + } + _ = c.ServeJSON() +} diff --git a/go/controllers/auth/login.go b/go/controllers/auth/login.go new file mode 100644 index 0000000..9b41ce3 --- /dev/null +++ b/go/controllers/auth/login.go @@ -0,0 +1,375 @@ +package auth + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "strings" + + authsvc "server/services/auth" + + "server/models" + "server/pkg/jwtutil" + + beego "github.com/beego/beego/v2/server/web" + "github.com/beego/beego/v2/server/web/context" +) + +// AuthLoginController 认证中心登录相关端点 +type AuthLoginController struct { + beego.Controller +} + +func (c *AuthLoginController) serveJSON(data map[string]interface{}) { + c.Data["json"] = data + _ = c.ServeJSON() +} + +// LoginPage 统一登录页(beego 模板渲染) +// GET /auth/login?redirect=&client_id=xxx&step=tenant +func (c *AuthLoginController) LoginPage() { + clientID := strings.TrimSpace(c.GetString("client_id")) + redirect := c.GetString("redirect") + step := c.GetString("step") + errMsg := c.GetString("error") + + // 已登录且已选企业:直接放行到 authorize,无需再输密码 + sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName)) + if sid != "" && step != "tenant" { + if session, err := authsvc.GetSession(sid); err == nil && session.Tid != authsvc.PendingTenantID { + if back := decodeRedirect(redirect); back != "" { + c.Redirect(back, 302) + return + } + } + } + + // 企业选择步骤:需要展示可进入的企业列表 + tenants := make([]authsvc.TenantOption, 0) + if sid != "" { + if session, err := authsvc.GetSession(sid); err == nil { + list, _ := authsvc.ListTenantOptions(session.IdentityID) + tenants = list + // 只有一家企业直接进入,无需展示选择页 + if len(tenants) == 1 && step == "tenant" { + if _, _, err := authsvc.ChooseTenant(sid, tenants[0].Tid, clientID); err == nil { + if back := decodeRedirect(redirect); back != "" { + c.Redirect(back, 302) + return + } + } + } + } + } + + c.Data["ClientID"] = clientID + c.Data["Redirect"] = redirect + c.Data["Step"] = step + c.Data["Error"] = errMsg + c.Data["Tenants"] = tenants + c.TplName = "auth/login.tpl" +} + +// LoginSubmit 提交登录 +// POST /auth/login { account, password, client_id, redirect } +func (c *AuthLoginController) LoginSubmit() { + var req struct { + Account string `json:"account"` + Password string `json:"password"` + ClientID string `json:"client_id"` + Redirect string `json:"redirect"` + DeviceID string `json:"device_id"` + } + + body := c.Ctx.Input.RequestBody + if len(body) == 0 { + var err error + body, err = io.ReadAll(c.Ctx.Request.Body) + if err != nil || len(body) == 0 { + c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"}) + return + } + } + if err := json.Unmarshal(body, &req); err != nil { + c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"}) + return + } + if strings.TrimSpace(req.Account) == "" || req.Password == "" { + c.serveJSON(map[string]interface{}{"code": 400, "msg": "请输入账号和密码"}) + return + } + + clientIP := c.Ctx.Input.IP() + userAgent := c.Ctx.Request.UserAgent() + + result, err := authsvc.PasswordLogin(req.Account, req.Password, req.ClientID, clientIP, userAgent) + if err != nil { + c.serveJSON(map[string]interface{}{"code": 401, "msg": err.Error()}) + return + } + + // 写入认证中心会话 Cookie,供后续 authorize 识别登录态 + setSessionCookieForCtx(c.Ctx, result.Tokens.Sid) + + if result.NeedChooseTenant { + c.serveJSON(map[string]interface{}{ + "code": 200, + "msg": "请选择要进入的企业", + "need_choose_tenant": true, + "tenants": result.Tenants, + "sid": result.Tokens.Sid, + }) + return + } + + back := decodeRedirect(req.Redirect) + c.serveJSON(map[string]interface{}{ + "code": 200, + "msg": "登录成功", + "redirect": back, + "tokens": result.Tokens, + "tenants": result.Tenants, + }) +} + +// Tenants 当前登录身份可进入的企业列表(Bearer Token) +// GET /auth/tenants +func (c *AuthLoginController) Tenants() { + claims := claimsFromHeaderLogin(c) + if claims == nil { + c.Ctx.Output.SetStatus(401) + c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"}) + return + } + list, err := authsvc.ListTenantOptions(uint64(claims.UserID)) + if err != nil { + c.serveJSON(map[string]interface{}{"code": 500, "msg": "查询失败"}) + return + } + c.serveJSON(map[string]interface{}{"code": 200, "data": list}) +} + +// SwitchTenant 免密切换企业 +// POST /auth/switch-tenant { tid, client_id, redirect } +func (c *AuthLoginController) SwitchTenant() { + var req struct { + Tid uint64 `json:"tid"` + ClientID string `json:"client_id"` + Redirect string `json:"redirect"` + } + body := c.Ctx.Input.RequestBody + if len(body) == 0 { + body, _ = io.ReadAll(c.Ctx.Request.Body) + } + _ = json.Unmarshal(body, &req) + + sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName)) + if sid == "" { + if claims := claimsFromHeaderLogin(c); claims != nil { + sid = claims.Sid + } + } + if sid == "" || req.Tid == 0 { + c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"}) + return + } + + tokens, profile, err := authsvc.ChooseTenant(sid, req.Tid, req.ClientID) + if err != nil { + c.serveJSON(map[string]interface{}{"code": 403, "msg": err.Error()}) + return + } + setSessionCookieForCtx(c.Ctx, tokens.Sid) + c.serveJSON(map[string]interface{}{ + "code": 200, + "msg": "切换成功", + "redirect": decodeRedirect(req.Redirect), + "data": map[string]interface{}{"tokens": tokens, "identity": profile}, + }) +} + +// LogoutPage 单点登出入口(OIDC end_session_endpoint) +// GET /auth/logout?post_logout_redirect_uri=&client_id= +// +// 清除认证中心 Cookie 并吊销会话与令牌;回跳地址必须在应用白名单内。 +// 登记了 backchannel_logout_uri 的应用会收到服务端登出通知(P2 阶段接入)。 +func (c *AuthLoginController) LogoutPage() { + sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName)) + access := bearerTokenLogin(c) + if sid != "" { + _ = authsvc.RevokeSession(sid, models.RevokeReasonLogout) + } + if access != "" { + _ = authsvc.RevokeTokenPair("", access, models.RevokeReasonLogout) + } + clearSessionCookieForCtx(c.Ctx) + + back := strings.TrimSpace(c.GetString("post_logout_redirect_uri")) + cid := strings.TrimSpace(c.GetString("client_id")) + if back != "" && cid != "" { + if client, err := findClient(cid); err == nil && allowRedirect(client, back) { + c.Redirect(back, 302) + return + } + } + c.serveJSON(map[string]interface{}{"code": 200, "msg": "已登出"}) +} + +// Sessions 在线设备列表 +// GET /auth/sessions +func (c *AuthLoginController) Sessions() { + claims := claimsFromHeaderLogin(c) + if claims == nil { + c.Ctx.Output.SetStatus(401) + c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"}) + return + } + list, err := authsvc.ListActiveSessions(uint64(claims.UserID)) + if err != nil { + c.serveJSON(map[string]interface{}{"code": 500, "msg": "查询失败"}) + return + } + c.serveJSON(map[string]interface{}{"code": 200, "data": list}) +} + +// KickSession 踢下线指定设备 +// POST /auth/sessions/kick { sid } +func (c *AuthLoginController) KickSession() { + var req struct { + Sid string `json:"sid"` + } + body := c.Ctx.Input.RequestBody + if len(body) == 0 { + body, _ = io.ReadAll(c.Ctx.Request.Body) + } + _ = json.Unmarshal(body, &req) + + claims := claimsFromHeaderLogin(c) + if claims == nil { + c.Ctx.Output.SetStatus(401) + c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"}) + return + } + if req.Sid == "" { + c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"}) + return + } + // 只允许踢自己名下的会话 + owned := false + if list, err := authsvc.ListActiveSessions(uint64(claims.UserID)); err == nil { + for _, s := range list { + if s.Sid == req.Sid { + owned = true + break + } + } + } + if !owned { + c.serveJSON(map[string]interface{}{"code": 403, "msg": "无权操作"}) + return + } + if err := authsvc.RevokeSession(req.Sid, models.RevokeReasonAdmin); err != nil { + c.serveJSON(map[string]interface{}{"code": 500, "msg": "操作失败"}) + return + } + c.serveJSON(map[string]interface{}{"code": 200, "msg": "已下线"}) +} + +// LogoutAction 退出登录(清 Cookie + 吊销会话与令牌) +// POST /auth/logout +func (c *AuthLoginController) LogoutAction() { + sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName)) + access := "" + if claims := claimsFromHeaderLogin(c); claims != nil { + if sid == "" { + sid = claims.Sid + } + access = bearerTokenLogin(c) + } + _ = authsvc.Logout(access, "") + if sid != "" { + _ = authsvc.RevokeSession(sid, models.RevokeReasonLogout) + } + clearSessionCookieForCtx(c.Ctx) + c.serveJSON(map[string]interface{}{"code": 200, "msg": "已登出"}) +} + +// VerifyConfig 租户登录验证配置(替代 /backend/login/getOpenVerify) +// GET /auth/verify-config?tid= +func (c *AuthLoginController) VerifyConfig() { + tid, _ := c.GetInt64("tid", 0) + cfg := authsvc.GetTenantSessionPolicy(uint64(tid)) + + var authCfg models.AuthTenantAuthConfig + verifyType := "captcha" + openVerify := 1 + if err := models.Orm.QueryTable(new(models.AuthTenantAuthConfig)). + Filter("tid", tid).One(&authCfg); err == nil { + verifyType = authCfg.VerifyType + openVerify = int(authCfg.OpenVerify) + } + c.serveJSON(map[string]interface{}{ + "code": 200, + "data": map[string]interface{}{ + "openVerify": openVerify, + "verifyType": verifyType, + "sessionTTL": cfg.SessionTTL, + "maxSession": cfg.MaxSession, + "mfaRequired": 0, + }, + }) +} + +// ---------------------------------------------------------------- 工具 + +func decodeRedirect(encoded string) string { + if encoded == "" { + return "" + } + raw, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return "" + } + back := string(raw) + // 只允许站内地址,防开放重定向 + if !strings.HasPrefix(back, "/auth/") && !strings.HasPrefix(back, "http") { + return "" + } + return back +} + +func claimsFromHeaderLogin(c *AuthLoginController) *jwtutil.Claims { + token := bearerTokenLogin(c) + if token == "" { + return nil + } + claims, err := jwtutil.ParseToken(token) + if err != nil { + return nil + } + return claims +} + +func bearerTokenLogin(c *AuthLoginController) string { + header := c.Ctx.Request.Header.Get("Authorization") + if header == "" { + return "" + } + parts := strings.SplitN(header, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return "" + } + return strings.TrimSpace(parts[1]) +} + +func setSessionCookieForCtx(ctx *context.Context, sid string) { + ctx.Output.Header("Set-Cookie", + fmt.Sprintf("%s=%s; Path=/; Max-Age=%d; HttpOnly; Secure; SameSite=Lax", + sessionCookieName, sid, sessionCookieTTL)) +} + +func clearSessionCookieForCtx(ctx *context.Context) { + ctx.Output.Header("Set-Cookie", + fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax", sessionCookieName)) +} diff --git a/go/controllers/auth/oidc.go b/go/controllers/auth/oidc.go new file mode 100644 index 0000000..671c774 --- /dev/null +++ b/go/controllers/auth/oidc.go @@ -0,0 +1,526 @@ +// Package auth 统一认证中心(UAC)控制器:api.yunzer.cn/auth +// +// 实现 OIDC 1.0(基于 OAuth 2.1 + PKCE)标准端点, +// 以后每开发一个新软件,只需在 yz_auth_client 注册一条即可接入。 +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" + + authsvc "server/services/auth" + + "server/models" + "server/pkg/jwtutil" + + beego "github.com/beego/beego/v2/server/web" +) + +// 认证中心会话 Cookie(仅作用于认证中心域名,用于 authorize 阶段识别登录态) +const ( + sessionCookieName = "yz_sid" + sessionCookieTTL = 7200 +) + +// grant_type 常量 +const ( + GrantAuthCode = "authorization_code" + GrantRefresh = "refresh_token" + ResponseTypeCode = "code" +) + +// AuthOidcController OIDC 标准端点 +type AuthOidcController struct { + beego.Controller +} + +func (c *AuthOidcController) serveJSON(data map[string]interface{}) { + c.Data["json"] = data + _ = c.ServeJSON() +} + +func (c *AuthOidcController) fail(status int, msg string) { + c.Ctx.Output.SetStatus(status) + c.serveJSON(map[string]interface{}{"error": msg}) +} + +// Discovery OIDC 发现文档 +// GET /auth/.well-known/openid-configuration +func (c *AuthOidcController) Discovery() { + issuer := jwtutil.Issuer() + if issuer == "" { + issuer = fmt.Sprintf("https://%s/auth", c.Ctx.Request.Host) + } + c.Data["json"] = map[string]interface{}{ + "issuer": issuer, + "authorization_endpoint": issuer + "/authorize", + "token_endpoint": issuer + "/token", + "userinfo_endpoint": issuer + "/userinfo", + "introspection_endpoint": issuer + "/introspect", + "revocation_endpoint": issuer + "/revoke", + "end_session_endpoint": issuer + "/logout", + "jwks_uri": issuer + "/jwks.json", + "response_types_supported": []string{"code"}, + "grant_types_supported": []string{GrantAuthCode, GrantRefresh}, + "subject_types_supported": []string{"public"}, + "id_token_signing_alg_values_supported": []string{jwtutil.AlgRS256, jwtutil.AlgHS256}, + "code_challenge_methods_supported": []string{"S256"}, + "scopes_supported": []string{"openid", "profile", "tenant"}, + } + _ = c.ServeJSON() +} + +// JWKS 公钥集合(各应用本地验签用) +// GET /auth/jwks.json +func (c *AuthOidcController) JWKS() { + keys := jwtutil.JWKS() + if keys == nil { + keys = []jwtutil.JWK{} + } + c.Data["json"] = map[string]interface{}{"keys": keys} + _ = c.ServeJSON() +} + +// Authorize 授权端点 +// GET /auth/authorize?client_id=&redirect_uri=&response_type=code&scope=&state=&code_challenge=&code_challenge_method=S256 +// +// 未登录时重定向到统一登录页,登录后再回到本端点完成授权。 +func (c *AuthOidcController) Authorize() { + clientID := strings.TrimSpace(c.GetString("client_id")) + redirectURI := strings.TrimSpace(c.GetString("redirect_uri")) + responseType := strings.TrimSpace(c.GetString("response_type")) + state := c.GetString("state") + scope := c.GetString("scope") + if scope == "" { + scope = "openid" + } + challenge := c.GetString("code_challenge") + challengeMethod := c.GetString("code_challenge_method") + if challengeMethod == "" { + challengeMethod = "S256" + } + nonce := c.GetString("nonce") + + if clientID == "" || redirectURI == "" { + c.Ctx.Output.SetStatus(400) + _, _ = c.Ctx.ResponseWriter.Write([]byte("缺少 client_id 或 redirect_uri")) + return + } + if responseType != ResponseTypeCode { + c.Ctx.Output.SetStatus(400) + _, _ = c.Ctx.ResponseWriter.Write([]byte("仅支持 response_type=code")) + return + } + + client, err := findClient(clientID) + if err != nil { + c.Ctx.Output.SetStatus(400) + _, _ = c.Ctx.ResponseWriter.Write([]byte("client_id 无效")) + return + } + if !allowRedirect(client, redirectURI) { + c.Ctx.Output.SetStatus(400) + _, _ = c.Ctx.ResponseWriter.Write([]byte("redirect_uri 未登记")) + return + } + + // 登录态:Cookie 优先(浏览器跳转),其次 Authorization(服务端调用) + sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName)) + if sid == "" { + if claims := claimsFromHeader(c); claims != nil { + sid = claims.Sid + } + } + if sid == "" { + // 未登录 → 去登录页,登录后带着参数回来 + back := fmt.Sprintf("%s?%s", authorizePath(c), c.Ctx.Request.URL.RawQuery) + target := fmt.Sprintf("/auth/login?redirect=%s&client_id=%s", + base64.RawURLEncoding.EncodeToString([]byte(back)), clientID) + c.Redirect(target, 302) + return + } + + session, err := authsvc.GetSession(sid) + if err != nil { + clearSessionCookie(c) + back := fmt.Sprintf("%s?%s", authorizePath(c), c.Ctx.Request.URL.RawQuery) + target := fmt.Sprintf("/auth/login?redirect=%s&client_id=%s", + base64.RawURLEncoding.EncodeToString([]byte(back)), clientID) + c.Redirect(target, 302) + return + } + + // 已登录但未选择企业:跳登录页的企业选择步骤 + if session.Tid == authsvc.PendingTenantID { + back := fmt.Sprintf("%s?%s", authorizePath(c), c.Ctx.Request.URL.RawQuery) + target := fmt.Sprintf("/auth/login?step=tenant&redirect=%s&client_id=%s", + base64.RawURLEncoding.EncodeToString([]byte(back)), clientID) + c.Redirect(target, 302) + return + } + + code, err := issueAuthCode(client.ClientID, session.IdentityID, session.Tid, redirectURI, challenge, challengeMethod, scope, nonce) + if err != nil { + c.Ctx.Output.SetStatus(500) + _, _ = c.Ctx.ResponseWriter.Write([]byte("签发授权码失败")) + return + } + + sep := "?" + if strings.Contains(redirectURI, "?") { + sep = "&" + } + c.Redirect(fmt.Sprintf("%s%scode=%s&state=%s", redirectURI, sep, code, state), 302) +} + +// Token 令牌端点 +// POST /auth/token +// - grant_type=authorization_code:code + code_verifier(PKCE)+ client_id +// - grant_type=refresh_token:refresh_token + client_id +func (c *AuthOidcController) Token() { + grantType := strings.TrimSpace(c.GetString("grant_type")) + clientID := strings.TrimSpace(c.GetString("client_id")) + if clientID == "" { + // 兼容表单/JSON 以外的取参方式 + clientID = strings.TrimSpace(c.Ctx.Request.FormValue("client_id")) + } + if grantType == "" { + grantType = strings.TrimSpace(c.Ctx.Request.FormValue("grant_type")) + } + + switch grantType { + case GrantAuthCode: + code := strings.TrimSpace(c.GetString("code")) + verifier := strings.TrimSpace(c.GetString("code_verifier")) + if code == "" || verifier == "" || clientID == "" { + c.fail(400, "invalid_request") + return + } + pair, claims, err := exchangeCode(code, verifier, clientID) + if err != nil { + c.fail(400, err.Error()) + return + } + idToken, _ := buildIDToken(claims, clientID) + c.serveJSON(map[string]interface{}{ + "access_token": pair.AccessToken, + "refresh_token": pair.RefreshToken, + "token_type": pair.TokenType, + "expires_in": pair.ExpiresIn, + "id_token": idToken, + "sid": pair.Sid, + }) + case GrantRefresh: + refresh := strings.TrimSpace(c.GetString("refresh_token")) + if refresh == "" || clientID == "" { + c.fail(400, "invalid_request") + return + } + pair, err := authsvc.RefreshTokens(refresh, clientID) + if err != nil { + c.fail(400, err.Error()) + return + } + c.serveJSON(map[string]interface{}{ + "access_token": pair.AccessToken, + "refresh_token": pair.RefreshToken, + "token_type": pair.TokenType, + "expires_in": pair.ExpiresIn, + "sid": pair.Sid, + }) + default: + c.fail(400, "unsupported_grant_type") + } +} + +// UserInfo 用户信息端点,需 Bearer Token +// GET /auth/userinfo +func (c *AuthOidcController) UserInfo() { + claims := claimsFromHeader(c) + if claims == nil { + c.Ctx.Output.SetStatus(401) + c.serveJSON(map[string]interface{}{"error": "invalid_token"}) + return + } + if claims.UserID <= 0 { + c.fail(401, "invalid_token") + return + } + var identity models.AuthIdentity + if err := models.Orm.QueryTable(new(models.AuthIdentity)). + Filter("id", claims.UserID).One(&identity); err != nil { + c.fail(401, "invalid_token") + return + } + profile, err := authsvc.BuildProfile(&identity) + if err != nil { + c.fail(500, "server_error") + return + } + c.serveJSON(map[string]interface{}{ + "sub": fmt.Sprintf("%d", identity.ID), + "union_id": identity.UnionID, + "tid": claims.TenantId, + "nickname": profile.Nickname, + "mobile": profile.Mobile, + "email": profile.Email, + "avatar": profile.Avatar, + "tenants": profile.Tenants, + "client_id": claims.ClientID, + }) +} + +// Introspect 令牌校验,供资源服务(各业务后端)调用 +// POST /auth/token 之外的独立端点;比本地 JWKS 验签更实时(可查黑名单) +func (c *AuthOidcController) Introspect() { + token := strings.TrimSpace(c.GetString("token")) + if token == "" { + token = strings.TrimSpace(c.Ctx.Request.FormValue("token")) + } + if token == "" { + c.fail(400, "invalid_request") + return + } + claims, err := jwtutil.ParseToken(token) + if err != nil { + c.serveJSON(map[string]interface{}{"active": false}) + return + } + if authsvc.IsBlacklisted(claims.ID) { + c.serveJSON(map[string]interface{}{"active": false}) + return + } + c.serveJSON(map[string]interface{}{ + "active": true, + "sub": claims.Subject, + "user_id": claims.UserID, + "tid": claims.TenantId, + "client_id": claims.ClientID, + "sid": claims.Sid, + "scope": claims.Scope, + "amr": claims.Amr, + "exp": claims.ExpiresAt.Unix(), + }) +} + +// Revoke 吊销令牌(登出/踢下线) +// POST /auth/revoke +func (c *AuthOidcController) Revoke() { + token := strings.TrimSpace(c.GetString("token")) + refresh := strings.TrimSpace(c.GetString("refresh_token")) + if token == "" { + token = strings.TrimSpace(c.Ctx.Request.FormValue("token")) + } + if token == "" && refresh == "" { + c.fail(400, "invalid_request") + return + } + _ = authsvc.RevokeTokenPair(refresh, token, models.RevokeReasonLogout) + c.serveJSON(map[string]interface{}{"code": 200, "msg": "已吊销"}) +} + +// ---------------------------------------------------------------- 内部工具 + +func authorizePath(c *AuthOidcController) string { + return "/auth/authorize" +} + +// clearSessionCookie 清除认证中心会话 Cookie +func clearSessionCookie(c *AuthOidcController) { + c.Ctx.Output.Header("Set-Cookie", + fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax", sessionCookieName)) +} + +// setSessionCookie 写入认证中心会话 Cookie +func setSessionCookie(c *AuthOidcController, sid string) { + c.Ctx.Output.Header("Set-Cookie", + fmt.Sprintf("%s=%s; Path=/; Max-Age=%d; HttpOnly; Secure; SameSite=Lax", + sessionCookieName, sid, sessionCookieTTL)) +} + +func bearerToken(c *AuthOidcController) string { + header := c.Ctx.Request.Header.Get("Authorization") + if header == "" { + return "" + } + parts := strings.SplitN(header, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return "" + } + return strings.TrimSpace(parts[1]) +} + +func claimsFromHeader(c *AuthOidcController) *jwtutil.Claims { + token := bearerToken(c) + if token == "" { + return nil + } + claims, err := jwtutil.ParseToken(token) + if err != nil { + return nil + } + return claims +} + +// findClient 查询启用状态的应用 +func findClient(clientID string) (*models.AuthClient, error) { + var client models.AuthClient + err := models.Orm.QueryTable(new(models.AuthClient)). + Filter("client_id", clientID). + Filter("status", 1). + One(&client) + if err != nil { + return nil, err + } + return &client, nil +} + +// allowRedirect 校验回跳地址是否在白名单内(精确匹配,防钓鱼) +func allowRedirect(client *models.AuthClient, uri string) bool { + if client.RedirectURIs == nil || *client.RedirectURIs == "" { + return false + } + var list []string + if err := json.Unmarshal([]byte(*client.RedirectURIs), &list); err != nil { + return false + } + for _, item := range list { + if strings.TrimSpace(item) == strings.TrimSpace(uri) { + return true + } + } + return false +} + +// issueAuthCode 生成一次性授权码(明文返回,库中只存哈希) +func issueAuthCode(clientID string, identityID, tid uint64, redirectURI, challenge, method, scope, nonce string) (string, error) { + plain, err := randomString(32) + if err != nil { + return "", err + } + sum := sha256.Sum256([]byte(plain)) + code := &models.AuthCode{ + CodeHash: fmt.Sprintf("%x", sum[:]), + ClientID: clientID, + IdentityID: identityID, + Tid: tid, + RedirectURI: redirectURI, + CodeChallenge: challenge, + CodeChallengeMethod: method, + ExpiresAt: time.Now().Add(60 * time.Second), + } + if scope != "" { + code.Scope = &scope + } + if nonce != "" { + code.Nonce = &nonce + } + if _, err := models.Orm.Insert(code); err != nil { + return "", err + } + return plain, nil +} + +// exchangeCode 用授权码换令牌(校验 PKCE、一次性、有效期) +func exchangeCode(code, verifier, clientID string) (*authsvc.TokenPair, *jwtutil.Claims, error) { + sum := sha256.Sum256([]byte(code)) + var stored models.AuthCode + if err := models.Orm.QueryTable(new(models.AuthCode)). + Filter("code_hash", fmt.Sprintf("%x", sum[:])).One(&stored); err != nil { + return nil, nil, fmt.Errorf("invalid_grant") + } + if stored.Used != 0 || stored.ExpiresAt.Before(time.Now()) { + return nil, nil, fmt.Errorf("invalid_grant") + } + if stored.ClientID != clientID { + return nil, nil, fmt.Errorf("invalid_client") + } + if !verifyPKCE(verifier, stored.CodeChallenge, stored.CodeChallengeMethod) { + return nil, nil, fmt.Errorf("invalid_grant") + } + // 一次性:立即标记已用 + _, _ = models.Orm.QueryTable(new(models.AuthCode)). + Filter("code_hash", stored.CodeHash). + Update(map[string]interface{}{"used": 1}) + + session, err := authsvc.CreateSession(authsvc.SessionInfo{ + IdentityID: stored.IdentityID, + Tid: stored.Tid, + ClientID: clientID, + LoginType: authsvc.LoginTypePassword, + Amr: authsvc.AmrPwd, + }) + if err != nil { + return nil, nil, err + } + + client, err := findClient(clientID) + accessTTL := 1800 + refreshTTL := 2592000 + if err == nil { + accessTTL = client.AccessTTL + refreshTTL = client.RefreshTTL + } + + pair, err := authsvc.IssueTokens(authsvc.TokenIssue{ + IdentityID: stored.IdentityID, + Tid: stored.Tid, + ClientID: clientID, + Sid: session.Sid, + UserType: "tenant", + Amr: authsvc.AmrPwd, + AccessTTL: accessTTL, + RefreshTTL: refreshTTL, + }) + if err != nil { + return nil, nil, err + } + + claims, err := jwtutil.ParseToken(pair.AccessToken) + if err != nil { + return nil, nil, err + } + return pair, claims, nil +} + +// verifyPKCE 校验 PKCE(S256 或 plain) +func verifyPKCE(verifier, challenge, method string) bool { + if challenge == "" { + return false + } + if method == "S256" { + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) == challenge + } + return verifier == challenge +} + +// buildIDToken 生成 OIDC ID Token +func buildIDToken(claims *jwtutil.Claims, clientID string) (string, error) { + return jwtutil.SignToken(jwtutil.TokenOptions{ + Alg: jwtutil.AlgRS256, + UserID: claims.UserID, + TenantID: claims.TenantId, + UserType: claims.UserType, + ClientID: clientID, + Sid: claims.Sid, + Subject: claims.Subject, + Audience: []string{clientID}, + Amr: claims.Amr, + TTL: time.Hour, + }) +} + +// randomString 生成随机串 +func randomString(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} diff --git a/go/controllers/backend_admin_user.go b/go/controllers/backend_admin_user.go index ac518e1..2c5accd 100644 --- a/go/controllers/backend_admin_user.go +++ b/go/controllers/backend_admin_user.go @@ -1,647 +1,668 @@ -package controllers - -import ( - "encoding/json" - "fmt" - "io" - "strconv" - "strings" - - "server/models" - "server/pkg/passwordutil" - "server/services" - - "github.com/beego/beego/v2/client/orm" - beego "github.com/beego/beego/v2/server/web" -) - -type BackendAdminUserController struct { - beego.Controller -} - -type backendUserInfoDTO struct { - ID uint64 `json:"id"` - Tid uint64 `json:"tid"` - Uid uint64 `json:"uid"` - GroupID uint64 `json:"group_id"` - Account *string `json:"account"` - Name *string `json:"name"` - Phone *string `json:"phone"` - Email *string `json:"email"` - Sex uint8 `json:"sex"` - Birth *string `json:"birth"` - IsDefault int8 `json:"is_default"` - Status int8 `json:"status"` - Remark *string `json:"remark"` - CreateTime string `json:"create_time"` - UpdateTime *string `json:"update_time"` - TenantName string `json:"tenant_name"` - TenantCode string `json:"tenant_code"` -} - -type backendTenantUserPayload struct { - Tid uint64 `json:"tid"` - Uid uint64 `json:"uid"` - GroupID *uint64 `json:"group_id"` - Account *string `json:"account"` - Name *string `json:"name"` - Phone *string `json:"phone"` - Email *string `json:"email"` - Sex *uint8 `json:"sex"` - Birth *string `json:"birth"` - Password *string `json:"password"` - IsDefault *int8 `json:"is_default"` - Status *int8 `json:"status"` - Remark *string `json:"remark"` -} - -type backendChangePasswordPayload struct { - ID uint64 `json:"id"` - Password string `json:"password"` -} - -func formatBackendBirth(birth *string) *string { - if birth == nil { - return nil - } - - s := strings.TrimSpace(*birth) - if s == "" { - return nil - } - - if len(s) >= 10 { - date := s[:10] - return &date - } - - return &s -} - -func toBackendUserInfoDTO(u models.SystemTenantUser) backendUserInfoDTO { - var updateTime *string - if u.UpdateTime != nil { - s := u.UpdateTime.Format("2006-01-02 15:04:05") - updateTime = &s - } - - tenantName := "未知租户" - tenantCode := "" - - tenant, err := services.GetTenantByID(u.Tid) - if err == nil && tenant != nil { - tenantName = tenant.TenantName - tenantCode = tenant.TenantCode - } - - return backendUserInfoDTO{ - ID: u.ID, - Tid: u.Tid, - Uid: u.Uid, - GroupID: u.GroupID, - Account: u.Account, - Name: u.Name, - Phone: u.Phone, - Email: u.Email, - Sex: u.Sex, - Birth: formatBackendBirth(u.Birth), - IsDefault: u.IsDefault, - Status: u.Status, - Remark: u.Remark, - CreateTime: u.CreateTime.Format("2006-01-02 15:04:05"), - UpdateTime: updateTime, - TenantName: tenantName, - TenantCode: tenantCode, - } -} - -func (c *BackendAdminUserController) getJWTUidTid() (uint64, uint64) { - var uid uint64 - var tid uint64 - - data := c.Ctx.Input.Data() - - if jwtUid := data["uid"]; jwtUid != nil { - if v, ok := jwtUid.(uint64); ok { - uid = v - } - } - - if jwtTid := data["tid"]; jwtTid != nil { - if v, ok := jwtTid.(uint64); ok { - tid = v - } - } - - return uid, tid -} - -func (c *BackendAdminUserController) parseTenantUserPayload() (backendTenantUserPayload, bool) { - var p backendTenantUserPayload - - raw, _ := io.ReadAll(c.Ctx.Request.Body) - if err := json.Unmarshal(raw, &p); err != nil { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": "参数错误"} - _ = c.ServeJSON() - return backendTenantUserPayload{}, false - } - - return p, true -} - -func findBackendTenantUser(idOrUid uint64, jwtTid uint64) (*models.SystemTenantUser, error) { - var row models.SystemTenantUser - - qs := models.Orm.QueryTable(new(models.SystemTenantUser)). - Filter("delete_time__isnull", true) - - if jwtTid > 0 { - qs = qs.Filter("tid", jwtTid) - } - - err := qs.Filter("id", idOrUid).One(&row) - if err == nil { - return &row, nil - } - - qs = models.Orm.QueryTable(new(models.SystemTenantUser)). - Filter("delete_time__isnull", true) - - if jwtTid > 0 { - qs = qs.Filter("tid", jwtTid) - } - - err = qs.Filter("uid", idOrUid).One(&row) - if err != nil { - return nil, err - } - - return &row, nil -} - -// GetAllUsers 获取当前租户后台用户列表 -// GET /backend/getAllUsers -func (c *BackendAdminUserController) GetAllUsers() { - _, jwtTid := c.getJWTUidTid() - - keyword := strings.TrimSpace(c.GetString("keyword")) - tid, _ := c.GetUint64("tid") - - if jwtTid > 0 { - tid = jwtTid - } - - cond := orm.NewCondition().And("delete_time__isnull", true) - - if tid > 0 { - cond = cond.And("tid", tid) - } - - if keyword != "" { - kwCond := orm.NewCondition(). - Or("name__icontains", keyword). - Or("phone__icontains", keyword). - Or("email__icontains", keyword). - Or("account__icontains", keyword) - cond = cond.AndCond(kwCond) - } - - var rows []models.SystemTenantUser - _, err := models.Orm.QueryTable(new(models.SystemTenantUser)). - SetCond(cond). - OrderBy("-is_default", "-id"). - All(&rows) - if err != nil { - c.Data["json"] = map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()} - _ = c.ServeJSON() - return - } - - list := make([]backendUserInfoDTO, 0, len(rows)) - for _, row := range rows { - list = append(list, toBackendUserInfoDTO(row)) - } - - c.Data["json"] = map[string]interface{}{ - "code": 200, - "msg": "success", - "data": map[string]interface{}{ - "list": list, - "total": len(list), - }, - } - _ = c.ServeJSON() -} - -// GetTenantUsers 获取指定租户后台用户 -// GET /backend/getTenantUsers/:tid -func (c *BackendAdminUserController) GetTenantUsers() { - tidStr := c.Ctx.Input.Param(":tid") - tid, _ := strconv.ParseUint(tidStr, 10, 64) - - _, jwtTid := c.getJWTUidTid() - if jwtTid > 0 { - tid = jwtTid - } - - if tid == 0 { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": "tid 不能为空"} - _ = c.ServeJSON() - return - } - - var rows []models.SystemTenantUser - _, err := models.Orm.QueryTable(new(models.SystemTenantUser)). - Filter("tid", tid). - Filter("delete_time__isnull", true). - OrderBy("-is_default", "-id"). - All(&rows) - - if err != nil { - c.Data["json"] = map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()} - _ = c.ServeJSON() - return - } - - list := make([]backendUserInfoDTO, 0, len(rows)) - for _, row := range rows { - list = append(list, toBackendUserInfoDTO(row)) - } - - c.Data["json"] = map[string]interface{}{ - "code": 200, - "msg": "success", - "data": map[string]interface{}{ - "list": list, - "total": len(list), - }, - } - _ = c.ServeJSON() -} - -// GetUserInfo 获取后台租户用户详情 -// GET /backend/getUserInfo/:id -func (c *BackendAdminUserController) GetUserInfo() { - jwtUid, jwtTid := c.getJWTUidTid() - - idStr := c.Ctx.Input.Param(":id") - id, _ := strconv.ParseUint(idStr, 10, 64) - - if id == 0 { - id = jwtUid - } - - if id == 0 { - c.Data["json"] = map[string]interface{}{"code": 401, "msg": "未登录或非法请求"} - _ = c.ServeJSON() - return - } - - u, err := findBackendTenantUser(id, jwtTid) - if err != nil || u == nil { - c.Data["json"] = map[string]interface{}{"code": 404, "msg": "后台用户信息不存在"} - _ = c.ServeJSON() - return - } - - c.Data["json"] = map[string]interface{}{ - "code": 200, - "msg": "success", - "data": toBackendUserInfoDTO(*u), - } - _ = c.ServeJSON() -} - -// AddUser 添加后台租户用户 -// POST /backend/addUser -func (c *BackendAdminUserController) AddUser() { - p, ok := c.parseTenantUserPayload() - if !ok { - return - } - - _, jwtTid := c.getJWTUidTid() - if jwtTid > 0 { - p.Tid = jwtTid - } - - if p.Tid == 0 { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": "tid 不能为空"} - _ = c.ServeJSON() - return - } - - if p.Account == nil || strings.TrimSpace(*p.Account) == "" { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": "account 不能为空"} - _ = c.ServeJSON() - return - } - - if p.Password == nil || strings.TrimSpace(*p.Password) == "" { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": "password 不能为空"} - _ = c.ServeJSON() - return - } - - account := strings.TrimSpace(*p.Account) - p.Account = &account - - hashed, err := passwordutil.Hash(strings.TrimSpace(*p.Password)) - if err != nil { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()} - _ = c.ServeJSON() - return - } - p.Password = &hashed - - if p.Uid == 0 { - uid, err := generateTenantUID(p.Tid) - if err != nil { - c.Data["json"] = map[string]interface{}{"code": 500, "msg": "生成用户ID失败"} - _ = c.ServeJSON() - return - } - p.Uid = uid - } - - isDefault := int8(0) - if p.IsDefault != nil { - isDefault = *p.IsDefault - } - - status := int8(1) - if p.Status != nil { - status = *p.Status - } - - // 用户数配额校验:达到上限后需要平台端为租户增购用户数才能继续添加 - if check, err := services.CheckTenantUserQuota(p.Tid, p.Uid); err != nil { - c.Data["json"] = map[string]interface{}{"code": 500, "msg": "用户数校验失败: " + err.Error()} - _ = c.ServeJSON() - return - } else if !check.Allowed { - c.Data["json"] = map[string]interface{}{ - "code": 400, - "msg": fmt.Sprintf("当前租户用户数已达上限(%d/%d),请联系平台管理员增购用户数", check.Used, check.Quota), - } - _ = c.ServeJSON() - return - } - - id, err := services.BindTenantUser( - p.Tid, - p.Uid, - p.Account, - p.Name, - p.Phone, - p.Email, - p.Sex, - p.Birth, - p.Password, - isDefault, - status, - p.Remark, - ) - - if err != nil { - c.Data["json"] = map[string]interface{}{"code": 500, "msg": "添加失败: " + err.Error()} - _ = c.ServeJSON() - return - } - - if isDefault == 1 { - _ = services.SetDefaultTenant(p.Uid, p.Tid) - } - - // 关联角色(group_id):0 表示未分配,前端展示为"未分配/全权限" - if p.GroupID != nil && *p.GroupID > 0 { - _, _ = models.Orm.QueryTable(new(models.SystemTenantUser)). - Filter("id", id). - Update(map[string]interface{}{"group_id": *p.GroupID}) - } else if isDefault == 1 { - // 租户的第一个账号即租户管理员:自动绑定全局「租户管理员」角色(拥有 backend 全权限)。 - if adminRoleID, err := models.GetTenantAdminRole(); err == nil && adminRoleID > 0 { - _, _ = models.Orm.QueryTable(new(models.SystemTenantUser)). - Filter("id", id). - Update(map[string]interface{}{"group_id": adminRoleID}) - } - } - - c.Data["json"] = map[string]interface{}{ - "code": 200, - "msg": "success", - "data": map[string]interface{}{"id": id}, - } - _ = c.ServeJSON() -} - -// EditUser 编辑后台租户用户 -// POST /backend/editUser/:id -func (c *BackendAdminUserController) EditUser() { - idStr := c.Ctx.Input.Param(":id") - id, _ := strconv.ParseUint(idStr, 10, 64) - - if id == 0 { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": "id 不能为空"} - _ = c.ServeJSON() - return - } - - p, ok := c.parseTenantUserPayload() - if !ok { - return - } - - _, jwtTid := c.getJWTUidTid() - - row, err := findBackendTenantUser(id, jwtTid) - if err != nil || row == nil { - c.Data["json"] = map[string]interface{}{"code": 404, "msg": "用户不存在"} - _ = c.ServeJSON() - return - } - - update := map[string]interface{}{} - - if p.Tid > 0 && jwtTid == 0 { - update["tid"] = p.Tid - } - - if p.Uid > 0 { - update["uid"] = p.Uid - } - - if p.Account != nil { - account := strings.TrimSpace(*p.Account) - if account != "" { - update["account"] = account - } - } - - if p.Name != nil { - update["name"] = *p.Name - } - - if p.Phone != nil { - update["phone"] = *p.Phone - } - - if p.Email != nil { - update["email"] = *p.Email - } - - if p.Sex != nil { - update["sex"] = *p.Sex - } - - if p.Birth != nil { - birth := strings.TrimSpace(*p.Birth) - if birth == "" { - update["birth"] = nil - } else { - update["birth"] = birth - } - } - - if p.Password != nil && strings.TrimSpace(*p.Password) != "" { - hashed, err := passwordutil.Hash(strings.TrimSpace(*p.Password)) - if err != nil { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()} - _ = c.ServeJSON() - return - } - update["password"] = hashed - } - - if p.IsDefault != nil { - update["is_default"] = *p.IsDefault - } - - if p.Status != nil { - update["status"] = *p.Status - } - - if p.Remark != nil { - update["remark"] = *p.Remark - } - - if p.GroupID != nil { - update["group_id"] = *p.GroupID - } - - if len(update) == 0 { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无更新字段"} - _ = c.ServeJSON() - return - } - - _, err = models.Orm.QueryTable(new(models.SystemTenantUser)). - Filter("id", row.ID). - Update(update) - - if err != nil { - c.Data["json"] = map[string]interface{}{"code": 500, "msg": "编辑失败: " + err.Error()} - _ = c.ServeJSON() - return - } - - if p.IsDefault != nil && *p.IsDefault == 1 { - _ = services.SetDefaultTenant(row.Uid, row.Tid) - } - - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} - _ = c.ServeJSON() -} - -// DeleteUser 删除后台租户用户 -// DELETE /backend/deleteUser/:id -// -// 用户管理里的账号属于租户层面的登录凭证(yz_system_tenant_user),与员工 -//(yz_backend_employee)、人事档案(yz_backend_employee_file)、通讯录 -//(yz_backend_erp_contact)是三份不同用途的数据:账号只用于登录系统,员工与档案属于企业数据。 -// 因此这里只解除登录绑定,不做任何级联操作——删除账号不会影响员工档案和通讯录记录。 -// 员工侧的离职/禁用走 deleteEmployee,档案侧的离职走 employeefile/delete。 -func (c *BackendAdminUserController) DeleteUser() { - idStr := c.Ctx.Input.Param(":id") - id, _ := strconv.ParseUint(idStr, 10, 64) - - if id == 0 { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": "id 不能为空"} - _ = c.ServeJSON() - return - } - - _, jwtTid := c.getJWTUidTid() - - row, err := findBackendTenantUser(id, jwtTid) - if err != nil || row == nil { - c.Data["json"] = map[string]interface{}{"code": 404, "msg": "用户不存在"} - _ = c.ServeJSON() - return - } - - if err := services.UnbindTenantUser(row.ID); err != nil { - c.Data["json"] = map[string]interface{}{"code": 500, "msg": "删除失败: " + err.Error()} - _ = c.ServeJSON() - return - } - - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} - _ = c.ServeJSON() -} - -// ChangePassword 修改后台租户用户密码 -// POST /backend/changePassword -func (c *BackendAdminUserController) ChangePassword() { - var p backendChangePasswordPayload - - raw, _ := io.ReadAll(c.Ctx.Request.Body) - if err := json.Unmarshal(raw, &p); err != nil { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": "参数错误"} - _ = c.ServeJSON() - return - } - - if p.ID == 0 { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": "id 不能为空"} - _ = c.ServeJSON() - return - } - - if strings.TrimSpace(p.Password) == "" { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": "password 不能为空"} - _ = c.ServeJSON() - return - } - - _, jwtTid := c.getJWTUidTid() - - row, err := findBackendTenantUser(p.ID, jwtTid) - if err != nil || row == nil { - c.Data["json"] = map[string]interface{}{"code": 404, "msg": "用户不存在"} - _ = c.ServeJSON() - return - } - - hashed, err := passwordutil.Hash(strings.TrimSpace(p.Password)) - if err != nil { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()} - _ = c.ServeJSON() - return - } - - _, err = models.Orm.QueryTable(new(models.SystemTenantUser)). - Filter("id", row.ID). - Update(map[string]interface{}{ - "password": hashed, - }) - - if err != nil { - c.Data["json"] = map[string]interface{}{"code": 500, "msg": "修改失败: " + err.Error()} - _ = c.ServeJSON() - return - } - - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "修改成功"} - _ = c.ServeJSON() -} +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + + "server/models" + "server/pkg/passwordutil" + "server/services" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +type BackendAdminUserController struct { + beego.Controller +} + +type backendUserInfoDTO struct { + ID uint64 `json:"id"` + Tid uint64 `json:"tid"` + Uid uint64 `json:"uid"` + GroupID uint64 `json:"group_id"` + Account *string `json:"account"` + Name *string `json:"name"` + Phone *string `json:"phone"` + Email *string `json:"email"` + Sex uint8 `json:"sex"` + Birth *string `json:"birth"` + IsDefault int8 `json:"is_default"` + Status int8 `json:"status"` + Remark *string `json:"remark"` + CreateTime string `json:"create_time"` + UpdateTime *string `json:"update_time"` + TenantName string `json:"tenant_name"` + TenantCode string `json:"tenant_code"` +} + +type backendTenantUserPayload struct { + Tid uint64 `json:"tid"` + Uid uint64 `json:"uid"` + GroupID *uint64 `json:"group_id"` + Account *string `json:"account"` + Name *string `json:"name"` + Phone *string `json:"phone"` + Email *string `json:"email"` + Sex *uint8 `json:"sex"` + Birth *string `json:"birth"` + Password *string `json:"password"` + IsDefault *int8 `json:"is_default"` + Status *int8 `json:"status"` + Remark *string `json:"remark"` +} + +type backendChangePasswordPayload struct { + ID uint64 `json:"id"` + Password string `json:"password"` +} + +func formatBackendBirth(birth *string) *string { + if birth == nil { + return nil + } + + s := strings.TrimSpace(*birth) + if s == "" { + return nil + } + + if len(s) >= 10 { + date := s[:10] + return &date + } + + return &s +} + +func toBackendUserInfoDTO(u models.SystemTenantUser) backendUserInfoDTO { + var updateTime *string + if u.UpdateTime != nil { + s := u.UpdateTime.Format("2006-01-02 15:04:05") + updateTime = &s + } + + tenantName := "未知租户" + tenantCode := "" + + tenant, err := services.GetTenantByID(u.Tid) + if err == nil && tenant != nil { + tenantName = tenant.TenantName + tenantCode = tenant.TenantCode + } + + return backendUserInfoDTO{ + ID: u.ID, + Tid: u.Tid, + Uid: u.Uid, + GroupID: u.GroupID, + Account: u.Account, + Name: u.Name, + Phone: u.Phone, + Email: u.Email, + Sex: u.Sex, + Birth: formatBackendBirth(u.Birth), + IsDefault: u.IsDefault, + Status: u.Status, + Remark: u.Remark, + CreateTime: u.CreateTime.Format("2006-01-02 15:04:05"), + UpdateTime: updateTime, + TenantName: tenantName, + TenantCode: tenantCode, + } +} + +func (c *BackendAdminUserController) getJWTUidTid() (uint64, uint64) { + var uid uint64 + var tid uint64 + + data := c.Ctx.Input.Data() + + if jwtUid := data["uid"]; jwtUid != nil { + if v, ok := jwtUid.(uint64); ok { + uid = v + } + } + + if jwtTid := data["tid"]; jwtTid != nil { + if v, ok := jwtTid.(uint64); ok { + tid = v + } + } + + return uid, tid +} + +func (c *BackendAdminUserController) parseTenantUserPayload() (backendTenantUserPayload, bool) { + var p backendTenantUserPayload + + raw, _ := io.ReadAll(c.Ctx.Request.Body) + if err := json.Unmarshal(raw, &p); err != nil { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "参数错误"} + _ = c.ServeJSON() + return backendTenantUserPayload{}, false + } + + return p, true +} + +func findBackendTenantUser(idOrUid uint64, jwtTid uint64) (*models.SystemTenantUser, error) { + var row models.SystemTenantUser + + qs := models.Orm.QueryTable(new(models.SystemTenantUser)). + Filter("delete_time__isnull", true) + + if jwtTid > 0 { + qs = qs.Filter("tid", jwtTid) + } + + err := qs.Filter("id", idOrUid).One(&row) + if err == nil { + return &row, nil + } + + qs = models.Orm.QueryTable(new(models.SystemTenantUser)). + Filter("delete_time__isnull", true) + + if jwtTid > 0 { + qs = qs.Filter("tid", jwtTid) + } + + err = qs.Filter("uid", idOrUid).One(&row) + if err != nil { + return nil, err + } + + return &row, nil +} + +// GetAllUsers 获取当前租户后台用户列表 +// GET /backend/getAllUsers +func (c *BackendAdminUserController) GetAllUsers() { + _, jwtTid := c.getJWTUidTid() + + keyword := strings.TrimSpace(c.GetString("keyword")) + tid, _ := c.GetUint64("tid") + + if jwtTid > 0 { + tid = jwtTid + } + + cond := orm.NewCondition().And("delete_time__isnull", true) + + if tid > 0 { + cond = cond.And("tid", tid) + } + + if keyword != "" { + kwCond := orm.NewCondition(). + Or("name__icontains", keyword). + Or("phone__icontains", keyword). + Or("email__icontains", keyword). + Or("account__icontains", keyword) + cond = cond.AndCond(kwCond) + } + + var rows []models.SystemTenantUser + _, err := models.Orm.QueryTable(new(models.SystemTenantUser)). + SetCond(cond). + OrderBy("-is_default", "-id"). + All(&rows) + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()} + _ = c.ServeJSON() + return + } + + list := make([]backendUserInfoDTO, 0, len(rows)) + for _, row := range rows { + list = append(list, toBackendUserInfoDTO(row)) + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{ + "list": list, + "total": len(list), + }, + } + _ = c.ServeJSON() +} + +// GetTenantUsers 获取指定租户后台用户 +// GET /backend/getTenantUsers/:tid +func (c *BackendAdminUserController) GetTenantUsers() { + tidStr := c.Ctx.Input.Param(":tid") + tid, _ := strconv.ParseUint(tidStr, 10, 64) + + _, jwtTid := c.getJWTUidTid() + if jwtTid > 0 { + tid = jwtTid + } + + if tid == 0 { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "tid 不能为空"} + _ = c.ServeJSON() + return + } + + var rows []models.SystemTenantUser + _, err := models.Orm.QueryTable(new(models.SystemTenantUser)). + Filter("tid", tid). + Filter("delete_time__isnull", true). + OrderBy("-is_default", "-id"). + All(&rows) + + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()} + _ = c.ServeJSON() + return + } + + list := make([]backendUserInfoDTO, 0, len(rows)) + for _, row := range rows { + list = append(list, toBackendUserInfoDTO(row)) + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{ + "list": list, + "total": len(list), + }, + } + _ = c.ServeJSON() +} + +// GetUserInfo 获取后台租户用户详情 +// GET /backend/getUserInfo/:id +func (c *BackendAdminUserController) GetUserInfo() { + jwtUid, jwtTid := c.getJWTUidTid() + + idStr := c.Ctx.Input.Param(":id") + id, _ := strconv.ParseUint(idStr, 10, 64) + + if id == 0 { + id = jwtUid + } + + if id == 0 { + c.Data["json"] = map[string]interface{}{"code": 401, "msg": "未登录或非法请求"} + _ = c.ServeJSON() + return + } + + u, err := findBackendTenantUser(id, jwtTid) + if err != nil || u == nil { + c.Data["json"] = map[string]interface{}{"code": 404, "msg": "后台用户信息不存在"} + _ = c.ServeJSON() + return + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": toBackendUserInfoDTO(*u), + } + _ = c.ServeJSON() +} + +// AddUser 添加后台租户用户 +// POST /backend/addUser +func (c *BackendAdminUserController) AddUser() { + p, ok := c.parseTenantUserPayload() + if !ok { + return + } + + _, jwtTid := c.getJWTUidTid() + if jwtTid > 0 { + p.Tid = jwtTid + } + + if p.Tid == 0 { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "tid 不能为空"} + _ = c.ServeJSON() + return + } + + if p.Account == nil || strings.TrimSpace(*p.Account) == "" { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "account 不能为空"} + _ = c.ServeJSON() + return + } + + if p.Password == nil || strings.TrimSpace(*p.Password) == "" { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "password 不能为空"} + _ = c.ServeJSON() + return + } + + account := strings.TrimSpace(*p.Account) + p.Account = &account + + hashed, err := passwordutil.Hash(strings.TrimSpace(*p.Password)) + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()} + _ = c.ServeJSON() + return + } + p.Password = &hashed + + if p.Uid == 0 { + uid, err := generateTenantUID(p.Tid) + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "生成用户ID失败"} + _ = c.ServeJSON() + return + } + p.Uid = uid + } + + isDefault := int8(0) + if p.IsDefault != nil { + isDefault = *p.IsDefault + } + + status := int8(1) + if p.Status != nil { + status = *p.Status + } + + // 用户数配额校验:达到上限后需要平台端为租户增购用户数才能继续添加 + if check, err := services.CheckTenantUserQuota(p.Tid, p.Uid); err != nil { + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "用户数校验失败: " + err.Error()} + _ = c.ServeJSON() + return + } else if !check.Allowed { + c.Data["json"] = map[string]interface{}{ + "code": 400, + "msg": fmt.Sprintf("当前租户用户数已达上限(%d/%d),请联系平台管理员增购用户数", check.Used, check.Quota), + } + _ = c.ServeJSON() + return + } + + id, err := services.BindTenantUser( + p.Tid, + p.Uid, + p.Account, + p.Name, + p.Phone, + p.Email, + p.Sex, + p.Birth, + p.Password, + isDefault, + status, + p.Remark, + ) + + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "添加失败: " + err.Error()} + _ = c.ServeJSON() + return + } + + if isDefault == 1 { + _ = services.SetDefaultTenant(p.Uid, p.Tid) + } + + // 关联角色(group_id):0 表示未分配,前端展示为"未分配/全权限" + if p.GroupID != nil && *p.GroupID > 0 { + _, _ = models.Orm.QueryTable(new(models.SystemTenantUser)). + Filter("id", id). + Update(map[string]interface{}{"group_id": *p.GroupID}) + } else if isDefault == 1 { + // 租户的第一个账号即租户管理员:自动绑定全局「租户管理员」角色(拥有 backend 全权限)。 + if adminRoleID, err := models.GetTenantAdminRole(); err == nil && adminRoleID > 0 { + _, _ = models.Orm.QueryTable(new(models.SystemTenantUser)). + Filter("id", id). + Update(map[string]interface{}{"group_id": adminRoleID}) + } + } + + // 双写:同步到统一认证中心(读回最新记录,确保 group_id 已落库) + var created models.SystemTenantUser + if e := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&created); e == nil { + syncTenantUserToAuth(&created) + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{"id": id}, + } + _ = c.ServeJSON() +} + +// EditUser 编辑后台租户用户 +// POST /backend/editUser/:id +func (c *BackendAdminUserController) EditUser() { + idStr := c.Ctx.Input.Param(":id") + id, _ := strconv.ParseUint(idStr, 10, 64) + + if id == 0 { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "id 不能为空"} + _ = c.ServeJSON() + return + } + + p, ok := c.parseTenantUserPayload() + if !ok { + return + } + + _, jwtTid := c.getJWTUidTid() + + row, err := findBackendTenantUser(id, jwtTid) + if err != nil || row == nil { + c.Data["json"] = map[string]interface{}{"code": 404, "msg": "用户不存在"} + _ = c.ServeJSON() + return + } + + update := map[string]interface{}{} + + if p.Tid > 0 && jwtTid == 0 { + update["tid"] = p.Tid + } + + if p.Uid > 0 { + update["uid"] = p.Uid + } + + if p.Account != nil { + account := strings.TrimSpace(*p.Account) + if account != "" { + update["account"] = account + } + } + + if p.Name != nil { + update["name"] = *p.Name + } + + if p.Phone != nil { + update["phone"] = *p.Phone + } + + if p.Email != nil { + update["email"] = *p.Email + } + + if p.Sex != nil { + update["sex"] = *p.Sex + } + + if p.Birth != nil { + birth := strings.TrimSpace(*p.Birth) + if birth == "" { + update["birth"] = nil + } else { + update["birth"] = birth + } + } + + if p.Password != nil && strings.TrimSpace(*p.Password) != "" { + hashed, err := passwordutil.Hash(strings.TrimSpace(*p.Password)) + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()} + _ = c.ServeJSON() + return + } + update["password"] = hashed + } + + if p.IsDefault != nil { + update["is_default"] = *p.IsDefault + } + + if p.Status != nil { + update["status"] = *p.Status + } + + if p.Remark != nil { + update["remark"] = *p.Remark + } + + if p.GroupID != nil { + update["group_id"] = *p.GroupID + } + + if len(update) == 0 { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无更新字段"} + _ = c.ServeJSON() + return + } + + _, err = models.Orm.QueryTable(new(models.SystemTenantUser)). + Filter("id", row.ID). + Update(update) + + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "编辑失败: " + err.Error()} + _ = c.ServeJSON() + return + } + + if p.IsDefault != nil && *p.IsDefault == 1 { + _ = services.SetDefaultTenant(row.Uid, row.Tid) + } + + // 双写:同步到统一认证中心 + var edited models.SystemTenantUser + if e := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", row.ID).One(&edited); e == nil { + syncTenantUserToAuth(&edited) + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() +} + +// DeleteUser 删除后台租户用户 +// DELETE /backend/deleteUser/:id +// +// 用户管理里的账号属于租户层面的登录凭证(yz_system_tenant_user),与员工 +//(yz_backend_employee)、人事档案(yz_backend_employee_file)、通讯录 +//(yz_backend_erp_contact)是三份不同用途的数据:账号只用于登录系统,员工与档案属于企业数据。 +// 因此这里只解除登录绑定,不做任何级联操作——删除账号不会影响员工档案和通讯录记录。 +// 员工侧的离职/禁用走 deleteEmployee,档案侧的离职走 employeefile/delete。 +func (c *BackendAdminUserController) DeleteUser() { + idStr := c.Ctx.Input.Param(":id") + id, _ := strconv.ParseUint(idStr, 10, 64) + + if id == 0 { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "id 不能为空"} + _ = c.ServeJSON() + return + } + + _, jwtTid := c.getJWTUidTid() + + row, err := findBackendTenantUser(id, jwtTid) + if err != nil || row == nil { + c.Data["json"] = map[string]interface{}{"code": 404, "msg": "用户不存在"} + _ = c.ServeJSON() + return + } + + if err := services.UnbindTenantUser(row.ID); err != nil { + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "删除失败: " + err.Error()} + _ = c.ServeJSON() + return + } + + // 双写:删除认证中心中的企业绑定 + removeTenantUserFromAuth(row) + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() +} + +// ChangePassword 修改后台租户用户密码 +// POST /backend/changePassword +func (c *BackendAdminUserController) ChangePassword() { + var p backendChangePasswordPayload + + raw, _ := io.ReadAll(c.Ctx.Request.Body) + if err := json.Unmarshal(raw, &p); err != nil { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "参数错误"} + _ = c.ServeJSON() + return + } + + if p.ID == 0 { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "id 不能为空"} + _ = c.ServeJSON() + return + } + + if strings.TrimSpace(p.Password) == "" { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "password 不能为空"} + _ = c.ServeJSON() + return + } + + _, jwtTid := c.getJWTUidTid() + + row, err := findBackendTenantUser(p.ID, jwtTid) + if err != nil || row == nil { + c.Data["json"] = map[string]interface{}{"code": 404, "msg": "用户不存在"} + _ = c.ServeJSON() + return + } + + hashed, err := passwordutil.Hash(strings.TrimSpace(p.Password)) + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()} + _ = c.ServeJSON() + return + } + + _, err = models.Orm.QueryTable(new(models.SystemTenantUser)). + Filter("id", row.ID). + Update(map[string]interface{}{ + "password": hashed, + }) + + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "修改失败: " + err.Error()} + _ = c.ServeJSON() + return + } + + // 双写:同步新密码到统一认证中心 + var updated models.SystemTenantUser + if e := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", row.ID).One(&updated); e == nil { + syncTenantUserToAuth(&updated) + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "修改成功"} + _ = c.ServeJSON() +} diff --git a/go/controllers/platform_tenant_user.go b/go/controllers/platform_tenant_user.go index 480583e..5b35cbb 100644 --- a/go/controllers/platform_tenant_user.go +++ b/go/controllers/platform_tenant_user.go @@ -1,362 +1,436 @@ -package controllers - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "math/rand" - "strconv" - "strings" - "time" - - "server/models" - "server/pkg/passwordutil" - "server/services" - - "github.com/beego/beego/v2/client/orm" - beego "github.com/beego/beego/v2/server/web" -) - -// PlatformTenantUserController 平台租户用户绑定管理 -type PlatformTenantUserController struct { - beego.Controller -} - -type tenantUserPayload struct { - Tid uint64 `json:"tid"` - Uid uint64 `json:"uid"` - Account *string `json:"account"` - Name *string `json:"name"` - Phone *string `json:"phone"` - Email *string `json:"email"` - Password *string `json:"password"` - IsDefault *int8 `json:"is_default"` - Status *int8 `json:"status"` - OrgID *uint64 `json:"org_id"` - Remark *string `json:"remark"` -} - -// GetTenantUserList 获取绑定列表(支持按 tid / uid 过滤,keyword 对姓名/手机/邮箱/账号模糊匹配) -// GET /platform/tenantUser/list?tid=1&uid=2&keyword=xxx -func (c *PlatformTenantUserController) GetTenantUserList() { - tid, _ := c.GetUint64("tid") - uid, _ := c.GetUint64("uid") - keyword := strings.TrimSpace(c.GetString("keyword")) - - qs := models.Orm.QueryTable(new(models.SystemTenantUser)) - - var cond *orm.Condition - needCond := false - if tid > 0 { - if cond == nil { - cond = orm.NewCondition() - } - cond = cond.And("tid", tid) - needCond = true - } - if uid > 0 { - if cond == nil { - cond = orm.NewCondition() - } - cond = cond.And("uid", uid) - needCond = true - } - if keyword != "" { - kwCond := orm.NewCondition() - kwCond = kwCond.Or("name__icontains", keyword). - Or("phone__icontains", keyword). - Or("email__icontains", keyword). - Or("account__icontains", keyword) - if cond == nil { - cond = kwCond - } else { - cond = cond.AndCond(kwCond) - } - needCond = true - } - if needCond { - qs = qs.SetCond(cond) - } - - var rows []models.SystemTenantUser - _, err := qs.OrderBy("-is_default", "-id").All(&rows) - if err != nil { - c.Data["json"] = map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()} - _ = c.ServeJSON() - return - } - - c.Data["json"] = map[string]interface{}{ - "code": 200, - "msg": "success", - "data": map[string]interface{}{ - "list": rows, - "total": len(rows), - }, - } - _ = c.ServeJSON() -} - -// GetTenantUsersByTid 兼容旧路由,根据租户 ID 获取租户用户列表 -// GET /platform/getTenantUsers/:tid -func (c *PlatformTenantUserController) GetTenantUsersByTid() { - tidStr := c.Ctx.Input.Param(":tid") - tid, _ := strconv.ParseUint(tidStr, 10, 64) - if tid == 0 { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": "tid 不能为空"} - _ = c.ServeJSON() - return - } - var rows []models.SystemTenantUser - _, err := models.Orm.QueryTable(new(models.SystemTenantUser)). - Filter("tid", tid). - OrderBy("-is_default", "-id"). - All(&rows) - if err != nil { - c.Data["json"] = map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()} - _ = c.ServeJSON() - return - } - c.Data["json"] = map[string]interface{}{ - "code": 200, - "msg": "success", - "data": map[string]interface{}{"list": rows, "total": len(rows)}, - } - _ = c.ServeJSON() -} - -// GetTenantUserDetail 获取绑定详情 -// GET /platform/tenantUser/detail/:id -func (c *PlatformTenantUserController) GetTenantUserDetail() { - id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) - if err != nil || id == 0 { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效ID"} - _ = c.ServeJSON() - return - } - - var row models.SystemTenantUser - err = models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&row) - if err != nil { - c.Data["json"] = map[string]interface{}{"code": 404, "msg": "记录不存在"} - _ = c.ServeJSON() - return - } - - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": row} - _ = c.ServeJSON() -} - -// CreateTenantUser 创建租户用户绑定(写入 yz_system_tenant_user;uid 为空时自动生成) -// POST /platform/tenantUser/create -func (c *PlatformTenantUserController) CreateTenantUser() { - p, ok := c.parsePayload() - if !ok { - return - } - if p.Tid == 0 { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": "tid 不能为空"} - _ = c.ServeJSON() - return - } - if p.Account == nil || strings.TrimSpace(*p.Account) == "" { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": "account 不能为空"} - _ = c.ServeJSON() - return - } - if p.Password == nil || strings.TrimSpace(*p.Password) == "" { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": "password 不能为空"} - _ = c.ServeJSON() - return - } - hashed, err := passwordutil.Hash(*p.Password) - if err != nil { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()} - _ = c.ServeJSON() - return - } - p.Password = &hashed - if p.Uid == 0 { - uid, err := generateTenantUID(p.Tid) - if err != nil { - c.Data["json"] = map[string]interface{}{"code": 500, "msg": "生成租户用户ID失败"} - _ = c.ServeJSON() - return - } - p.Uid = uid - } - - // 用户数配额校验:租户用户数达到上限时不允许新增(增购用户数后放开) - if check, err := services.CheckTenantUserQuota(p.Tid, p.Uid); err != nil { - c.Data["json"] = map[string]interface{}{"code": 500, "msg": "用户数校验失败: " + err.Error()} - _ = c.ServeJSON() - return - } else if !check.Allowed { - c.Data["json"] = map[string]interface{}{ - "code": 400, - "msg": fmt.Sprintf("该租户用户数已达上限(%d/%d),请先为租户增购用户数", check.Used, check.Quota), - } - _ = c.ServeJSON() - return - } - - isDefault := int8(0) - status := int8(1) - if p.IsDefault != nil { - isDefault = *p.IsDefault - } - if p.Status != nil { - status = *p.Status - } - - id, err := services.BindTenantUser(p.Tid, p.Uid, p.Account, p.Name, p.Phone, p.Email, nil, nil, p.Password, isDefault, status, p.Remark) - if err != nil { - c.Data["json"] = map[string]interface{}{"code": 500, "msg": "创建失败: " + err.Error()} - _ = c.ServeJSON() - return - } - if p.OrgID != nil && *p.OrgID > 0 { - _, _ = models.Orm.QueryTable(new(models.SystemTenantUser)). - Filter("id", id). - Update(map[string]interface{}{"org_id": *p.OrgID}) - } - if isDefault == 1 { - _ = services.SetDefaultTenant(p.Uid, p.Tid) - // 租户的第一个账号即租户管理员:自动绑定全局「租户管理员」角色(拥有 backend 全权限)。 - if adminRoleID, err := models.GetTenantAdminRole(); err == nil && adminRoleID > 0 { - _, _ = models.Orm.QueryTable(new(models.SystemTenantUser)). - Filter("id", id). - Update(map[string]interface{}{"group_id": adminRoleID}) - } - } - - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": map[string]interface{}{"id": id}} - _ = c.ServeJSON() -} - -// EditTenantUser 编辑绑定 -// POST /platform/tenantUser/edit/:id -func (c *PlatformTenantUserController) EditTenantUser() { - id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) - if err != nil || id == 0 { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效ID"} - _ = c.ServeJSON() - return - } - - p, ok := c.parsePayload() - if !ok { - return - } - - update := map[string]interface{}{} - if p.Tid > 0 { - update["tid"] = p.Tid - } - if p.Uid > 0 { - update["uid"] = p.Uid - } - if p.Account != nil { - update["account"] = p.Account - } - if p.Name != nil { - update["name"] = p.Name - } - if p.Phone != nil { - update["phone"] = p.Phone - } - if p.Email != nil { - update["email"] = p.Email - } - if p.Password != nil { - hashed, err := passwordutil.Hash(*p.Password) - if err != nil { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()} - _ = c.ServeJSON() - return - } - update["password"] = hashed - } - if p.IsDefault != nil { - update["is_default"] = *p.IsDefault - } - if p.Status != nil { - update["status"] = *p.Status - } - if p.OrgID != nil { - update["org_id"] = *p.OrgID - } - if p.Remark != nil { - update["remark"] = p.Remark - } - - if len(update) == 0 { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无更新字段"} - _ = c.ServeJSON() - return - } - - _, err = models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).Update(update) - if err != nil { - c.Data["json"] = map[string]interface{}{"code": 500, "msg": "更新失败: " + err.Error()} - _ = c.ServeJSON() - return - } - - if p.IsDefault != nil && *p.IsDefault == 1 && p.Uid > 0 && p.Tid > 0 { - _ = services.SetDefaultTenant(p.Uid, p.Tid) - } - - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} - _ = c.ServeJSON() -} - -// DeleteTenantUser 删除绑定 -// DELETE /platform/tenantUser/delete/:id -func (c *PlatformTenantUserController) DeleteTenantUser() { - id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) - if err != nil || id == 0 { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效ID"} - _ = c.ServeJSON() - return - } - - if err := services.UnbindTenantUser(id); err != nil { - c.Data["json"] = map[string]interface{}{"code": 500, "msg": "删除失败: " + err.Error()} - _ = c.ServeJSON() - return - } - - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} - _ = c.ServeJSON() -} - -func (c *PlatformTenantUserController) parsePayload() (tenantUserPayload, bool) { - var p tenantUserPayload - raw, _ := io.ReadAll(c.Ctx.Request.Body) - if err := json.Unmarshal(raw, &p); err != nil { - c.Data["json"] = map[string]interface{}{"code": 400, "msg": "参数错误"} - _ = c.ServeJSON() - return tenantUserPayload{}, false - } - return p, true -} - -func generateTenantUID(tid uint64) (uint64, error) { - rand.Seed(time.Now().UnixNano()) - for i := 0; i < 8; i++ { - uid := uint64(10000000 + rand.Intn(90000000)) - cnt, err := models.Orm.QueryTable(new(models.SystemTenantUser)). - Filter("tid", tid). - Filter("uid", uid). - Count() - if err != nil { - return 0, err - } - if cnt == 0 { - return uid, nil - } - } - return 0, errors.New("uid collision") -} +package controllers + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log" + "math/rand" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/passwordutil" + "server/services" + authsvc "server/services/auth" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +// syncTenantUserToAuth 把租户用户同步到统一认证中心(双写)。 +// 同步失败只记日志,不影响平台端主流程(认证中心为新增能力)。 +func syncTenantUserToAuth(row *models.SystemTenantUser) { + if row == nil { + return + } + in := authsvc.SyncTenantUserInput{ + Tid: row.Tid, + GroupID: row.GroupID, + OrgID: row.OrgID, + Status: row.Status, + IsDefault: row.IsDefault, + } + if row.Account != nil { + in.Account = strings.TrimSpace(*row.Account) + } + if row.Name != nil { + in.Name = strings.TrimSpace(*row.Name) + } + if row.Phone != nil { + in.Phone = strings.TrimSpace(*row.Phone) + } + if row.Email != nil { + in.Email = strings.TrimSpace(*row.Email) + } + if row.Password != nil { + in.PasswordHash = *row.Password + } + if err := authsvc.SyncTenantUser(in); err != nil { + log.Printf("[auth] 同步租户用户到认证中心失败 tid=%d account=%s: %v", row.Tid, in.Account, err) + } +} + +// removeTenantUserFromAuth 删除认证中心中的企业绑定(保留身份) +func removeTenantUserFromAuth(row *models.SystemTenantUser) { + if row == nil { + return + } + account, phone, email := "", "", "" + if row.Account != nil { + account = strings.TrimSpace(*row.Account) + } + if row.Phone != nil { + phone = strings.TrimSpace(*row.Phone) + } + if row.Email != nil { + email = strings.TrimSpace(*row.Email) + } + if err := authsvc.RemoveTenantUser(row.Tid, account, phone, email); err != nil { + log.Printf("[auth] 删除认证中心企业绑定失败 tid=%d account=%s: %v", row.Tid, account, err) + } +} + +// PlatformTenantUserController 平台租户用户绑定管理 +type PlatformTenantUserController struct { + beego.Controller +} + +type tenantUserPayload struct { + Tid uint64 `json:"tid"` + Uid uint64 `json:"uid"` + Account *string `json:"account"` + Name *string `json:"name"` + Phone *string `json:"phone"` + Email *string `json:"email"` + Password *string `json:"password"` + IsDefault *int8 `json:"is_default"` + Status *int8 `json:"status"` + OrgID *uint64 `json:"org_id"` + Remark *string `json:"remark"` +} + +// GetTenantUserList 获取绑定列表(支持按 tid / uid 过滤,keyword 对姓名/手机/邮箱/账号模糊匹配) +// GET /platform/tenantUser/list?tid=1&uid=2&keyword=xxx +func (c *PlatformTenantUserController) GetTenantUserList() { + tid, _ := c.GetUint64("tid") + uid, _ := c.GetUint64("uid") + keyword := strings.TrimSpace(c.GetString("keyword")) + + qs := models.Orm.QueryTable(new(models.SystemTenantUser)) + + var cond *orm.Condition + needCond := false + if tid > 0 { + if cond == nil { + cond = orm.NewCondition() + } + cond = cond.And("tid", tid) + needCond = true + } + if uid > 0 { + if cond == nil { + cond = orm.NewCondition() + } + cond = cond.And("uid", uid) + needCond = true + } + if keyword != "" { + kwCond := orm.NewCondition() + kwCond = kwCond.Or("name__icontains", keyword). + Or("phone__icontains", keyword). + Or("email__icontains", keyword). + Or("account__icontains", keyword) + if cond == nil { + cond = kwCond + } else { + cond = cond.AndCond(kwCond) + } + needCond = true + } + if needCond { + qs = qs.SetCond(cond) + } + + var rows []models.SystemTenantUser + _, err := qs.OrderBy("-is_default", "-id").All(&rows) + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()} + _ = c.ServeJSON() + return + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{ + "list": rows, + "total": len(rows), + }, + } + _ = c.ServeJSON() +} + +// GetTenantUsersByTid 兼容旧路由,根据租户 ID 获取租户用户列表 +// GET /platform/getTenantUsers/:tid +func (c *PlatformTenantUserController) GetTenantUsersByTid() { + tidStr := c.Ctx.Input.Param(":tid") + tid, _ := strconv.ParseUint(tidStr, 10, 64) + if tid == 0 { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "tid 不能为空"} + _ = c.ServeJSON() + return + } + var rows []models.SystemTenantUser + _, err := models.Orm.QueryTable(new(models.SystemTenantUser)). + Filter("tid", tid). + OrderBy("-is_default", "-id"). + All(&rows) + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()} + _ = c.ServeJSON() + return + } + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{"list": rows, "total": len(rows)}, + } + _ = c.ServeJSON() +} + +// GetTenantUserDetail 获取绑定详情 +// GET /platform/tenantUser/detail/:id +func (c *PlatformTenantUserController) GetTenantUserDetail() { + id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + if err != nil || id == 0 { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效ID"} + _ = c.ServeJSON() + return + } + + var row models.SystemTenantUser + err = models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&row) + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 404, "msg": "记录不存在"} + _ = c.ServeJSON() + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": row} + _ = c.ServeJSON() +} + +// CreateTenantUser 创建租户用户绑定(写入 yz_system_tenant_user;uid 为空时自动生成) +// POST /platform/tenantUser/create +func (c *PlatformTenantUserController) CreateTenantUser() { + p, ok := c.parsePayload() + if !ok { + return + } + if p.Tid == 0 { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "tid 不能为空"} + _ = c.ServeJSON() + return + } + if p.Account == nil || strings.TrimSpace(*p.Account) == "" { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "account 不能为空"} + _ = c.ServeJSON() + return + } + if p.Password == nil || strings.TrimSpace(*p.Password) == "" { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "password 不能为空"} + _ = c.ServeJSON() + return + } + hashed, err := passwordutil.Hash(*p.Password) + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()} + _ = c.ServeJSON() + return + } + p.Password = &hashed + if p.Uid == 0 { + uid, err := generateTenantUID(p.Tid) + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "生成租户用户ID失败"} + _ = c.ServeJSON() + return + } + p.Uid = uid + } + + // 用户数配额校验:租户用户数达到上限时不允许新增(增购用户数后放开) + if check, err := services.CheckTenantUserQuota(p.Tid, p.Uid); err != nil { + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "用户数校验失败: " + err.Error()} + _ = c.ServeJSON() + return + } else if !check.Allowed { + c.Data["json"] = map[string]interface{}{ + "code": 400, + "msg": fmt.Sprintf("该租户用户数已达上限(%d/%d),请先为租户增购用户数", check.Used, check.Quota), + } + _ = c.ServeJSON() + return + } + + isDefault := int8(0) + status := int8(1) + if p.IsDefault != nil { + isDefault = *p.IsDefault + } + if p.Status != nil { + status = *p.Status + } + + id, err := services.BindTenantUser(p.Tid, p.Uid, p.Account, p.Name, p.Phone, p.Email, nil, nil, p.Password, isDefault, status, p.Remark) + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "创建失败: " + err.Error()} + _ = c.ServeJSON() + return + } + if p.OrgID != nil && *p.OrgID > 0 { + _, _ = models.Orm.QueryTable(new(models.SystemTenantUser)). + Filter("id", id). + Update(map[string]interface{}{"org_id": *p.OrgID}) + } + if isDefault == 1 { + _ = services.SetDefaultTenant(p.Uid, p.Tid) + // 租户的第一个账号即租户管理员:自动绑定全局「租户管理员」角色(拥有 backend 全权限)。 + if adminRoleID, err := models.GetTenantAdminRole(); err == nil && adminRoleID > 0 { + _, _ = models.Orm.QueryTable(new(models.SystemTenantUser)). + Filter("id", id). + Update(map[string]interface{}{"group_id": adminRoleID}) + } + } + + // 双写:同步到统一认证中心(读回最新记录,确保 group_id/org_id 已落库) + var created models.SystemTenantUser + if err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&created); err == nil { + syncTenantUserToAuth(&created) + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": map[string]interface{}{"id": id}} + _ = c.ServeJSON() +} + +// EditTenantUser 编辑绑定 +// POST /platform/tenantUser/edit/:id +func (c *PlatformTenantUserController) EditTenantUser() { + id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + if err != nil || id == 0 { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效ID"} + _ = c.ServeJSON() + return + } + + p, ok := c.parsePayload() + if !ok { + return + } + + update := map[string]interface{}{} + if p.Tid > 0 { + update["tid"] = p.Tid + } + if p.Uid > 0 { + update["uid"] = p.Uid + } + if p.Account != nil { + update["account"] = p.Account + } + if p.Name != nil { + update["name"] = p.Name + } + if p.Phone != nil { + update["phone"] = p.Phone + } + if p.Email != nil { + update["email"] = p.Email + } + if p.Password != nil { + hashed, err := passwordutil.Hash(*p.Password) + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()} + _ = c.ServeJSON() + return + } + update["password"] = hashed + } + if p.IsDefault != nil { + update["is_default"] = *p.IsDefault + } + if p.Status != nil { + update["status"] = *p.Status + } + if p.OrgID != nil { + update["org_id"] = *p.OrgID + } + if p.Remark != nil { + update["remark"] = p.Remark + } + + if len(update) == 0 { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无更新字段"} + _ = c.ServeJSON() + return + } + + _, err = models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).Update(update) + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "更新失败: " + err.Error()} + _ = c.ServeJSON() + return + } + + if p.IsDefault != nil && *p.IsDefault == 1 && p.Uid > 0 && p.Tid > 0 { + _ = services.SetDefaultTenant(p.Uid, p.Tid) + } + + // 双写:同步到统一认证中心 + var updated models.SystemTenantUser + if err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&updated); err == nil { + syncTenantUserToAuth(&updated) + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() +} + +// DeleteTenantUser 删除绑定 +// DELETE /platform/tenantUser/delete/:id +func (c *PlatformTenantUserController) DeleteTenantUser() { + id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + if err != nil || id == 0 { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效ID"} + _ = c.ServeJSON() + return + } + + // 删除前先读出记录,供认证中心侧定位(删除后就读不到了) + var row models.SystemTenantUser + _ = models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&row) + + if err := services.UnbindTenantUser(id); err != nil { + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "删除失败: " + err.Error()} + _ = c.ServeJSON() + return + } + + // 双写:删除认证中心中的企业绑定 + removeTenantUserFromAuth(&row) + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() +} + +func (c *PlatformTenantUserController) parsePayload() (tenantUserPayload, bool) { + var p tenantUserPayload + raw, _ := io.ReadAll(c.Ctx.Request.Body) + if err := json.Unmarshal(raw, &p); err != nil { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "参数错误"} + _ = c.ServeJSON() + return tenantUserPayload{}, false + } + return p, true +} + +func generateTenantUID(tid uint64) (uint64, error) { + rand.Seed(time.Now().UnixNano()) + for i := 0; i < 8; i++ { + uid := uint64(10000000 + rand.Intn(90000000)) + cnt, err := models.Orm.QueryTable(new(models.SystemTenantUser)). + Filter("tid", tid). + Filter("uid", uid). + Count() + if err != nil { + return 0, err + } + if cnt == 0 { + return uid, nil + } + } + return 0, errors.New("uid collision") +} diff --git a/go/docs/sql/create_auth_tables.sql b/go/docs/sql/create_auth_tables.sql new file mode 100644 index 0000000..c7b0c01 --- /dev/null +++ b/go/docs/sql/create_auth_tables.sql @@ -0,0 +1,258 @@ +-- ============================================================= +-- 统一认证中心(UAC)表结构 数据库:go-platform +-- 路径:api.yunzer.cn/auth +-- +-- 设计要点: +-- 1. yz_auth_identity 为「自然人」层,密码上提到这一层; +-- 2. yz_auth_tenant_user 为「身份-企业」绑定,一人可在多家企业任职; +-- 3. 密码列存 PHC 格式($argon2id$...),盐内联,无需独立 salt 列; +-- 历史数据为 salt$sha256(legacy),首次登录时自动升级; +-- 4. 会话/刷新令牌/授权码均存哈希,明文不下发到数据库; +-- 5. 本脚本只建表不写数据,数据迁移由 scripts/migrate_auth.go 完成。 +-- ============================================================= + +SET NAMES utf8mb4; + +-- ---------------- 1. 统一身份(自然人) ---------------- +CREATE TABLE IF NOT EXISTS `yz_auth_identity` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '身份ID', + `union_id` VARCHAR(32) NOT NULL COMMENT '全局唯一标识(32位随机串)', + `mobile` VARCHAR(20) DEFAULT NULL COMMENT '手机号,全局唯一', + `email` VARCHAR(128) DEFAULT NULL COMMENT '邮箱,全局唯一', + `password_algo` VARCHAR(16) NOT NULL DEFAULT 'argon2id' COMMENT 'argon2id/legacy', + `password_hash` VARCHAR(255) DEFAULT NULL COMMENT 'PHC格式,含盐与参数', + `password_ver` INT NOT NULL DEFAULT 1 COMMENT '密码版本,改密时+1用于踢下线', + `nickname` VARCHAR(64) DEFAULT NULL, + `avatar` VARCHAR(500) DEFAULT NULL, + `mfa_enabled` TINYINT NOT NULL DEFAULT 0 COMMENT '是否开启二次验证', + `mfa_secret` VARCHAR(128) DEFAULT NULL COMMENT 'TOTP密钥(加密存储)', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用/0禁用/2锁定', + `fail_count` INT NOT NULL DEFAULT 0 COMMENT '连续失败次数', + `locked_until` DATETIME DEFAULT NULL COMMENT '锁定截止时间', + `last_login_at` DATETIME DEFAULT NULL, + `last_login_ip` VARCHAR(45) DEFAULT NULL, + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `delete_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_union_id` (`union_id`), + UNIQUE KEY `uk_mobile` (`mobile`), + UNIQUE KEY `uk_email` (`email`), + KEY `idx_status` (`status`), + KEY `idx_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-身份表'; + +-- ---------------- 2. 身份-企业绑定(一人多企) ---------------- +CREATE TABLE IF NOT EXISTS `yz_auth_tenant_user` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `tid` BIGINT UNSIGNED NOT NULL COMMENT '租户ID', + `identity_id` BIGINT UNSIGNED NOT NULL COMMENT '身份ID', + `account` VARCHAR(64) DEFAULT NULL COMMENT '企业内账号,可空(用手机号登录)', + `name` VARCHAR(64) DEFAULT NULL COMMENT '在该企业的姓名', + `phone` VARCHAR(20) DEFAULT NULL, + `email` VARCHAR(128) DEFAULT NULL, + `group_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '角色ID(cid=2的租户角色)', + `org_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '部门ID', + `is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认企业', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用/0禁用', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + `delete_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tid_identity` (`tid`, `identity_id`), + KEY `idx_identity` (`identity_id`), + KEY `idx_tid_account` (`tid`, `account`), + KEY `idx_tid_status` (`tid`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-身份企业绑定'; + +-- ---------------- 3. 接入应用(OIDC Client) ---------------- +CREATE TABLE IF NOT EXISTS `yz_auth_client` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `client_id` VARCHAR(64) NOT NULL, + `client_secret` VARCHAR(128) DEFAULT NULL COMMENT '机密客户端才有,存哈希', + `app_code` VARCHAR(32) NOT NULL COMMENT 'crm/oa/uniapp/website...', + `name` VARCHAR(128) NOT NULL, + `app_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1Web后端 2SPA 3原生APP 4小程序', + `redirect_uris` TEXT COMMENT 'JSON数组,精确匹配白名单', + `post_logout_uris` TEXT COMMENT '登出后允许回跳的地址', + `backchannel_logout_uri` VARCHAR(500) DEFAULT NULL COMMENT '单点登出回调(服务端通知)', + `grant_types` VARCHAR(255) NOT NULL DEFAULT 'authorization_code,refresh_token', + `scope` VARCHAR(255) DEFAULT 'openid,profile,tenant', + `access_ttl` INT NOT NULL DEFAULT 1800 COMMENT '访问令牌有效期(秒)', + `refresh_ttl` INT NOT NULL DEFAULT 2592000 COMMENT '刷新令牌有效期(秒)', + `realm` VARCHAR(16) NOT NULL DEFAULT 'tenant' COMMENT 'tenant/platform', + `status` TINYINT NOT NULL DEFAULT 1, + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_client_id` (`client_id`), + UNIQUE KEY `uk_app_code` (`app_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-接入应用'; + +-- ---------------- 4. 登录会话(1号1机 / 强制下线) ---------------- +CREATE TABLE IF NOT EXISTS `yz_auth_session` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `sid` VARCHAR(64) NOT NULL COMMENT '会话ID,写入JWT的sid', + `identity_id` BIGINT UNSIGNED NOT NULL, + `tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前所选企业', + `client_id` VARCHAR(64) NOT NULL DEFAULT '', + `device_id` VARCHAR(64) DEFAULT NULL, + `device_name` VARCHAR(128) DEFAULT NULL, + `ip` VARCHAR(45) DEFAULT NULL, + `user_agent` VARCHAR(500) DEFAULT NULL, + `login_type` VARCHAR(20) NOT NULL DEFAULT 'password', + `amr` VARCHAR(64) DEFAULT NULL COMMENT 'pwd/sms/otp/wx/dingtalk/feishu/qq/github/google', + `login_at` DATETIME NOT NULL, + `last_access_at` DATETIME NOT NULL, + `expires_at` DATETIME NOT NULL, + `revoked` TINYINT NOT NULL DEFAULT 0, + `revoke_reason` VARCHAR(64) DEFAULT NULL COMMENT 'logout/kicked/admin/expired', + `revoke_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_sid` (`sid`), + KEY `idx_identity_status` (`identity_id`, `revoked`), + KEY `idx_tid` (`tid`), + KEY `idx_expires` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-登录会话'; + +-- ---------------- 5. 刷新令牌(轮换 + 重放检测) ---------------- +CREATE TABLE IF NOT EXISTS `yz_auth_refresh_token` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `token_hash` VARCHAR(128) NOT NULL COMMENT '不透明串的哈希,明文不下发落库', + `identity_id` BIGINT UNSIGNED NOT NULL, + `tid` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `client_id` VARCHAR(64) NOT NULL DEFAULT '', + `sid` VARCHAR(64) NOT NULL DEFAULT '', + `family_id` VARCHAR(64) NOT NULL COMMENT '轮换家族,检测到重放则整族吊销', + `rotated_from` VARCHAR(128) DEFAULT NULL, + `used` TINYINT NOT NULL DEFAULT 0, + `revoked` TINYINT NOT NULL DEFAULT 0, + `expires_at` DATETIME NOT NULL, + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_token_hash` (`token_hash`), + KEY `idx_family` (`family_id`), + KEY `idx_identity` (`identity_id`), + KEY `idx_expires` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-刷新令牌'; + +-- ---------------- 6. 授权码(一次性,60秒) ---------------- +CREATE TABLE IF NOT EXISTS `yz_auth_code` ( + `code_hash` VARCHAR(128) NOT NULL COMMENT '授权码哈希', + `client_id` VARCHAR(64) NOT NULL, + `identity_id` BIGINT UNSIGNED NOT NULL, + `tid` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `redirect_uri` VARCHAR(500) NOT NULL, + `code_challenge` VARCHAR(128) NOT NULL COMMENT 'PKCE', + `code_challenge_method` VARCHAR(10) NOT NULL DEFAULT 'S256', + `scope` VARCHAR(255) DEFAULT NULL, + `nonce` VARCHAR(128) DEFAULT NULL COMMENT 'OIDC nonce,进ID Token', + `used` TINYINT NOT NULL DEFAULT 0, + `expires_at` DATETIME NOT NULL, + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`code_hash`), + KEY `idx_expires` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-授权码'; + +-- ---------------- 7. 令牌吊销表(登出/踢下线) ---------------- +CREATE TABLE IF NOT EXISTS `yz_auth_token_blacklist` ( + `jti` VARCHAR(64) NOT NULL COMMENT 'JWT ID', + `identity_id` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `sid` VARCHAR(64) DEFAULT NULL, + `reason` VARCHAR(64) DEFAULT NULL, + `expires_at` DATETIME NOT NULL COMMENT '原token过期时间,到期后可清理', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`jti`), + KEY `idx_expires` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-令牌吊销表'; + +-- ---------------- 8. 第三方身份绑定 ---------------- +CREATE TABLE IF NOT EXISTS `yz_auth_identity_third` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `identity_id` BIGINT UNSIGNED NOT NULL, + `provider` VARCHAR(32) NOT NULL COMMENT 'wechat/dingtalk/feishu/qq/github/google', + `open_id` VARCHAR(128) NOT NULL COMMENT '该平台内的唯一ID', + `union_id` VARCHAR(128) DEFAULT NULL COMMENT '跨平台唯一ID(微信/钉钉有)', + `nickname` VARCHAR(128) DEFAULT NULL, + `avatar` VARCHAR(500) DEFAULT NULL, + `raw` TEXT COMMENT '原始用户信息JSON', + `bind_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_provider_open` (`provider`, `open_id`), + KEY `idx_identity` (`identity_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-第三方绑定'; + +-- ---------------- 9. 租户自带身份源(企业微信/钉钉/飞书/OIDC) ---------------- +CREATE TABLE IF NOT EXISTS `yz_auth_tenant_idp` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `tid` BIGINT UNSIGNED NOT NULL, + `provider` VARCHAR(32) NOT NULL COMMENT 'dingtalk/feishu/wework/oidc/saml', + `name` VARCHAR(128) DEFAULT NULL COMMENT '显示名称', + `app_id` VARCHAR(128) DEFAULT NULL, + `app_secret` VARCHAR(512) DEFAULT NULL COMMENT '加密存储', + `issuer` VARCHAR(500) DEFAULT NULL COMMENT 'OIDC/SAML issuer', + `auth_url` VARCHAR(500) DEFAULT NULL, + `token_url` VARCHAR(500) DEFAULT NULL, + `userinfo_url` VARCHAR(500) DEFAULT NULL, + `jwks_url` VARCHAR(500) DEFAULT NULL, + `scopes` VARCHAR(255) DEFAULT NULL, + `attr_map` TEXT COMMENT 'JSON:字段映射(手机/姓名/部门)', + `proxy_url` VARCHAR(500) DEFAULT NULL COMMENT 'GitHub/Google等需要代理时填写', + `status` TINYINT NOT NULL DEFAULT 1, + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tid_provider` (`tid`, `provider`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-租户身份源'; + +-- ---------------- 10. 租户登录策略 ---------------- +CREATE TABLE IF NOT EXISTS `yz_auth_tenant_auth_config` ( + `tid` BIGINT UNSIGNED NOT NULL COMMENT '租户ID', + `verify_type` VARCHAR(20) NOT NULL DEFAULT 'captcha' COMMENT 'none/captcha/sms/email/geetest', + `open_verify` TINYINT NOT NULL DEFAULT 1 COMMENT '是否开启登录二次验证', + `pwd_min_len` INT NOT NULL DEFAULT 8, + `pwd_complexity` TINYINT NOT NULL DEFAULT 0 COMMENT '0不限制 1字母+数字 2大小写+数字+符号', + `session_ttl` INT NOT NULL DEFAULT 7200 COMMENT '会话有效期(秒)', + `max_session` INT NOT NULL DEFAULT 1 COMMENT '同账号最大同时在线数(默认1号1机)', + `kick_strategy` TINYINT NOT NULL DEFAULT 1 COMMENT '1踢掉旧会话 2拒绝新登录', + `mfa_required` TINYINT NOT NULL DEFAULT 0, + `ip_whitelist` TEXT COMMENT 'JSON数组,为空表示不限制', + `allow_third` VARCHAR(255) DEFAULT NULL COMMENT '允许的第三方登录,逗号分隔,空表示全部', + `update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`tid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-租户登录策略'; + +-- ---------------- 11. 统一登录日志 ---------------- +CREATE TABLE IF NOT EXISTS `yz_auth_login_log` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `tid` BIGINT UNSIGNED DEFAULT NULL, + `identity_id` BIGINT UNSIGNED DEFAULT NULL, + `account` VARCHAR(64) NOT NULL DEFAULT '', + `user_name` VARCHAR(64) NOT NULL DEFAULT '', + `tenant_name` VARCHAR(64) NOT NULL DEFAULT '', + `client_id` VARCHAR(64) NOT NULL DEFAULT '', + `login_type` VARCHAR(20) NOT NULL DEFAULT 'password' COMMENT 'password/sms/otp/third', + `amr` VARCHAR(64) DEFAULT NULL, + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1成功/0失败', + `message` VARCHAR(255) NOT NULL DEFAULT '', + `ip` VARCHAR(45) NOT NULL DEFAULT '', + `user_agent` VARCHAR(500) NOT NULL DEFAULT '', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_tid` (`tid`), + KEY `idx_identity` (`identity_id`), + KEY `idx_account` (`account`), + KEY `idx_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-登录日志'; + +-- ============================================================= +-- 内置接入应用(后续由 platform 端「应用注册」页面维护) +-- client_secret 为占位值,正式启用前必须在管理端重新生成 +-- ============================================================= +INSERT INTO `yz_auth_client` + (`client_id`, `app_code`, `name`, `app_type`, `grant_types`, `scope`, `access_ttl`, `refresh_ttl`, `realm`) +VALUES + ('yz-backend', 'backend', '租户管理后台', 2, 'authorization_code,refresh_token', 'openid,profile,tenant', 1800, 2592000, 'tenant'), + ('yz-uniapp', 'uniapp', '移动端APP', 3, 'authorization_code,refresh_token', 'openid,profile,tenant', 1800, 2592000, 'tenant'), + ('yz-website', 'website', '企业官网', 2, 'authorization_code,refresh_token', 'openid,profile,tenant', 1800, 2592000, 'tenant') + ON DUPLICATE KEY UPDATE `name` = VALUES(`name`); diff --git a/go/middleware/jwt.go b/go/middleware/jwt.go index 3ac905e..c4e4f35 100644 --- a/go/middleware/jwt.go +++ b/go/middleware/jwt.go @@ -1,75 +1,198 @@ -package middleware - -import ( - "strings" - - "server/pkg/jwtutil" - - "github.com/beego/beego/v2/server/web" - "github.com/beego/beego/v2/server/web/context" -) - -// JWTAuthMiddleware JWT认证中间件 -func JWTAuthMiddleware() web.FilterFunc { - return func(ctx *context.Context) { - // 跳过登录相关的路由 - if strings.HasPrefix(ctx.Request.RequestURI, "/api/login") || - strings.HasPrefix(ctx.Request.RequestURI, "/api/reset-password") { - return - } - - // 从请求头中获取Authorization - authHeader := ctx.Request.Header.Get("Authorization") - if authHeader == "" { - ctx.Output.SetStatus(401) - ctx.Output.JSON(map[string]interface{}{ - "success": false, - "message": "未提供认证信息", - }, false, false) - return - } - - // 按空格分割 - authParts := strings.SplitN(authHeader, " ", 2) - if !(len(authParts) == 2 && authParts[0] == "Bearer") { - ctx.Output.SetStatus(401) - ctx.Output.JSON(map[string]interface{}{ - "success": false, - "message": "认证信息格式错误", - }, false, false) - return - } - - // 解析token - claims, err := jwtutil.ParseToken(authParts[1]) - if err != nil { - // 处理各种错误情况 - ctx.Output.SetStatus(401) - switch err.Error() { - case "token is expired": - ctx.Output.JSON(map[string]interface{}{ - "success": false, - "message": "token已过期", - }, false, false) - default: - ctx.Output.JSON(map[string]interface{}{ - "success": false, - "message": "无效的token", - }, false, false) - } - return - } - - // 将用户信息存储在上下文 - ctx.Input.SetData("userId", claims.UserID) - ctx.Input.SetData("username", claims.Username) - ctx.Input.SetData("tenantId", claims.TenantId) - - // 从token中获取用户类型(如果token中没有,则默认为"user") - userType := claims.UserType - if userType == "" { - userType = "user" - } - ctx.Input.SetData("userType", userType) - } -} +package middleware + +import ( + "log" + "net/http" + "strings" + "sync" + + "server/pkg/jwtutil" + + beego "github.com/beego/beego/v2/server/web" + "github.com/beego/beego/v2/server/web/context" +) + +// 鉴权开关(app.conf: auth_enforce) +// - off :完全不启用(等同改造前的行为) +// - warn :观察模式,只记录「无 token / token 无效」的接口,不拦截(默认,零风险上线) +// - on :真正拦截并返回 401 +// +// 上线建议:先 warn 跑一段时间,用日志核对白名单是否遗漏,确认无误后再切 on。 +const ( + AuthModeOff = "off" + AuthModeWarn = "warn" + AuthModeOn = "on" +) + +// protectedPrefixes 需要鉴权的业务前缀。 +// 仅保护业务 API:官网(/ /site/ /index/…)、开放 API(/api/…)、 +// 静态资源与 ACME 验证路径均不在其中,避免误伤公开接口。 +var protectedPrefixes = []string{"/backend/", "/platform/", "/app/"} + +// authWhitelist 受保护前缀内无需鉴权的路径(前缀匹配) +var authWhitelist = []string{ + // 统一认证中心(P1 上线) + "/auth/", + + // backend 登录/注册/找回密码 + "/backend/login", + "/backend/sendLoginCode", + "/backend/register", + "/backend/sendRegisterCode", + "/backend/resetPassword", + "/backend/sendResetCode", + "/backend/verifyAccount", + "/backend/logout", + + // platform 登录/找回密码 + "/platform/login", + "/platform/sendLoginCode", + "/platform/resetPassword", + "/platform/logout", + // 平台开放接口(客户端更新通知,无需登录) + "/platform/api/", + + // app 登录/注册/找回密码 + "/app/login", + "/app/sendLoginCode", + "/app/register", + "/app/sendRegisterCode", + "/app/resetPassword", + "/app/sendResetCode", + "/app/verifyAccount", + "/app/logout", +} + +var ( + modeOnce sync.Once + modeVal = AuthModeWarn + warnSeen sync.Map // 观察模式下按 path 去重,避免日志刷屏 +) + +func authMode() string { + modeOnce.Do(func() { + if v, err := beego.AppConfig.String("auth_enforce"); err == nil { + switch strings.ToLower(strings.TrimSpace(v)) { + case AuthModeOff, AuthModeWarn, AuthModeOn: + modeVal = strings.ToLower(strings.TrimSpace(v)) + } + } + // 额外白名单:auth_whitelist = /backend/xxx,/app/yyy + if extra, err := beego.AppConfig.String("auth_whitelist"); err == nil && strings.TrimSpace(extra) != "" { + for _, p := range strings.Split(extra, ",") { + if p = strings.TrimSpace(p); p != "" { + authWhitelist = append(authWhitelist, p) + } + } + } + log.Printf("[auth] JWT 全局鉴权模式=%s,受保护前缀=%v", modeVal, protectedPrefixes) + }) + return modeVal +} + +// cleanPath 去掉 query / fragment 后返回纯路径 +func cleanPath(uri string) string { + if i := strings.IndexAny(uri, "?#"); i >= 0 { + return uri[:i] + } + return uri +} + +func isProtected(path string) bool { + for _, p := range protectedPrefixes { + if strings.HasPrefix(path, p) { + return true + } + } + return false +} + +func isWhitelisted(path string) bool { + for _, p := range authWhitelist { + if strings.HasPrefix(path, p) { + return true + } + } + return false +} + +// warnLog 观察模式下记录一次「本会被拦截」的请求(按路径去重) +func warnLog(path, reason string) { + if _, loaded := warnSeen.LoadOrStore(path, struct{}{}); loaded { + return + } + log.Printf("[auth][warn] 该接口在 enforce=on 时会被拦截: %s (%s)", path, reason) +} + +// JWTAuthMiddleware JWT认证中间件 +func JWTAuthMiddleware() beego.FilterFunc { + return func(ctx *context.Context) { + mode := authMode() + if mode == AuthModeOff { + return + } + // 预检请求直接放行 + if ctx.Input.Method() == http.MethodOptions { + return + } + + path := cleanPath(ctx.Request.RequestURI) + if !isProtected(path) || isWhitelisted(path) { + return + } + + // 失败处理:观察模式只记日志,强制模式才真正拦截 + reject := func(status int, message string) { + if mode == AuthModeWarn { + warnLog(path, message) + return + } + ctx.Output.SetStatus(status) + ctx.Output.JSON(map[string]interface{}{ + "success": false, + "message": message, + }, false, false) + } + + // 从请求头中获取Authorization + authHeader := ctx.Request.Header.Get("Authorization") + if authHeader == "" { + reject(401, "未提供认证信息") + return + } + + // 按空格分割 + authParts := strings.SplitN(authHeader, " ", 2) + if !(len(authParts) == 2 && authParts[0] == "Bearer") { + reject(401, "认证信息格式错误") + return + } + + // 解析token + claims, err := jwtutil.ParseToken(authParts[1]) + if err != nil { + message := "无效的token" + if strings.Contains(err.Error(), "expired") { + message = "token已过期" + } + reject(401, message) + return + } + + // 将用户信息存储在上下文 + ctx.Input.SetData("userId", claims.UserID) + ctx.Input.SetData("username", claims.Username) + ctx.Input.SetData("tenantId", claims.TenantId) + + // 从token中获取用户类型(如果token中没有,则默认为"user") + userType := claims.UserType + if userType == "" { + userType = "user" + } + ctx.Input.SetData("userType", userType) + + // 认证中心扩展字段(旧 token 为空值,不影响现有逻辑) + ctx.Input.SetData("clientId", claims.ClientID) + ctx.Input.SetData("sid", claims.Sid) + } +} diff --git a/go/migrate_auth_conflict_20260919004551.csv b/go/migrate_auth_conflict_20260919004551.csv new file mode 100644 index 0000000..0e4bc1c --- /dev/null +++ b/go/migrate_auth_conflict_20260919004551.csv @@ -0,0 +1,2 @@ +merge_key,row_count,password_variants,tids,representative_id +m:19895983967,2,2,1|2,4 diff --git a/go/migrate_auth_conflict_20260919004725.csv b/go/migrate_auth_conflict_20260919004725.csv new file mode 100644 index 0000000..0e4bc1c --- /dev/null +++ b/go/migrate_auth_conflict_20260919004725.csv @@ -0,0 +1,2 @@ +merge_key,row_count,password_variants,tids,representative_id +m:19895983967,2,2,1|2,4 diff --git a/go/migrate_auth_map_20260919004727.csv b/go/migrate_auth_map_20260919004727.csv new file mode 100644 index 0000000..9e1fd56 --- /dev/null +++ b/go/migrate_auth_map_20260919004727.csv @@ -0,0 +1,4 @@ +old_id,old_uid,tid,identity_id,tenant_user_id,account,merge_key,conflict +2,96085169,2,1,1,test1,m:13357867407,false +1,88888888,1,2,2,hero920103,m:19895983967,true +4,67091493,2,2,3,hero920103,m:19895983967,true diff --git a/go/models/auth_client.go b/go/models/auth_client.go new file mode 100644 index 0000000..4cfd7a5 --- /dev/null +++ b/go/models/auth_client.go @@ -0,0 +1,43 @@ +package models + +import "time" + +// 接入应用类型 +const ( + AuthAppTypeWeb = 1 // 有后端的 Web 应用(可安全保存 secret) + AuthAppTypeSPA = 2 // 单页应用(只能走 PKCE,不存 secret) + AuthAppTypeNative = 3 // 原生 APP / uniapp + AuthAppTypeMiniProg = 4 // 小程序 +) + +// 领域隔离:租户域与平台域使用不同 realm,避免平台账号登录租户应用 +const ( + AuthRealmTenant = "tenant" + AuthRealmPlatform = "platform" +) + +// AuthClient 接入应用(OIDC Client):yz_auth_client +// 以后每开发一个新软件,只需在这里注册一条即可接入统一认证。 +type AuthClient struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + ClientID string `orm:"column(client_id);size(64)" json:"client_id"` + ClientSecret *string `orm:"column(client_secret);size(128);null" json:"-"` + AppCode string `orm:"column(app_code);size(32)" json:"app_code"` + Name string `orm:"column(name);size(128)" json:"name"` + AppType int8 `orm:"column(app_type);default(1)" json:"app_type"` + RedirectURIs *string `orm:"column(redirect_uris);type(text);null" json:"redirect_uris"` + PostLogoutURIs *string `orm:"column(post_logout_uris);type(text);null" json:"post_logout_uris"` + BackchannelLogoutURI *string `orm:"column(backchannel_logout_uri);size(500);null" json:"backchannel_logout_uri"` + GrantTypes string `orm:"column(grant_types);size(255)" json:"grant_types"` + Scope *string `orm:"column(scope);size(255);null" json:"scope"` + AccessTTL int `orm:"column(access_ttl);default(1800)" json:"access_ttl"` + RefreshTTL int `orm:"column(refresh_ttl);default(2592000)" json:"refresh_ttl"` + Realm string `orm:"column(realm);size(16);default(tenant)" json:"realm"` + Status int8 `orm:"column(status);default(1)" json:"status"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` +} + +func (m *AuthClient) TableName() string { + return "yz_auth_client" +} diff --git a/go/models/auth_identity.go b/go/models/auth_identity.go new file mode 100644 index 0000000..fe878a7 --- /dev/null +++ b/go/models/auth_identity.go @@ -0,0 +1,72 @@ +package models + +import "time" + +// 身份状态 +const ( + AuthIdentityStatusEnabled = 1 // 启用 + AuthIdentityStatusDisabled = 0 // 禁用 + AuthIdentityStatusLocked = 2 // 锁定(连续失败过多) +) + +// AuthIdentity 统一身份(自然人):yz_auth_identity +// +// 统一认证中心的核心表。密码上提到这一层,一个自然人一份密码, +// 通过 yz_auth_tenant_user 绑定到多家企业(一人可在多家企业任职)。 +// +// password_hash 存储 PHC 格式($argon2id$v=19$m=...,t=...,p=...$salt$hash), +// 盐与参数内联,无需独立 salt 列;历史数据为 salt$sha256(legacy), +// 首次登录成功时自动重新哈希为 argon2id。 +type AuthIdentity struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + UnionID string `orm:"column(union_id);size(32)" json:"union_id"` + Mobile *string `orm:"column(mobile);size(20);null" json:"mobile"` + Email *string `orm:"column(email);size(128);null" json:"email"` + PasswordAlgo string `orm:"column(password_algo);size(16);default(argon2id)" json:"password_algo"` + PasswordHash *string `orm:"column(password_hash);size(255);null" json:"-"` + PasswordVer int `orm:"column(password_ver);default(1)" json:"password_ver"` + Nickname *string `orm:"column(nickname);size(64);null" json:"nickname"` + Avatar *string `orm:"column(avatar);size(500);null" json:"avatar"` + MfaEnabled int8 `orm:"column(mfa_enabled);default(0)" json:"mfa_enabled"` + MfaSecret *string `orm:"column(mfa_secret);size(128);null" json:"-"` + Status int8 `orm:"column(status);default(1)" json:"status"` + FailCount int `orm:"column(fail_count);default(0)" json:"fail_count"` + LockedUntil *time.Time `orm:"column(locked_until);type(datetime);null" json:"locked_until"` + LastLoginAt *time.Time `orm:"column(last_login_at);type(datetime);null" json:"last_login_at"` + LastLoginIP *string `orm:"column(last_login_ip);size(45);null" json:"last_login_ip"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *AuthIdentity) TableName() string { + return "yz_auth_identity" +} + +// 第三方登录 provider 常量 +const ( + IdPWechat = "wechat" + IdPDingTalk = "dingtalk" + IdPFeishu = "feishu" + IdPQQ = "qq" + IdPGitHub = "github" + IdPGoogle = "google" +) + +// AuthIdentityThird 第三方身份绑定:yz_auth_identity_third +// 同一身份可绑定多个第三方账号;同一 provider 的 open_id 全局唯一。 +type AuthIdentityThird struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"` + Provider string `orm:"column(provider);size(32)" json:"provider"` + OpenID string `orm:"column(open_id);size(128)" json:"open_id"` + UnionID *string `orm:"column(union_id);size(128);null" json:"union_id"` + Nickname *string `orm:"column(nickname);size(128);null" json:"nickname"` + Avatar *string `orm:"column(avatar);size(500);null" json:"avatar"` + Raw *string `orm:"column(raw);type(text);null" json:"raw"` + BindTime time.Time `orm:"column(bind_time);auto_now_add;type(datetime)" json:"bind_time"` +} + +func (m *AuthIdentityThird) TableName() string { + return "yz_auth_identity_third" +} diff --git a/go/models/auth_login_log.go b/go/models/auth_login_log.go new file mode 100644 index 0000000..03bc0c3 --- /dev/null +++ b/go/models/auth_login_log.go @@ -0,0 +1,26 @@ +package models + +import "time" + +// AuthLoginLog 统一登录日志:yz_auth_login_log +// 认证中心上线后逐步替代 yz_system_login_log,补充 client_id / amr 字段。 +type AuthLoginLog struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid *uint64 `orm:"column(tid);null" json:"tid"` + IdentityID *uint64 `orm:"column(identity_id);null" json:"identity_id"` + Account string `orm:"column(account);size(64);default('')" json:"account"` + UserName string `orm:"column(user_name);size(64);default('')" json:"user_name"` + TenantName string `orm:"column(tenant_name);size(64);default('')" json:"tenant_name"` + ClientID string `orm:"column(client_id);size(64);default('')" json:"client_id"` + LoginType string `orm:"column(login_type);size(20);default(password)" json:"login_type"` + Amr *string `orm:"column(amr);size(64);null" json:"amr"` + Status int8 `orm:"column(status);default(1)" json:"status"` + Message string `orm:"column(message);size(255);default('')" json:"message"` + IP string `orm:"column(ip);size(45);default('')" json:"ip"` + UserAgent string `orm:"column(user_agent);size(500);default('')" json:"user_agent"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` +} + +func (m *AuthLoginLog) TableName() string { + return "yz_auth_login_log" +} diff --git a/go/models/auth_session.go b/go/models/auth_session.go new file mode 100644 index 0000000..43e8f82 --- /dev/null +++ b/go/models/auth_session.go @@ -0,0 +1,94 @@ +package models + +import "time" + +// 会话吊销原因 +const ( + RevokeReasonLogout = "logout" // 用户主动登出 + RevokeReasonKicked = "kicked" // 被新登录踢下线(1号1机) + RevokeReasonAdmin = "admin" // 管理员强制下线 + RevokeReasonExpired = "expired" // 过期清理 +) + +// AuthSession 登录会话:yz_auth_session +// 用于「1号1机」并发控制、在线设备列表、强制下线。 +type AuthSession struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Sid string `orm:"column(sid);size(64)" json:"sid"` + IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"` + Tid uint64 `orm:"column(tid);default(0)" json:"tid"` + ClientID string `orm:"column(client_id);size(64);default('')" json:"client_id"` + DeviceID *string `orm:"column(device_id);size(64);null" json:"device_id"` + DeviceName *string `orm:"column(device_name);size(128);null" json:"device_name"` + IP *string `orm:"column(ip);size(45);null" json:"ip"` + UserAgent *string `orm:"column(user_agent);size(500);null" json:"user_agent"` + LoginType string `orm:"column(login_type);size(20);default(password)" json:"login_type"` + Amr *string `orm:"column(amr);size(64);null" json:"amr"` + LoginAt time.Time `orm:"column(login_at);type(datetime)" json:"login_at"` + LastAccessAt time.Time `orm:"column(last_access_at);type(datetime)" json:"last_access_at"` + ExpiresAt time.Time `orm:"column(expires_at);type(datetime)" json:"expires_at"` + Revoked int8 `orm:"column(revoked);default(0)" json:"revoked"` + RevokeReason *string `orm:"column(revoke_reason);size(64);null" json:"revoke_reason"` + RevokeAt *time.Time `orm:"column(revoke_at);type(datetime);null" json:"revoke_at"` +} + +func (m *AuthSession) TableName() string { + return "yz_auth_session" +} + +// AuthRefreshToken 刷新令牌:yz_auth_refresh_token +// 明文仅在颁发时返回一次,库中只存哈希;轮换时通过 family_id 检测重放。 +type AuthRefreshToken struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + TokenHash string `orm:"column(token_hash);size(128)" json:"token_hash"` + IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"` + Tid uint64 `orm:"column(tid);default(0)" json:"tid"` + ClientID string `orm:"column(client_id);size(64);default('')" json:"client_id"` + Sid string `orm:"column(sid);size(64);default('')" json:"sid"` + FamilyID string `orm:"column(family_id);size(64)" json:"family_id"` + RotatedFrom *string `orm:"column(rotated_from);size(128);null" json:"rotated_from"` + Used int8 `orm:"column(used);default(0)" json:"used"` + Revoked int8 `orm:"column(revoked);default(0)" json:"revoked"` + ExpiresAt time.Time `orm:"column(expires_at);type(datetime)" json:"expires_at"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` +} + +func (m *AuthRefreshToken) TableName() string { + return "yz_auth_refresh_token" +} + +// AuthCode 授权码:yz_auth_code +// 一次性、60 秒有效,配合 PKCE(S256)使用。 +type AuthCode struct { + CodeHash string `orm:"column(code_hash);size(128);pk" json:"code_hash"` + ClientID string `orm:"column(client_id);size(64)" json:"client_id"` + IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"` + Tid uint64 `orm:"column(tid);default(0)" json:"tid"` + RedirectURI string `orm:"column(redirect_uri);size(500)" json:"redirect_uri"` + CodeChallenge string `orm:"column(code_challenge);size(128)" json:"code_challenge"` + CodeChallengeMethod string `orm:"column(code_challenge_method);size(10);default(S256)" json:"code_challenge_method"` + Scope *string `orm:"column(scope);size(255);null" json:"scope"` + Nonce *string `orm:"column(nonce);size(128);null" json:"nonce"` + Used int8 `orm:"column(used);default(0)" json:"used"` + ExpiresAt time.Time `orm:"column(expires_at);type(datetime)" json:"expires_at"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` +} + +func (m *AuthCode) TableName() string { + return "yz_auth_code" +} + +// AuthTokenBlacklist 令牌吊销表:yz_auth_token_blacklist +// 记录已登出/被踢下线的 access token 的 jti,过期后可定期清理。 +type AuthTokenBlacklist struct { + Jti string `orm:"column(jti);size(64);pk" json:"jti"` + IdentityID uint64 `orm:"column(identity_id);default(0)" json:"identity_id"` + Sid *string `orm:"column(sid);size(64);null" json:"sid"` + Reason *string `orm:"column(reason);size(64);null" json:"reason"` + ExpiresAt time.Time `orm:"column(expires_at);type(datetime)" json:"expires_at"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` +} + +func (m *AuthTokenBlacklist) TableName() string { + return "yz_auth_token_blacklist" +} diff --git a/go/models/auth_tenant_config.go b/go/models/auth_tenant_config.go new file mode 100644 index 0000000..e3dcacf --- /dev/null +++ b/go/models/auth_tenant_config.go @@ -0,0 +1,66 @@ +package models + +import "time" + +// 租户自带身份源 provider +const ( + TenantIdPDingTalk = "dingtalk" + TenantIdPFeishu = "feishu" + TenantIdPWeWork = "wework" + TenantIdPOIDC = "oidc" + TenantIdPSAML = "saml" +) + +// AuthTenantIdp 租户自带身份源:yz_auth_tenant_idp +// 企业客户可配置自己的钉钉/飞书/企业微信或标准 OIDC/SAML 上游, +// 登录时按 tid 路由到对应身份源(第10条需求)。 +type AuthTenantIdp struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid)" json:"tid"` + Provider string `orm:"column(provider);size(32)" json:"provider"` + Name *string `orm:"column(name);size(128);null" json:"name"` + AppID *string `orm:"column(app_id);size(128);null" json:"app_id"` + AppSecret *string `orm:"column(app_secret);size(512);null" json:"-"` + Issuer *string `orm:"column(issuer);size(500);null" json:"issuer"` + AuthURL *string `orm:"column(auth_url);size(500);null" json:"auth_url"` + TokenURL *string `orm:"column(token_url);size(500);null" json:"token_url"` + UserinfoURL *string `orm:"column(userinfo_url);size(500);null" json:"userinfo_url"` + JwksURL *string `orm:"column(jwks_url);size(500);null" json:"jwks_url"` + Scopes *string `orm:"column(scopes);size(255);null" json:"scopes"` + AttrMap *string `orm:"column(attr_map);type(text);null" json:"attr_map"` + ProxyURL *string `orm:"column(proxy_url);size(500);null" json:"proxy_url"` + Status int8 `orm:"column(status);default(1)" json:"status"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` +} + +func (m *AuthTenantIdp) TableName() string { + return "yz_auth_tenant_idp" +} + +// 并发超限策略 +const ( + KickStrategyKickOld = 1 // 踢掉旧会话(默认) + KickStrategyReject = 2 // 拒绝新登录 +) + +// AuthTenantAuthConfig 租户登录策略:yz_auth_tenant_auth_config +// 每个租户可自定义验证码方式、密码强度、会话时长与并发设备数。 +type AuthTenantAuthConfig struct { + Tid uint64 `orm:"column(tid);pk" json:"tid"` + VerifyType string `orm:"column(verify_type);size(20);default(captcha)" json:"verify_type"` + OpenVerify int8 `orm:"column(open_verify);default(1)" json:"open_verify"` + PwdMinLen int `orm:"column(pwd_min_len);default(8)" json:"pwd_min_len"` + PwdComplexity int8 `orm:"column(pwd_complexity);default(0)" json:"pwd_complexity"` + SessionTTL int `orm:"column(session_ttl);default(7200)" json:"session_ttl"` + MaxSession int `orm:"column(max_session);default(1)" json:"max_session"` + KickStrategy int8 `orm:"column(kick_strategy);default(1)" json:"kick_strategy"` + MfaRequired int8 `orm:"column(mfa_required);default(0)" json:"mfa_required"` + IPWhitelist *string `orm:"column(ip_whitelist);type(text);null" json:"ip_whitelist"` + AllowThird *string `orm:"column(allow_third);size(255);null" json:"allow_third"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` +} + +func (m *AuthTenantAuthConfig) TableName() string { + return "yz_auth_tenant_auth_config" +} diff --git a/go/models/auth_tenant_user.go b/go/models/auth_tenant_user.go new file mode 100644 index 0000000..e30062e --- /dev/null +++ b/go/models/auth_tenant_user.go @@ -0,0 +1,28 @@ +package models + +import "time" + +// AuthTenantUser 身份-企业绑定:yz_auth_tenant_user +// +// 一个 AuthIdentity 可有多条绑定(在多家企业任职),各企业内 +// 独立维护姓名、部门、角色与启用状态;密码不属于这一层。 +type AuthTenantUser struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid)" json:"tid"` + IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"` + Account *string `orm:"column(account);size(64);null" json:"account"` + Name *string `orm:"column(name);size(64);null" json:"name"` + Phone *string `orm:"column(phone);size(20);null" json:"phone"` + Email *string `orm:"column(email);size(128);null" json:"email"` + GroupID uint64 `orm:"column(group_id);default(0)" json:"group_id"` + OrgID uint64 `orm:"column(org_id);default(0)" json:"org_id"` + IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"` + Status int8 `orm:"column(status);default(1)" json:"status"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *AuthTenantUser) TableName() string { + return "yz_auth_tenant_user" +} diff --git a/go/models/init.go b/go/models/init.go index f873cf9..8109074 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -97,6 +97,8 @@ func Init(_ string) { new(TenantCrmContract), new(TenantCrmPayback), new(TenantCrmPaybackRecord), + // 平台更新通知(docs/sql/platform_upgrade.sql) + new(PlatformUpgradeNotice), new(ErpAccountSet), new(ErpNormalSetting), new(ErpCompanyContact), @@ -176,6 +178,19 @@ func Init(_ string) { new(WechatMpConfig), new(WechatMpFollower), new(WechatMpVerifyCode), + + // 统一认证中心(UAC):docs/sql/create_auth_tables.sql,人工执行建表 + new(AuthIdentity), + new(AuthIdentityThird), + new(AuthTenantUser), + new(AuthClient), + new(AuthSession), + new(AuthRefreshToken), + new(AuthCode), + new(AuthTokenBlacklist), + new(AuthTenantIdp), + new(AuthTenantAuthConfig), + new(AuthLoginLog), ) // 创建全局 Ormer @@ -192,5 +207,3 @@ func Init(_ string) { // 新建与调整走 SQL:docs/sql/update_role_system.sql,代码不做自动补建。 Orm = orm.NewOrm() } - - diff --git a/go/models/platform_upgrade.go b/go/models/platform_upgrade.go new file mode 100644 index 0000000..5375517 --- /dev/null +++ b/go/models/platform_upgrade.go @@ -0,0 +1,23 @@ +package models + +import "time" + +// PlatformUpgradeNotice 平台更新通知记录表:yz_platform_upgrade_notice +// 用于展示平台近期更新内容,支持按时间排序展示 +type PlatformUpgradeNotice struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"` // 租户 ID,0 表示全局 + UpdateDate string `orm:"column(update_date);size(20)" json:"update_date"` // 更新日期 YYYY-MM-DD + Title string `orm:"column(title);size(255)" json:"title"` // 更新标题 + Content string `orm:"column(content);type(text)" json:"content"` // 更新内容详情 + Sort int `orm:"column(sort);default(0)" json:"sort"` // 排序权重,越大越靠前 + Status int8 `orm:"column(status);default(1)" json:"status"` // 状态:0-隐藏 1-显示 + CreateUserID string `orm:"column(create_user_id);size(64)" json:"create_user_id"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *PlatformUpgradeNotice) TableName() string { + return "yz_platform_upgrade_notice" +} diff --git a/go/pkg/jwtutil/jwt.go b/go/pkg/jwtutil/jwt.go index 42a4103..e9affb6 100644 --- a/go/pkg/jwtutil/jwt.go +++ b/go/pkg/jwtutil/jwt.go @@ -1,63 +1,321 @@ -package jwtutil - -import ( - "errors" - "time" - - "github.com/golang-jwt/jwt/v5" -) - -// 密钥(后续可从配置中读取) -var secret = []byte("yunzer_jwt_secret_key") - -// Claims 定义JWT的claims结构 -type Claims struct { - UserID int `json:"user_id"` - Username string `json:"username"` - TenantId int `json:"tenant_id"` // 租户ID - UserType string `json:"user_type"` // 用户类型:"user" / "employee" / "platform" 等 - jwt.RegisteredClaims -} - -// GenerateToken 生成JWT token -func GenerateToken(userID int, username string, tenantId int, userType string) (string, error) { - expirationTime := time.Now().Add(24 * time.Hour) - - claims := &Claims{ - UserID: userID, - Username: username, - TenantId: tenantId, - UserType: userType, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(expirationTime), - IssuedAt: jwt.NewNumericDate(time.Now()), - NotBefore: jwt.NewNumericDate(time.Now()), - }, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - tokenString, err := token.SignedString(secret) - return tokenString, err -} - -// ParseToken 解析JWT token -func ParseToken(tokenString string) (*Claims, error) { - claims := &Claims{} - token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, errors.New("unexpected signing method") - } - return secret, nil - }) - - if err != nil { - return nil, err - } - - if !token.Valid { - return nil, errors.New("invalid token") - } - - return claims, nil -} - +package jwtutil + +import ( + "crypto/rsa" + "encoding/base64" + "errors" + "fmt" + "math/big" + "os" + "strings" + "sync" + "time" + + beego "github.com/beego/beego/v2/server/web" + "github.com/golang-jwt/jwt/v5" +) + +// 算法常量 +const ( + AlgHS256 = "HS256" + AlgRS256 = "RS256" +) + +// DefaultKid 默认密钥 ID。历史 token 未携带 kid,统一按此 ID 处理。 +const DefaultKid = "default" + +// legacySecret 兼容用的内置密钥:仅用于解析历史已签发的 token。 +// 生产环境必须在 app.conf 中配置 jwt_secret,否则会一直使用该固定值。 +const legacySecret = "yunzer_jwt_secret_key" + +// Claims JWT 载荷。旧字段保持不变,新增字段均为 omitempty, +// 历史 token 解析后为零值,不影响现有 78 处解析点。 +type Claims struct { + UserID int `json:"user_id"` + Username string `json:"username"` + TenantId int `json:"tenant_id"` // 租户ID + UserType string `json:"user_type"` // 用户类型:"user" / "employee" / "platform" 等 + + // ---- 统一认证中心(OIDC)扩展字段 ---- + ClientID string `json:"client_id,omitempty"` // 接入应用 client_id,即 aud 的业务标识 + Sid string `json:"sid,omitempty"` // 会话ID,用于单点登出/踢下线 + Scope string `json:"scope,omitempty"` + Amr string `json:"amr,omitempty"` // 认证方式:pwd/sms/otp/wx/... + + jwt.RegisteredClaims +} + +// ---------------------------------------------------------------- 密钥管理 + +var ( + keyOnce sync.Once + hsKeys map[string][]byte // kid -> HMAC 密钥 + rsaPriv *rsa.PrivateKey // RS256 签名 + rsaPub *rsa.PublicKey // RS256 验签 + rsaKid string // RS256 密钥 ID + issuerVal string +) + +func loadKeys() { + hsKeys = map[string][]byte{DefaultKid: []byte(legacySecret)} + + // 主密钥(覆盖内置默认值) + if s, _ := beego.AppConfig.String("jwt_secret"); strings.TrimSpace(s) != "" { + hsKeys[DefaultKid] = []byte(strings.TrimSpace(s)) + } + // 轮换密钥:jwt_secrets = kid1:secret1,kid2:secret2 + if s, _ := beego.AppConfig.String("jwt_secrets"); strings.TrimSpace(s) != "" { + for _, item := range strings.Split(s, ",") { + kv := strings.SplitN(strings.TrimSpace(item), ":", 2) + if len(kv) == 2 && strings.TrimSpace(kv[0]) != "" && strings.TrimSpace(kv[1]) != "" { + hsKeys[strings.TrimSpace(kv[0])] = []byte(strings.TrimSpace(kv[1])) + } + } + } + + // RS256 密钥对:优先读文件路径,其次读内联 PEM + privPEM := readKeyConf("jwt_rsa_private_key", "jwt_rsa_private_key_file") + pubPEM := readKeyConf("jwt_rsa_public_key", "jwt_rsa_public_key_file") + if privPEM != "" { + if k, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privPEM)); err == nil { + rsaPriv = k + rsaPub = &k.PublicKey + } + } + if pubPEM != "" { + if k, err := jwt.ParseRSAPublicKeyFromPEM([]byte(pubPEM)); err == nil { + rsaPub = k + } + } + if rsaKid == "" { + rsaKid, _ = beego.AppConfig.String("jwt_rsa_kid") + } + if rsaKid == "" { + rsaKid = "rsa-1" + } + issuerVal, _ = beego.AppConfig.String("jwt_issuer") +} + +func readKeyConf(inlineKey, fileKey string) string { + if v, _ := beego.AppConfig.String(inlineKey); strings.TrimSpace(v) != "" { + return strings.ReplaceAll(strings.TrimSpace(v), `\n`, "\n") + } + if p, _ := beego.AppConfig.String(fileKey); strings.TrimSpace(p) != "" { + if b, err := os.ReadFile(strings.TrimSpace(p)); err == nil { + return string(b) + } + } + return "" +} + +func ensureKeys() { + keyOnce.Do(loadKeys) +} + +// keyFunc 按 token header 的 alg + kid 选择验签密钥。 +// 未携带 kid 或 kid 未注册时回落到默认密钥,保证历史 token 仍可解析。 +func keyFunc(token *jwt.Token) (interface{}, error) { + ensureKeys() + kid, _ := token.Header["kid"].(string) + switch token.Method.Alg() { + case AlgHS256: + if sec, ok := hsKeys[kid]; ok && kid != "" { + return sec, nil + } + return hsKeys[DefaultKid], nil + case AlgRS256: + if rsaPub == nil { + return nil, errors.New("服务端未配置 RS256 公钥") + } + return rsaPub, nil + } + return nil, fmt.Errorf("unsupported signing method: %v", token.Header["alg"]) +} + +// ---------------------------------------------------------------- 签发 + +// TokenOptions 认证中心签发参数 +type TokenOptions struct { + Alg string // HS256 / RS256;留空时优先 RS256,未配置 RSA 则回落 HS256 + UserID int // 身份 ID(OIDC sub 用字符串形式) + Username string + TenantID int + UserType string + ClientID string + Sid string + Scope string + Amr string + Subject string + Audience []string + Jti string // JWT ID,用于吊销(登出/踢下线) + TTL time.Duration // 留空默认 30 分钟 + Kid string +} + +// SignToken 签发 token。RSA 未配置时自动回落 HS256,保证服务可启动。 +func SignToken(opt TokenOptions) (string, error) { + ensureKeys() + + alg := strings.ToUpper(strings.TrimSpace(opt.Alg)) + if alg == "" { + alg = AlgRS256 + } + if alg == AlgRS256 && rsaPriv == nil { + alg = AlgHS256 + } + + ttl := opt.TTL + if ttl <= 0 { + ttl = 30 * time.Minute + } + now := time.Now() + claims := &Claims{ + UserID: opt.UserID, + Username: opt.Username, + TenantId: opt.TenantID, + UserType: opt.UserType, + ClientID: opt.ClientID, + Sid: opt.Sid, + Scope: opt.Scope, + Amr: opt.Amr, + RegisteredClaims: jwt.RegisteredClaims{ + ID: opt.Jti, + Subject: opt.Subject, + Audience: opt.Audience, + ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + }, + } + if issuerVal != "" { + claims.Issuer = issuerVal + } + if opt.Subject == "" && opt.UserID > 0 { + claims.Subject = fmt.Sprintf("%d", opt.UserID) + } + + var method jwt.SigningMethod + switch alg { + case AlgRS256: + method = jwt.SigningMethodRS256 + default: + method = jwt.SigningMethodHS256 + } + token := jwt.NewWithClaims(method, claims) + + kid := strings.TrimSpace(opt.Kid) + if kid == "" && alg == AlgRS256 { + kid = rsaKid + } + if kid != "" { + token.Header["kid"] = kid + } + + var key interface{} + if alg == AlgRS256 { + key = rsaPriv + } else { + kidToUse := kid + if kidToUse == "" { + kidToUse = DefaultKid + } + if sec, ok := hsKeys[kidToUse]; ok { + key = sec + } else { + key = hsKeys[DefaultKid] + } + } + return token.SignedString(key) +} + +// GenerateToken 生成 JWT token(兼容旧签名与行为)。 +// 默认 HS256;若 app.conf 配置了 jwt_issuer 则带上 iss。 +func GenerateToken(userID int, username string, tenantId int, userType string) (string, error) { + ttl := 24 * time.Hour + ensureKeys() + now := time.Now() + claims := &Claims{ + UserID: userID, + Username: username, + TenantId: tenantId, + UserType: userType, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + }, + } + if issuerVal != "" { + claims.Issuer = issuerVal + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(hsKeys[DefaultKid]) +} + +// ---------------------------------------------------------------- 解析 + +// ParseToken 解析并校验 JWT。自动识别 HS256 / RS256,函数签名与旧版一致。 +func ParseToken(tokenString string) (*Claims, error) { + claims := &Claims{} + _, err := jwt.ParseWithClaims(tokenString, claims, keyFunc, + jwt.WithValidMethods([]string{AlgHS256, AlgRS256})) + if err != nil { + return nil, err + } + return claims, nil +} + +// ParseTokenRaw 解析 token 但不校验有效期,用于登出/吊销场景获取 jti。 +func ParseTokenRaw(tokenString string) (*Claims, error) { + claims := &Claims{} + parser := jwt.NewParser(jwt.WithValidMethods([]string{AlgHS256, AlgRS256}), jwt.WithoutClaimsValidation()) + if _, err := parser.ParseWithClaims(tokenString, claims, keyFunc); err != nil { + return nil, err + } + return claims, nil +} + +// Issuer 返回配置的签发者(未配置时为空) +func Issuer() string { + ensureKeys() + return issuerVal +} + +// JWK JSON Web Key(RS256 公钥) +type JWK struct { + Kty string `json:"kty"` + Use string `json:"use"` + Alg string `json:"alg"` + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` +} + +// JWKS 返回 RSA 公钥集合(供 /auth/jwks.json 暴露)。 +// 各应用本地用公钥验签即可,无需每次回调认证中心的 introspect 接口。 +func JWKS() []JWK { + ensureKeys() + if rsaPub == nil || rsaPub.N == nil { + return nil + } + return []JWK{{ + Kty: "RSA", + Use: "sig", + Alg: AlgRS256, + Kid: rsaKid, + N: base64.RawURLEncoding.EncodeToString(rsaPub.N.Bytes()), + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaPub.E)).Bytes()), + }} +} + +// HasRSA 是否已配置 RS256 密钥对(决定认证中心能否签发非对称 token) +func HasRSA() bool { + ensureKeys() + return rsaPriv != nil && rsaPub != nil +} + +// Kid 返回当前 RS256 密钥 ID +func Kid() string { + ensureKeys() + return rsaKid +} diff --git a/go/pkg/jwtutil/jwt_test.go b/go/pkg/jwtutil/jwt_test.go new file mode 100644 index 0000000..70a3335 --- /dev/null +++ b/go/pkg/jwtutil/jwt_test.go @@ -0,0 +1,70 @@ +package jwtutil + +import ( + "testing" + "time" +) + +// TestGenerateParseBackwardCompat 旧签发方式产生的 token 必须仍能被解析(向后兼容) +func TestGenerateParseBackwardCompat(t *testing.T) { + token, err := GenerateToken(1001, "zhangsan", 7, "backend") + if err != nil { + t.Fatalf("GenerateToken 失败: %v", err) + } + claims, err := ParseToken(token) + if err != nil { + t.Fatalf("ParseToken 失败: %v", err) + } + if claims.UserID != 1001 || claims.Username != "zhangsan" || claims.TenantId != 7 || claims.UserType != "backend" { + t.Fatalf("claims 解析不符: %+v", claims) + } + // 旧 token 无认证中心扩展字段 + if claims.Sid != "" || claims.ClientID != "" { + t.Fatal("旧 token 不应携带 sid/client_id") + } +} + +// TestSignTokenFallback 未配置 RSA 时自动回落 HS256,且可解析 +func TestSignTokenFallback(t *testing.T) { + token, err := SignToken(TokenOptions{ + Alg: AlgRS256, // 期望回落到 HS256 + UserID: 42, + Username: "u", + TenantID: 3, + UserType: "tenant", + ClientID: "crm", + Sid: "sid-001", + Subject: "42", + TTL: 15 * time.Minute, + }) + if err != nil { + t.Fatalf("SignToken 失败: %v", err) + } + claims, err := ParseToken(token) + if err != nil { + t.Fatalf("ParseToken 失败: %v", err) + } + if claims.ClientID != "crm" || claims.Sid != "sid-001" || claims.Subject != "42" { + t.Fatalf("扩展 claims 解析不符: %+v", claims) + } +} + +// TestParseInvalid 非法 token 必须报错 +func TestParseInvalid(t *testing.T) { + if _, err := ParseToken("not-a-token"); err == nil { + t.Fatal("非法 token 应返回错误") + } + if _, err := ParseToken(""); err == nil { + t.Fatal("空 token 应返回错误") + } +} + +// TestHasRSA 无配置时不应 panic +func TestHasRSA(t *testing.T) { + if HasRSA() { + t.Log("已配置 RSA 密钥对") + } + if Kid() == "" { + t.Fatal("kid 不应为空") + } +} diff --git a/go/pkg/passwordutil/password.go b/go/pkg/passwordutil/password.go index d17ff6f..7eee613 100644 --- a/go/pkg/passwordutil/password.go +++ b/go/pkg/passwordutil/password.go @@ -1,55 +1,149 @@ -package passwordutil - -import ( - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "errors" - "strings" -) - -const ( - saltBytes = 16 - separator = "$" - hashLength = 64 // sha256 hex length -) - -// Hash 生成 salt+hash 的存储串,格式:salt$hash(均为 hex) -func Hash(plain string) (string, error) { - plain = strings.TrimSpace(plain) - if plain == "" { - return "", errors.New("password 不能为空") - } - salt := make([]byte, saltBytes) - if _, err := rand.Read(salt); err != nil { - return "", err - } - saltHex := hex.EncodeToString(salt) - hashHex := hashHex(saltHex, plain) - return saltHex + separator + hashHex, nil -} - -// Verify 校验存储串(salt$hash)是否匹配输入明文密码。 -func Verify(stored, plain string) bool { - stored = strings.TrimSpace(stored) - plain = strings.TrimSpace(plain) - if stored == "" || plain == "" { - return false - } - parts := strings.Split(stored, separator) - if len(parts) != 2 { - return false - } - saltHex := strings.TrimSpace(parts[0]) - hashHexStored := strings.TrimSpace(parts[1]) - if saltHex == "" || len(hashHexStored) != hashLength { - return false - } - return hashHex(saltHex, plain) == strings.ToLower(hashHexStored) -} - -func hashHex(saltHex, plain string) string { - sum := sha256.Sum256([]byte(saltHex + plain)) - return hex.EncodeToString(sum[:]) -} - +package passwordutil + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +// 算法标识。 +// 历史数据用 legacy(sha256 单轮,无迭代拉伸,抗 GPU 爆破能力弱); +// 新密码统一用 argon2id,登录成功检测到 legacy 时应重新哈希升级。 +const ( + AlgoArgon2id = "argon2id" + AlgoLegacy = "legacy" +) + +// argon2id 参数(OWASP 推荐起步配置:64MB / 3 轮 / 并行 2) +const ( + argonTime = 3 + argonMemory = 64 * 1024 // 单位 KB,即 64MB + argonThreads = 2 + argonKeyLen = 32 + argonSaltLen = 16 +) + +// legacy 兼容参数 +const ( + saltBytes = 16 + separator = "$" + hashLength = 64 // sha256 hex length +) + +// Hash 使用当前默认算法(argon2id)生成密码存储串。 +// +// 返回 PHC 标准格式(盐与参数内联,无需独立 salt 列): +// +// $argon2id$v=19$m=65536,t=3,p=2$$ +// +// 注意:函数签名与旧版一致,全部调用点无需改动即可切换到新算法。 +func Hash(plain string) (string, error) { + plain = strings.TrimSpace(plain) + if plain == "" { + return "", errors.New("password 不能为空") + } + salt := make([]byte, argonSaltLen) + if _, err := rand.Read(salt); err != nil { + return "", err + } + key := argon2.IDKey([]byte(plain), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, + argonMemory, argonTime, argonThreads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(key), + ), nil +} + +// Verify 校验明文密码是否匹配存储串,自动识别算法: +// - $argon2id$... → argon2id(新) +// - $ → legacy sha256(旧,兼容) +func Verify(stored, plain string) bool { + stored = strings.TrimSpace(stored) + plain = strings.TrimSpace(plain) + if stored == "" || plain == "" { + return false + } + if strings.HasPrefix(stored, "$argon2id$") { + return verifyArgon2id(stored, plain) + } + return verifyLegacy(stored, plain) +} + +// NeedsRehash 判断已存储的密码是否需要用当前算法重新哈希。 +// 登录成功后调用:返回 true 时应拿当次登录的明文重新 Hash 并落库, +// 实现旧算法用户「首次登录自动升级」,无需强制全员重置密码。 +func NeedsRehash(stored string) bool { + return !strings.HasPrefix(strings.TrimSpace(stored), "$argon2id$") +} + +// AlgoOf 返回存储串使用的算法标识,用于审计/统计旧算法存量。 +func AlgoOf(stored string) string { + if strings.HasPrefix(strings.TrimSpace(stored), "$argon2id$") { + return AlgoArgon2id + } + return AlgoLegacy +} + +// verifyArgon2id 解析 PHC 串并用相同参数重算比对(恒定时间比较)。 +func verifyArgon2id(stored, plain string) bool { + parts := strings.Split(stored, "$") + // ["", "argon2id", "v=19", "m=...,t=...,p=...", salt, hash] + if len(parts) != 6 { + return false + } + var memory, iterations, parallelism uint32 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, ¶llelism); err != nil { + return false + } + if memory == 0 || iterations == 0 || parallelism == 0 || parallelism > 255 { + return false + } + threads := uint8(parallelism) + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil || len(salt) == 0 { + return false + } + want, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil || len(want) == 0 { + return false + } + got := argon2.IDKey([]byte(plain), salt, iterations, memory, threads, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1 +} + +// verifyLegacy 校验旧格式:salt$hash(均为 hex),sha256(salt+plain)。 +// 仅用于兼容历史数据,不再用于新密码。 +func verifyLegacy(stored, plain string) bool { + stored = strings.TrimSpace(stored) + plain = strings.TrimSpace(plain) + if stored == "" || plain == "" { + return false + } + parts := strings.Split(stored, separator) + if len(parts) != 2 { + return false + } + saltHex := strings.TrimSpace(parts[0]) + hashHexStored := strings.TrimSpace(parts[1]) + if saltHex == "" || len(hashHexStored) != hashLength { + return false + } + if _, err := hex.DecodeString(saltHex); err != nil { + return false + } + return hashHex(saltHex, plain) == strings.ToLower(hashHexStored) +} + +// hashHex 旧算法:sha256(saltHex + plain),供 verifyLegacy 使用。 +func hashHex(saltHex, plain string) string { + sum := sha256.Sum256([]byte(saltHex + plain)) + return hex.EncodeToString(sum[:]) +} diff --git a/go/pkg/passwordutil/password_test.go b/go/pkg/passwordutil/password_test.go new file mode 100644 index 0000000..6a74f3e --- /dev/null +++ b/go/pkg/passwordutil/password_test.go @@ -0,0 +1,69 @@ +package passwordutil + +import ( + "crypto/sha256" + "encoding/hex" + "testing" +) + +// TestHashVerifyArgon2id 新算法:哈希后可校验,错误密码拒绝 +func TestHashVerifyArgon2id(t *testing.T) { + stored, err := Hash("Passw0rd@2026") + if err != nil { + t.Fatalf("Hash 失败: %v", err) + } + if !verifyArgon2id(stored, "Passw0rd@2026") { + t.Fatal("正确密码应校验通过") + } + if verifyArgon2id(stored, "wrong-password") { + t.Fatal("错误密码不应通过") + } + if NeedsRehash(stored) { + t.Fatal("argon2id 不应需要重新哈希") + } + if AlgoOf(stored) != AlgoArgon2id { + t.Fatalf("算法标识应为 argon2id,实际 %s", AlgoOf(stored)) + } +} + +// TestLegacyCompat 旧算法兼容:历史 salt$sha256 串仍可登录,且被标记为需要升级 +func TestLegacyCompat(t *testing.T) { + saltHex := "0123456789abcdef0123456789abcdef" + sum := sha256.Sum256([]byte(saltHex + "oldpass")) + legacy := saltHex + "$" + hex.EncodeToString(sum[:]) + + if !Verify(legacy, "oldpass") { + t.Fatal("历史密码应校验通过(兼容)") + } + if Verify(legacy, "other") { + t.Fatal("错误密码不应通过") + } + if !NeedsRehash(legacy) { + t.Fatal("历史密码应标记为需要重新哈希") + } + if AlgoOf(legacy) != AlgoLegacy { + t.Fatalf("算法标识应为 legacy,实际 %s", AlgoOf(legacy)) + } +} + +// TestDifferentSaltSamePassword 相同明文两次哈希结果必须不同(盐随机) +func TestDifferentSaltSamePassword(t *testing.T) { + a, _ := Hash("same-password") + b, _ := Hash("same-password") + if a == b { + t.Fatal("相同明文两次哈希不应相同(盐必须随机)") + } + if !Verify(a, "same-password") || !Verify(b, "same-password") { + t.Fatal("两份哈希都应能通过校验") + } +} + +// TestEmptyPassword 空密码边界 +func TestEmptyPassword(t *testing.T) { + if _, err := Hash(" "); err == nil { + t.Fatal("空密码应返回错误") + } + if Verify("", "x") || Verify("x", "") { + t.Fatal("空输入不应通过校验") + } +} diff --git a/go/routers/api/api.go b/go/routers/api/api.go index 6572128..905b0a7 100644 --- a/go/routers/api/api.go +++ b/go/routers/api/api.go @@ -8,6 +8,12 @@ import ( // Register 注册移动端 / 开放 API(api)路由。 func Register() { + // 平台更新通知列表(无需登录) + beego.Router("/platform/api/upgrade/list", &controllers.ApiPlatformUpgradeController{}, "get:List") + + // 平台更新通知详情(无需登录) + beego.Router("/platform/api/upgrade/detail", &controllers.ApiPlatformUpgradeController{}, "get:Detail") + // 客户端检查更新(无需登录) beego.Router("/api/softwareupgrade/check", &controllers.ApiSoftwareUpgradeController{}, "get:Check") diff --git a/go/routers/auth/auth.go b/go/routers/auth/auth.go new file mode 100644 index 0000000..da929db --- /dev/null +++ b/go/routers/auth/auth.go @@ -0,0 +1,34 @@ +// Package auth 统一认证中心(UAC)路由:api.yunzer.cn/auth +// +// 重要:该路由组必须在 router.go 的所有运行模式下都注册 +// (platform / backend / index / api / app / all), +// 否则对应模式下认证中心不可用。 +package auth + +import ( + authctl "server/controllers/auth" + + beego "github.com/beego/beego/v2/server/web" +) + +// Register 注册认证中心路由 +func Register() { + // ---- OIDC 标准端点 ---- + beego.Router("/auth/.well-known/openid-configuration", &authctl.AuthOidcController{}, "get:Discovery") + beego.Router("/auth/jwks.json", &authctl.AuthOidcController{}, "get:JWKS") + beego.Router("/auth/authorize", &authctl.AuthOidcController{}, "get:Authorize") + beego.Router("/auth/token", &authctl.AuthOidcController{}, "post:Token") + beego.Router("/auth/userinfo", &authctl.AuthOidcController{}, "get:UserInfo;post:UserInfo") + beego.Router("/auth/introspect", &authctl.AuthOidcController{}, "post:Introspect") + beego.Router("/auth/revoke", &authctl.AuthOidcController{}, "post:Revoke") + + // ---- 登录与会话 ---- + // 注意:beego 同一路径重复 Router 会覆盖,故 /auth/logout 只注册一次 + beego.Router("/auth/login", &authctl.AuthLoginController{}, "get:LoginPage;post:LoginSubmit") + beego.Router("/auth/logout", &authctl.AuthLoginController{}, "get:LogoutPage;post:LogoutAction") + beego.Router("/auth/tenants", &authctl.AuthLoginController{}, "get:Tenants") + beego.Router("/auth/switch-tenant", &authctl.AuthLoginController{}, "post:SwitchTenant") + beego.Router("/auth/sessions", &authctl.AuthLoginController{}, "get:Sessions") + beego.Router("/auth/sessions/kick", &authctl.AuthLoginController{}, "post:KickSession") + beego.Router("/auth/verify-config", &authctl.AuthLoginController{}, "get:VerifyConfig") +} diff --git a/go/routers/router.go b/go/routers/router.go index 5f0a062..f49f8a7 100644 --- a/go/routers/router.go +++ b/go/routers/router.go @@ -1,80 +1,90 @@ -package routers - -import ( - "os" - - "server/controllers" - "server/middleware" - "server/routers/api" - "server/routers/app" - "server/routers/backend" - "server/routers/index" - "server/routers/platform" - - beego "github.com/beego/beego/v2/server/web" - "github.com/beego/beego/v2/server/web/context" -) - -// 初始化路由(精简版) -func init() { - // 全局 CORS 处理 + 预检请求 - // 注意:Allow-Origin 为 * 时不能同时设置 Allow-Credentials: true,否则浏览器会拒绝带 Authorization 的预检(上传/接口跨域常见现象)。 - // 当前 JWT 走 Header、前端 axios withCredentials=false,无需携带 Cookie,故不返回 Allow-Credentials。 - beego.InsertFilter("*", beego.BeforeRouter, func(ctx *context.Context) { - ctx.Output.Header("Access-Control-Allow-Origin", "*") - ctx.Output.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS") - ctx.Output.Header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization") - ctx.Output.Header("Access-Control-Max-Age", "86400") - - if ctx.Input.Method() == "OPTIONS" { - ctx.Output.Status = 200 - ctx.Output.Body([]byte("")) - return - } - }) - - // 全局操作日志:请求开始采集并异步写入 - beego.InsertFilter("*", beego.BeforeRouter, middleware.BeginOperationLog) - - // Let's Encrypt HTTP-01 验证回访。 - // 无条件注册:证书颁发机构何时来验证与 APP_MODE 无关,任何启动模式都必须能应答。 - beego.Router("/.well-known/acme-challenge/:token", &controllers.AcmeChallengeController{}, "get:Serve") - - // 根据运行模式选择要注册的路由组 - // 优先读取环境变量 APP_MODE,其次读取配置 app_mode,默认 all - mode := os.Getenv("APP_MODE") - if mode == "" { - mode, _ = beego.AppConfig.String("app_mode") - } - if mode == "" { - mode = "all" - } - - switch mode { - case "platform": - platform.Register() - app.Register() - // 在 platform 模式下,仍保留 backend 登录相关路由,避免后台登录 404 - backend.RegisterAuthRoutes() - case "backend": - backend.Register() - app.Register() - case "index": - index.Register() - app.Register() - case "api": - api.Register() - case "app": - app.Register() - case "all": - platform.Register() - backend.Register() - index.Register() - api.Register() - app.Register() - default: - // 未知模式时,退回到只启用平台端,避免启动失败 - platform.Register() - app.Register() - } -} +package routers + +import ( + "os" + + "server/controllers" + "server/middleware" + "server/routers/api" + "server/routers/app" + authrouter "server/routers/auth" + "server/routers/backend" + "server/routers/index" + "server/routers/platform" + + beego "github.com/beego/beego/v2/server/web" + "github.com/beego/beego/v2/server/web/context" +) + +// 初始化路由(精简版) +func init() { + // 全局 CORS 处理 + 预检请求 + // 注意:Allow-Origin 为 * 时不能同时设置 Allow-Credentials: true,否则浏览器会拒绝带 Authorization 的预检(上传/接口跨域常见现象)。 + // 当前 JWT 走 Header、前端 axios withCredentials=false,无需携带 Cookie,故不返回 Allow-Credentials。 + beego.InsertFilter("*", beego.BeforeRouter, func(ctx *context.Context) { + ctx.Output.Header("Access-Control-Allow-Origin", "*") + ctx.Output.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS") + ctx.Output.Header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization") + ctx.Output.Header("Access-Control-Max-Age", "86400") + + if ctx.Input.Method() == "OPTIONS" { + ctx.Output.Status = 200 + ctx.Output.Body([]byte("")) + return + } + }) + + // 全局 JWT 鉴权:解析 token 并写入上下文(userId/tenantId/userType/...)。 + // 模式由 app.conf 的 auth_enforce 控制: + // warn(默认,观察模式,只记录不拦截)→ on(真正拦截)→ off(紧急关闭) + // 放在操作日志之前注册,保证操作日志能取到 userId。 + beego.InsertFilter("*", beego.BeforeRouter, middleware.JWTAuthMiddleware()) + + // 全局操作日志:请求开始采集并异步写入 + beego.InsertFilter("*", beego.BeforeRouter, middleware.BeginOperationLog) + + // Let's Encrypt HTTP-01 验证回访。 + // 无条件注册:证书颁发机构何时来验证与 APP_MODE 无关,任何启动模式都必须能应答。 + beego.Router("/.well-known/acme-challenge/:token", &controllers.AcmeChallengeController{}, "get:Serve") + + // 统一认证中心(UAC):所有运行模式都必须注册,否则对应模式下无法登录 + authrouter.Register() + + // 根据运行模式选择要注册的路由组 + // 优先读取环境变量 APP_MODE,其次读取配置 app_mode,默认 all + mode := os.Getenv("APP_MODE") + if mode == "" { + mode, _ = beego.AppConfig.String("app_mode") + } + if mode == "" { + mode = "all" + } + + switch mode { + case "platform": + platform.Register() + app.Register() + // 在 platform 模式下,仍保留 backend 登录相关路由,避免后台登录 404 + backend.RegisterAuthRoutes() + case "backend": + backend.Register() + app.Register() + case "index": + index.Register() + app.Register() + case "api": + api.Register() + case "app": + app.Register() + case "all": + platform.Register() + backend.Register() + index.Register() + api.Register() + app.Register() + default: + // 未知模式时,退回到只启用平台端,避免启动失败 + platform.Register() + app.Register() + } +} diff --git a/go/scripts/migrate_auth.go b/go/scripts/migrate_auth.go new file mode 100644 index 0000000..b1160c0 --- /dev/null +++ b/go/scripts/migrate_auth.go @@ -0,0 +1,392 @@ +// 统一认证中心一次性数据迁移: +// yz_system_tenant_user → yz_auth_identity(自然人)+ yz_auth_tenant_user(身份-企业绑定) +// +// 前置条件: +// 1. 已人工执行 docs/sql/create_auth_tables.sql 建表; +// 2. 在 go/ 目录下运行(需读取 conf/app.conf)。 +// +// 用法: +// go run scripts/migrate_auth.go -check 预检:只输出统计与冲突报告,不写任何数据 +// go run scripts/migrate_auth.go -apply 执行迁移(自动备份源表后再写入) +// go run scripts/migrate_auth.go -apply -force 目标表已有数据时强制重跑 +// +// 迁移规则: +// - 归并键优先级:手机号 > 邮箱 > 租户内账号;三者皆空时按 tid+原 uid 兜底; +// - 同一归并键命中多条(多企业任职)→ 只建 1 个 identity,再建 N 条企业绑定; +// - 同一归并键下密码不一致 → 记为冲突,取 is_default 优先、其次 id 最大的那条, +// 并写入冲突报告供人工核对; +// - 历史密码为 legacy(salt$sha256),无法离线转 argon2id,原样搬迁, +// 用户登录成功时自动重新哈希升级。 +package main + +import ( + "encoding/csv" + "flag" + "fmt" + "log" + "os" + "sort" + "strings" + "time" + + beego "github.com/beego/beego/v2/server/web" + "github.com/google/uuid" + + "server/models" + "server/pkg/passwordutil" +) + +// srcRow 源表行(yz_system_tenant_user) +type srcRow struct { + ID uint64 + Tid uint64 + Uid uint64 + Account string + Name string + Phone string + Email string + Password string + GroupID uint64 + OrgID uint64 + IsDefault int8 + Status int8 +} + +// group 同一归并键下的多条源记录 +type group struct { + key string + rows []srcRow + conflict bool // 同键下密码不一致 + pwdValues int +} + +func trimPtr(p *string) string { + if p == nil { + return "" + } + return strings.TrimSpace(*p) +} + +// mergeKey 归并键:手机 > 邮箱 > 账号;都为空时返回空串(由调用方兜底) +func mergeKey(r srcRow) string { + if r.Phone != "" { + return "m:" + r.Phone + } + if r.Email != "" { + return "e:" + r.Email + } + if r.Account != "" { + return "a:" + r.Account + } + return "" +} + +func main() { + check := flag.Bool("check", false, "预检模式,只输出报告不写数据") + apply := flag.Bool("apply", false, "执行迁移") + force := flag.Bool("force", false, "目标表已有数据时强制重跑") + flag.Parse() + + if !*check && !*apply { + fmt.Println("请指定 -check(预检)或 -apply(执行迁移)") + os.Exit(1) + } + + if err := beego.LoadAppConfig("ini", "conf/app.conf"); err != nil { + log.Printf("加载 conf/app.conf 失败(若已自动加载可忽略): %v", err) + } + models.Init("") + + rows, err := loadSourceRows() + if err != nil { + log.Fatalf("读取源表失败: %v", err) + } + if len(rows) == 0 { + log.Fatal("源表 yz_system_tenant_user 无有效数据,无需迁移") + } + log.Printf("源表有效记录: %d 条", len(rows)) + + groups := buildGroups(rows) + log.Printf("归并后身份数: %d 个(其中冲突 %d 组)", len(groups), countConflict(groups)) + + // 冲突报告 + if err := writeConflictReport(groups); err != nil { + log.Printf("写入冲突报告失败: %v", err) + } + + if *check { + printSummary(rows, groups) + log.Println("预检完成(未写入任何数据)。确认无误后执行: go run scripts/migrate_auth.go -apply") + return + } + + // ---- 执行迁移 ---- + if err := ensureTargetWritable(*force); err != nil { + log.Fatalf("目标表检查失败: %v", err) + } + if err := backupSourceTable(); err != nil { + log.Fatalf("备份源表失败(已中止迁移): %v", err) + } + + migratedBindings := 0 + conflictHandled := 0 + mapFile, err := os.Create(fmt.Sprintf("migrate_auth_map_%s.csv", time.Now().Format("20060102150405"))) + if err != nil { + log.Fatalf("创建映射文件失败: %v", err) + } + defer mapFile.Close() + mapWriter := csv.NewWriter(mapFile) + defer mapWriter.Flush() + _ = mapWriter.Write([]string{"old_id", "old_uid", "tid", "identity_id", "tenant_user_id", "account", "merge_key", "conflict"}) + + for _, g := range groups { + // 代表行:is_default 优先,其次 id 最大 + rep := pickRepresentative(g) + identityID, err := insertIdentity(rep, g.key) + if err != nil { + log.Fatalf("写入身份失败(key=%s): %v", g.key, err) + } + if g.conflict { + conflictHandled++ + } + for _, r := range g.rows { + tu := models.AuthTenantUser{ + Tid: r.Tid, + IdentityID: identityID, + GroupID: r.GroupID, + OrgID: r.OrgID, + IsDefault: r.IsDefault, + Status: r.Status, + } + if r.Account != "" { + tu.Account = &r.Account + } + if r.Name != "" { + tu.Name = &r.Name + } + if r.Phone != "" { + tu.Phone = &r.Phone + } + if r.Email != "" { + tu.Email = &r.Email + } + newID, err := models.Orm.Insert(&tu) + if err != nil { + log.Fatalf("写入企业绑定失败(tid=%d, identity=%d): %v", r.Tid, identityID, err) + } + migratedBindings++ + _ = mapWriter.Write([]string{ + fmt.Sprintf("%d", r.ID), fmt.Sprintf("%d", r.Uid), + fmt.Sprintf("%d", r.Tid), fmt.Sprintf("%d", identityID), + fmt.Sprintf("%d", newID), r.Account, g.key, + fmt.Sprintf("%v", g.conflict), + }) + } + } + + log.Printf("迁移完成:身份 %d 个,企业绑定 %d 条,处理冲突 %d 组", len(groups), migratedBindings, conflictHandled) + log.Printf("映射文件已生成:%s(保存好,回滚或排查用)", mapFile.Name()) +} + +// loadSourceRows 读取未软删的源记录 +func loadSourceRows() ([]srcRow, error) { + var list []models.SystemTenantUser + _, err := models.Orm.QueryTable(new(models.SystemTenantUser)). + Filter("delete_time__isnull", true). + All(&list) + if err != nil { + return nil, err + } + rows := make([]srcRow, 0, len(list)) + for _, m := range list { + rows = append(rows, srcRow{ + ID: m.ID, + Tid: m.Tid, + Uid: m.Uid, + Account: trimPtr(m.Account), + Name: trimPtr(m.Name), + Phone: trimPtr(m.Phone), + Email: trimPtr(m.Email), + Password: trimPtr(m.Password), + GroupID: m.GroupID, + OrgID: m.OrgID, + IsDefault: m.IsDefault, + Status: m.Status, + }) + } + return rows, nil +} + +// buildGroups 按归并键分组,并标记密码冲突 +func buildGroups(rows []srcRow) []*group { + buckets := map[string]*group{} + order := make([]string, 0) + for _, r := range rows { + key := mergeKey(r) + if key == "" { + // 手机/邮箱/账号全空:无法归并,按 tid+uid 兜底为独立身份 + key = fmt.Sprintf("u:%d-%d", r.Tid, r.Uid) + } + g, ok := buckets[key] + if !ok { + g = &group{key: key} + buckets[key] = g + order = append(order, key) + } + g.rows = append(g.rows, r) + } + sort.Strings(order) + out := make([]*group, 0, len(order)) + for _, k := range order { + g := buckets[k] + pwdSet := map[string]bool{} + for _, r := range g.rows { + pwdSet[r.Password] = true + } + g.pwdValues = len(pwdSet) + g.conflict = len(pwdSet) > 1 + out = append(out, g) + } + return out +} + +// pickRepresentative 选出代表行:is_default 优先,其次 id 最大 +func pickRepresentative(g *group) srcRow { + sorted := make([]srcRow, len(g.rows)) + copy(sorted, g.rows) + sort.SliceStable(sorted, func(i, j int) bool { + if sorted[i].IsDefault != sorted[j].IsDefault { + return sorted[i].IsDefault > sorted[j].IsDefault + } + return sorted[i].ID > sorted[j].ID + }) + return sorted[0] +} + +func countConflict(groups []*group) int { + n := 0 + for _, g := range groups { + if g.conflict { + n++ + } + } + return n +} + +// insertIdentity 写入身份;密码算法按原值识别(legacy 保持原样,首登自动升级) +func insertIdentity(rep srcRow, key string) (uint64, error) { + algo := passwordutil.AlgoLegacy + if passwordutil.AlgoOf(rep.Password) == passwordutil.AlgoArgon2id { + algo = passwordutil.AlgoArgon2id + } + identity := models.AuthIdentity{ + UnionID: strings.ReplaceAll(uuid.NewString(), "-", ""), + PasswordAlgo: algo, + Status: rep.Status, + } + if rep.Phone != "" { + identity.Mobile = &rep.Phone + } + if rep.Email != "" { + identity.Email = &rep.Email + } + if rep.Password != "" { + identity.PasswordHash = &rep.Password + } + if rep.Name != "" { + identity.Nickname = &rep.Name + } + id, err := models.Orm.Insert(&identity) + return uint64(id), err +} + +// ensureTargetWritable 目标表已有数据时需 -force 才继续,避免重复迁移 +func ensureTargetWritable(force bool) error { + cnt, err := models.Orm.QueryTable(new(models.AuthTenantUser)).Count() + if err != nil { + return err + } + if cnt > 0 && !force { + return fmt.Errorf("yz_auth_tenant_user 已存在 %d 条数据,如确认要重跑请加 -force(建议先清空目标表)", cnt) + } + return nil +} + +// backupSourceTable 备份源表 +func backupSourceTable() error { + name := fmt.Sprintf("yz_system_tenant_user_bak_%s", time.Now().Format("20060102150405")) + _, err := models.Orm.Raw(fmt.Sprintf("CREATE TABLE `%s` AS SELECT * FROM `yz_system_tenant_user`", name)).Exec() + if err != nil { + return err + } + log.Printf("源表已备份为:%s", name) + return nil +} + +// writeConflictReport 输出冲突报告 CSV +func writeConflictReport(groups []*group) error { + conflicts := make([]*group, 0) + for _, g := range groups { + if g.conflict { + conflicts = append(conflicts, g) + } + } + // 无法归并(手机/邮箱/账号全空)的记录也提示 + unkeyed := 0 + for _, g := range groups { + if strings.HasPrefix(g.key, "u:") { + unkeyed += len(g.rows) + } + } + filename := fmt.Sprintf("migrate_auth_conflict_%s.csv", time.Now().Format("20060102150405")) + f, err := os.Create(filename) + if err != nil { + return err + } + defer f.Close() + w := csv.NewWriter(f) + defer w.Flush() + _ = w.Write([]string{"merge_key", "row_count", "password_variants", "tids", "representative_id"}) + for _, g := range conflicts { + tids := make([]string, 0, len(g.rows)) + for _, r := range g.rows { + tids = append(tids, fmt.Sprintf("%d", r.Tid)) + } + rep := pickRepresentative(g) + _ = w.Write([]string{ + g.key, + fmt.Sprintf("%d", len(g.rows)), + fmt.Sprintf("%d", g.pwdValues), + strings.Join(tids, "|"), + fmt.Sprintf("%d", rep.ID), + }) + } + if len(conflicts) > 0 { + log.Printf("发现密码冲突 %d 组,详见 %s(迁移取 is_default 优先的那条,请人工核对)", len(conflicts), filename) + } + if unkeyed > 0 { + log.Printf("警告:有 %d 条记录手机/邮箱/账号全空,已按 tid+uid 各自独立建身份,建议补齐手机号", unkeyed) + } + return nil +} + +// printSummary 预检模式输出摘要 +func printSummary(rows []srcRow, groups []*group) { + tenantSet := map[uint64]bool{} + for _, r := range rows { + tenantSet[r.Tid] = true + } + multi := 0 + for _, g := range groups { + if len(g.rows) > 1 { + multi++ + } + } + log.Println("---------------- 预检摘要 ----------------") + log.Printf("源记录数 : %d", len(rows)) + log.Printf("涉及企业数 : %d", len(tenantSet)) + log.Printf("归并后身份数 : %d", len(groups)) + log.Printf("多企业任职身份 : %d", multi) + log.Printf("密码冲突组数 : %d", countConflict(groups)) + log.Println("------------------------------------------") +} diff --git a/go/services/auth/identity.go b/go/services/auth/identity.go new file mode 100644 index 0000000..8e40cf7 --- /dev/null +++ b/go/services/auth/identity.go @@ -0,0 +1,148 @@ +// Package auth 统一认证中心(UAC)业务层。 +// +// 认证中心只负责「你是谁、你能进哪些企业、你能用哪些应用」, +// 菜单/按钮/数据权限仍由各业务端自治。 +package auth + +import ( + "strings" + + "github.com/beego/beego/v2/client/orm" + + "server/models" +) + +// TenantOption 登录后可选择进入的企业(一人可在多家企业任职) +type TenantOption struct { + Tid uint64 `json:"tid"` + TenantName string `json:"tenant_name"` + Name string `json:"name"` + GroupID uint64 `json:"group_id"` + OrgID uint64 `json:"org_id"` + IsDefault bool `json:"is_default"` +} + +// IdentityProfile 认证中心视角的身份概要 +type IdentityProfile struct { + ID uint64 `json:"id"` + UnionID string `json:"union_id"` + Mobile string `json:"mobile"` + Email string `json:"email"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Status int8 `json:"status"` + MfaOn bool `json:"mfa_enabled"` + Tenants []TenantOption `json:"tenants"` +} + +// FindIdentityByAccount 按登录账号查找身份。 +// 匹配顺序:手机号 → 邮箱 → 企业内账号(先查绑定再回查身份)。 +func FindIdentityByAccount(account string) (*models.AuthIdentity, error) { + account = strings.TrimSpace(account) + if account == "" { + return nil, orm.ErrNoRows + } + base := models.Orm.QueryTable(new(models.AuthIdentity)).Filter("delete_time__isnull", true) + + for _, field := range []string{"mobile", "email"} { + m := &models.AuthIdentity{} + if err := base.Filter(field, account).One(m); err == nil { + return m, nil + } + } + + // 企业内账号:yz_auth_tenant_user.account → identity_id + var bind models.AuthTenantUser + if err := models.Orm.QueryTable(new(models.AuthTenantUser)). + Filter("account", account). + Filter("delete_time__isnull", true). + OrderBy("-is_default", "id"). + One(&bind); err == nil { + m := &models.AuthIdentity{} + if err := models.Orm.QueryTable(new(models.AuthIdentity)).Filter("id", bind.IdentityID).One(m); err == nil { + return m, nil + } + } + return nil, orm.ErrNoRows +} + +// ListTenantOptions 列出该身份可登录的企业(过滤企业停用与绑定停用)。 +func ListTenantOptions(identityID uint64) ([]TenantOption, error) { + var binds []models.AuthTenantUser + if _, err := models.Orm.QueryTable(new(models.AuthTenantUser)). + Filter("identity_id", identityID). + Filter("status", 1). + Filter("delete_time__isnull", true). + OrderBy("-is_default", "id"). + All(&binds); err != nil { + return nil, err + } + + out := make([]TenantOption, 0, len(binds)) + for _, b := range binds { + var tenant models.SystemTenant + if err := models.Orm.QueryTable(new(models.SystemTenant)). + Filter("id", b.Tid). + Filter("delete_time__isnull", true). + One(&tenant); err != nil { + continue // 租户不存在或已删除 + } + if tenant.Status != 1 { + continue // 租户停用 + } + opt := TenantOption{ + Tid: b.Tid, + TenantName: strings.TrimSpace(tenant.TenantName), + GroupID: b.GroupID, + OrgID: b.OrgID, + IsDefault: b.IsDefault == 1, + } + if b.Name != nil { + opt.Name = strings.TrimSpace(*b.Name) + } + out = append(out, opt) + } + return out, nil +} + +// GetTenantUser 获取身份在指定企业的绑定关系 +func GetTenantUser(identityID, tid uint64) (*models.AuthTenantUser, error) { + var bind models.AuthTenantUser + err := models.Orm.QueryTable(new(models.AuthTenantUser)). + Filter("identity_id", identityID). + Filter("tid", tid). + Filter("delete_time__isnull", true). + One(&bind) + if err != nil { + return nil, err + } + return &bind, nil +} + +// BuildProfile 组装身份概要(含可选企业列表) +func BuildProfile(m *models.AuthIdentity) (*IdentityProfile, error) { + tenants, err := ListTenantOptions(m.ID) + if err != nil { + return nil, err + } + p := &IdentityProfile{ + ID: m.ID, + UnionID: m.UnionID, + Status: m.Status, + MfaOn: m.MfaEnabled == 1, + Tenants: tenants, + } + if m.Mobile != nil { + p.Mobile = *m.Mobile + } + if m.Email != nil { + p.Email = *m.Email + } + if m.Nickname != nil { + p.Nickname = *m.Nickname + } + if m.Avatar != nil { + p.Avatar = *m.Avatar + } + return p, nil +} diff --git a/go/services/auth/login.go b/go/services/auth/login.go new file mode 100644 index 0000000..5b558fb --- /dev/null +++ b/go/services/auth/login.go @@ -0,0 +1,268 @@ +package auth + +import ( + "errors" + "strings" + "time" + + "server/models" + "server/pkg/passwordutil" +) + +// 登录方式与认证手段(amr) +const ( + LoginTypePassword = "password" + LoginTypeSMS = "sms" + LoginTypeThird = "third" + + AmrPwd = "pwd" + AmrSms = "sms" + AmrOtp = "otp" +) + +// 失败锁定策略 +const ( + MaxFailCount = 5 // 连续失败次数阈值 + LockDuration = 15 * time.Minute // 锁定时长 + PendingTenantID = 0 // 未选择企业时的 tid +) + +// 登录失败错误 +var ( + ErrAccountDisabled = errors.New("账号已禁用") + ErrAccountLocked = errors.New("账号已被锁定,请稍后再试") + ErrPasswordWrong = errors.New("账号或密码错误") + ErrNoTenantBound = errors.New("该账号未绑定任何企业") + ErrTenantNotAllowed = errors.New("无权访问该企业") + ErrTenantUserDisabled = errors.New("在该企业的账号已被停用") +) + +// LoginResult 登录结果。 +// 当 NeedChooseTenant 为 true 时表示一人多企业,前端需展示企业列表让用户选择, +// 选择后调用 ChooseTenant 换取正式令牌。 +type LoginResult struct { + NeedChooseTenant bool `json:"need_choose_tenant"` + Identity *IdentityProfile `json:"identity"` + Tenants []TenantOption `json:"tenants"` + Tokens *TokenPair `json:"tokens,omitempty"` +} + +// PasswordLogin 账号密码登录 +// +// 流程:查身份 → 校验状态/锁定 → 校验密码 → 失败计数 → 列出可登录企业 +// - 只绑定 1 家企业:直接建会话并签发令牌 +// - 绑定多家企业:建待选会话(tid=0),签发临时令牌,等用户选择企业 +func PasswordLogin(account, password, clientID, ip, userAgent string) (*LoginResult, error) { + identity, err := FindIdentityByAccount(account) + if err != nil { + return nil, ErrPasswordWrong // 不暴露账号是否存在 + } + + if err := checkIdentityStatus(identity); err != nil { + return nil, err + } + + if identity.PasswordHash == nil || !passwordutil.Verify(*identity.PasswordHash, password) { + _ = recordFail(identity.ID) + return nil, ErrPasswordWrong + } + + // 登录成功:重置失败计数,旧算法密码自动升级 + _ = resetFail(identity.ID) + if passwordutil.NeedsRehash(*identity.PasswordHash) { + if hashed, hErr := passwordutil.Hash(password); hErr == nil { + _, _ = models.Orm.QueryTable(new(models.AuthIdentity)). + Filter("id", identity.ID). + Update(map[string]interface{}{ + "password_hash": hashed, + "password_algo": passwordutil.AlgoArgon2id, + }) + } + } + + tenants, err := ListTenantOptions(identity.ID) + if err != nil { + return nil, err + } + if len(tenants) == 0 { + _ = writeLoginLog(0, identity.ID, account, "", clientID, LoginTypePassword, AmrPwd, 0, ErrNoTenantBound.Error(), ip, userAgent) + return nil, ErrNoTenantBound + } + + profile, err := BuildProfile(identity) + if err != nil { + return nil, err + } + + result := &LoginResult{Tenants: tenants, Identity: profile} + + // 唯一企业直接进入;多家企业先建待选会话 + targetTid := uint64(PendingTenantID) + if len(tenants) == 1 { + targetTid = tenants[0].Tid + } + sess, err := CreateSession(SessionInfo{ + IdentityID: identity.ID, + Tid: targetTid, + ClientID: clientID, + IP: ip, + UserAgent: userAgent, + LoginType: LoginTypePassword, + Amr: AmrPwd, + }) + if err != nil { + return nil, err + } + + // 待选状态下令牌有效期缩短为 10 分钟(仅够用户做完选择) + accessTTL := 0 + if targetTid == PendingTenantID { + accessTTL = 600 + result.NeedChooseTenant = true + } + + tokens, err := IssueTokens(TokenIssue{ + IdentityID: identity.ID, + Tid: targetTid, + ClientID: clientID, + Sid: sess.Sid, + Username: profile.Nickname, + UserType: "tenant", + Amr: AmrPwd, + AccessTTL: accessTTL, + }) + if err != nil { + return nil, err + } + result.Tokens = tokens + + tenantName := "" + if targetTid != PendingTenantID { + for _, t := range tenants { + if t.Tid == targetTid { + tenantName = t.TenantName + } + } + } + _ = writeLoginLog(targetTid, identity.ID, account, profile.Nickname, clientID, LoginTypePassword, AmrPwd, 1, "登录成功", ip, userAgent) + _ = tenantName + + return result, nil +} + +// ChooseTenant 选择进入的企业:校验绑定后更新会话 tid 并重新签发令牌 +func ChooseTenant(sid string, tid uint64, clientID string) (*TokenPair, *IdentityProfile, error) { + sess, err := GetSession(sid) + if err != nil { + return nil, nil, err + } + bind, err := GetTenantUser(sess.IdentityID, tid) + if err != nil { + return nil, nil, ErrTenantNotAllowed + } + if bind.Status != 1 { + return nil, nil, ErrTenantUserDisabled + } + + var identity models.AuthIdentity + if err := models.Orm.QueryTable(new(models.AuthIdentity)). + Filter("id", sess.IdentityID).One(&identity); err != nil { + return nil, nil, ErrPasswordWrong + } + + if err := SwitchSessionTenant(sid, tid); err != nil { + return nil, nil, err + } + + profile, err := BuildProfile(&identity) + if err != nil { + return nil, nil, err + } + tokens, err := IssueTokens(TokenIssue{ + IdentityID: identity.ID, + Tid: tid, + ClientID: clientID, + Sid: sid, + Username: profile.Nickname, + UserType: "tenant", + }) + if err != nil { + return nil, nil, err + } + return tokens, profile, nil +} + +// checkIdentityStatus 校验身份状态与锁定时间 +func checkIdentityStatus(m *models.AuthIdentity) error { + if m.Status == models.AuthIdentityStatusDisabled { + return ErrAccountDisabled + } + if m.Status == models.AuthIdentityStatusLocked { + if m.LockedUntil != nil && m.LockedUntil.After(time.Now()) { + return ErrAccountLocked + } + // 锁定已到期,自动解锁 + _, _ = models.Orm.QueryTable(new(models.AuthIdentity)). + Filter("id", m.ID). + Update(map[string]interface{}{"status": models.AuthIdentityStatusEnabled, "fail_count": 0, "locked_until": nil}) + } + return nil +} + +// recordFail 记录一次失败,达到阈值则锁定 +func recordFail(identityID uint64) error { + var m models.AuthIdentity + if err := models.Orm.QueryTable(new(models.AuthIdentity)).Filter("id", identityID).One(&m); err != nil { + return err + } + fail := m.FailCount + 1 + update := map[string]interface{}{"fail_count": fail} + if fail >= MaxFailCount { + lockUntil := time.Now().Add(LockDuration) + update["status"] = models.AuthIdentityStatusLocked + update["locked_until"] = lockUntil + } + _, err := models.Orm.QueryTable(new(models.AuthIdentity)).Filter("id", identityID).Update(update) + return err +} + +// resetFail 登录成功后重置失败计数与锁定 +func resetFail(identityID uint64) error { + _, err := models.Orm.QueryTable(new(models.AuthIdentity)). + Filter("id", identityID). + Update(map[string]interface{}{ + "fail_count": 0, + "locked_until": nil, + "status": models.AuthIdentityStatusEnabled, + "last_login_at": time.Now(), + }) + return err +} + +// writeLoginLog 写入统一登录日志(失败不影响主流程) +func writeLoginLog(tid uint64, identityID uint64, account, userName, clientID, loginType, amr string, status int8, message, ip, userAgent string) error { + log := &models.AuthLoginLog{ + IdentityID: &identityID, + Account: strings.TrimSpace(account), + UserName: userName, + ClientID: clientID, + LoginType: loginType, + Status: status, + Message: message, + IP: ip, + UserAgent: userAgent, + } + if tid > 0 { + log.Tid = &tid + } + if amr != "" { + log.Amr = &amr + } + _, err := models.Orm.Insert(log) + return err +} + +// Logout 登出:吊销刷新令牌、会话与访问令牌 +func Logout(accessToken, refreshToken string) error { + return RevokeTokenPair(refreshToken, accessToken, models.RevokeReasonLogout) +} diff --git a/go/services/auth/session.go b/go/services/auth/session.go new file mode 100644 index 0000000..4cdff59 --- /dev/null +++ b/go/services/auth/session.go @@ -0,0 +1,220 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" + "time" + + "server/models" +) + +// 默认会话配置(租户未配置 yz_auth_tenant_auth_config 时使用) +const ( + DefaultSessionTTL = 7200 // 会话有效期(秒) + DefaultMaxSession = 1 // 默认 1 号 1 机 +) + +// TenantSessionPolicy 租户会话策略 +type TenantSessionPolicy struct { + SessionTTL int + MaxSession int + KickStrategy int8 +} + +// GetTenantSessionPolicy 读取租户登录策略,未配置时返回默认值 +func GetTenantSessionPolicy(tid uint64) TenantSessionPolicy { + policy := TenantSessionPolicy{ + SessionTTL: DefaultSessionTTL, + MaxSession: DefaultMaxSession, + KickStrategy: models.KickStrategyKickOld, + } + var cfg models.AuthTenantAuthConfig + if err := models.Orm.QueryTable(new(models.AuthTenantAuthConfig)). + Filter("tid", tid).One(&cfg); err != nil { + return policy + } + if cfg.SessionTTL > 0 { + policy.SessionTTL = cfg.SessionTTL + } + if cfg.MaxSession > 0 { + policy.MaxSession = cfg.MaxSession + } + if cfg.KickStrategy == models.KickStrategyReject { + policy.KickStrategy = models.KickStrategyReject + } + return policy +} + +// SessionInfo 创建会话的入参 +type SessionInfo struct { + IdentityID uint64 + Tid uint64 + ClientID string + DeviceID string + DeviceName string + IP string + UserAgent string + LoginType string + Amr string +} + +// CreateSession 创建会话并执行并发控制。 +// +// 并发策略(租户可配): +// - KickStrategyKickOld(默认):超出上限时踢掉最旧的会话(1号1机) +// - KickStrategyReject:超出上限时拒绝新登录 +func CreateSession(info SessionInfo) (*models.AuthSession, error) { + policy := GetTenantSessionPolicy(info.Tid) + + // 已占用的活跃会话 + var actives []models.AuthSession + if _, err := models.Orm.QueryTable(new(models.AuthSession)). + Filter("identity_id", info.IdentityID). + Filter("revoked", 0). + OrderBy("login_at"). + All(&actives); err != nil { + return nil, err + } + // 过期会话先作废,不计入占用 + now := time.Now() + valid := make([]models.AuthSession, 0, len(actives)) + for _, s := range actives { + if s.ExpiresAt.Before(now) { + _ = RevokeSession(s.Sid, models.RevokeReasonExpired) + continue + } + valid = append(valid, s) + } + + if len(valid) >= policy.MaxSession { + if policy.KickStrategy == models.KickStrategyReject { + return nil, ErrSessionLimitExceeded + } + // 踢掉最旧的,直到腾出名额 + kick := len(valid) - policy.MaxSession + 1 + for i := 0; i < kick && i < len(valid); i++ { + _ = RevokeSession(valid[i].Sid, models.RevokeReasonKicked) + } + } + + sid, err := randomToken(32) + if err != nil { + return nil, err + } + ttl := time.Duration(policy.SessionTTL) * time.Second + s := &models.AuthSession{ + Sid: sid, + IdentityID: info.IdentityID, + Tid: info.Tid, + ClientID: info.ClientID, + LoginType: orDefault(info.LoginType, "password"), + LoginAt: now, + LastAccessAt: now, + ExpiresAt: now.Add(ttl), + } + if info.DeviceID != "" { + s.DeviceID = &info.DeviceID + } + if info.DeviceName != "" { + s.DeviceName = &info.DeviceName + } + if info.IP != "" { + s.IP = &info.IP + } + if info.UserAgent != "" { + s.UserAgent = &info.UserAgent + } + if info.Amr != "" { + s.Amr = &info.Amr + } + if _, err := models.Orm.Insert(s); err != nil { + return nil, err + } + return s, nil +} + +// GetSession 查询有效会话(已吊销或已过期返回错误) +func GetSession(sid string) (*models.AuthSession, error) { + var s models.AuthSession + if err := models.Orm.QueryTable(new(models.AuthSession)).Filter("sid", sid).One(&s); err != nil { + return nil, err + } + if s.Revoked != 0 { + return nil, ErrSessionRevoked + } + if s.ExpiresAt.Before(time.Now()) { + return nil, ErrSessionExpired + } + return &s, nil +} + +// TouchSession 更新会话最近访问时间(建议每 5~10 分钟一次,避免高频写库) +func TouchSession(sid string) error { + last := time.Now() + _, err := models.Orm.QueryTable(new(models.AuthSession)). + Filter("sid", sid). + Update(map[string]interface{}{"last_access_at": last}) + return err +} + +// SwitchSessionTenant 切换当前会话所属企业(免密切换) +func SwitchSessionTenant(sid string, tid uint64) error { + _, err := models.Orm.QueryTable(new(models.AuthSession)). + Filter("sid", sid). + Update(map[string]interface{}{"tid": tid}) + return err +} + +// RevokeSession 吊销单个会话 +func RevokeSession(sid, reason string) error { + now := time.Now() + _, err := models.Orm.QueryTable(new(models.AuthSession)). + Filter("sid", sid). + Update(map[string]interface{}{ + "revoked": 1, + "revoke_reason": reason, + "revoke_at": now, + }) + return err +} + +// RevokeAllSessions 吊销该身份的全部会话(改密、管理员下线等场景) +func RevokeAllSessions(identityID uint64, reason string) error { + now := time.Now() + _, err := models.Orm.QueryTable(new(models.AuthSession)). + Filter("identity_id", identityID). + Filter("revoked", 0). + Update(map[string]interface{}{ + "revoked": 1, + "revoke_reason": reason, + "revoke_at": now, + }) + return err +} + +// ListActiveSessions 在线设备列表 +func ListActiveSessions(identityID uint64) ([]models.AuthSession, error) { + var list []models.AuthSession + _, err := models.Orm.QueryTable(new(models.AuthSession)). + Filter("identity_id", identityID). + Filter("revoked", 0). + OrderBy("-login_at"). + All(&list) + return list, err +} + +// randomToken 生成 URL 安全的随机串(用于 sid / refresh token 明文) +func randomToken(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func orDefault(v, def string) string { + if v == "" { + return def + } + return v +} diff --git a/go/services/auth/sync.go b/go/services/auth/sync.go new file mode 100644 index 0000000..de3873a --- /dev/null +++ b/go/services/auth/sync.go @@ -0,0 +1,219 @@ +package auth + +import ( + "strings" + + "github.com/google/uuid" + + "server/models" + "server/pkg/passwordutil" +) + +// SyncTenantUserInput 老表 → 认证中心同步入参 +type SyncTenantUserInput struct { + Tid uint64 + Account string // 企业内账号 + Name string + Phone string + Email string + PasswordHash string // 已哈希的密码;为空表示不修改密码 + GroupID uint64 + OrgID uint64 + Status int8 + IsDefault int8 +} + +// SyncTenantUser 把租户用户同步到统一认证中心(幂等,可重复调用)。 +// +// 归并规则与迁移脚本一致:手机号 > 邮箱 > 企业内账号。 +// - 身份不存在 → 创建;已存在 → 更新手机/邮箱/昵称/状态 +// - 绑定不存在 → 创建;已存在 → 更新账号/姓名/部门/角色/状态 +// +// 注意:密码属于身份层(一人一份),因此在任一企业修改密码, +// 该用户在其他企业的登录密码会同步变化——这是统一认证的预期行为。 +func SyncTenantUser(in SyncTenantUserInput) error { + identity, err := findOrCreateIdentity(in) + if err != nil { + return err + } + return upsertTenantBinding(identity, in) +} + +// RemoveTenantUser 删除该用户在指定企业的绑定(保留身份本身, +// 因为该身份可能还绑定着其他企业)。 +func RemoveTenantUser(tid uint64, account, phone, email string) error { + identity := findIdentityByKey(account, phone, email) + if identity == nil { + return nil // 认证中心无此身份,无需处理 + } + _, err := models.Orm.QueryTable(new(models.AuthTenantUser)). + Filter("tid", tid). + Filter("identity_id", identity.ID). + Delete() + return err +} + +// findOrCreateIdentity 按归并键查找身份,不存在则创建 +func findOrCreateIdentity(in SyncTenantUserInput) (*models.AuthIdentity, error) { + if m := findIdentityByKey(in.Account, in.Phone, in.Email); m != nil { + // 补全手机/邮箱/昵称;密码仅在显式传入时更新 + update := map[string]interface{}{} + if in.Phone != "" { + update["mobile"] = in.Phone + } + if in.Email != "" { + update["email"] = in.Email + } + if in.Name != "" { + update["nickname"] = in.Name + } + if in.Status > 0 { + update["status"] = in.Status + } + if in.PasswordHash != "" { + update["password_hash"] = in.PasswordHash + update["password_algo"] = passwordutil.AlgoOf(in.PasswordHash) + } + if len(update) > 0 { + _, _ = models.Orm.QueryTable(new(models.AuthIdentity)). + Filter("id", m.ID). + Update(update) + } + // 重新读取,返回最新值 + _ = models.Orm.QueryTable(new(models.AuthIdentity)).Filter("id", m.ID).One(m) + return m, nil + } + + item := &models.AuthIdentity{ + UnionID: strings.ReplaceAll(uuid.NewString(), "-", ""), + PasswordAlgo: passwordutil.AlgoLegacy, + Status: in.Status, + } + if in.Status == 0 { + item.Status = models.AuthIdentityStatusEnabled + } + if in.Phone != "" { + item.Mobile = &in.Phone + } + if in.Email != "" { + item.Email = &in.Email + } + if in.Name != "" { + item.Nickname = &in.Name + } + if in.PasswordHash != "" { + item.PasswordHash = &in.PasswordHash + item.PasswordAlgo = passwordutil.AlgoOf(in.PasswordHash) + } + id, err := models.Orm.Insert(item) + if err != nil { + return nil, err + } + item.ID = uint64(id) + return item, nil +} + +// findIdentityByKey 按 手机 > 邮箱 > 账号 查找身份 +func findIdentityByKey(account, phone, email string) *models.AuthIdentity { + base := models.Orm.QueryTable(new(models.AuthIdentity)).Filter("delete_time__isnull", true) + + if v := strings.TrimSpace(phone); v != "" { + m := &models.AuthIdentity{} + if err := base.Filter("mobile", v).One(m); err == nil { + return m + } + } + if v := strings.TrimSpace(email); v != "" { + m := &models.AuthIdentity{} + if err := base.Filter("email", v).One(m); err == nil { + return m + } + } + // 企业内账号:先找绑定关系再回查身份 + if v := strings.TrimSpace(account); v != "" { + var bind models.AuthTenantUser + if err := models.Orm.QueryTable(new(models.AuthTenantUser)). + Filter("account", v). + Filter("delete_time__isnull", true). + OrderBy("-is_default", "id"). + One(&bind); err == nil { + m := &models.AuthIdentity{} + if err := models.Orm.QueryTable(new(models.AuthIdentity)). + Filter("id", bind.IdentityID).One(m); err == nil { + return m + } + } + } + return nil +} + +// upsertTenantBinding 创建或更新身份-企业绑定 +func upsertTenantBinding(identity *models.AuthIdentity, in SyncTenantUserInput) error { + var existed models.AuthTenantUser + err := models.Orm.QueryTable(new(models.AuthTenantUser)). + Filter("tid", in.Tid). + Filter("identity_id", identity.ID). + One(&existed) + + if err != nil { + // 新建绑定 + item := &models.AuthTenantUser{ + Tid: in.Tid, + IdentityID: identity.ID, + GroupID: in.GroupID, + OrgID: in.OrgID, + Status: in.Status, + IsDefault: in.IsDefault, + } + if in.Status == 0 { + item.Status = 1 + } + if in.Account != "" { + item.Account = &in.Account + } + if in.Name != "" { + item.Name = &in.Name + } + if in.Phone != "" { + item.Phone = &in.Phone + } + if in.Email != "" { + item.Email = &in.Email + } + _, err := models.Orm.Insert(item) + return err + } + + update := map[string]interface{}{} + if in.Account != "" { + update["account"] = in.Account + } + if in.Name != "" { + update["name"] = in.Name + } + if in.Phone != "" { + update["phone"] = in.Phone + } + if in.Email != "" { + update["email"] = in.Email + } + if in.GroupID > 0 { + update["group_id"] = in.GroupID + } + if in.OrgID > 0 { + update["org_id"] = in.OrgID + } + if in.Status > 0 { + update["status"] = in.Status + } + if in.IsDefault >= 0 { + update["is_default"] = in.IsDefault + } + if len(update) == 0 { + return nil + } + _, err = models.Orm.QueryTable(new(models.AuthTenantUser)). + Filter("id", existed.ID). + Update(update) + return err +} diff --git a/go/services/auth/token.go b/go/services/auth/token.go new file mode 100644 index 0000000..60b94f6 --- /dev/null +++ b/go/services/auth/token.go @@ -0,0 +1,217 @@ +package auth + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "strconv" + "time" + + "github.com/google/uuid" + + "server/models" + "server/pkg/jwtutil" +) + +// 认证中心错误定义 +var ( + ErrSessionLimitExceeded = errors.New("同时在线设备数已达上限") + ErrSessionRevoked = errors.New("会话已失效,请重新登录") + ErrSessionExpired = errors.New("会话已过期,请重新登录") + ErrRefreshTokenInvalid = errors.New("刷新令牌无效或已过期") + ErrRefreshTokenReused = errors.New("刷新令牌已被使用,疑似重放攻击,已吊销该登录") +) + +// TokenPair 签发的令牌对 +type TokenPair struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Sid string `json:"sid"` +} + +// TokenIssue 签发令牌的入参 +type TokenIssue struct { + IdentityID uint64 + Tid uint64 + ClientID string + Sid string + Username string + UserType string + Amr string + AccessTTL int // 秒 + RefreshTTL int // 秒 +} + +// IssueTokens 签发访问令牌与刷新令牌。 +// 刷新令牌明文只在本次返回,库中仅存哈希。 +func IssueTokens(opt TokenIssue) (*TokenPair, error) { + accessTTL := opt.AccessTTL + if accessTTL <= 0 { + accessTTL = 1800 + } + refreshTTL := opt.RefreshTTL + if refreshTTL <= 0 { + refreshTTL = 2592000 + } + + jti := uuid.NewString() + access, err := jwtutil.SignToken(jwtutil.TokenOptions{ + Alg: jwtutil.AlgRS256, + UserID: int(opt.IdentityID), + Username: opt.Username, + TenantID: int(opt.Tid), + UserType: opt.UserType, + ClientID: opt.ClientID, + Sid: opt.Sid, + Amr: opt.Amr, + Subject: strconv.FormatUint(opt.IdentityID, 10), + Audience: []string{opt.ClientID}, + Jti: jti, + TTL: time.Duration(accessTTL) * time.Second, + }) + if err != nil { + return nil, err + } + + plain, err := randomToken(32) + if err != nil { + return nil, err + } + family := uuid.NewString() + now := time.Now() + rt := &models.AuthRefreshToken{ + TokenHash: hashToken(plain), + IdentityID: opt.IdentityID, + Tid: opt.Tid, + ClientID: opt.ClientID, + Sid: opt.Sid, + FamilyID: family, + ExpiresAt: now.Add(time.Duration(refreshTTL) * time.Second), + } + if _, err := models.Orm.Insert(rt); err != nil { + return nil, err + } + + return &TokenPair{ + AccessToken: access, + RefreshToken: plain, + TokenType: "Bearer", + ExpiresIn: accessTTL, + Sid: opt.Sid, + }, nil +} + +// RefreshTokens 用刷新令牌换取新的令牌对(轮换 + 重放检测)。 +// +// 安全要点:检测到已使用的刷新令牌再次出现时,判定为重放, +// 吊销整个 family(该次登录的全部令牌)。 +func RefreshTokens(plain, clientID string) (*TokenPair, error) { + h := hashToken(plain) + var rt models.AuthRefreshToken + if err := models.Orm.QueryTable(new(models.AuthRefreshToken)).Filter("token_hash", h).One(&rt); err != nil { + return nil, ErrRefreshTokenInvalid + } + if rt.ClientID != "" && clientID != "" && rt.ClientID != clientID { + return nil, ErrRefreshTokenInvalid + } + if rt.Revoked != 0 || rt.ExpiresAt.Before(time.Now()) { + return nil, ErrRefreshTokenInvalid + } + if rt.Used != 0 { + // 重放:吊销同族全部令牌与该会话 + _, _ = models.Orm.QueryTable(new(models.AuthRefreshToken)). + Filter("family_id", rt.FamilyID). + Update(map[string]interface{}{"revoked": 1}) + _ = RevokeSession(rt.Sid, models.RevokeReasonAdmin) + return nil, ErrRefreshTokenReused + } + + // 标记已用(保留 Row 以便审计) + _, _ = models.Orm.QueryTable(new(models.AuthRefreshToken)). + Filter("id", rt.ID). + Update(map[string]interface{}{"used": 1}) + + // 会话校验:已吊销/过期则拒绝续期 + s, err := GetSession(rt.Sid) + if err != nil { + return nil, err + } + _ = TouchSession(rt.Sid) + + pair, err := IssueTokens(TokenIssue{ + IdentityID: rt.IdentityID, + Tid: s.Tid, // 以会话当前企业为准(支持切换企业后刷新) + ClientID: rt.ClientID, + Sid: rt.Sid, + Amr: derefStr(s.Amr), + }) + if err != nil { + return nil, err + } + // 新令牌继承同一 family,便于后续溯源与整族吊销 + if _, err := models.Orm.QueryTable(new(models.AuthRefreshToken)). + Filter("token_hash", hashToken(pair.RefreshToken)). + Update(map[string]interface{}{"family_id": rt.FamilyID, "rotated_from": h}); err != nil { + return nil, err + } + return pair, nil +} + +// RevokeTokenPair 登出:吊销刷新令牌、会话,并把 access token 的 jti 加入黑名单。 +func RevokeTokenPair(plain, accessToken, reason string) error { + if plain != "" { + _, _ = models.Orm.QueryTable(new(models.AuthRefreshToken)). + Filter("token_hash", hashToken(plain)). + Update(map[string]interface{}{"revoked": 1}) + } + if accessToken != "" { + if claims, err := jwtutil.ParseTokenRaw(accessToken); err == nil { + _ = Blacklist(claims.ID, claims.Sid, reason, time.Unix(claims.ExpiresAt.Unix(), 0)) + if claims.Sid != "" { + _ = RevokeSession(claims.Sid, reason) + } + } + } + return nil +} + +// Blacklist 把 jti 加入吊销表 +func Blacklist(jti, sid, reason string, expiresAt time.Time) error { + if jti == "" { + return nil + } + item := &models.AuthTokenBlacklist{ + Jti: jti, + ExpiresAt: expiresAt, + } + if sid != "" { + item.Sid = &sid + } + if reason != "" { + item.Reason = &reason + } + _, err := models.Orm.InsertOrUpdate(item, "jti") + return err +} + +// IsBlacklisted 判断 jti 是否已被吊销 +func IsBlacklisted(jti string) bool { + if jti == "" { + return false + } + return models.Orm.QueryTable(new(models.AuthTokenBlacklist)).Filter("jti", jti).Exist() +} + +func hashToken(plain string) string { + sum := sha256.Sum256([]byte(plain)) + return hex.EncodeToString(sum[:]) +} + +func derefStr(p *string) string { + if p == nil { + return "" + } + return *p +} diff --git a/go/services/password_reset.go b/go/services/password_reset.go index bd9288d..fe487cf 100644 --- a/go/services/password_reset.go +++ b/go/services/password_reset.go @@ -10,6 +10,7 @@ import ( "server/models" "server/pkg/passwordutil" + authsvc "server/services/auth" ) // resetCodeItem 存储找回密码的验证码 @@ -250,9 +251,31 @@ func ResetPassword(tenantName, account, phone, smsCode, newPassword, confirmPass return errors.New("密码更新失败") } + // 双写:同步新密码到统一认证中心(失败不影响找回密码主流程) + _ = authsvc.SyncTenantUser(authsvc.SyncTenantUserInput{ + Tid: tenantUser.Tid, + Account: trimStrPtr(tenantUser.Account), + Name: trimStrPtr(tenantUser.Name), + Phone: trimStrPtr(tenantUser.Phone), + Email: trimStrPtr(tenantUser.Email), + PasswordHash: hashedPassword, + GroupID: tenantUser.GroupID, + OrgID: tenantUser.OrgID, + Status: tenantUser.Status, + IsDefault: tenantUser.IsDefault, + }) + return nil } +// trimStrPtr 取指针字符串并去空格;nil 时返回空串 +func trimStrPtr(p *string) string { + if p == nil { + return "" + } + return strings.TrimSpace(*p) +} + // enqueueSMSTaskForPasswordReset 发送密码重置短信任务 func enqueueSMSTaskForPasswordReset(tid uint64, phone, content, code string) error { // 重用已有的短信发送逻辑 diff --git a/go/services/platform_auth.go b/go/services/platform_auth.go index 0a25f1b..7d22d87 100644 --- a/go/services/platform_auth.go +++ b/go/services/platform_auth.go @@ -73,6 +73,13 @@ func PlatformAdminLogin(account, password string) (string, *PlatformLoginUser, e if !passwordutil.Verify(user.Password, password) { return "", nil, errors.New("用户名或密码错误") } + // 历史 sha256 密码:本次登录成功后自动升级为 argon2id(失败不影响登录) + upgradePasswordIfNeeded(user.Password, password, func(hashed string) error { + _, err := models.Orm.QueryTable(new(models.AdminUser)). + Filter("id", user.ID). + Update(map[string]interface{}{"password": hashed}) + return err + }) const tenantID = 0 const userType = "platform" @@ -116,6 +123,13 @@ func BackendLogin(tenantName, account, password string) (string, *PlatformLoginU if tenantUser.Password == nil || !passwordutil.Verify(*tenantUser.Password, password) { return "", nil, errors.New("用户名或密码错误") } + // 历史 sha256 密码:本次登录成功后自动升级为 argon2id(失败不影响登录) + upgradePasswordIfNeeded(*tenantUser.Password, password, func(hashed string) error { + _, err := models.Orm.QueryTable(new(models.SystemTenantUser)). + Filter("id", tenantUser.ID). + Update(map[string]interface{}{"password": hashed}) + return err + }) tenantID := int(tenant.ID) const userType = "backend" @@ -144,6 +158,22 @@ func BackendLogin(tenantName, account, password string) (string, *PlatformLoginU return token, loginUser, nil } +// upgradePasswordIfNeeded 旧算法(legacy sha256)密码在登录成功后自动重新哈希为 argon2id。 +// +// 旧哈希无法离线转换成新算法(无法反推明文),只能借登录时拿到的明文重新哈希, +// 因此采用「首次登录自动升级」的渐进方式,无需强制全员重置密码。 +// 重新哈希失败不影响本次登录,仅下次登录时重试。 +func upgradePasswordIfNeeded(stored, plain string, update func(hashed string) error) { + if !passwordutil.NeedsRehash(stored) { + return + } + hashed, err := passwordutil.Hash(plain) + if err != nil { + return + } + _ = update(hashed) +} + // PlatformGetCurrentUser 根据平台管理员用户 ID 返回登录用户信息(含角色名称)。 func PlatformGetCurrentUser(uid uint64) (*PlatformLoginUser, error) { u, err := GetAdminUserByID(uid) diff --git a/go/views/auth/login.tpl b/go/views/auth/login.tpl new file mode 100644 index 0000000..3243a8d --- /dev/null +++ b/go/views/auth/login.tpl @@ -0,0 +1,165 @@ + + + + + + 统一认证中心 - 登录 + + + +
+ + +
+ + {{if eq .Step "tenant"}} + +
    + {{range .Tenants}} +
  • +
    +
    {{.TenantName}}
    +
    {{if .Name}}{{.Name}} · {{end}}企业ID {{.Tid}}
    +
    + › +
  • + {{end}} +
+ + {{else}} + +
+
+ + +
+
+ + +
+ +
+ {{end}} + +
统一身份认证 · 一次登录,全平台通行
+
+ + + + diff --git a/platform/src/views/login/index.vue b/platform/src/views/login/index.vue index 868b7b3..ac48d67 100644 --- a/platform/src/views/login/index.vue +++ b/platform/src/views/login/index.vue @@ -229,16 +229,16 @@ const performLoginRequest = async () => { if (res && res.code === 200) { // 登录成功:处理"记住我" + // 安全:只记住账号,绝不明文存储密码 if (rememberMe.value) { localStorage.setItem("loginAccount", account.value); - localStorage.setItem("loginPassword", password.value); localStorage.setItem("loginRememberMe", "true"); } else { localStorage.removeItem("loginAccount"); localStorage.removeItem("loginTenantName"); - localStorage.removeItem("loginPassword"); localStorage.setItem("loginRememberMe", "false"); } + localStorage.removeItem("loginPassword"); authStore.setLoginInfo(res.data); @@ -353,16 +353,16 @@ const startGeetest4 = async () => { }); if (loginRes && loginRes.code === 200) { + // 安全:只记住账号,绝不明文存储密码 if (rememberMe.value) { localStorage.setItem("loginAccount", account.value); - localStorage.setItem("loginPassword", password.value); localStorage.setItem("loginRememberMe", "true"); } else { localStorage.removeItem("loginAccount"); localStorage.removeItem("loginTenantName"); - localStorage.removeItem("loginPassword"); localStorage.setItem("loginRememberMe", "false"); } + localStorage.removeItem("loginPassword"); authStore.setLoginInfo(loginRes.data); const { useTabsStore } = await import("@/stores"); @@ -468,7 +468,7 @@ const handleRememberMeChange = async () => { if (rememberMe.value) { try { await ElMessageBox.confirm( - "请确认电脑环境是可信的,登录成功后会自动记住密码。", + "请确认电脑环境是可信的,登录成功后会自动记住账号(不会保存密码)。", "安全提示", { confirmButtonText: "确定", @@ -489,7 +489,6 @@ onMounted(() => { const savedRemember = localStorage.getItem("loginRememberMe"); if (savedRemember === "true") { account.value = localStorage.getItem("loginAccount") || ""; - password.value = localStorage.getItem("loginPassword") || ""; rememberMe.value = true; } generateCaptcha(); diff --git a/platform/src/views/platform/platformupgrade/index.vue b/platform/src/views/platform/platformupgrade/index.vue new file mode 100644 index 0000000..10f326b --- /dev/null +++ b/platform/src/views/platform/platformupgrade/index.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/uniapp/api/auth.js b/uniapp/api/auth.js index 4878baf..bd23de8 100644 --- a/uniapp/api/auth.js +++ b/uniapp/api/auth.js @@ -3,7 +3,7 @@ */ import { request, requestRaw } from '@/api/request.js' -/** 账号密码登录 */ +/** 账号密码登录(旧:租户端直登) */ export function login(data) { return request({ url: '/app/login', @@ -12,6 +12,52 @@ export function login(data) { }) } +/* ==================== 统一认证中心(UAC) ==================== + * APP 不走浏览器跳转,直接用密码直连模式: + * POST /auth/login 拿 access + refresh,多企业时再调 switch-tenant。 + * 开关:VITE_AUTH_MODE=sso 时启用(见 utils/auth.js 的 isSSOEnabled) + * ============================================================ */ + +/** 统一认证:账号密码登录 */ +export function loginUAC(data) { + return request({ + url: '/auth/login', + method: 'POST', + data: Object.assign({ client_id: 'yz-uniapp' }, data) + }) +} + +/** 统一认证:选择/切换企业(一人多企业) */ +export function switchTenant(data) { + return request({ + url: '/auth/switch-tenant', + method: 'POST', + data: Object.assign({ client_id: 'yz-uniapp' }, data) + }) +} + +/** 统一认证:刷新访问令牌 */ +export function refreshTokenUAC(data) { + return request({ + url: '/auth/token', + method: 'POST', + header: { 'Content-Type': 'application/x-www-form-urlencoded' }, + data: Object.assign({ grant_type: 'refresh_token', client_id: 'yz-uniapp' }, data), + silent: true + }) +} + +/** 统一认证:吊销令牌(登出) */ +export function revokeUAC(data = {}) { + return request({ + url: '/auth/revoke', + method: 'POST', + data, + silent: true, + timeout: 5000 + }) +} + /** 发送登录验证码(账号二次校验用,需后台开启验证) */ export function sendLoginCode(data) { return request({ diff --git a/uniapp/api/request.js b/uniapp/api/request.js index 912c31a..f71392b 100644 --- a/uniapp/api/request.js +++ b/uniapp/api/request.js @@ -2,7 +2,7 @@ * 统一请求封装:baseURL、Bearer Token、业务 code 处理。 * 接口地址统一从 .env 的 VITE_API_BASE_URL 读取(见 api/config.js)。 */ -import { getToken, logout } from '@/utils/auth.js' +import { getToken, logout, isSSOEnabled, getRefreshToken, setAuthTokens } from '@/utils/auth.js' import { resolveBaseURL, getBaseURL } from '@/api/config.js' export { getBaseURL } @@ -11,6 +11,63 @@ function buildFullUrl(url) { return resolveBaseURL().replace(/\/$/, '') + url } +/** + * 用 refresh_token 换取新的访问令牌(独立封装,避免与 request 递归调用)。 + * 仅在统一认证模式(VITE_AUTH_MODE=sso)下使用。 + */ +function refreshAccessToken() { + return new Promise((resolve, reject) => { + const refresh = getRefreshToken() + if (!refresh) { + reject(new Error('无刷新令牌')) + return + } + uni.request({ + url: buildFullUrl('/auth/token'), + method: 'POST', + header: { 'Content-Type': 'application/x-www-form-urlencoded' }, + data: `grant_type=refresh_token&refresh_token=${encodeURIComponent(refresh)}&client_id=yz-uniapp`, + timeout: 10000, + success(res) { + const body = res.data || {} + if (res.statusCode === 200 && body.access_token) { + setAuthTokens(body) + resolve(body.access_token) + } else { + reject(new Error((body && (body.error || body.msg)) || '刷新失败')) + } + }, + fail: reject + }) + }) +} + +/** 续期后重放原请求,返回与 request 一致的 data 解包结果 */ +async function replayRequest(options) { + const token = await refreshAccessToken() + const res = await new Promise((resolve, reject) => { + uni.request({ + url: buildFullUrl(options.url), + method: (options.method || 'GET').toUpperCase(), + data: options.data || {}, + header: Object.assign( + { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + options.header || {} + ), + timeout: options.timeout || 30000, + success: (r) => resolve(r.data || {}), + fail: reject + }) + }) + if (typeof res.code !== 'undefined' && res.code !== 200) { + const err = new Error(res.msg || '操作失败') + err.code = res.code + err.response = res + throw err + } + return typeof res.data !== 'undefined' ? res.data : res +} + /** * @param {{ url: string, method?: string, data?: object, header?: object, silent?: boolean }} options * @returns {Promise} 业务 data 字段(若无 data 则返回整包) @@ -35,11 +92,21 @@ export function request(options = {}) { data: data || {}, header: headers, timeout, - success(res) { + async success(res) { const status = res.statusCode const body = res.data || {} if (status === 401 || body.code === 401) { + // 统一认证模式:先用 refresh_token 静默续期并重放请求, + // 续期失败(令牌过期/被吊销/被踢下线)才真正登出。 + if (isSSOEnabled()) { + try { + resolve(await replayRequest(options)) + return + } catch (e) { + // 续期失败,继续走下面的登出流程 + } + } logout() if (!silent) { uni.showToast({ title: body.msg || '请重新登录', icon: 'none' }) diff --git a/uniapp/pages/login/login.vue b/uniapp/pages/login/login.vue index 76fc642..feeebf1 100644 --- a/uniapp/pages/login/login.vue +++ b/uniapp/pages/login/login.vue @@ -137,7 +137,9 @@ import { login, loginBySms, sendLoginCode, - fetchVerifyConfig + fetchVerifyConfig, + loginUAC, + switchTenant } from '@/api/auth.js' import { loginSuccess, @@ -146,7 +148,11 @@ import { getTenantName, getRememberLogin, saveRememberLogin, - clearRememberLogin + clearRememberLogin, + isSSOEnabled, + setAuthTokens, + setTid, + getToken } from '@/utils/auth.js' import { showGeetest4 } from '@/utils/geetest.js' @@ -191,7 +197,6 @@ onMounted(async () => { rememberMe.value = true tenantName.value = remembered.tenantName username.value = remembered.account - password.value = remembered.password } else { tenantName.value = getTenantName() } @@ -276,6 +281,84 @@ function navigateAfterLogin() { }, 400) } +/* ==================== 统一认证(UAC)登录 ==================== + * APP 无浏览器回跳,走密码直连: + * 1) POST /auth/login 拿 access + refresh + * 2) 一人多企业时弹出企业列表让用户选,选完调 /auth/switch-tenant + * ============================================================ */ + +/** 选择要进入的企业(一人多企业场景) */ +function chooseTenant(tenants) { + return new Promise((resolve, reject) => { + const names = (tenants || []).map(t => t.tenant_name || `企业${t.tid}`) + uni.showActionSheet({ + itemList: names, + title: '选择要进入的企业', + success: (res) => resolve(tenants[res.tapIndex]), + fail: () => reject(new Error('已取消选择')) + }) + }) +} + +/** 统一认证:账号密码登录 */ +async function submitSSOLogin() { + const account = username.value.trim() + const pwd = password.value + if (!account) { + uni.showToast({ title: '请输入账号', icon: 'none' }) + return false + } + if (!pwd) { + uni.showToast({ title: '请输入密码', icon: 'none' }) + return false + } + + const data = await loginUAC({ account, password: pwd }) + if (!data || data.code !== 200) { + uni.showToast({ title: (data && data.msg) || '登录失败', icon: 'none' }) + return false + } + + // 记住账号(不含密码) + if (rememberMe.value) { + saveRememberLogin({ tenantName: tenantName.value.trim(), account }) + } else { + clearRememberLogin() + } + + let currentTid = 0 + if (data.need_choose_tenant) { + // 多企业:先记住令牌,再让用户选择 + setAuthTokens(data.tokens || { access_token: data.access_token, refresh_token: data.refresh_token, sid: data.sid }) + const picked = await chooseTenant(data.tenants) + const res = await switchTenant({ tid: picked.tid }) + if (res && res.code === 200 && res.data && res.data.tokens) { + setAuthTokens(res.data.tokens) + currentTid = picked.tid + } else { + uni.showToast({ title: (res && res.msg) || '进入企业失败', icon: 'none' }) + return false + } + } else { + setAuthTokens(data.tokens || {}) + if (data.tenants && data.tenants.length > 0) currentTid = data.tenants[0].tid + } + + setTid(currentTid) + loginSuccess({ + token: getToken(), + user: { + id: data.identity ? data.identity.id : 0, + account, + name: (data.identity && (data.identity.nickname || data.identity.mobile)) || account, + tid: currentTid, + tenant_name: (data.tenants || []).map(t => t.tenant_name).join('、') + } + }) + navigateAfterLogin() + return true +} + async function submitAccountLogin(geetestResult = null) { if (!username.value.trim()) { uni.showToast({ title: '请输入账号', icon: 'none' }) @@ -311,8 +394,7 @@ async function submitAccountLogin(geetestResult = null) { if (rememberMe.value) { saveRememberLogin({ tenantName: tenantName.value.trim(), - account: username.value.trim(), - password: password.value + account: username.value.trim() }) } else { clearRememberLogin() @@ -326,6 +408,16 @@ async function handleLogin() { uni.showToast({ title: '请先同意用户协议', icon: 'none' }) return } + // 统一认证模式:账号即手机/邮箱,无需填写租户名 + if (isSSOEnabled()) { + submitting.value = true + try { + await submitSSOLogin() + } finally { + submitting.value = false + } + return + } if (!tenantName.value.trim()) { uni.showToast({ title: '请输入租户名称', icon: 'none' }) return diff --git a/uniapp/pages/tools/schedule/index.vue b/uniapp/pages/tools/schedule/index.vue index 75a323d..73e6b0a 100644 --- a/uniapp/pages/tools/schedule/index.vue +++ b/uniapp/pages/tools/schedule/index.vue @@ -39,45 +39,6 @@ {{ group.label }} - - - - - {{ getScheduleSummary(item) }} - - - - {{ formatDate(item.datetime, true) }} - - - {{ getRemindChannelLabel(ch) }} - {{ getRemindMinutesLabel(item.remindMinutes) }} - - - - - - {{ item.completed ? '已完成' : '标记完成' }} - - - - 编辑 - - - diff --git a/uniapp/utils/auth.js b/uniapp/utils/auth.js index a788252..fa8a1df 100644 --- a/uniapp/utils/auth.js +++ b/uniapp/utils/auth.js @@ -1,6 +1,41 @@ const TOKEN_KEY = 'app_token' const USER_KEY = 'app_user' const TENANT_KEY = 'app_tenant_name' +const TID_KEY = 'app_tid' +const REFRESH_KEY = 'app_refresh_token' +const SID_KEY = 'app_sid' + +/** 是否启用统一认证:.env 中配置 VITE_AUTH_MODE=sso */ +export function isSSOEnabled() { + try { + return (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.VITE_AUTH_MODE) === 'sso' + } catch (e) { + return false + } +} + +/** 统一认证:保存令牌与会话信息 */ +export function setAuthTokens(data = {}) { + if (data.access_token) setToken(data.access_token) + if (data.refresh_token) uni.setStorageSync(REFRESH_KEY, data.refresh_token) + if (data.sid) uni.setStorageSync(SID_KEY, data.sid) +} + +export function getRefreshToken() { + return uni.getStorageSync(REFRESH_KEY) || '' +} + +export function getSid() { + return uni.getStorageSync(SID_KEY) || '' +} + +export function setTid(tid) { + uni.setStorageSync(TID_KEY, tid || '') +} + +export function getTid() { + return Number(uni.getStorageSync(TID_KEY) || 0) +} const REMEMBER_KEY = 'app_remember_me' const REMEMBER_TENANT_KEY = 'app_remember_tenant' const REMEMBER_ACCOUNT_KEY = 'app_remember_account' @@ -37,6 +72,8 @@ export function isLoggedIn() { export function logout() { uni.removeStorageSync(TOKEN_KEY) uni.removeStorageSync(USER_KEY) + uni.removeStorageSync(REFRESH_KEY) + uni.removeStorageSync(SID_KEY) } /** 是否开启记住账号登录信息 */ @@ -44,25 +81,28 @@ export function isRememberLogin() { return uni.getStorageSync(REMEMBER_KEY) === '1' } -/** 读取记住的账号登录信息 */ +/** + * 读取记住的账号登录信息。 + * 安全说明:不再保存/回填密码,仅记住租户名与账号。 + */ export function getRememberLogin() { if (!isRememberLogin()) { - return { rememberMe: false, tenantName: '', account: '', password: '' } + return { rememberMe: false, tenantName: '', account: '' } } return { rememberMe: true, tenantName: uni.getStorageSync(REMEMBER_TENANT_KEY) || '', - account: uni.getStorageSync(REMEMBER_ACCOUNT_KEY) || '', - password: uni.getStorageSync(REMEMBER_PASSWORD_KEY) || '' + account: uni.getStorageSync(REMEMBER_ACCOUNT_KEY) || '' } } -/** 保存账号登录信息(租户、账号、密码) */ -export function saveRememberLogin({ tenantName = '', account = '', password = '' } = {}) { +/** 保存账号登录信息(租户、账号;不含密码) */ +export function saveRememberLogin({ tenantName = '', account = '' } = {}) { uni.setStorageSync(REMEMBER_KEY, '1') uni.setStorageSync(REMEMBER_TENANT_KEY, tenantName) uni.setStorageSync(REMEMBER_ACCOUNT_KEY, account) - uni.setStorageSync(REMEMBER_PASSWORD_KEY, password) + // 历史版本可能已落盘明文密码,这里一并清理 + uni.removeStorageSync(REMEMBER_PASSWORD_KEY) } /** 清除记住的账号登录信息 */ diff --git a/website/src/config/nav.ts b/website/src/config/nav.ts index 0f1ae91..ad6efb6 100644 --- a/website/src/config/nav.ts +++ b/website/src/config/nav.ts @@ -15,6 +15,7 @@ import { ref } from "vue"; import { getJSON } from "@/api/request"; import { navItems } from "./site"; +import { sections } from "./sections"; export interface NavMenuItem { /** v-for key:后端菜单用 nav-{id},静态回退用 static-{path} */ @@ -53,13 +54,28 @@ function toMenuItem(node: RemoteNavNode): NavMenuItem { /** 静态回退菜单:后台未配置时使用 */ const fallbackMenus = (): NavMenuItem[] => - navItems.map((item) => ({ - key: `static-${item.path}`, - label: item.label, - path: item.path, - external: isExternalPath(item.path), - children: [], - })); + navItems.map((item) => { + const menuItem: NavMenuItem = { + key: `static-${item.path}`, + label: item.label, + path: item.path, + external: isExternalPath(item.path), + children: [], + }; + + // 新闻中心:默认把「新闻中心」下的分类作为二级菜单(鼠标悬停展开) + if (item.path === "/news") { + menuItem.children = sections.news.categories.map((cat) => ({ + key: `static-news-${cat.slug}`, + label: cat.name, + path: `/news/category/${cat.slug}`, + external: false, + children: [], + })); + } + + return menuItem; + }); /** 页头导航菜单(应用启动时由 loadNavMenus 拉取,初始为静态回退) */ export const navMenus = ref(fallbackMenus()); diff --git a/website/src/router/index.ts b/website/src/router/index.ts index 99d6672..1caf7f1 100644 --- a/website/src/router/index.ts +++ b/website/src/router/index.ts @@ -4,9 +4,6 @@ import { siteInfo } from "@/config/site"; // 站点设置(站点名称 {yz:sit const DefaultLayout = () => import("../layout/DefaultLayout.vue"); const HomeView = () => import("../views/home/index.vue"); -const ListView = () => import("../views/list.vue"); -const DetailView = () => import("../views/detail.vue"); -const SingleView = () => import("../views/single.vue"); const NotFoundView = () => import("../views/not-found.vue"); /** @@ -33,19 +30,19 @@ const routes: RouteRecordRaw[] = [ { path: "news", name: "News", - component: ListView, + component: () => import("../views/news/index.vue"), meta: { title: "新闻中心" }, }, { path: "news/category/:slug", name: "NewsCategory", - component: ListView, + component: () => import("../views/news/index.vue"), meta: { title: "新闻中心" }, }, { path: "news/detail/:slug", name: "NewsDetail", - component: DetailView, + component: () => import("../views/news/detail.vue"), meta: { title: "新闻详情" }, }, @@ -53,19 +50,19 @@ const routes: RouteRecordRaw[] = [ { path: "products", name: "Products", - component: ListView, + component: () => import("../views/products/index.vue"), meta: { title: "产品中心" }, }, { path: "products/category/:slug", name: "ProductCategory", - component: ListView, + component: () => import("../views/products/index.vue"), meta: { title: "产品中心" }, }, { path: "products/detail/:slug", name: "ProductDetail", - component: DetailView, + component: () => import("../views/products/detail.vue"), meta: { title: "产品详情" }, }, @@ -73,19 +70,19 @@ const routes: RouteRecordRaw[] = [ { path: "solutions", name: "Solutions", - component: ListView, + component: () => import("../views/solutions/index.vue"), meta: { title: "解决方案" }, }, { path: "solutions/category/:slug", name: "SolutionCategory", - component: ListView, + component: () => import("../views/solutions/index.vue"), meta: { title: "解决方案" }, }, { path: "solutions/detail/:slug", name: "SolutionDetail", - component: DetailView, + component: () => import("../views/solutions/detail.vue"), meta: { title: "方案详情" }, }, @@ -93,19 +90,19 @@ const routes: RouteRecordRaw[] = [ { path: "cases", name: "Cases", - component: ListView, + component: () => import("../views/cases/index.vue"), meta: { title: "客户案例" }, }, { path: "cases/category/:slug", name: "CaseCategory", - component: ListView, + component: () => import("../views/cases/index.vue"), meta: { title: "客户案例" }, }, { path: "cases/detail/:slug", name: "CaseDetail", - component: DetailView, + component: () => import("../views/cases/detail.vue"), meta: { title: "案例详情" }, }, @@ -113,19 +110,19 @@ const routes: RouteRecordRaw[] = [ { path: "events", name: "Events", - component: ListView, + component: () => import("../views/events/index.vue"), meta: { title: "市场活动" }, }, { path: "events/category/:slug", name: "EventCategory", - component: ListView, + component: () => import("../views/events/index.vue"), meta: { title: "市场活动" }, }, { path: "events/detail/:slug", name: "EventDetail", - component: DetailView, + component: () => import("../views/events/detail.vue"), meta: { title: "活动详情" }, }, @@ -133,43 +130,49 @@ const routes: RouteRecordRaw[] = [ { path: "about", name: "About", - component: SingleView, + component: () => import("../views/about.vue"), meta: { title: "关于云泽" }, }, { path: "careers", name: "Careers", - component: SingleView, + component: () => import("../views/careers.vue"), meta: { title: "加入我们" }, }, { path: "partners", name: "Partners", - component: SingleView, + component: () => import("../views/partners.vue"), meta: { title: "合作伙伴" }, }, { path: "contact", name: "Contact", - component: SingleView, + component: () => import("../views/contact.vue"), meta: { title: "联系我们" }, }, { path: "support", name: "Support", - component: SingleView, + component: () => import("../views/support/index.vue"), meta: { title: "客户支持中心" }, }, { path: "support/:slug", name: "SupportDetail", - component: SingleView, + component: () => import("../views/support/detail.vue"), meta: { title: "客户支持" }, }, { - path: "policy/:slug", + path: "policy", name: "Policy", - component: SingleView, + component: () => import("../views/policy/index.vue"), + meta: { title: "政策条款" }, + }, + { + path: "policy/:slug", + name: "PolicyDetail", + component: () => import("../views/policy/detail.vue"), meta: { title: "政策条款" }, }, diff --git a/website/src/views/about.vue b/website/src/views/about.vue new file mode 100644 index 0000000..f6176a9 --- /dev/null +++ b/website/src/views/about.vue @@ -0,0 +1,8 @@ + + + diff --git a/website/src/views/careers.vue b/website/src/views/careers.vue new file mode 100644 index 0000000..385e5de --- /dev/null +++ b/website/src/views/careers.vue @@ -0,0 +1,8 @@ + + + diff --git a/website/src/views/cases/detail.vue b/website/src/views/cases/detail.vue new file mode 100644 index 0000000..d27beec --- /dev/null +++ b/website/src/views/cases/detail.vue @@ -0,0 +1,8 @@ + + + diff --git a/website/src/views/cases/index.vue b/website/src/views/cases/index.vue new file mode 100644 index 0000000..a50d982 --- /dev/null +++ b/website/src/views/cases/index.vue @@ -0,0 +1,8 @@ + + + diff --git a/website/src/views/detail.vue b/website/src/views/common/DetailPage.vue similarity index 100% rename from website/src/views/detail.vue rename to website/src/views/common/DetailPage.vue diff --git a/website/src/views/list.vue b/website/src/views/common/ListPage.vue similarity index 100% rename from website/src/views/list.vue rename to website/src/views/common/ListPage.vue diff --git a/website/src/views/single.vue b/website/src/views/common/SinglePage.vue similarity index 100% rename from website/src/views/single.vue rename to website/src/views/common/SinglePage.vue diff --git a/website/src/views/contact.vue b/website/src/views/contact.vue new file mode 100644 index 0000000..84c9c19 --- /dev/null +++ b/website/src/views/contact.vue @@ -0,0 +1,8 @@ + + + diff --git a/website/src/views/events/detail.vue b/website/src/views/events/detail.vue new file mode 100644 index 0000000..191feda --- /dev/null +++ b/website/src/views/events/detail.vue @@ -0,0 +1,8 @@ + + + diff --git a/website/src/views/events/index.vue b/website/src/views/events/index.vue new file mode 100644 index 0000000..0b82195 --- /dev/null +++ b/website/src/views/events/index.vue @@ -0,0 +1,8 @@ + + + diff --git a/website/src/views/home/components/HeroBanner.vue b/website/src/views/home/components/HeroBanner.vue index 0d1d459..f39a460 100644 --- a/website/src/views/home/components/HeroBanner.vue +++ b/website/src/views/home/components/HeroBanner.vue @@ -24,7 +24,7 @@ import { siteInfo } from "@/config/site"; diff --git a/website/src/views/home/components/ScheduleSection.vue b/website/src/views/home/components/ScheduleSection.vue index a078dfa..7c30074 100644 --- a/website/src/views/home/components/ScheduleSection.vue +++ b/website/src/views/home/components/ScheduleSection.vue @@ -1,53 +1,57 @@ @@ -108,7 +118,7 @@ const schedules: ScheduleItem[] = [ &__inner { position: relative; display: grid; - grid-template-columns: minmax(0, 1fr) 360px; + /* grid-template-columns: minmax(0, 1fr) 360px; */ gap: 26px; align-items: stretch; } @@ -157,7 +167,7 @@ const schedules: ScheduleItem[] = [ min-width: 0; } - &__range { + &__date { font-size: 25px; font-weight: 700; line-height: 36px; @@ -213,6 +223,15 @@ const schedules: ScheduleItem[] = [ color: @ink-400; } + &__date { + font-size: 25px; + font-weight: 700; + line-height: 36px; + letter-spacing: 0.01em; + color: @ink-900; + white-space: nowrap; + } + &__name { margin-top: 8px; font-size: 16px; @@ -237,6 +256,26 @@ const schedules: ScheduleItem[] = [ &__item:hover &__name { color: @blue-600; } + + &__empty { + grid-column: 1 / -1; + text-align: center; + padding: 40px; + font-size: 14px; + color: @ink-400; + } + + &__loading { + grid-column: 1 / -1; + text-align: center; + padding: 40px; + font-size: 14px; + color: @ink-500; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + } } /* ---------------- 活动入口卡 ---------------- */ @@ -350,6 +389,11 @@ const schedules: ScheduleItem[] = [ line-height: 32px; } + &__date { + font-size: 21px; + line-height: 32px; + } + &__axis { margin-right: -34px; } @@ -427,6 +471,11 @@ const schedules: ScheduleItem[] = [ line-height: 28px; } + &__date { + font-size: 19px; + line-height: 28px; + } + &__desc { margin-top: 8px; font-size: 12.5px; diff --git a/website/src/views/news/detail.vue b/website/src/views/news/detail.vue new file mode 100644 index 0000000..b36b67b --- /dev/null +++ b/website/src/views/news/detail.vue @@ -0,0 +1,8 @@ + + + diff --git a/website/src/views/news/index.vue b/website/src/views/news/index.vue new file mode 100644 index 0000000..e5064dc --- /dev/null +++ b/website/src/views/news/index.vue @@ -0,0 +1,8 @@ + + + diff --git a/website/src/views/partners.vue b/website/src/views/partners.vue new file mode 100644 index 0000000..a307f79 --- /dev/null +++ b/website/src/views/partners.vue @@ -0,0 +1,8 @@ + + + diff --git a/website/src/views/policy/detail.vue b/website/src/views/policy/detail.vue new file mode 100644 index 0000000..dcf959e --- /dev/null +++ b/website/src/views/policy/detail.vue @@ -0,0 +1,8 @@ + + + diff --git a/website/src/views/policy/index.vue b/website/src/views/policy/index.vue new file mode 100644 index 0000000..b8879f1 --- /dev/null +++ b/website/src/views/policy/index.vue @@ -0,0 +1,8 @@ + + + diff --git a/website/src/views/products/detail.vue b/website/src/views/products/detail.vue new file mode 100644 index 0000000..dbe8b65 --- /dev/null +++ b/website/src/views/products/detail.vue @@ -0,0 +1,8 @@ + + + diff --git a/website/src/views/products/index.vue b/website/src/views/products/index.vue new file mode 100644 index 0000000..e411ec9 --- /dev/null +++ b/website/src/views/products/index.vue @@ -0,0 +1,8 @@ + + + diff --git a/website/src/views/solutions/detail.vue b/website/src/views/solutions/detail.vue new file mode 100644 index 0000000..76cf693 --- /dev/null +++ b/website/src/views/solutions/detail.vue @@ -0,0 +1,8 @@ + + + diff --git a/website/src/views/solutions/index.vue b/website/src/views/solutions/index.vue new file mode 100644 index 0000000..a6eb105 --- /dev/null +++ b/website/src/views/solutions/index.vue @@ -0,0 +1,8 @@ + + + diff --git a/website/src/views/support/detail.vue b/website/src/views/support/detail.vue new file mode 100644 index 0000000..6eb3fd5 --- /dev/null +++ b/website/src/views/support/detail.vue @@ -0,0 +1,8 @@ + + + diff --git a/website/src/views/support/index.vue b/website/src/views/support/index.vue new file mode 100644 index 0000000..5533089 --- /dev/null +++ b/website/src/views/support/index.vue @@ -0,0 +1,8 @@ + + +