diff --git a/management/package-lock.json b/management/package-lock.json index 8a6b39d3..05c335b8 100644 --- a/management/package-lock.json +++ b/management/package-lock.json @@ -13,6 +13,7 @@ "echarts": "^6.0.0", "element-plus": "^2.11.4", "pinia": "^3.0.3", + "pinia-plugin-persistedstate": "^4.7.1", "quill": "^2.0.3", "vue": "^3.5.22", "vue-router": "^4.5.1" @@ -1182,6 +1183,12 @@ "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.18.tgz", "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==" }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1980,8 +1987,9 @@ }, "node_modules/pinia": { "version": "3.0.3", - "resolved": "https://registry.npmmirror.com/pinia/-/pinia-3.0.3.tgz", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.3.tgz", "integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==", + "license": "MIT", "dependencies": { "@vue/devtools-api": "^7.7.2" }, @@ -1998,6 +2006,31 @@ } } }, + "node_modules/pinia-plugin-persistedstate": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-4.7.1.tgz", + "integrity": "sha512-WHOqh2esDlR3eAaknPbqXrkkj0D24h8shrDPqysgCFR6ghqP/fpFfJmMPJp0gETHsvrh9YNNg6dQfo2OEtDnIQ==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.4" + }, + "peerDependencies": { + "@nuxt/kit": ">=3.0.0", + "@pinia/nuxt": ">=0.10.0", + "pinia": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@pinia/nuxt": { + "optional": true + }, + "pinia": { + "optional": true + } + } + }, "node_modules/pinia/node_modules/@vue/devtools-api": { "version": "7.7.7", "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.7.7.tgz", @@ -3476,6 +3509,11 @@ "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.18.tgz", "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==" }, + "defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==" + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3967,7 +4005,7 @@ }, "pinia": { "version": "3.0.3", - "resolved": "https://registry.npmmirror.com/pinia/-/pinia-3.0.3.tgz", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.3.tgz", "integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==", "requires": { "@vue/devtools-api": "^7.7.2" @@ -3983,6 +4021,14 @@ } } }, + "pinia-plugin-persistedstate": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-4.7.1.tgz", + "integrity": "sha512-WHOqh2esDlR3eAaknPbqXrkkj0D24h8shrDPqysgCFR6ghqP/fpFfJmMPJp0gETHsvrh9YNNg6dQfo2OEtDnIQ==", + "requires": { + "defu": "^6.1.4" + } + }, "postcss": { "version": "8.5.6", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", diff --git a/management/package.json b/management/package.json index e748a84e..55b5c2a3 100644 --- a/management/package.json +++ b/management/package.json @@ -14,6 +14,7 @@ "echarts": "^6.0.0", "element-plus": "^2.11.4", "pinia": "^3.0.3", + "pinia-plugin-persistedstate": "^4.7.1", "quill": "^2.0.3", "vue": "^3.5.22", "vue-router": "^4.5.1" diff --git a/management/src/App.vue b/management/src/App.vue index 4035e20e..87ed26ed 100644 --- a/management/src/App.vue +++ b/management/src/App.vue @@ -1,309 +1,3 @@ - - - - \ No newline at end of file + + \ No newline at end of file diff --git a/management/src/Layout/Layout.vue b/management/src/Layout/Layout.vue new file mode 100644 index 00000000..47f511e0 --- /dev/null +++ b/management/src/Layout/Layout.vue @@ -0,0 +1,339 @@ + + + + + \ No newline at end of file diff --git a/management/src/main.ts b/management/src/main.ts index 6fc57df9..234b7180 100644 --- a/management/src/main.ts +++ b/management/src/main.ts @@ -8,9 +8,10 @@ import Quill from 'quill' import 'quill/dist/quill.core.css' import 'quill/dist/quill.snow.css' import 'quill/dist/quill.bubble.css' - +import store from './store' const app = createApp(App) app.use(router) +app.use(store) app.use(ElementPlus) for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.component(key, component) diff --git a/management/src/router/index.ts b/management/src/router/index.ts index 9844e6b4..ade6b827 100644 --- a/management/src/router/index.ts +++ b/management/src/router/index.ts @@ -1,7 +1,6 @@ import { createRouter, createWebHistory } from 'vue-router' // 关键修复:使用 import type 导入类型 import type { RouteRecordRaw } from 'vue-router' - // 懒加载组件 const HomeView = () => import('../views/HomeView.vue') const AboutView = () => import('../views/AboutView.vue') @@ -19,187 +18,206 @@ const teachingCaseView = ()=>import('../views/caseresource/teachingcase/teaching const videosCaseView = ()=>import('../views/caseresource/videoscase/videosCaseView.vue') const CaseSettingView = ()=>import('../views/caseresource/setting/caseSettingView.vue') const PersonView = () =>import('../views/person/personView.vue') - +const Layout = ()=>import('../Layout/Layout.vue') +const LoginView=()=>import('../views/login/login.vue') // 定义路由规则(现在 RouteRecordRaw 导入正确) const routes: RouteRecordRaw[] = [ { - path: '/home', - name: 'Home', - component: HomeView, + path: '/login', + name: 'Login', + component: LoginView, meta: { - title: '首页', + title: '登录', requiresAuth: false } }, { - path: '/news', - name: 'News', - component: NewsView, - meta: { - title: '新闻动态', - requiresAuth: false - } - }, - { - path: '/person', - name: 'Person', - component: PersonView, - meta: { - title: '个人中心', - requiresAuth: false - } - } - , - { - path: '/publish', - name: 'Publish', - component: PublishView, - meta: { - title: '文章发布', - requiresAuth: false - } - }, - { - path: '/about', - name: 'About', - component: AboutView, - meta: { - title: '关于我们' - } - }, - { - - path: '/baseoverview', - name: 'baseoverview', - component: BaseOverview, - meta: { - title: '编辑基地概况', - requiresAuth: false - } - }, - { - - path: '/devproject', - name: 'devproject', - component: DevProjectView, - meta: { - title: '编辑科学研究', - requiresAuth: false - } - }, - { - - path: '/meeting', - name: 'meeting', - component: MeetingView, - meta: { - title: '编辑会议', - requiresAuth: false - } - }, - { - path: '/service', // 父菜单对应路径(与 el-sub-menu 的 index 一致) - name: 'service', - meta: { - title: '社会服务', // 父菜单标题 - requiresAuth: false - }, + path: '/', + component: Layout, // 渲染App.vue(导航栏布局) children: [ - // 子菜单1:校企合作(对应菜单 index="/service/schoolEnterprise") { - path: 'schoolEnterprise', // 完整路径为 /service/schoolEnterprise - name: 'serviceSchoolEnterprise', - component: SchoolEnterpriseView, + path: 'home', + name: 'Home', + component: HomeView, meta: { - title: '校企合作', // 与子菜单标题一致 + title: '首页', requiresAuth: false } }, - // 子菜单2:研究实习项目(对应菜单 index="/service/internship") { - path: 'internship', // 完整路径为 /service/internship - name: 'serviceInternship', - component: IntershipView, + path: 'news', + name: 'News', + component: NewsView, meta: { - title: '研究实习项目', // 与子菜单标题一致 + title: '新闻动态', requiresAuth: false } }, - // 子菜单3:乡村政府项目(对应菜单 index="/service/government") { - path: 'government', // 完整路径为 /service/government - name: 'serviceGovernment', - component: GovernmentView, + path: 'person', + name: 'Person', + component: PersonView, meta: { - title: '乡村政府项目', // 与子菜单标题一致 - requiresAuth: false - } - }, - // 子菜单3:乡村政府项目(对应菜单 index="/service/government") - { - path: 'serviceimg', // 完整路径为 /service/government - name: 'serviceimg', - component: ServiceimgView, - meta: { - title: '封面设置', // 与子菜单标题一致 + title: '个人中心', requiresAuth: false } } + , + { + path: 'publish', + name: 'Publish', + component: PublishView, + meta: { + title: '文章发布', + requiresAuth: false + } + }, + { + path: 'about', + name: 'About', + component: AboutView, + meta: { + title: '关于我们' + } + }, + { - ] - } - , - { - path: '/resourcecase', // 父菜单对应路径(与 el-sub-menu 的 index 一致) - name: 'resourcecase', - meta: { - title: '案例资源', // 父菜单标题 - requiresAuth: false - }, - children: [ - // 子菜单3:乡村政府项目(对应菜单 index="/service/government") - { - path: 'setting', // 完整路径为 /service/government - name: 'setting', - component: CaseSettingView, + path: 'baseoverview', + name: 'baseoverview', + component: BaseOverview, meta: { - title: '封面设置', // 与子菜单标题一致 + title: '编辑基地概况', requiresAuth: false } }, - // 子菜单1:校企合作(对应菜单 index="/service/schoolEnterprise") { - path: 'onlinecourse', // 完整路径为 /service/schoolEnterprise - name: 'onlinecourse', - component: OnlineCourseView, + + path: 'devproject', + name: 'devproject', + component: DevProjectView, meta: { - title: '线上课程', // 与子菜单标题一致 + title: '编辑科学研究', + requiresAuth: false + } + }, { + + path: 'meeting', + name: 'meeting', + component: MeetingView, + meta: { + title: '编辑会议', requiresAuth: false } }, - // 子菜单2:研究实习项目(对应菜单 index="/service/internship") { - path: 'teachingcase', // 完整路径为 /service/internship - name: 'teachingcase', - component: teachingCaseView, + path: 'service', // 父菜单对应路径(与 el-sub-menu 的 index 一致) + name: 'service', meta: { - title: '教学案例', // 与子菜单标题一致 + title: '社会服务', // 父菜单标题 requiresAuth: false - } - }, - // 子菜单3:乡村政府项目(对应菜单 index="/service/government") - { - path: 'videoscase', // 完整路径为 /service/government - name: 'videoscase', - component: videosCaseView, - meta: { - title: '视频案例', // 与子菜单标题一致 - requiresAuth: false - } + }, + children: [ + // 子菜单1:校企合作(对应菜单 index="/service/schoolEnterprise") + { + path: 'schoolEnterprise', // 完整路径为 /service/schoolEnterprise + name: 'serviceSchoolEnterprise', + component: SchoolEnterpriseView, + meta: { + title: '校企合作', // 与子菜单标题一致 + requiresAuth: false + } + }, + // 子菜单2:研究实习项目(对应菜单 index="/service/internship") + { + path: 'internship', // 完整路径为 /service/internship + name: 'serviceInternship', + component: IntershipView, + meta: { + title: '研究实习项目', // 与子菜单标题一致 + requiresAuth: false + } + }, + // 子菜单3:乡村政府项目(对应菜单 index="/service/government") + { + path: 'government', // 完整路径为 /service/government + name: 'serviceGovernment', + component: GovernmentView, + meta: { + title: '乡村政府项目', // 与子菜单标题一致 + requiresAuth: false + } + }, + // 子菜单3:乡村政府项目(对应菜单 index="/service/government") + { + path: 'serviceimg', // 完整路径为 /service/government + name: 'serviceimg', + component: ServiceimgView, + meta: { + title: '封面设置', // 与子菜单标题一致 + requiresAuth: false + } + } + + ] } + , + { + path: 'resourcecase', // 父菜单对应路径(与 el-sub-menu 的 index 一致) + name: 'resourcecase', + meta: { + title: '案例资源', // 父菜单标题 + requiresAuth: false + }, + children: [ + // 子菜单3:乡村政府项目(对应菜单 index="/service/government") + { + path: 'setting', // 完整路径为 /service/government + name: 'setting', + component: CaseSettingView, + meta: { + title: '封面设置', // 与子菜单标题一致 + requiresAuth: false + } + }, + // 子菜单1:校企合作(对应菜单 index="/service/schoolEnterprise") + { + path: 'onlinecourse', // 完整路径为 /service/schoolEnterprise + name: 'onlinecourse', + component: OnlineCourseView, + meta: { + title: '线上课程', // 与子菜单标题一致 + requiresAuth: false + } + }, + // 子菜单2:研究实习项目(对应菜单 index="/service/internship") + { + path: 'teachingcase', // 完整路径为 /service/internship + name: 'teachingcase', + component: teachingCaseView, + meta: { + title: '教学案例', // 与子菜单标题一致 + requiresAuth: false + } + }, + // 子菜单3:乡村政府项目(对应菜单 index="/service/government") + { + path: 'videoscase', // 完整路径为 /service/government + name: 'videoscase', + component: videosCaseView, + meta: { + title: '视频案例', // 与子菜单标题一致 + requiresAuth: false + } + } + ] + } ] + }, + { + path: '', + redirect: '/home' } ] @@ -208,11 +226,5 @@ const router = createRouter({ routes }) -// 导航守卫:自动更新页面标题 -router.beforeEach((to) => { - if (to.meta.title) { - document.title = to.meta.title as string - } -}) export default router diff --git a/management/src/store/index.ts b/management/src/store/index.ts new file mode 100644 index 00000000..933fe910 --- /dev/null +++ b/management/src/store/index.ts @@ -0,0 +1,8 @@ +import { createPinia } from 'pinia' +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' +// 创建pinia实例 +const store = createPinia() + +store.use(piniaPluginPersistedstate) + +export default store \ No newline at end of file diff --git a/management/src/store/user.ts b/management/src/store/user.ts new file mode 100644 index 00000000..9234bd13 --- /dev/null +++ b/management/src/store/user.ts @@ -0,0 +1,72 @@ +import { defineStore } from 'pinia'; +import type { PersistenceOptions } from 'pinia-plugin-persistedstate'; +import router from "../router"; + +// 1. 定义用户信息类型(不变) +export interface UserInfo { + id: number; + username: string; + cover_url: string; + intro: string; + role: string; +} + +// 2. 新增 expireTime 字段(存储过期时间戳,毫秒级) +interface UserState { + userInfo: UserInfo | null; + session: string; + isLogin: boolean; + expireTime: number | null; // 新增:过期时间戳(null 表示未设置) +} + +// 3. 同步更新 Actions 类型(setLoginInfo 新增 expireTime 参数) +interface UserActions { + setLoginInfo: (userInfo: UserInfo, session: string, expireTime: number) => void; // 新增 expireTime 参数 + logout: () => void; +} + +// 4. 持久化配置类型(不变,paths 后续会加 expireTime) +type UserPersistOptions = PersistenceOptions & { + key: string; + paths: (keyof UserState)[]; +}; + +// 5. Store选项配置(重点修改3处) +const storeOptions = { + state: (): UserState => ({ + userInfo: null, + session: '', + isLogin: false, + expireTime: null // 初始值设为 null + }), + actions: { + // 6. setLoginInfo 新增 expireTime 参数并赋值 + setLoginInfo(this: UserState & UserActions, userInfo: UserInfo, session: string, expireTime: number) { + this.userInfo = userInfo; + this.session = session; + this.isLogin = true; + this.expireTime = expireTime; // 存储后端返回的过期时间戳 + router.push('/home'); + }, + // 7. logout 时重置 expireTime + logout(this: UserState & UserActions) { + this.userInfo = null; + this.session = ''; + this.isLogin = false; + this.expireTime = null; // 清空过期时间 + router.push('/login'); + ElMessage.error("用户信息失效,请重新登录") + } + }, + // 8. 持久化配置添加 expireTime(确保刷新页面后不丢失) + persist: { + key: 'admin_user_store', + paths: ['userInfo', 'session', 'isLogin', 'expireTime'], // 新增 expireTime + storage: localStorage + } as UserPersistOptions +}; + +// 9. 创建Store(类型泛型不变,自动适配新增字段) +const useUserStore = defineStore<'user', UserState, {}, UserActions>('user', storeOptions); + +export default useUserStore; \ No newline at end of file diff --git a/management/src/utils/request.ts b/management/src/utils/request.ts index 9096e5af..ff71cf6a 100644 --- a/management/src/utils/request.ts +++ b/management/src/utils/request.ts @@ -1,41 +1,90 @@ -import axios from 'axios'; +// src/utils/request.ts +import axios, { + type InternalAxiosRequestConfig, // 关键:导入内部配置类型 + type AxiosError, + type AxiosResponse +} from 'axios'; +import { ElMessage } from 'element-plus'; +import router from "../router"; +import useUserStore from "../store/user.ts"; -// 从环境变量中获取基础地址 -const baseURL = import.meta.env.VITE_API_BASE_URL; - -// 创建 Axios 实例 +// 创建实例(配置不变) const request = axios.create({ - baseURL, // 自动拼接基础地址 - timeout: 5000, // 超时时间 - headers: { - 'Content-Type': 'application/json' - } + baseURL: import.meta.env.VITE_API_BASE_URL, + timeout: 10000, + headers: { 'Content-Type': 'application/json' }, }); -// 请求拦截器(可选,可添加 token 等) +// 1. 请求拦截器:使用 InternalAxiosRequestConfig 类型 request.interceptors.request.use( - (config) => { - // 示例:添加认证 token - // const token = localStorage.getItem('token'); - // if (token) { - // config.headers.Authorization = `Bearer ${token}`; - // } - return config; + (config: InternalAxiosRequestConfig) => { // 类型改为 InternalAxiosRequestConfig + const userStore = useUserStore(); + if (userStore.session) { + // 关键:用 set 方法添加自定义头,保持 headers 类型为 AxiosHeaders + console.log("发送session:", userStore.session); + config.headers.set('session_id', userStore.session); + } + return config; // 返回类型自动匹配 }, - (error) => { + (error: AxiosError) => { + ElMessage.error('请求配置错误,请检查'); return Promise.reject(error); } ); -// 响应拦截器(可选,统一处理错误) request.interceptors.response.use( - (response) => { - return response.data; // 直接返回响应体中的 data + (response: AxiosResponse) => { // 明确响应类型 + const res = response.data; + if ('code' in res) { + // 有 code 字段:按原有规则校验(code=0 视为成功) + if (res.code == '400') { + ElMessage.error(res.msg || '操作失败'); + return Promise.reject(res); // 业务错误抛错 + } + // 有 code 时,返回 res.data(保持原有逻辑) + return res.data; + } else { + // 2. 无 code 字段:默认接口成功,直接返回原始响应数据(或根据实际结构调整) + // 注意:根据无 code 接口的实际返回格式修改(可能是 res 本身,也可能是 res.data) + return res; // 假设无 code 的接口直接返回业务数据(如 { ok: true, list: [...] }) + } }, - (error) => { - console.error('请求错误:', error); + (error: AxiosError) => { + const status = error.response?.status; + switch (status) { + case 401: + const userStore = useUserStore(); + userStore.logout(); + router.push('/login'); + ElMessage.error('登录已过期,请重新登录'); + break; + case 403: + ElMessage.error('没有权限访问'); + break; + case 500: + ElMessage.error('服务器内部错误'); + break; + default: + ElMessage.error('网络异常,请稍后重试'); + } return Promise.reject(error); } ); +// 3. 封装请求方法(类型同步修改) +export const requestUtil = { + get: (url: string, config?: InternalAxiosRequestConfig) => { // 用内部类型 + return request.get(url, config); + }, + post: (url: string, data?: any, config?: InternalAxiosRequestConfig) => { + return request.post(url, data, config); + }, + put: (url: string, data?: any, config?: InternalAxiosRequestConfig) => { + return request.put(url, data, config); + }, + delete: (url: string, config?: InternalAxiosRequestConfig) => { + return request.delete(url, config); + }, +}; + export default request; \ No newline at end of file diff --git a/management/src/views/login/login.vue b/management/src/views/login/login.vue new file mode 100644 index 00000000..536c8d66 --- /dev/null +++ b/management/src/views/login/login.vue @@ -0,0 +1,340 @@ + + + + + \ No newline at end of file diff --git a/management/src/views/publish/QuillEditor.vue b/management/src/views/publish/QuillEditor.vue index 0f3dba9d..3e45c4a6 100644 --- a/management/src/views/publish/QuillEditor.vue +++ b/management/src/views/publish/QuillEditor.vue @@ -39,7 +39,22 @@ 新闻 - + + + 案例资源 + + + + 社区服务 + + + + 学生获奖 + + + + 论文发表 + @@ -237,8 +252,8 @@ const beforeCoverUpload: UploadProps['beforeUpload'] = (rawFile) => { ElMessage.error('仅支持JPG/PNG/WEBP格式的图片'); return false; } - if (rawFile.size / 1024 / 1024 > 20) { - ElMessage.error('图片大小不能超过20MB'); + if (rawFile.size / 1024 / 1024 > 5) { + ElMessage.error('图片大小不能超过5MB'); return false; } return true; diff --git a/server/database/local/user.db b/server/database/local/user.db index c99cc93a..2bec3e90 100644 Binary files a/server/database/local/user.db and b/server/database/local/user.db differ diff --git a/server/go.mod b/server/go.mod index c99c726b..7b288ae5 100644 --- a/server/go.mod +++ b/server/go.mod @@ -6,6 +6,7 @@ require ( github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible github.com/gin-gonic/gin v1.11.0 github.com/go-playground/validator/v10 v10.28.0 + github.com/google/uuid v1.6.0 github.com/zeromicro/go-zero v1.9.2 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.39.1 @@ -31,7 +32,6 @@ require ( github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.18.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.1 // indirect diff --git a/server/init/database/cache/InitCache.go b/server/init/database/cache/InitCache.go index 713e4db1..e12af084 100644 --- a/server/init/database/cache/InitCache.go +++ b/server/init/database/cache/InitCache.go @@ -12,7 +12,7 @@ import ( ) var ( - globalDB *sql.DB + GlobalDB *sql.DB once sync.Once // 确保 InitCache 只执行一次(单例初始化) ) @@ -46,23 +46,23 @@ func InitCache() { db.SetConnMaxIdleTime(30 * time.Minute) // 空闲连接30分钟超时释放 // 6. 赋值全局变量,初始化完成 - globalDB = db + GlobalDB = db fmt.Printf("SQLite 数据库初始化成功,文件路径:%s\n", dbPath) }) } // GetCacheDB 获取全局唯一的 SQLite 连接(必须先调用 InitCache 初始化) func GetCacheDB() *sql.DB { - if globalDB == nil { + if GlobalDB == nil { panic("数据库未初始化,请先调用 cache.InitCache()") } - return globalDB + return GlobalDB } // CloseCache 关闭数据库连接(程序退出时调用,释放资源) func CloseCache() error { - if globalDB != nil { - return globalDB.Close() + if GlobalDB != nil { + return GlobalDB.Close() } return nil } diff --git a/server/internal/admin/etc/admin-api.yaml b/server/internal/admin/etc/admin-api.yaml deleted file mode 100644 index d7c0751b..00000000 --- a/server/internal/admin/etc/admin-api.yaml +++ /dev/null @@ -1,3 +0,0 @@ -Name: admin-api -Host: 0.0.0.0 -Port: 8888 diff --git a/server/internal/admin/handler/admin/AvaLoginhandler.go b/server/internal/admin/handler/admin/AvaLoginhandler.go new file mode 100644 index 00000000..be24a1d3 --- /dev/null +++ b/server/internal/admin/handler/admin/AvaLoginhandler.go @@ -0,0 +1,30 @@ +package admin + +import ( + "fmt" + "net/http" + + "github.com/JACKYMYPERSON/hldrCenter/config" +) + +func AVALogin(cfg *config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // 核心:获取请求头中的 session_id(与前端发送的头名称一致) + sessionID := r.Header.Get("session_id") + + // 处理头不存在的情况(返回空字符串) + if sessionID == "" { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"code":401,"msg":"未获取到 session_id,请登录"}`)) + return + } + + // 后续逻辑:验证 sessionID 有效性... + // 例如:查询数据库、缓存判断 session 是否有效 + fmt.Printf("获取到的 session_id:%s\n", sessionID) + + // 响应成功(示例) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"code":0,"msg":"验证成功"}`)) + } +} diff --git a/server/internal/admin/handler/admin/adminloginhandler.go b/server/internal/admin/handler/admin/adminloginhandler.go new file mode 100644 index 00000000..7a6b151e --- /dev/null +++ b/server/internal/admin/handler/admin/adminloginhandler.go @@ -0,0 +1,89 @@ +package admin + +import ( + "errors" + "fmt" + "net" + "net/http" + "strings" + + "github.com/JACKYMYPERSON/hldrCenter/config" + "github.com/JACKYMYPERSON/hldrCenter/internal/admin/internal/logic/admin" + "github.com/JACKYMYPERSON/hldrCenter/internal/admin/internal/model" + "github.com/JACKYMYPERSON/hldrCenter/internal/admin/internal/types" + "github.com/zeromicro/go-zero/core/stores/sqlx" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func AdminLogin(cfg *config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.LoginReq + if err := httpx.Parse(r, &req); err != nil { + httpx.ErrorCtx(r.Context(), w, fmt.Errorf("参数解析失败:%v", err)) + return + } + + // 验证必填参数 + if req.Username == "" { + httpx.ErrorCtx(r.Context(), w, errors.New("用户名不能为空")) + return + } + if req.Password == "" { + httpx.ErrorCtx(r.Context(), w, errors.New("密码不能为空")) + return + } + + mysqlCfg := cfg.MySQL + dsn := fmt.Sprintf( + "%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=true&loc=Local", + mysqlCfg.Username, + mysqlCfg.Password, + mysqlCfg.Host, + mysqlCfg.Port, + mysqlCfg.Database, + mysqlCfg.Charset, + ) + fmt.Println("接收到articlePost请求") + conn := sqlx.NewSqlConn("mysql", dsn) + AdminModel := model.NewAdminModel(conn) + + l := admin.NewLoginAdminLogic(r.Context(), cfg, AdminModel) + + clientIP := GetClientIP(r) + resp, err := l.LoginAdmin(&req, clientIP) + if err != nil { + httpx.ErrorCtx(r.Context(), w, err) + } else { + httpx.OkJsonCtx(r.Context(), w, resp) + } + } +} + +func GetClientIP(r *http.Request) string { + // 1. 优先从代理头获取(如果经过反向代理,如Nginx) + ip := r.Header.Get("X-Forwarded-For") + if ip != "" { + // X-Forwarded-For 可能包含多个IP(客户端IP, 代理1IP, 代理2IP...),取第一个 + parts := strings.Split(ip, ",") + if len(parts) > 0 { + ip = strings.TrimSpace(parts[0]) + if ip != "" { + return ip + } + } + } + + // 2. 其次从 X-Real-IP 获取(部分代理会设置此头) + ip = r.Header.Get("X-Real-IP") + if ip != "" { + return ip + } + + // 3. 最后从 RemoteAddr 获取(原始客户端IP,可能包含端口) + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + // 若解析失败,直接返回 RemoteAddr(可能包含端口) + return r.RemoteAddr + } + return ip +} diff --git a/server/internal/admin/internal/logic/admin/adminloginlogic.go b/server/internal/admin/internal/logic/admin/adminloginlogic.go new file mode 100644 index 00000000..251bb2ac --- /dev/null +++ b/server/internal/admin/internal/logic/admin/adminloginlogic.go @@ -0,0 +1,136 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.9.1 + +package admin + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/JACKYMYPERSON/hldrCenter/config" + "github.com/JACKYMYPERSON/hldrCenter/internal/admin/internal/model" + "github.com/JACKYMYPERSON/hldrCenter/internal/admin/internal/types" + "github.com/JACKYMYPERSON/hldrCenter/util/auth" + "golang.org/x/crypto/bcrypt" + + "github.com/zeromicro/go-zero/core/logx" +) + +type LoginAdminLogic struct { + logx.Logger + ctx context.Context + cfg *config.Config + model model.AdminModel +} + +func NewLoginAdminLogic(ctx context.Context, cfg *config.Config, model model.AdminModel) *LoginAdminLogic { + return &LoginAdminLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + cfg: cfg, + model: model, + } +} + +func (l *LoginAdminLogic) LoginAdmin(req *types.LoginReq, ip string) (resp *types.LoginResp, err error) { + // 1. 验证请求参数 + if err := l.validateReq(req); err != nil { + return &types.LoginResp{ + Code: 1, + Msg: err.Error(), + }, nil + } + + // 2. 从数据库查询用户(通过 admin 表模型查询,符合 go-zero 分层规范) + admin, err := l.model.FindOneByUsername(l.ctx, req.Username) + if err != nil { + // 处理查询错误:区分"用户不存在"和"数据库异常" + if err == model.ErrNotFound { + return &types.LoginResp{ + Code: 1, + Msg: "用户名或密码错误", + }, nil + } + // 数据库异常(记录日志,返回通用错误) + l.Logger.Errorf("查询管理员失败: %v, username: %s", err, req.Username) + return &types.LoginResp{ + Code: 500, + Msg: "系统内部错误,请稍后再试", + }, nil + } + + // 3. 检查账号状态(1=启用,0=禁用,与表结构一致) + if admin.Status != 1 { + return &types.LoginResp{ + Code: 1, + Msg: "账号已被禁用,请联系管理员", + }, nil + } + + // 4. 验证密码(数据库存储 bcrypt 加密后的密码) + if _, err := VerifyPassword(req.Password, admin.Password); err != nil { + return &types.LoginResp{ + Code: 1, + Msg: "用户名或密码错误", + }, nil + } + + session, err := auth.CreateSession(int(admin.Id), "Kehuduan", ip, 2*time.Hour) + if err != nil { + return nil, err + } + + return &types.LoginResp{ + Code: 0, + Msg: "登录成功", + Session: session.SessionID, + Data: types.AdminInfoResq{ + Id: admin.Id, + Username: NullStringToString(admin.Username), + Role: NullStringToString(admin.Role), + CoverUrl: NullStringToString(admin.CoverUrl), + Intro: NullStringToString(admin.Intro), + }, + }, nil +} + +// 验证请求参数 +func (l *LoginAdminLogic) validateReq(req *types.LoginReq) error { + if req.Username == "" { + return errors.New("用户名不能为空") + } + if req.Password == "" { + return errors.New("密码不能为空") + } + return nil +} + +func VerifyPassword(plainPassword, hashedPassword string) (bool, error) { + plainBytes := []byte(plainPassword) + hashedBytes := []byte(hashedPassword) + + err := bcrypt.CompareHashAndPassword(hashedBytes, plainBytes) + if err == nil { + return true, nil // 匹配成功(密码正确) + } else if err == bcrypt.ErrMismatchedHashAndPassword { + return false, nil // 匹配失败(密码错误) + } else { + return false, err // 其他异常(如哈希格式错误、内存不足等) + } +} + +func NullStringToString(ns sql.NullString) string { + if ns.Valid { + return ns.String + } + return "" +} + +func StringToNullString(s string) sql.NullString { + if s != "" { + return sql.NullString{String: s, Valid: true} + } + return sql.NullString{Valid: false} +} diff --git a/server/internal/admin/internal/model/adminmodel_gen.go b/server/internal/admin/internal/model/adminmodel_gen.go index 3c093388..ee77acd3 100644 --- a/server/internal/admin/internal/model/adminmodel_gen.go +++ b/server/internal/admin/internal/model/adminmodel_gen.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/zeromicro/go-zero/core/logx" "github.com/zeromicro/go-zero/core/stores/builder" "github.com/zeromicro/go-zero/core/stores/sqlx" "github.com/zeromicro/go-zero/core/stringx" @@ -29,6 +30,7 @@ type ( FindOne(ctx context.Context, id int64) (*Admin, error) Update(ctx context.Context, data *Admin) error Delete(ctx context.Context, id int64) error + FindOneByUsername(ctx context.Context, username string) (*Admin, error) } defaultAdminModel struct { @@ -88,6 +90,37 @@ func (m *defaultAdminModel) Update(ctx context.Context, data *Admin) error { return err } +func (m *defaultAdminModel) FindOneByUsername(ctx context.Context, username string) (*Admin, error) { + // 1. 构建查询语句:使用model的table字段(避免硬编码表名),查询所有字段(与Admin结构体对应) + // 字段顺序与Admin结构体字段顺序一致,确保映射正确 + query := fmt.Sprintf(` + SELECT + id, username, password, cover_url, intro, create_time, update_time, role, status + FROM %s + WHERE username = ? + LIMIT 1 + `, m.table) // 使用m.table替代硬编码"admin",适配表名可能的变化 + + var admin Admin + err := m.conn.QueryRowCtx(ctx, &admin, query, username) + if err != nil { + // 3. 错误处理:区分"记录不存在"和"查询异常" + if err == sql.ErrNoRows { + // 返回自定义的"记录不存在"错误,上层可明确判断 + return nil, err + } + // 其他错误(如SQL语法错误、连接异常等),记录日志并返回 + logx.WithContext(ctx).Errorf( + "failed to find admin by username: %s, err: %v, query: %s", + username, err, query, + ) + return nil, err + } + + // 4. 查询成功,返回管理员信息 + return &admin, nil +} + func (m *defaultAdminModel) tableName() string { return m.table } diff --git a/server/internal/admin/internal/types/types.go b/server/internal/admin/internal/types/types.go index e94d82a0..e3135ce0 100644 --- a/server/internal/admin/internal/types/types.go +++ b/server/internal/admin/internal/types/types.go @@ -85,3 +85,23 @@ type UpdateAdminReq struct { type UpdateAdminResp struct { BaseResp } + +type LoginReq struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type AdminInfoResq struct { + Id int64 `json:"id"` // 用户ID + Username string `json:"username"` // 用户名 + CoverUrl string `json:"cover_url"` // 封面URL + Intro string `json:"intro"` // 简介 + Role string `json:"role"` // 角色(super_admin/normal_admin) +} + +type LoginResp struct { + Code int `json:"code"` // 响应码(0=成功) + Msg string `json:"msg"` // 提示信息 + Session string `json:"session"` + Data AdminInfoResq `json:"data"` // 成功返回的用户信息 +} diff --git a/server/main.go b/server/main.go index 4c48152b..5f255ab2 100644 --- a/server/main.go +++ b/server/main.go @@ -5,7 +5,7 @@ import ( "github.com/JACKYMYPERSON/hldrCenter/config" "github.com/JACKYMYPERSON/hldrCenter/init/database/cache" - "github.com/JACKYMYPERSON/hldrCenter/middleware" + "github.com/JACKYMYPERSON/hldrCenter/middleware/cors" "github.com/JACKYMYPERSON/hldrCenter/router" ) @@ -29,7 +29,7 @@ func main() { r := router.SetupRouter(cfg) // 应用跨域中间件 - r.Use(middleware.CorsMiddleware(&cfg.Server)) + r.Use(cors.CorsMiddleware(&cfg.Server)) // 启动服务 addr := fmt.Sprintf(":%s", cfg.Server.Port) diff --git a/server/middleware/auth/auth.go b/server/middleware/auth/auth.go new file mode 100644 index 00000000..61a6db58 --- /dev/null +++ b/server/middleware/auth/auth.go @@ -0,0 +1,40 @@ +package auth + +import ( + "net/http" + + "github.com/JACKYMYPERSON/hldrCenter/util/auth" + "github.com/gin-gonic/gin" +) + +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + sessionID := c.GetHeader("session_id") + if sessionID == "" { // 检查头是否为空 + c.JSON(http.StatusUnauthorized, gin.H{ + "code": 401, + "msg": "未登录,请先登录", + }) + c.Abort() + return + } + + // 2. 验证会话有效性(调用你的ValidateSession函数) + session, err := auth.ValidateSession(sessionID) // 假设该函数已存在,返回*Session和error + if err != nil { + // 会话无效(过期/已注销等),返回401 + c.JSON(http.StatusUnauthorized, gin.H{ + "code": 401, + "msg": "会话无效或已过期,请重新登录", + }) + c.Abort() + return + } + + // 3. 会话有效,将用户ID存入Gin上下文(供后续处理器使用) + // 后续handler可通过 c.Get("user_id") 获取 + c.Set("user_id", session.UserID) + c.Next() + + } +} diff --git a/server/middleware/cors.go b/server/middleware/cors/cors.go similarity index 95% rename from server/middleware/cors.go rename to server/middleware/cors/cors.go index 3a0cbb84..91a47419 100644 --- a/server/middleware/cors.go +++ b/server/middleware/cors/cors.go @@ -1,4 +1,4 @@ -package middleware +package cors import ( "fmt" @@ -47,7 +47,7 @@ func CorsMiddleware(serverConfig *config.ServerConfig) gin.HandlerFunc { // 允许的方法(包含上传需要的POST) c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH") // 允许的头(包含上传可能用到的Content-Type) - c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, X-Requested-With") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, X-Requested-With,session_id") // 允许携带凭证(如果前端需要) c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") // 预检请求缓存时间(24小时) diff --git a/server/router/admin/admin.go b/server/router/admin/admin.go new file mode 100644 index 00000000..000ff67f --- /dev/null +++ b/server/router/admin/admin.go @@ -0,0 +1,15 @@ +package admin + +import ( + "github.com/JACKYMYPERSON/hldrCenter/config" + "github.com/JACKYMYPERSON/hldrCenter/internal/admin/handler/admin" + "github.com/gin-gonic/gin" +) + +func AdminRouter(api *gin.RouterGroup, cfg *config.Config) { + adminGroup := api.Group("/admin") + { + adminGroup.POST("/login", gin.WrapH(admin.AdminLogin(cfg))) + } + +} diff --git a/server/router/admin/tmp b/server/router/admin/tmp deleted file mode 100644 index e69de29b..00000000 diff --git a/server/router/article/tmp b/server/router/article/tmp deleted file mode 100644 index e69de29b..00000000 diff --git a/server/router/router.go b/server/router/router.go index d035f315..08ee1c13 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -2,7 +2,8 @@ package router import ( "github.com/JACKYMYPERSON/hldrCenter/config" - "github.com/JACKYMYPERSON/hldrCenter/middleware" + "github.com/JACKYMYPERSON/hldrCenter/middleware/cors" + "github.com/JACKYMYPERSON/hldrCenter/router/admin" "github.com/JACKYMYPERSON/hldrCenter/router/article" "github.com/JACKYMYPERSON/hldrCenter/router/baseoverview" "github.com/JACKYMYPERSON/hldrCenter/router/course/course_activity" @@ -32,31 +33,40 @@ func SetupRouter(cfg *config.Config) *gin.Engine { r := gin.Default() // 关键:跨域中间件必须在所有路由定义之前应用 - r.Use(middleware.CorsMiddleware(&cfg.Server)) + r.Use(cors.CorsMiddleware(&cfg.Server)) + //r.Use(auth.AuthMiddleware()) api := r.Group("/api") { - images.FileImagesRouter(api, cfg) - cover.FileCoverRouter(api, cfg) - file.FileCoverRouter(api, cfg) - ping.PingRouter(api, cfg) - article.ArticleRouter(api, cfg) - baseoverview.BaseOverViewRouter(api, cfg) - devproject.DevProjectRouter(api, cfg) - page_imgs.Pages_imgs_Router(api, cfg) - main_meeting.MainMeetingRouter(api, cfg) - meeting_speaker.Meeting_Speaker_Router(api, cfg) - social_service.Social_Service_Router(api, cfg) - social_service_internship.Social_Service_Internship_Router(api, cfg) - social_service_governmentprogram.Social_Service_Government_Router(api, cfg) - maincourse.MainCourseRouter(api, cfg) - teaching_case.Teaching_Case_Router(api, cfg) - video_case.Video_Case_Router(api, cfg) - course_content.Course_Content_Router(api, cfg) - course_file.Course_Content_Router(api, cfg) - course_activity.Course_Activity_Router(api, cfg) - course_resource.Course_Resource_Router(api, cfg) - course_teacher.Course_Teacher_Router(api, cfg) + authRequired := api.Group("/") + //authRequired.Use(auth.AuthMiddleware()) // 仅对该子分组下的路由生效 + { + // 所有需要验证的路由都注册到 authRequired 下 + images.FileImagesRouter(authRequired, cfg) + cover.FileCoverRouter(authRequired, cfg) + file.FileCoverRouter(authRequired, cfg) + ping.PingRouter(authRequired, cfg) + article.ArticleRouter(authRequired, cfg) + baseoverview.BaseOverViewRouter(authRequired, cfg) + devproject.DevProjectRouter(authRequired, cfg) + page_imgs.Pages_imgs_Router(authRequired, cfg) + main_meeting.MainMeetingRouter(authRequired, cfg) + meeting_speaker.Meeting_Speaker_Router(authRequired, cfg) + social_service.Social_Service_Router(authRequired, cfg) + social_service_internship.Social_Service_Internship_Router(authRequired, cfg) + social_service_governmentprogram.Social_Service_Government_Router(authRequired, cfg) + maincourse.MainCourseRouter(authRequired, cfg) + teaching_case.Teaching_Case_Router(authRequired, cfg) + video_case.Video_Case_Router(authRequired, cfg) + course_content.Course_Content_Router(authRequired, cfg) + course_file.Course_Content_Router(authRequired, cfg) + course_activity.Course_Activity_Router(authRequired, cfg) + course_resource.Course_Resource_Router(authRequired, cfg) + course_teacher.Course_Teacher_Router(authRequired, cfg) + } + + // 2. 不需要身份验证的路由:直接注册到 api 分组下,不应用 auth 中间件 + admin.AdminRouter(api, cfg) // admin 路由无需验证 } return r diff --git a/server/util/auth/auth.go b/server/util/auth/auth.go new file mode 100644 index 00000000..5bdd7025 --- /dev/null +++ b/server/util/auth/auth.go @@ -0,0 +1,184 @@ +package auth + +import ( + "database/sql" + "errors" + "fmt" + "time" + + "github.com/JACKYMYPERSON/hldrCenter/init/database/cache" + "github.com/google/uuid" + _ "modernc.org/sqlite" +) + +type Session struct { + SessionID string // 会话ID(主键) + UserID int // 关联用户ID + CreatedAt time.Time // 创建时间 + ExpiredAt time.Time // 过期时间 + UserAgent string // 客户端标识 + IpAddress string // 客户端IP + IsValid int // 是否有效(1=有效,0=无效) +} + +var db *sql.DB + +// InitDB 初始化数据库连接并创建会话表 +func InitDB(dbPath string) error { + var err error + // 打开SQLite数据库(文件不存在则自动创建) + db, err = sql.Open("sqlite", dbPath) + if err != nil { + return fmt.Errorf("数据库连接失败: %w", err) + } + + // 验证连接有效性 + if err := db.Ping(); err != nil { + return fmt.Errorf("数据库ping失败: %w", err) + } + + // 创建sessions表(如果不存在) + createTableSQL := ` + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expired_at DATETIME NOT NULL, + user_agent TEXT, + ip_address TEXT, + is_valid INTEGER NOT NULL DEFAULT 1 + ); + ` + _, err = db.Exec(createTableSQL) + if err != nil { + return fmt.Errorf("创建会话表失败: %w", err) + } + + return nil +} + +var utc8, _ = time.LoadLocation("Asia/Shanghai") + +func CreateSession(userID int, userAgent, ipAddress string, expireDuration time.Duration) (*Session, error) { + // 生成唯一session_id(UUID v4) + sessionID := uuid.New().String() + + // 获取东八区当前时间 + now := time.Now().In(utc8) + // 计算东八区过期时间(当前时间+过期时长) + expiredAt := now.Add(expireDuration) + + // 插入会话记录(时间格式化为东八区的RFC3339字符串) + _, err := cache.GlobalDB.Exec(` + INSERT INTO auth_sessions + (session_id, user_id, created_at, expired_at, user_agent, ip_address) + VALUES (?, ?, ?, ?, ?, ?) + `, + sessionID, + userID, + now.Format(time.RFC3339), // 东八区创建时间 + expiredAt.Format(time.RFC3339), // 东八区过期时间 + userAgent, + ipAddress) + + if err != nil { + return nil, fmt.Errorf("创建会话失败:%v", err) + } + + // 返回创建的会话信息(时间均为东八区) + return &Session{ + SessionID: sessionID, + UserID: userID, + CreatedAt: now, // 东八区创建时间 + ExpiredAt: expiredAt, // 东八区过期时间 + UserAgent: userAgent, + IpAddress: ipAddress, + IsValid: 1, + }, nil +} + +// ValidateSession 验证会话有效性(请求时身份校验) +// 参数:session_id(从Cookie或Header中获取) +// 返回:有效则返回会话信息,无效则返回错误 +func ValidateSession(sessionID string) (*Session, error) { + if sessionID == "" { + return nil, errors.New("session_id不能为空") + } + + // 查询会话记录(同时检查是否有效、是否过期) + var session Session + err := cache.GlobalDB.QueryRow(` + SELECT session_id, user_id, created_at, expired_at, user_agent, ip_address, is_valid + FROM auth_sessions + WHERE session_id = ? + AND is_valid = 1 + AND expired_at > CURRENT_TIMESTAMP + `, sessionID).Scan( + &session.SessionID, + &session.UserID, + &session.CreatedAt, + &session.ExpiredAt, + &session.UserAgent, + &session.IpAddress, + &session.IsValid, + ) + + // 处理查询结果 + switch { + case err == sql.ErrNoRows: + return nil, errors.New("会话无效或已过期") + case err != nil: + return nil, fmt.Errorf("验证会话失败:%v", err) + default: + return &session, nil + } +} + +// InvalidateSession 注销会话(用户登出时调用) +func InvalidateSession(sessionID string) error { + if sessionID == "" { + return errors.New("session_id不能为空") + } + + // 将会话标记为无效(而非删除,便于日志追溯) + result, err := db.Exec(` + UPDATE auth_sessions + SET is_valid = 0 + WHERE session_id = ? + `, sessionID) + if err != nil { + return fmt.Errorf("注销会话失败:%v", err) + } + + // 检查是否有记录被更新 + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("检查会话状态失败:%v", err) + } + if rowsAffected == 0 { + return errors.New("会话不存在或已失效") + } + return nil +} + +// CleanupExpiredSessions 清理过期/无效会话(建议定时任务调用) +func CleanupExpiredSessions() error { + // 删除已过期或已无效的会话 + _, err := db.Exec(` + DELETE FROM auth_sessions + WHERE is_valid = 0 + OR expired_at <= CURRENT_TIMESTAMP + `) + if err != nil { + return fmt.Errorf("清理过期会话失败:%v", err) + } + return nil +} + +// CloseDB 关闭数据库连接 +func CloseDB() error { + if db != nil { + return db.Close() + } + return nil +} diff --git a/server/util/test/authmain.go b/server/util/test/authmain.go new file mode 100644 index 00000000..240cb77b --- /dev/null +++ b/server/util/test/authmain.go @@ -0,0 +1,69 @@ +package main + +import ( + "log" + + "golang.org/x/crypto/bcrypt" +) + +// EncryptPassword 对明文密码进行 bcrypt 加密 +// 输入:明文密码(string) +// 返回:加密后的哈希字符串(string)和可能的错误(error) +func EncryptPassword(plainPassword string) (string, error) { + passwordBytes := []byte(plainPassword) + // cost=12,生产环境推荐值 + hashBytes, err := bcrypt.GenerateFromPassword(passwordBytes, 12) + if err != nil { + return "", err + } + return string(hashBytes), nil +} + +// VerifyPassword 验证明文密码与哈希密码是否匹配(核心:替代“解密”) +// 输入:明文密码(用户登录时输入)、数据库存储的哈希密码 +// 返回:是否匹配(bool)、错误(error) +func VerifyPassword(plainPassword, hashedPassword string) (bool, error) { + // 1. 转换为字节切片 + plainBytes := []byte(plainPassword) + hashedBytes := []byte(hashedPassword) + + // 2. 核心验证:bcrypt 自动提取哈希中的盐值,用同样算法加密明文后比对 + // 注意:这里返回的 error 是“验证失败”或“算法异常”,不是解密错误 + err := bcrypt.CompareHashAndPassword(hashedBytes, plainBytes) + if err == nil { + return true, nil // 匹配成功(密码正确) + } else if err == bcrypt.ErrMismatchedHashAndPassword { + return false, nil // 匹配失败(密码错误) + } else { + return false, err // 其他异常(如哈希格式错误、内存不足等) + } +} + +// 示例使用 +func main() { + // 明文密码 + plainPwd := "admin" + + // 加密 + hashedPwd, err := EncryptPassword(plainPwd) + if err != nil { + log.Fatalf("密码加密失败: %v", err) + } + + // 输出加密结果(可存储到数据库的 password 字段) + log.Printf("明文密码: %s\n加密后: %s", plainPwd, hashedPwd) + + loginInputPwd := "admin" // 用户登录时输入的明文(正确) + // loginInputPwd := "wrong123" // 错误密码(测试用) + + match, err := VerifyPassword(loginInputPwd, hashedPwd) + if err != nil { + log.Fatalf("验证异常: %v", err) + } + + if match { + log.Println("验证成功!密码正确") + } else { + log.Println("验证失败!密码错误") + } +}