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 @@
+
+
+
+
+
登录失败
+
{{ error }}
+
+
+
+
+
+
+
+
+
+
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
+
🎉 核心功能更新
+
+ - CRM 模块增强: 新增上下游项目金额拆分管理
+ - 回款精细化管理: 支持多种回款周期类型
+
+
+💡 体验优化
+
+ - 移动端全面适配
+ - 性能优化,加载速度提升 50%
+
+```
+
+### 删除记录
+
+建议使用"隐藏"状态而非直接删除,保留历史记录便于追溯。如需彻底删除,请在后台管理中操作。
+
+## 🔧 常见问题
+
+### 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"}}请选择要进入的企业{{else}}登录后即可使用全部应用{{end}}
+
+
+
+
+ {{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 @@
-
+
-
近期活动与发布节奏
-
2026 年 · 活动日历
+
近期平台更新内容
+
Platform Updates
-
+
-
-
{{ item.range }}
+ {{ item.update_date }}
@@ -55,27 +59,33 @@ const schedules: ScheduleItem[] = [
- {{ item.period }}
- {{ item.name }}
- {{ item.desc }}
+ {{ item.title }}
+
+
+ -
+ 暂无更新内容
+
+
+ 加载中...
+
-
-
+
+
@@ -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 @@
+
+
+
+
+