diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5557eae..f0cc320 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(git add:*)" + "Bash(git add:*)", + "Bash(git commit:*)" ], "deny": [], "ask": [] diff --git a/docs/公告管理使用说明.md b/docs/公告管理使用说明.md index 5352ce2..e86e5ba 100644 --- a/docs/公告管理使用说明.md +++ b/docs/公告管理使用说明.md @@ -1,117 +1,259 @@ -# 公告管理使用说明 +基础信息 -## 功能概述 + - 基础路径: /api/admin/announcement + - 认证方式: JWT Token(必须在Header中添加:Authorization: Bearer ) + - 内容类型: application/json -公告管理模块提供了完整的公告信息管理功能,包括公告的创建、编辑、删除、启用/禁用等操作。 + --- + 1. 创建公告 -## 功能特性 + POST /api/admin/announcement -- ✅ 公告列表查看(支持分页) -- ✅ 按标题/内容关键词搜索 -- ✅ 按启用状态筛选 -- ✅ 创建新公告 -- ✅ 编辑现有公告 -- ✅ 删除公告 -- ✅ 一键启用/禁用公告 -- ✅ 支持跳转链接设置 -- ✅ 权限控制(管理员和代理商不同权限) + 请求头 -## 权限说明 + Authorization: Bearer + Content-Type: application/json -### 管理员权限 -- 查看公告列表 -- 创建新公告 -- 编辑现有公告 -- 删除公告 -- 启用/禁用公告 + 请求参数 -### 代理商权限 -- 查看公告列表(只读) + { + "title": "string", // 必填 - 公告标题,最大长度100字符 + "content": "string", // 必填 - 公告内容 + "enabled": boolean, // 必填 - 是否启用 + "jumpUrl": "string" // 可选 - 跳转链接 + } -## 使用步骤 + 注意: belongId 字段已从请求参数中移除,系统会自动从JWT token中解析当前用户ID并设置为 belongId -### 1. 访问公告管理 -- 登录管理后台 -- 在左侧导航菜单中点击"公告管理" + 请求示例 -### 2. 查看公告列表 -- 公告列表显示所有公告信息 -- 支持按关键词搜索(标题或内容) -- 支持按启用状态筛选 -- 支持分页浏览 + { + "title": "系统维护通知", + "content": "系统将于今晚22:00-24:00进行维护升级,请提前保存工作内容", + "enabled": true, + "jumpUrl": "https://example.com/maintenance" + } -### 3. 创建新公告 -1. 点击"新增公告"按钮 -2. 填写公告信息: - - **标题**:必填,公告标题 - - **内容**:必填,公告详细内容 - - **跳转链接**:可选,点击公告后跳转的URL - - **状态**:选择是否启用 -3. 点击"保存"完成创建 + 响应结果 -### 4. 编辑公告 -1. 在公告列表中找到要编辑的公告 -2. 点击"编辑"按钮 -3. 修改公告信息 -4. 点击"保存"完成修改 + { + "success": true, + "message": "公告创建成功", + "id": 1 + } -### 5. 删除公告 -1. 在公告列表中找到要删除的公告 -2. 点击"删除"按钮 -3. 确认删除操作 + 错误响应 -### 6. 启用/禁用公告 -- 在公告列表中,直接点击状态开关即可快速启用或禁用公告 -- 只有启用的公告才会对用户可见 + { + "success": false, + "message": "用户未认证" + } -## 字段说明 + --- + 2. 获取公告列表(分页) -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| 标题 | 文本 | 是 | 公告标题,用于列表显示 | -| 内容 | 文本 | 是 | 公告详细内容 | -| 跳转链接 | URL | 否 | 点击公告后跳转的链接地址 | -| 状态 | 布尔 | 是 | 是否启用,只有启用的公告用户才能看到 | + GET /api/admin/announcement/list -## 注意事项 + 查询参数 -1. **权限控制**:只有具备相应权限的用户才能进行相应操作 -2. **数据验证**:标题和内容为必填字段 -3. **URL验证**:跳转链接必须是有效的URL格式(以http://或https://开头) -4. **状态控制**:只有启用的公告才会在前端显示给用户 -5. **操作记录**:所有操作都会记录创建时间和更新时间 + | 参数名 | 类型 | 必填 | 默认值 | 说明 | + |----------|---------|-----|-----|---------| + | page | integer | 否 | 1 | 页码 | + | size | integer | 否 | 20 | 每页大小 | + | enabled | boolean | 否 | - | 按启用状态筛选 | + | belongId | integer | 否 | - | 按归属ID筛选 | -## API接口 + 请求示例 -公告管理使用以下API接口: + GET /api/admin/announcement/list?page=1&size=10&enabled=true&belongId=123 -- `GET /api/admin/announcement/list` - 获取公告列表 -- `POST /api/admin/announcement` - 创建公告 -- `PUT /api/admin/announcement/{id}` - 更新公告 -- `DELETE /api/admin/announcement/{id}` - 删除公告 -- `PUT /api/admin/announcement/{id}/enabled` - 更新启用状态 -- `GET /api/admin/announcement/enabled` - 获取启用的公告 + 响应结果 -详细的API文档请参考项目根目录的接口文档。 + { + "items": [ + { + "id": 1, + "title": "系统维护通知", + "content": "系统将于今晚22:00-24:00进行维护升级", + "enabled": true, + "jumpUrl": "https://example.com/maintenance", + "belongId": 123, + "createdAt": "2025-08-29T10:30:00", + "updatedAt": "2025-08-29T10:30:00" + } + ], + "total": 1, + "page": 1, + "size": 10 + } -## 故障排除 + --- + 3. 获取公告详情 -### 常见问题 + GET /api/admin/announcement/{id} -1. **无法访问公告管理页面** - - 检查用户是否有相应权限 - - 确认已正确登录 + 路径参数 -2. **创建公告失败** - - 检查标题和内容是否已填写 - - 检查跳转链接格式是否正确 + | 参数名 | 类型 | 必填 | 说明 | + |-----|------|-----|------| + | id | long | 是 | 公告ID | -3. **状态切换失败** - - 检查网络连接 - - 确认用户有编辑权限 + 请求示例 -4. **搜索无结果** - - 检查搜索关键词是否正确 - - 尝试重置搜索条件 + GET /api/admin/announcement/1 -如有其他问题,请联系系统管理员。 + 响应结果 + + { + "id": 1, + "title": "系统维护通知", + "content": "系统将于今晚22:00-24:00进行维护升级,请提前保存工作内容", + "enabled": true, + "jumpUrl": "https://example.com/maintenance", + "belongId": 123, + "createdAt": "2025-08-29T10:30:00", + "updatedAt": "2025-08-29T10:30:00" + } + + --- + 4. 更新公告 + + PUT /api/admin/announcement/{id} + + 请求头 + + Authorization: Bearer + Content-Type: application/json + + 路径参数 + + | 参数名 | 类型 | 必填 | 说明 | + |-----|------|-----|------| + | id | long | 是 | 公告ID | + + 请求参数 + + { + "title": "string", // 可选 - 公告标题 + "content": "string", // 可选 - 公告内容 + "enabled": boolean, // 可选 - 是否启用 + "jumpUrl": "string" // 可选 - 跳转链接 + } + + 注意: 更新时 belongId 会自动从JWT token中获取并更新 + + 请求示例 + + { + "title": "系统维护通知(更新)", + "content": "系统维护时间调整为23:00-01:00", + "enabled": true + } + + 响应结果 + + { + "success": true, + "message": "公告更新成功" + } + + --- + 5. 删除公告 + + DELETE /api/admin/announcement/{id} + + 路径参数 + + | 参数名 | 类型 | 必填 | 说明 | + |-----|------|-----|------| + | id | long | 是 | 公告ID | + + 请求示例 + + DELETE /api/admin/announcement/1 + + 响应结果 + + { + "success": true, + "message": "公告删除成功" + } + + --- + 6. 更新公告启用状态 + + PUT /api/admin/announcement/{id}/enabled + + 路径参数 + + | 参数名 | 类型 | 必填 | 说明 | + |-----|------|-----|------| + | id | long | 是 | 公告ID | + + 查询参数 + + | 参数名 | 类型 | 必填 | 说明 | + |---------|---------|-----|------| + | enabled | boolean | 是 | 启用状态 | + + 请求示例 + + PUT /api/admin/announcement/1/enabled?enabled=false + + 响应结果 + + { + "success": true, + "message": "公告已禁用" + } + + --- + 7. 获取启用的公告 + + GET /api/admin/announcement/enabled + + 查询参数 + + | 参数名 | 类型 | 必填 | 说明 | + |----------|---------|-----|---------| + | belongId | integer | 否 | 按归属ID筛选 | + + 请求示例 + + GET /api/admin/announcement/enabled?belongId=123 + + 响应结果 + + [ + { + "id": 1, + "title": "系统维护通知", + "content": "系统将于今晚22:00-24:00进行维护升级", + "enabled": true, + "jumpUrl": "https://example.com/maintenance", + "belongId": 123, + "createdAt": "2025-08-29T10:30:00", + "updatedAt": "2025-08-29T10:30:00" + } + ] + + --- + 主要变更说明 + + 🔒 安全改进 + + - 自动用户识别: 创建和更新公告时,系统自动从JWT token中解析当前用户ID + - 防止伪造: 前端无法伪造 belongId,确保公告只能归属于当前登录用户 + + 📝 请求参数变更 + + - 移除: 创建和更新公告的请求中不再需要传递 belongId 参数 + - 简化: 前端调用更加简洁,无需关心用户ID的传递 + + 🎯 使用场景 + + 1. 管理员: 创建的公告 belongId 为管理员用户ID + 2. 代理商: 创建的公告 belongId 为代理商用户ID + 3. 权限隔离: 通过 belongId 实现不同角色的公告隔离 + + 这样的设计既保证了安全性,又提供了灵活的公告管理能力! \ No newline at end of file diff --git a/src/api/links.js b/src/api/links.js index 68f1bbb..374f897 100644 --- a/src/api/links.js +++ b/src/api/links.js @@ -27,9 +27,11 @@ export function refundLink(codeNo) { return http.post(`/api/link/${codeNo}/refund`) } -// 查询链接状态 +// 查询链接状态 - 使用GET请求,codeNo作为查询参数 export function getLinkStatus(codeNo) { - return http.get(`/api/link/${codeNo}/status`) + return http.get('/api/link/status', { + params: { codeNo } + }) } // 按状态批量删除链接 diff --git a/src/plugins/http.js b/src/plugins/http.js index a751282..91a1d0b 100644 --- a/src/plugins/http.js +++ b/src/plugins/http.js @@ -3,52 +3,73 @@ import { getAccessToken, getRefreshToken, getTokenType, setTokens, clearTokens } import { showErrorMessage } from '@/utils/error' import router from '@/router' -const baseURL = import.meta.env?.VITE_API_BASE || '/' +// —— 关键改动 1:默认用 /api,确保走 Vite 代理 —— +// 配合 .env.development 里:VITE_API_BASE=/api +const baseURL = import.meta.env?.VITE_API_BASE || '/api' +// 业务实例 const http = axios.create({ baseURL, timeout: 60000, }) -// 专用刷新客户端,避免拦截器递归 -const refreshClient = axios.create({ baseURL, timeout: 15000 }) +// 刷新专用实例(不挂业务拦截器,避免递归) +const refreshClient = axios.create({ + baseURL, + timeout: 15000, +}) let isRefreshing = false let pendingQueue = [] -function subscribeTokenRefresh(cb) { - pendingQueue.push(cb) -} - +function subscribeTokenRefresh(cb) { pendingQueue.push(cb) } function onRefreshed(newToken, tokenType) { - pendingQueue.forEach((cb) => cb(newToken, tokenType)) + pendingQueue.forEach(cb => cb(newToken, tokenType)) pendingQueue = [] } - function onRefreshFailed() { - pendingQueue.forEach((cb) => cb(null, null)) + pendingQueue.forEach(cb => cb(null, null)) pendingQueue = [] } -// 请求拦截:附加 Authorization +/** 统一获取“最终请求路径”(含 baseURL 的路径部分),用于可靠匹配公开接口 */ +function getPathname(config) { + const url = config?.url || '' + if (/^https?:\/\//i.test(url)) { + try { return new URL(url).pathname } catch { return url } + } + const base = config?.baseURL ?? baseURL + let basePath = '' + try { basePath = new URL(base, 'http://local').pathname } catch { basePath = base || '' } + + const joined = + (basePath.endsWith('/') ? basePath.slice(0, -1) : basePath) + + (url.startsWith('/') ? '' : '/') + url + return joined.replace(/\/{2,}/g, '/') +} + +// —— 关键改动 2:以“尾部路径”匹配公开接口,不受是否带 /api 影响 —— +// 注意最后一个是动态路径:/link/{code}/game-interface +const PUBLIC_PATTERNS = [ + /^\/(?:api\/)?link\/status$/, + /^\/(?:api\/)?link\/select-region$/, + /^\/(?:api\/)?link\/poll-login$/, + /^\/(?:api\/)?link\/progress$/, + /^\/(?:api\/)?link\/refresh$/, + /^\/(?:api\/)?link\/qr\.png$/, + /^\/(?:api\/)?link\/[^/]+\/game-interface$/, +] +function isPublicPathname(pathname) { + return PUBLIC_PATTERNS.some(re => re.test(pathname)) +} + +// 请求拦截:附加 Authorization(公开接口不带) http.interceptors.request.use( (config) => { - // 跳过公开API的身份验证(只跳过用户端游戏相关的公开接口) - const publicAPIs = [ - '/api/link/status', - '/api/link/select-region', - '/api/link/poll-login', - '/api/link/progress', - '/api/link/refresh', - '/api/link/qr.png' - ] - // 游戏界面接口使用动态路径,需要特殊处理 - const isGameInterfaceAPI = /\/api\/link\/[^\/]+\/game-interface/.test(config.url || '') - const isPublicAPI = publicAPIs.some(api => config.url?.includes(api)) || isGameInterfaceAPI - - if (!isPublicAPI) { + const pathname = getPathname(config) + if (!isPublicPathname(pathname)) { const token = getAccessToken() - const type = getTokenType() + const type = getTokenType() || 'Bearer' if (token) { config.headers = config.headers || {} config.headers.Authorization = `${type} ${token}` @@ -59,46 +80,55 @@ http.interceptors.request.use( (error) => Promise.reject(error) ) -// 刷新 token 请求 +// 刷新 token 请求(确保不携带 Authorization) async function refreshTokenRequest() { const refreshToken = getRefreshToken() if (!refreshToken) throw new Error('NO_REFRESH_TOKEN') - const { data } = await refreshClient.post('/auth/refresh', { refreshToken }) - // 兼容 { code, data } + const { data } = await refreshClient.post( + '/auth/refresh', + { refreshToken }, + { + transformRequest: [(payload, headers) => { + if (headers && 'Authorization' in headers) delete headers.Authorization + return JSON.stringify(payload) + }], + headers: { 'Content-Type': 'application/json' }, + } + ) const payload = data?.code === 0 && data?.data ? data.data : data if (!payload?.accessToken) throw new Error('INVALID_REFRESH_RESPONSE') - setTokens(payload) + setTokens(payload) // 内部应同时保存 tokenType/refreshToken 等 return payload } -// 响应拦截:处理 401 自动刷新 +function logStatus(status, config, tag = '') { + const method = (config?.method || '').toUpperCase() + const url = getPathname?.(config) || config?.url || '' + console.log(`[HTTP${tag ? ' ' + tag : ''}] status=${status} ${method} ${url}`) +} + +// 响应拦截:401 自动刷新 + 重放 + 打印状态码 http.interceptors.response.use( - (response) => response, + (response) => { + // 成功响应也打印 + logStatus(response?.status, response?.config, 'OK') + return response + }, async (error) => { + console.error("HTTP 响应拦截器捕获到错误:", error) const { config, response } = error || {} const status = response?.status - const url = config?.url || '' + const pathname = getPathname(config) + const isAuthPath = /\/auth\/(login|refresh)$/.test(pathname) + const publicAPI = isPublicPathname(pathname) - // 避免对登录/刷新自身和公开API进行重复刷新 - const isAuthPath = /\/auth\/(login|refresh)/.test(url || '') - const publicAPIs = [ - '/api/link/status', - '/api/link/select-region', - '/api/link/poll-login', - '/api/link/progress', - '/api/link/refresh', - '/api/link/qr.png' - ] - // 游戏界面接口使用动态路径,需要特殊处理 - const isGameInterfaceAPI = /\/api\/link\/[^\/]+\/game-interface/.test(url || '') - const isPublicAPI = publicAPIs.some(api => url?.includes(api)) || isGameInterfaceAPI - - if (status === 401 && !isAuthPath && !isPublicAPI) { - // 阻止浏览器显示基本认证弹窗 - if (error.response && error.response.headers) { + if (status === 401 && !isAuthPath && !publicAPI) { + console.log("检测到401错误,尝试刷新token") + // 有些后端会带 Basic 的挑战头,删除以避免奇怪行为(XHR一般不会弹窗,但删除更干净) + if (error.response?.headers?.['www-authenticate']) { delete error.response.headers['www-authenticate'] } - + if (!isRefreshing) { isRefreshing = true try { @@ -109,35 +139,26 @@ http.interceptors.response.use( isRefreshing = false clearTokens() onRefreshFailed() - // 跳转到登录页面,保存当前路径用于登录后重定向 const currentPath = router.currentRoute.value.fullPath if (currentPath !== '/login') { - router.replace({ - name: 'Login', - query: { redirect: currentPath } - }) + router.replace({ name: 'Login', query: { redirect: currentPath } }) } return Promise.reject(error) } } - // 返回一个新的 Promise,等待刷新完成后重试原请求 + // 等待刷新结果后重放原请求 return new Promise((resolve, reject) => { subscribeTokenRefresh((newToken, tokenType) => { - if (newToken) { - const retryConfig = { ...config } - retryConfig.headers = retryConfig.headers || {} - retryConfig.headers.Authorization = `${tokenType || 'Bearer'} ${newToken}` - resolve(http.request(retryConfig)) - } else { - // token刷新失败,拒绝请求 - reject(error) - } + if (!newToken) return reject(error) + const retry = { ...config } + retry.headers = { ...(config.headers || {}), Authorization: `${tokenType || 'Bearer'} ${newToken}` } + resolve(http.request(retry)) }) }) } - // 对于非401错误,显示错误消息 + // 其它错误直接提示 if (status !== 401) { showErrorMessage(error) } diff --git a/src/views/refund/RefundManagement.vue b/src/views/refund/RefundManagement.vue index 2a4038f..787af35 100644 --- a/src/views/refund/RefundManagement.vue +++ b/src/views/refund/RefundManagement.vue @@ -17,7 +17,7 @@ v-model="searchForm.codeNo" size="large" clearable - placeholder="请输入链接编号(支持粘贴后回车)" + placeholder="请输入链接编号或完整链接URL(支持粘贴后回车)" @keyup.enter="handleSearch" >