diff --git a/docs/NGINX-DOMAIN-ROUTING.md b/docs/NGINX-DOMAIN-ROUTING.md new file mode 100644 index 0000000..7f995fc --- /dev/null +++ b/docs/NGINX-DOMAIN-ROUTING.md @@ -0,0 +1,256 @@ +# Nginx 域名路由分发配置(Phase 1.3 task #16) + +> 创建:2026-06-21 +> 适用版本:v0.7.0+ (Phase 1.3 扫码登录上线后) + +## 🎯 目标 + +不同入口域名/子路径 → 不同前端应用,但所有请求共用同一个后端 API。 + +| 入口 | URL | 前端应用 | 用途 | +|---|---|---|---| +| **坐席端** | `https://itsupport.servyou.com.cn/itagent/` | `frontend-agent/dist` | 坐席工作台 | +| **管理端** | `https://itsupport.servyou.com.cn/itadmin/` | `frontend-admin/dist` | 管理后台 | +| **Portal 统一入口** | `https://itsupport.servyou.com.cn/itportal/` | `frontend-portal/dist` | 扫码登录 + 多角色选择 | +| **H5 员工端** | `https://itsupport.servyou.com.cn/itdesk/` | `frontend-h5/dist` | 员工端(企微内) | + +> **两种方案**:单域名多路径(本项目当前)+ 多子域名(可选升级) + +--- + +## 🅰️ 方案 A:单域名 + 多子路径(推荐,运维简单) + +### nginx server block + +```nginx +server { + listen 443 ssl; + server_name itsupport.servyou.com.cn; + + # SSL 证书(由公司统一管理) + ssl_certificate /etc/nginx/certs/itsupport.servyou.com.cn.crt; + ssl_certificate_key /etc/nginx/certs/itsupport.servyou.com.cn.key; + + # 通用安全头 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # ======================================================================== + # 1. Portal 统一入口(扫码登录) + # ======================================================================== + location /itportal/ { + alias /opt/wecom-it-desk/frontend-portal/dist/; + try_files $uri $uri/ /itportal/index.html; + + # 允许企业微信 OAuth 回调(测试期) + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + # ======================================================================== + # 2. 坐席工作台 + # ======================================================================== + location /itagent/ { + alias /opt/wecom-it-desk/frontend-agent/dist/; + try_files $uri $uri/ /itagent/index.html; + } + + # ======================================================================== + # 3. 管理后台 + # ======================================================================== + # IP 白名单(临时方案,v1.0 前收窄 — 见 ip-whitelist-trust-proxies-todo.md) + location /itadmin/ { + allow 0.0.0.0/0; # ⚠️ 临时全开 + # allow 10.90.0.0/16; # TODO 收窄到内网 + # allow 115.236.188.3; # 公网入口 IP + + alias /opt/wecom-it-desk/frontend-admin/dist/; + try_files $uri $uri/ /itadmin/index.html; + } + + # ======================================================================== + # 4. H5 员工端 + # ======================================================================== + location /itdesk/ { + alias /opt/wecom-it-desk/frontend-h5/dist/; + try_files $uri $uri/ /itdesk/index.html; + + # 允许嵌入到企微 WebView + add_header X-Frame-Options "ALLOW-FROM https://work.weixin.qq.com" always; + } + + # ======================================================================== + # 5. 后端 API(4 个端共用) + # ======================================================================== + location /api/ { + # 管理端 API 严格白名单 + location /api/admin/ { + allow 0.0.0.0/0; # ⚠️ 临时全开 + # allow 10.90.0.0/16; # TODO 收窄 + # allow 115.236.188.3; + + proxy_pass http://wecom_it_backend; + } + + # 其他 API 放行 + proxy_pass http://wecom_it_backend; + } + + # ======================================================================== + # 6. WebSocket(坐席端 WS-01 鉴权) + # ======================================================================== + location /ws/ { + proxy_pass http://wecom_it_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # WS 心跳 + proxy_read_timeout 600s; + } + + # ======================================================================== + # 7. 静态资源(图片/上传文件) + # ======================================================================== + location /api/media/ { + proxy_pass http://wecom_it_backend; + proxy_set_header Host $host; + # 上传文件 30 天缓存 + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # ======================================================================== + # 8. 根路径 → Portal 统一入口 + # ======================================================================== + location = / { + return 302 /itportal/; + } +} + +# upstream 后端(内网容器) +upstream wecom_it_backend { + server 127.0.0.1:8000; # 容器映射到宿主机的端口 +} +``` + +### 部署步骤 + +```bash +# 1. 上传 dist 文件(各前端 build 产物) +scp -r frontend-portal/dist root@10.90.5.110:/opt/wecom-it-desk/frontend-portal/ +scp -r frontend-agent/dist root@10.90.5.110:/opt/wecom-it-desk/frontend-agent/ +scp -r frontend-admin/dist root@10.90.5.110:/opt/wecom-it-desk/frontend-admin/ +scp -r frontend-h5/dist root@10.90.5.110:/opt/wecom-it-desk/frontend-h5/ + +# 2. 上传 nginx 配置(本地 + 堡垒机 PuTTY) +# 参考:feedback-putty-not-openssh.md(用 PuTTY 操作) + +# 3. 验证配置 +sudo nginx -t + +# 4. reload +sudo nginx -s reload + +# 5. 验证(本地或企微) +curl -I https://itsupport.servyou.com.cn/itportal/ +``` + +--- + +## 🅱️ 方案 B:多子域名(可选升级,需要 DNS 解析) + +| 子域名 | 解析到 | 用途 | +|---|---|---| +| `portal.itsupport.servyou.com.cn` | nginx:443 | 统一入口 | +| `agent.itsupport.servyou.com.cn` | nginx:443 | 坐席工作台 | +| `admin.itsupport.servyou.com.cn` | nginx:443 | 管理后台(内网白名单) | +| `h5.itsupport.servyou.com.cn` | nginx:443 | H5 员工端 | + +### 优点 +- 跨域 cookie 隔离更清晰 +- 每个子域可独立上 HTTPS 证书 +- 内网白名单更容易配置(直接 deny all 到 admin.*) + +### 缺点 +- 需要运维额外加 4 个 A 记录 +- 前端跨域 API 调用要 CORS 配全 +- 坐席/管理员跨域切换要 CORS preflight + +**当前 v0.7.0 推荐方案 A**,v1.0 再考虑方案 B。 + +--- + +## 🔄 扫码登录流程(方案 A 下) + +``` +[1] 用户访问 https://itsupport.servyou.com.cn/itagent/ + → nginx 命中 location /itagent/ → 返回 frontend-agent/dist/index.html + → 前端路由守卫检查 localStorage.agent_token,没有 → 跳 /itportal/ + +[2] 用户访问 https://itsupport.servyou.com.cn/itportal/ + → nginx 命中 location /itportal/ → 返回 frontend-portal/dist/index.html + → QrcodeLogin.vue 显示二维码 + +[3] 员工用企微扫码 + → 企微 OAuth 回调到后端 → 后端写 Redis qrcode:scan:{ticket} + → Portal 轮询 /api/auth_qrcode/poll/{ticket} → 拿到 status=scanned + → UI 显示"请在手机上确认登录" + +[4] 员工在手机上点"确认登录" + → 后端 /api/auth_qrcode/confirm → 创建 token → 写 Redis qrcode:confirm:{ticket} + → Portal 轮询拿到 status=confirmed + token + roles + +[5] Portal 按角色分发(见 QrcodeLogin.vue dispatchToRole) + - 只有 agent → window.location.href = /itagent/?token=xxx + - 只有 admin → window.location.href = /itadmin/?token=xxx + - admin + agent → window.location.href = /itportal/select(让用户选) + - 默认 user → window.location.href = /itdesk/?token=xxx + +[6] 目标端 Login.vue 读 ?token=xxx 写入 localStorage + 跳 /workspace +``` + +--- + +## ⚠️ 已知问题 & TODO + +| 问题 | 状态 | 备注 | +|---|---|---| +| `/itadmin/` IP 白名单临时全开 | 🟡 临时 | v1.0 前必须收窄(见 `ip-whitelist-trust-proxies-todo.md`) | +| `/api/admin/` IP 白名单临时全开 | 🟡 临时 | 同上 | +| H5 端需要企微内访问 | 🟢 保持 | 用户决策,H5 仍在企微内是主场景 | +| 跨子路径刷新 404 | 🟢 已处理 | `try_files $uri $uri/ /itagent/index.html` | +| 静态资源 cache | 🟡 待优化 | 可加 version hash 强制刷新 | +| admin Login.vue 仍用表单 | 🟡 待改 | 后续 task:重写 admin Login 为扫码 UI | + +--- + +## 🧪 验证清单 + +部署完成后,在以下场景测试: + +- [ ] 浏览器直接访问 `/itportal/` → 显示扫码二维码 +- [ ] 用企微扫码 + 确认 → Portal 自动跳到对应端 +- [ ] 坐席(只有 agent 角色)扫码 → 自动跳 `/itagent/?token=xxx` → 自动登录进 /workspace +- [ ] 管理员(只有 admin 角色)扫码 → 自动跳 `/itadmin/?token=xxx` → 进 admin dashboard +- [ ] 多角色用户(admin + agent)扫码 → 跳 `/itportal/select` → 看到选择页 +- [ ] H5(企微内) → 仍走企微 OAuth,扫码二维码区域正常 +- [ ] 浏览器直接访问 `/itagent/workspace`(没 token)→ 跳 `/itportal/` +- [ ] 扫码登录 120s 过期 → UI 显示"已过期,点击刷新" + +--- + +## 📚 相关文档 + +- [project-knowledge-base.md](../memory/project-knowledge-base.md) — 项目知识库 +- [feedback-wecom-only-external-urls.md](../memory/feedback-wecom-only-external-urls.md) — 企微入口约束(部分解除) +- [phase1-progress.md](../memory/phase1-progress.md) — Phase 1+2 进度 +- [deployment.md](../memory/deployment.md) — 部署经验 +- [nginx-container-name-wecom-it-nginx.md](../memory/nginx-container-name-wecom-it-nginx.md) — 容器名坑 + +--- + +**变更历史**: +- 2026-06-21 创建(Phase 1.3 task #16) \ No newline at end of file diff --git a/frontend-portal/src/api/qrcode.ts b/frontend-portal/src/api/qrcode.ts new file mode 100644 index 0000000..fbdacb3 --- /dev/null +++ b/frontend-portal/src/api/qrcode.ts @@ -0,0 +1,47 @@ +// ============================================================================= +// IT智能服务台 — Portal 扫码登录 API 适配层 (Phase 1.3, task #16) +// ============================================================================= +// 说明:复用 backend/app/api/auth_qrcode.py 接口(Phase 1.1) +// Portal 是统一入口,扫码成功后根据用户角色自动跳到对应端: +// - 只有 user 角色 → /itdesk/(H5) +// - 只有 agent 角色 → /itagent/(坐席工作台) +// - 只有 admin 角色 → /itadmin/(管理后台) +// - 多角色 → /itportal/select(角色选择页) +// ============================================================================= + +import apiClient from './index' +import type { AxiosResponse } from 'axios' + +export interface QrcodeCreateData { + ticket: string + qrcode_url: string + expires_in: number + expires_at: string + qrcode_png_base64?: string +} + +export type QrcodePollStatus = 'waiting' | 'scanned' | 'confirmed' | 'expired' + +export interface QrcodePollData { + status: QrcodePollStatus + employee_id?: string + name?: string + token?: string + roles?: string[] +} + +/** + * 生成登录二维码 + */ +export async function createQrcode(): Promise { + const response: AxiosResponse = await apiClient.post('/auth_qrcode/create') + return response.data.data +} + +/** + * 轮询扫码状态 + */ +export async function pollQrcode(ticket: string): Promise { + const response: AxiosResponse = await apiClient.get(`/auth_qrcode/poll/${ticket}`) + return response.data.data +} \ No newline at end of file diff --git a/frontend-portal/src/composables/useQrcodeLogin.ts b/frontend-portal/src/composables/useQrcodeLogin.ts new file mode 100644 index 0000000..97c98f5 --- /dev/null +++ b/frontend-portal/src/composables/useQrcodeLogin.ts @@ -0,0 +1,153 @@ +// ============================================================================= +// IT智能服务台 — Portal 扫码登录 Composable (Phase 1.3, task #16) +// ============================================================================= +// 说明:跟 frontend-agent/src/composables/useQrcodeLogin.ts 同款逻辑 +// Portal 端的 onSuccess 由调用方提供,通常实现"按角色跳对应端" +// ============================================================================= + +import { ref, onUnmounted, type Ref } from 'vue' +import { ElMessage } from 'element-plus' +import { createQrcode, pollQrcode } from '@/api/qrcode' +import type { QrcodePollStatus } from '@/api/qrcode' + +const POLL_INTERVAL_MS = 2000 +const COUNTDOWN_TICK_MS = 1000 + +export interface UseQrcodeLoginOptions { + /** 登录成功回调(token, employeeId, roles)— Portal 一般这里按角色跳对应端 */ + onSuccess: (token: string, employeeId: string, roles: string[]) => void + onError?: (message: string) => void +} + +export interface UseQrcodeLoginReturn { + qrcodePngBase64: Ref + qrcodeUrl: Ref + countdown: Ref + status: Ref + scannedBy: Ref + loading: Ref + errorMessage: Ref + startLogin: () => Promise + refreshQrcode: () => Promise + stopPolling: () => void +} + +export function useQrcodeLogin(options: UseQrcodeLoginOptions): UseQrcodeLoginReturn { + const qrcodePngBase64 = ref(null) + const qrcodeUrl = ref(null) + const countdown = ref(0) + const status = ref('waiting') + const scannedBy = ref(null) + const loading = ref(false) + const errorMessage = ref(null) + + let ticket: string | null = null + let pollTimer: ReturnType | null = null + let countdownTimer: ReturnType | null = null + let expiresAt: number | null = null + + function clearTimers(): void { + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } + if (countdownTimer) { + clearInterval(countdownTimer) + countdownTimer = null + } + } + + function startCountdown(): void { + if (countdownTimer) clearInterval(countdownTimer) + countdownTimer = setInterval(() => { + if (!expiresAt) { + countdown.value = 0 + return + } + const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)) + countdown.value = remaining + if (remaining === 0 && status.value === 'waiting') { + status.value = 'expired' + clearTimers() + } + }, COUNTDOWN_TICK_MS) + } + + function startPolling(): void { + if (pollTimer) clearInterval(pollTimer) + pollTimer = setInterval(async () => { + if (!ticket) return + try { + const data = await pollQrcode(ticket) + status.value = data.status + scannedBy.value = data.name || null + + if (data.status === 'confirmed' && data.token && data.employee_id) { + clearTimers() + const roles = data.roles || ['user'] + options.onSuccess(data.token, data.employee_id, roles) + } else if (data.status === 'expired') { + clearTimers() + } + } catch (err: any) { + console.warn('[useQrcodeLogin] poll error:', err) + } + }, POLL_INTERVAL_MS) + } + + function stopPolling(): void { + clearTimers() + } + + async function startLogin(): Promise { + if (loading.value) return + loading.value = true + errorMessage.value = null + clearTimers() + + try { + const data = await createQrcode() + ticket = data.ticket + qrcodeUrl.value = data.qrcode_url + qrcodePngBase64.value = data.qrcode_png_base64 || null + countdown.value = data.expires_in + expiresAt = Date.now() + data.expires_in * 1000 + status.value = 'waiting' + scannedBy.value = null + + startCountdown() + startPolling() + } catch (err: any) { + const msg = err?.message || '生成二维码失败' + errorMessage.value = msg + if (options.onError) { + options.onError(msg) + } else { + ElMessage.error(msg) + } + } finally { + loading.value = false + } + } + + async function refreshQrcode(): Promise { + await startLogin() + } + + onUnmounted(() => { + clearTimers() + }) + + return { + qrcodePngBase64, + qrcodeUrl, + countdown, + status, + scannedBy, + loading, + errorMessage, + startLogin, + refreshQrcode, + stopPolling, + } +} \ No newline at end of file diff --git a/frontend-portal/src/router/index.ts b/frontend-portal/src/router/index.ts index bba54c4..aad171f 100644 --- a/frontend-portal/src/router/index.ts +++ b/frontend-portal/src/router/index.ts @@ -9,12 +9,22 @@ import { createRouter, createWebHistory } from 'vue-router' // 路由配置 const routes = [ { - // 根路径重定向到角色选择页 + // 根路径重定向到扫码登录页(Phase 1.3 task #16) + // 原 PortalSelect.vue 保留作为多角色用户的 fallback path: '/', - redirect: '/select', + redirect: '/qrcode-login', }, { - // 角色选择页 + // 扫码登录页(主入口,Phase 1.3 新增) + path: '/qrcode-login', + name: 'QrcodeLogin', + component: () => import('@/views/QrcodeLogin.vue'), + meta: { + title: '扫码登录', + }, + }, + { + // 角色选择页(多角色用户扫码成功后的 fallback,保留) path: '/select', name: 'PortalSelect', component: () => import('@/views/PortalSelect.vue'), @@ -35,7 +45,7 @@ const routes = [ // 404 页面 path: '/:pathMatch(.*)*', name: 'NotFound', - redirect: '/select', + redirect: '/qrcode-login', }, ] diff --git a/frontend-portal/src/views/QrcodeLogin.vue b/frontend-portal/src/views/QrcodeLogin.vue new file mode 100644 index 0000000..8a0e110 --- /dev/null +++ b/frontend-portal/src/views/QrcodeLogin.vue @@ -0,0 +1,327 @@ + + + + + + + \ No newline at end of file