From eee2bcc07185a6af8e9703fb62cafc10fd45ae87 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 16 Jun 2026 19:24:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(dev):=20=E6=9C=AC=E5=9C=B0=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E5=B7=A5=E5=85=B7=E9=9B=86=20v0.5.6-dev-tooling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 包含本地 dev 链路完整跑通的工具集(不进生产): backend: - dev_auth.py: /api/dev/login Mock 企微 OAuth(/dev/* 路由) - messages.py: dev 模式短路企微推送,避免 invalid corpid 噪音 - main.py: dev 模式启动时建 5 条 demo conversation,让前端有数据可测 frontend: - PortalSelect.vue: dev 模式 enterRole 跳完整 URL(5173/5174/5175 端口),生产仍走相对路径 infrastructure: - docker-compose.dev.yml: dev compose(包含 backend/postgres/redis) scripts(Windows PowerShell): - dev-frontend-install.ps1: 一次性装 4 个前端依赖 - dev-frontend-start.ps1: 后台起 4 个前端 dev server - dev-check-schema-drift.ps1: 对比 SQLAlchemy 模型 vs Postgres schema,漂移 exit 1 docs: - CURRENT-FOCUS.md: 项目状态看板(每次 session 维护) --- CURRENT-FOCUS.md | 156 +++++++++++ backend/app/api/dev_auth.py | 2 +- backend/app/api/messages.py | 30 +- backend/app/main.py | 166 ++++++++++- docker-compose.dev.yml | 4 + frontend-portal/src/views/PortalSelect.vue | 58 +++- scripts/dev-check-schema-drift.ps1 | 161 +++++++++++ scripts/dev-frontend-install.ps1 | 106 ++++++++ scripts/dev-frontend-start.ps1 | 302 +++++++++++++++++++++ 9 files changed, 963 insertions(+), 22 deletions(-) create mode 100644 CURRENT-FOCUS.md create mode 100644 scripts/dev-check-schema-drift.ps1 create mode 100644 scripts/dev-frontend-install.ps1 create mode 100644 scripts/dev-frontend-start.ps1 diff --git a/CURRENT-FOCUS.md b/CURRENT-FOCUS.md new file mode 100644 index 0000000..baf182c --- /dev/null +++ b/CURRENT-FOCUS.md @@ -0,0 +1,156 @@ +# 企微IT智能服务台 — 项目状态看板 + +> 📌 **这个文件就是项目的"驾驶舱仪表盘"**。任何时候新开 session,**先读这个文件就懂上下文**。 +> +> 📝 **更新规则**:每次 Claude 完成 / 开始 / 阻塞重要任务,会主动更新本文件。你也可以自己改(纯 markdown,git 跟踪)。 + +最后更新:**2026-06-16 11:10**(Claude 自动维护,看板上一次刷新) + +--- + +## 🎯 一句话总览 + +**项目状态**:**v0.5.6-dev-tooling 完成**,本地 4 端 dev 链路全通(Mock 企微 OAuth + 3 个新 migration + 1 个 decorator bug 修复)。 +**当前主线**:**等用户决策要不要上生产**(生产 3 个 migration + 1 个 bug 修复可上,7 个 dev 改动留在本地)。 +**待回复**:#83 OTM 是什么 / 跟项目什么关系。 + +--- + +## 🟢 正在做(in_progress,1 件) + +| # | 任务 | 我做什么 | 你做什么 | 完成定义 | +|---|---|---|---|---| +| #90 | 后端 pytest 测试套件 | 补 token_service / scoring_service 等 | 等结果 | 20+ 测试通过 | + +--- + +## 🔴 P0 必做(下一个 sprint) + +| # | 任务 | 重要程度 | 说明 | +|---|---|---|---| +| #48 | v1.0 收窄 set_real_ip_from | 🔴 P0 | 现 allow 0.0.0.0/0 是临时方案,正式上线前必须改精确代理 IP | +| #81 | v0.6.0 敏感词检测 + 语气优化 | 🔴 P0 | 下一个版本的核心功能 | +| #80 | v0.5.4 应急页 nginx 路由 + 部署 | 🔴 P0 | 当前生产缺路由,功能上了但用户访问不到 | + +--- + +## 🟡 P1 重要(看时间做) + +| # | 任务 | 说明 | +|---|---|---| +| #73 | 修后端文件未真正覆盖 | `yes | cp -f` 路径,部署时偶尔没生效 | +| #86 | 排查流程图零依赖部分 review + 文档化 | 把 Mermaid 流程图从代码里剥离成可读文档 | +| #88 | 管理后台 RBAC 角色权限 | 管理后台细粒度角色权限(大功能,2-3 天) | +| #83 | 澄清"OTM 跟项目关系" | **我在这等你回答**:OTM 是什么?需要对接吗? | + +--- + +## 🟢 P2 / 等用户决策 + +| # | 任务 | 卡在哪 | +|---|---|---| +| **🆕 服务器更新?** | 把今天的 3 个 migration + 1 个 bug 修复部署到生产 v0.5.6 | **等你看这份看板后拍板** | +| #31 | 推 docker 镜像到生产 registry | 等你确认要走哪条路(自建 Harbor / 阿里云 / 别的) | +| #43 | 配置 HTTPS | 等域名备案完成 + 证书到位 | +| #53 | 用户在企微验证 /itportal/ | 等你去企微点一点 | + +--- + +## ✅ 最近搞定(给你信心) + +### 2026-06-16(今天) + +#### 🛠️ Dev 环境(本地链路全通) + +- ✅ **本地 dev 4 端链路跑通**(#89-92): + - backend (8000) + h5 (5174) + agent (5173) + admin (5175) + portal (5176) 全起 + - Mock 企微 OAuth 全通(`/api/dev/login` 给 token) + - portal → H5 / 坐席 / 管理员 跳转正常 +- ✅ **修了 3 个 dev 启动坑**: + 1. `pydantic==2.7.5` → `2.7.4`(2.7.5 被 PyPI yank) + 2. docker-compose 加 `PYTHONPATH=/app`(alembic 1.13+ 不再默认 prepend cwd) + 3. dev 启动必须用 `--env-file .env.dev`(根 `.env` 冲突) + +#### 🐛 Bug 修复 + +- ✅ **#93 修 portal dev 模式跳错端口**:`import.meta.env.DEV` 判断,生产走相对路径,dev 走完整 URL +- ✅ **#97 修 require_role 装饰器**:`@wraps` 让 FastAPI 看到 `__wrapped__` 签名,Depends 未被解析 → `current_user` 实际是 Depends 对象。用 `inspect` 合并 signature + 手动设 `wrapper.__signature__` 修 +- ✅ **#99 dev 模式短路企微推送**:避免 `.env.dev` 用 `dev_corp_id_xxxxx` 调企微 API 返 `invalid corpid` 噪音 + +#### 🗃️ 数据库 migration(3 个) + +- ✅ **#94 alembic 010**:加 `agents.otp_secret` + `agents.otp_enabled` +- ✅ **#94 alembic 011**:加 `conversations.impact_scope` + `is_blocking` + `emotion_state`(用户坐席发消息 500 的真因) +- ✅ **#96 alembic 012**:加 `conversations.dify_conversation_id` + `employees.it_level` + `it_level_source` + `notes` + +#### 🛡️ 防错工具(留底用) + +- ✅ **#95 dev-check-schema-drift.ps1**:对比 SQLAlchemy 模型 vs Postgres schema,漂移 exit 1。以后模型加字段忘 migration 一跑就发现(用 docker exec,免去 Python 依赖) + +#### 📋 其他 + +- ✅ **#68 H5 空白页闪一下**:dev 模式验证不再白屏(生产未复测) + +### 历史(选重点) + +- ✅ v0.5.5:应急页 v0.5.4 + 移除 IT 设备升级 + admin 登录修复 + 内容审核架构 +- ✅ v0.5.3:重打后端部署包(5 IT + 2 HR + 1 行政 + 1 财务 = 9 条) +- ✅ v0.5.6-dev-tooling 已 tag + push gitea(本地 dev 工具集) +- ✅ messages.id varchar=UUID SQL bug 修了(#60)+ 10 个回归测试通过 +- ✅ nginx /api/admin/ 和 /itadmin/ 修复 403/allow(#57) + +--- + +## 🚀 怎么跑起来(3 步) + +### 1. 后端 dev(已经在跑 ✅) + +```powershell +cd D:\资料\03-项目开发\wecom_it_smart_desk-claude +docker compose -f docker-compose.dev.yml --env-file .env.dev up -d +curl http://localhost:8000/api/dev/health +``` + +### 2. 前端 dev(已经在跑 ✅) + +```powershell +# 一次性装 4 个前端依赖(已装好) +.\scripts\dev-frontend-install.ps1 + +# 之后:一起起所有前端 +.\scripts\dev-frontend-start.ps1 +# 单独停:.\scripts\dev-frontend-start.ps1 -Stop +``` + +### 3. 浏览器验证 + +- portal:http://localhost:5176/itportal/select +- H5:http://localhost:5174/itdesk/ +- 坐席:http://localhost:5173/itagent/ +- 管理员:http://localhost:5175/itadmin/ + +--- + +## 📌 怎么读这份文档 + +**你是运维小白,不需要懂代码**。看这个文件就能 1 分钟懂: + +1. **"现在在干嘛?"** → 看「正在做」表 +2. **"接下来要干嘛?"** → 看「P0 必做」表 +3. **"我需要做什么?"** → 看「正在做」表里的「你做什么」列 +4. **"今天有啥进展?"** → 看「最近搞定」 + +--- + +## 🤖 Claude 怎么帮你 + +每次开新 session 我会: + +1. **第一件事**:读这个文件 + TaskList,告诉你"上次到这了" +2. **完成一件重要事**:更新这个文件(改状态、加完成项) +3. **遇到阻塞**:写在「P2 / 等用户决策」里,等你回话 +4. **新需求进来**:跟当前 in_progress 比较,看是**接着做**还是**并行加**(参考你的"并行处理"反馈) + +--- + +**这个文件就是你和 Claude 之间的"工作交接本"。有问题改这里就行。** diff --git a/backend/app/api/dev_auth.py b/backend/app/api/dev_auth.py index 3322d49..fb216e0 100644 --- a/backend/app/api/dev_auth.py +++ b/backend/app/api/dev_auth.py @@ -23,7 +23,7 @@ from app.services.token_service import TokenService logger = logging.getLogger(__name__) -router = APIRouter(prefix="/api/dev", tags=["dev-mock"]) +router = APIRouter(prefix="/dev", tags=["dev-mock"]) def _dev_mode_enabled() -> bool: diff --git a/backend/app/api/messages.py b/backend/app/api/messages.py index cb50acc..ec6dcad 100644 --- a/backend/app/api/messages.py +++ b/backend/app/api/messages.py @@ -200,23 +200,27 @@ async def send_message( # image/file 等非文本消息暂不通过企微推送(仅存储消息记录供坐席查看) # 跳过 Redis 连��可避免无谓的网络开销,减少截图发送超时 if body.msg_type == "text": - try: - import redis.asyncio as aioredis - from app.config import settings + # dev 模式短路:直接跳过企微推送,避免 invalid corpid 噪音 + from app.config import settings + if getattr(settings, 'dev_mode', False): + logger.debug(f"[DEV] 跳过企微推送: msg_id={message.id}") + else: + try: + import redis.asyncio as aioredis - redis_client = settings.create_redis_client() - wecom_service = WecomService(redis_client) + redis_client = settings.create_redis_client() + wecom_service = WecomService(redis_client) - await wecom_service.send_text_message( - conversation.employee_id, body.content - ) + await wecom_service.send_text_message( + conversation.employee_id, body.content + ) - await wecom_service.close() - await redis_client.close() + await wecom_service.close() + await redis_client.close() - except Exception as e: - # 企微 API 调用失败不阻塞消息存储 - logger.warning(f"企微消息发送失败(消息已存储): {e}") + except Exception as e: + # 企微 API 调用失败不阻塞消息存储 + logger.warning(f"企微消息发送失败(消息已存储): {e}") # 5. 更新消息状态为已发送 message.status = "sent" diff --git a/backend/app/main.py b/backend/app/main.py index 580e61a..039abda 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,12 +12,13 @@ import json import logging +import os from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from fastapi.middleware.cors import CORSMiddleware -from sqlalchemy import text +from sqlalchemy import select, text # 导入配置(读取环境变量) from app.config import settings @@ -179,6 +180,7 @@ async def _init_default_data(): 3. quick_reply_templates — 快速回复模板 4. approval_links — 审批流程链接 5. software_downloads — 软件下载入口 + 6. (dev 模式)demo_conversations — 演示用会话,让前端有数据可发 只在表为空时插入,避免重复插入。 """ @@ -188,6 +190,7 @@ async def _init_default_data(): from app.models.quick_reply_template import QuickReplyTemplate from app.models.approval_link import ApprovalLink from app.models.software_download import SoftwareDownload + from app.config import settings async_session_factory = _get_session_factory() async with async_session_factory() as db: @@ -207,6 +210,11 @@ async def _init_default_data(): # 5. 初始化软件下载入口 await _init_software_downloads(db, SoftwareDownload) + # 6. (dev 模式)初始化 demo 会话,让前端有数据可发 + # 真因:之前没建,前端硬编码的 conv-001 调 POST /messages 返 "会话不存在" 3003 + if getattr(settings, 'dev_mode', False) or os.getenv('DEV_MODE', '').lower() == 'true': + await _init_demo_conversations(db) + await db.commit() logger.info("默认数据初始化完成") @@ -215,6 +223,162 @@ async def _init_default_data(): logger.error(f"默认数据初始化失败: {e}") +async def _init_demo_conversations(db): + """(dev 模式专用)建 5 条 demo 会话,让前端有数据可测。 + + 涵盖各种状态: + - ai_handling: AI 正在处理(2 条,不同员工) + - queued: 等坐席接手 + - serving: 坐席服务中 + - resolved: 已结单 + + 只在 conversations 表为空时建,避免重复。 + """ + from app.models.conversation import Conversation + + existing = (await db.execute(select(Conversation).limit(1))).scalar_one_or_none() + if existing: + logger.info("demo 会话已存在,跳过") + return + + import uuid as _uuid + from datetime import datetime, timezone, timedelta + + now = datetime.now(timezone.utc) + demo_convs = [ + { + "id": "conv-001", + "corp_id": "wwa8c87970b2011f41", + "employee_id": "dev-user-001", + "employee_name": "张三(普通员工)", + "department": "财务部", + "position": "会计", + "level": "P5", + "status": "ai_handling", + "is_vip": False, + "is_pinned": False, + "is_todo": False, + "urgency_score": 30, + "tags": ["财务", "IT"], + "assigned_agent_id": None, + "collaborating_agent_ids": [], + "participants": [], + "ai_substantive_reply_count": 0, + "impact_scope": 1, + "is_blocking": False, + "emotion_state": "normal", + "dify_conversation_id": None, + "last_message_at": now - timedelta(minutes=2), + "last_message_summary": "想问下 VPN 怎么连", + }, + { + "id": "conv-002", + "corp_id": "wwa8c87970b2011f41", + "employee_id": "dev-user-001", + "employee_name": "张三(普通员工)", + "department": "财务部", + "position": "会计", + "level": "P5", + "status": "queued", + "is_vip": False, + "is_pinned": True, + "is_todo": True, + "urgency_score": 70, + "tags": ["紧急", "VPN"], + "assigned_agent_id": None, + "collaborating_agent_ids": [], + "participants": [], + "ai_substantive_reply_count": 2, + "impact_scope": 3, + "is_blocking": True, + "emotion_state": "worried", + "dify_conversation_id": "dify-conv-002", + "last_message_at": now - timedelta(minutes=5), + "last_message_summary": "VPN 连不上,影响工作", + }, + { + "id": "conv-003", + "corp_id": "wwa8c87970b2011f41", + "employee_id": "dev-multi-001", + "employee_name": "周八(多角色测试)", + "department": "测试部", + "position": "测试工程师", + "level": "P6", + "status": "serving", + "is_vip": True, + "is_pinned": False, + "is_todo": False, + "urgency_score": 50, + "tags": ["软件安装"], + "assigned_agent_id": "dev-agent-001", + "collaborating_agent_ids": [], + "participants": [], + "ai_substantive_reply_count": 1, + "impact_scope": 1, + "is_blocking": False, + "emotion_state": "normal", + "dify_conversation_id": "dify-conv-003", + "last_message_at": now - timedelta(minutes=10), + "last_message_summary": "需要装 WPS 专业版", + }, + { + "id": "conv-004", + "corp_id": "wwa8c87970b2011f41", + "employee_id": "dev-supervisor-001", + "employee_name": "王五(部门主管)", + "department": "信息技术部", + "position": "主管", + "level": "M3", + "status": "serving", + "is_vip": True, + "is_pinned": True, + "is_todo": False, + "urgency_score": 80, + "tags": ["系统升级"], + "assigned_agent_id": "dev-agent-001", + "collaborating_agent_ids": ["dev-admin-001"], + "participants": [], + "ai_substantive_reply_count": 3, + "impact_scope": 50, + "is_blocking": True, + "emotion_state": "urgent", + "dify_conversation_id": "dify-conv-004", + "last_message_at": now - timedelta(minutes=15), + "last_message_summary": "ERP 系统升级咨询", + }, + { + "id": "conv-005", + "corp_id": "wwa8c87970b2011f41", + "employee_id": "dev-security-001", + "employee_name": "赵六(安全团队)", + "department": "信息安全部", + "position": "安全工程师", + "level": "P7", + "status": "resolved", + "is_vip": False, + "is_pinned": False, + "is_todo": False, + "urgency_score": 20, + "tags": ["安全"], + "assigned_agent_id": "dev-agent-001", + "collaborating_agent_ids": [], + "participants": [], + "ai_substantive_reply_count": 5, + "impact_scope": 1, + "is_blocking": False, + "emotion_state": "normal", + "dify_conversation_id": "dify-conv-005", + "last_message_at": now - timedelta(hours=2), + "last_message_summary": "已处理:密码策略咨询", + }, + ] + + for data in demo_convs: + db.add(Conversation(**data)) + + logger.info(f"已初始化 {len(demo_convs)} 条 demo 会话(仅 dev 模式)") + + async def _init_system_configs(db, SystemConfig): """初始化系统配置项。""" from sqlalchemy import select, func diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2a918d6..7d2131a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -75,6 +75,10 @@ services: - REDIS_URL=redis://redis:6379/0 - DEV_MODE=true # 开启 Mock 企微 OAuth - CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176 + # PYTHONPATH 必须含 /app,否则 alembic upgrade head 跑 env.py 时 + # `from app.config import settings` 会 ModuleNotFoundError + # (alembic 1.13+ 不再默认 prepend cwd 到 sys.path) + - PYTHONPATH=/app ports: - "8000:8000" # 暴露到宿主机 volumes: diff --git a/frontend-portal/src/views/PortalSelect.vue b/frontend-portal/src/views/PortalSelect.vue index f4b3619..00f1625 100644 --- a/frontend-portal/src/views/PortalSelect.vue +++ b/frontend-portal/src/views/PortalSelect.vue @@ -140,12 +140,16 @@ const selectedRole = ref(null) // ==================== 生命周期 ==================== -onMounted(async () => { +/** + * 初始化门户会话(可重入) + * 流程:OAuth2 回调 → 缓存 token → 没登录就尝试 Mock(OAuth2 失败时)→ 加载用户信息 + */ +async function initPortalSession(): Promise { const urlParams = new URLSearchParams(window.location.search) const token = urlParams.get('token') const code = urlParams.get('code') - // 1. 企微 OAuth2 回调:URL 中有 code 参数 + // 1. 企微 OAuth2 回调:URL 中有 code 参数 if (code && !token) { loading.value = true try { @@ -197,6 +201,30 @@ onMounted(async () => { } } // 无 corpId 或获取失败:显示错误(开发环境可 Mock 登录) + // DEV_MODE 兜底:本地 dev 环境自动调 /api/dev/login 拿 token + if (import.meta.env.DEV || import.meta.env.VITE_DEV_MODE === 'true') { + try { + loading.value = true + const devResp = await apiClient.get('/dev/login', { + params: { + userid: 'dev-multi-001', // 多角色用户,可以看到完整角色选择 + name: 'Dev User', + dept: '信息技术部', + }, + }) + const devToken = devResp.data?.data?.token + if (devToken) { + portalStore.setToken(devToken) + // 直接刷新页面,onMounted 重跑,这时 token 已在 localStorage 里 + window.location.reload() + return + } + } catch (devErr) { + console.error('DEV_MODE Mock 登录失败:', devErr) + } finally { + loading.value = false + } + } error.value = '未登录,请通过企业微信工作台访问' return } @@ -218,6 +246,13 @@ onMounted(async () => { } } // 否则:显示角色选择页面(让用户选择) +} + +/** + * 钩到 Vue 生命周期:挂载时执行一次 + */ +onMounted(() => { + initPortalSession() }) // ==================== 方法 ==================== @@ -237,11 +272,20 @@ function enterRole(role: string) { localStorage.setItem('portal_selected_role', role) // 跳转到对应的工作台 - const roleUrls: Record = { - user: '/itdesk/', - agent: '/itagent/', - admin: '/itadmin/', - } + // dev 模式:4 个前端在不同端口,要用完整 URL(从 import.meta.env 读) + // 生产模式:nnginx 同一域名不同子路径,直接用相对路径 + const isDev = import.meta.env.DEV + const roleUrls: Record = isDev + ? { + user: import.meta.env.VITE_DESK_URL_DEV || 'http://localhost:5174/itdesk/', + agent: import.meta.env.VITE_AGENT_URL_DEV || 'http://localhost:5173/itagent/', + admin: import.meta.env.VITE_ADMIN_URL_DEV || 'http://localhost:5175/itadmin/', + } + : { + user: '/itdesk/', + agent: '/itagent/', + admin: '/itadmin/', + } const url = roleUrls[role] if (url) { diff --git a/scripts/dev-check-schema-drift.ps1 b/scripts/dev-check-schema-drift.ps1 new file mode 100644 index 0000000..895ed7a --- /dev/null +++ b/scripts/dev-check-schema-drift.ps1 @@ -0,0 +1,161 @@ +# ============================================================================= +# 企微IT智能服务台 — dev 模式 schema 漂移检测 (PowerShell 版) +# ============================================================================= +# 作用:对比 SQLAlchemy 模型 vs 实际 DB 字段,防止"模型加了字段忘写 migration" +# 历史踩坑: +# - 010: agents.otp_secret (修了一次) +# - 011: conversations.impact_scope/is_blocking/emotion_state (又踩一次) +# 用法:.\scripts\dev-check-schema-drift.ps1 +# 前置:dev 后端跑着(docker compose -f docker-compose.dev.yml up) +# 实现: +# 1. 在 backend 容器里跑 Python 一行反射模型 metadata +# 2. 在 db 容器里跑 psql 拿 information_schema +# 3. PowerShell 做差集对比输出 +# ============================================================================= + +$ErrorActionPreference = 'Stop' +$ProjectRoot = $PSScriptRoot | Split-Path -Parent +Set-Location $ProjectRoot + +function Write-Step($msg) { Write-Host "`n$msg" -ForegroundColor Cyan } +function Write-OK($msg) { Write-Host " ✓ $msg" -ForegroundColor Green } +function Write-Warn($msg) { Write-Host " ⚠ $msg" -ForegroundColor Yellow } +function Write-Err($msg) { Write-Host " ✗ $msg" -ForegroundColor Red } + +# -------------------------------------------------------------------------- +# 拿 backend 容器 ID +# -------------------------------------------------------------------------- +$BkId = docker compose --env-file .env.dev -f docker-compose.dev.yml ps -q backend +$DbId = docker compose --env-file .env.dev -f docker-compose.dev.yml ps -q postgres +if (-not $BkId -or -not $DbId) { + Write-Err "dev 后端或 db 容器没跑。先跑 dev-start.ps1" + exit 1 +} + +Write-Step "═══ Schema 漂移检测 ═══" + +# -------------------------------------------------------------------------- +# 1) 拿模型字段(在 backend 容器里跑 Python) +# -------------------------------------------------------------------------- +$ModelJson = docker exec $BkId python -c @' +import json +from app.database import Base +import app.models # 触发模型注册到 Base.metadata +out = {} +for tname, tbl in Base.metadata.tables.items(): + out[tname] = sorted([c.name for c in tbl.columns]) +print(json.dumps(out, ensure_ascii=False)) +'@ 2>&1 + +# docker exec 输出会带 status warning,过滤掉 +$ModelJson = ($ModelJson | Where-Object { $_ -notmatch '^\s*$' -and $_ -notmatch 'WARN|INFO' } | Select-Object -Last 1).Trim() +if (-not $ModelJson) { + Write-Err "拿不到模型字段。backend 容器里 Python 跑挂了" + docker logs $BkId --tail 10 + exit 1 +} + +try { + $ModelTables = $ModelJson | ConvertFrom-Json +} catch { + Write-Err "模型 JSON 解析失败: $ModelJson" + exit 1 +} + +Write-OK "模型字段: $($ModelTables.PSObject.Properties.Name.Count) 张表" + +# -------------------------------------------------------------------------- +# 2) 拿 DB 字段(在 db 容器里跑 psql) +# -------------------------------------------------------------------------- +$DbSql = "SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public' ORDER BY table_name, ordinal_position;" +$DbRaw = @(docker exec $DbId psql -U wecom -d wecom_it_desk_dev -A -t -F "`t" -c $DbSql 2>&1 | Where-Object { $_ -is [string] }) + +$DbColumns = @{} +foreach ($line in $DbRaw) { + if ($line -isnot [string]) { continue } + $line = $line.Trim() + if (-not $line) { continue } + $parts = $line -split "`t" + if ($parts.Count -lt 2) { continue } + $tname = $parts[0] + $cname = $parts[1] + if (-not $DbColumns.ContainsKey($tname)) { + $DbColumns[$tname] = New-Object System.Collections.Generic.List[string] + } + $DbColumns[$tname].Add($cname) +} + +# 拿 alembic version +$VersionRaw = @(docker exec $DbId psql -U wecom -d wecom_it_desk_dev -A -t -c "SELECT version_num FROM alembic_version;" 2>&1 | Where-Object { $_ -is [string] }) +$Version = ($VersionRaw | Where-Object { $_ -match '^\d|_' } | Select-Object -First 1) +if ($Version) { $Version = $Version.Trim() } +Write-OK "DB 字段: $($DbColumns.Keys.Count) 张表 | alembic = $Version" + +# -------------------------------------------------------------------------- +# 3) 差集对比 +# -------------------------------------------------------------------------- +$Drift = $false +$OnlyInModel = @() +$OnlyInDb = @() + +foreach ($tname in $ModelTables.PSObject.Properties.Name) { + $mcols = $ModelTables.$tname + $dcols = if ($DbColumns.ContainsKey($tname)) { $DbColumns[$tname] } else { @() } + + foreach ($c in $mcols) { + if ($null -eq $dcols -or -not $dcols.Contains($c)) { + $OnlyInModel += [PSCustomObject]@{ Table = $tname; Column = $c } + } + } + foreach ($c in $dcols) { + if ($null -ne $c -and -not $mcols.Contains($c) -and $tname -ne 'alembic_version') { + $OnlyInDb += [PSCustomObject]@{ Table = $tname; Column = $c } + } + } +} + +# -------------------------------------------------------------------------- +# 4) 输出报告 +# -------------------------------------------------------------------------- +Write-Host "" +Write-Host "════════════════════════════════════════════════════════════════════" -ForegroundColor White +Write-Host " alembic current = $Version" -ForegroundColor Gray +Write-Host " 模型表数 = $($ModelTables.PSObject.Properties.Name.Count) DB 表数 = $($DbColumns.Keys.Count)" -ForegroundColor Gray +Write-Host "════════════════════════════════════════════════════════════════════" -ForegroundColor White + +if ($OnlyInModel.Count -gt 0) { + $Drift = $true + Write-Host "" + Write-Host " ❌ 模型有,DB 缺($($OnlyInModel.Count) 个):" -ForegroundColor Red + foreach ($x in $OnlyInModel) { + Write-Host " - $($x.Table).$($x.Column)" -ForegroundColor Red + } +} + +if ($OnlyInDb.Count -gt 0) { + $Drift = $true + Write-Host "" + Write-Host " ⚠️ DB 有,模型没($($OnlyInDb.Count) 个,可能遗留):" -ForegroundColor Yellow + foreach ($x in $OnlyInDb) { + Write-Host " - $($x.Table).$($x.Column)" -ForegroundColor Yellow + } +} + +if ($Drift) { + Write-Host "" + Write-Host " 💡 修法:" -ForegroundColor Cyan + Write-Host " 1. cd backend" -ForegroundColor Gray + Write-Host " 2. alembic revision --autogenerate -m 'sync xxx fields'" -ForegroundColor Gray + Write-Host " 3. 检查生成的 migration 文件(review 一下,不要直接用)" -ForegroundColor Gray + Write-Host " 4. docker exec $BkId alembic upgrade head" -ForegroundColor Gray + Write-Host "" + exit 1 +} + +Write-Host "" +Write-Host " ✅ schema 一致,无漂移" -ForegroundColor Green +Write-Host " $($ModelTables.PSObject.Properties.Name.Count) 张表全部对齐" -ForegroundColor Green +Write-Host "" +Write-Host " 💡 建议:加到 Git pre-commit / dev 启动检查里" -ForegroundColor Gray +Write-Host "" +exit 0 diff --git a/scripts/dev-frontend-install.ps1 b/scripts/dev-frontend-install.ps1 new file mode 100644 index 0000000..29a81f0 --- /dev/null +++ b/scripts/dev-frontend-install.ps1 @@ -0,0 +1,106 @@ +# ============================================================================= +# 企微IT智能服务台 — 前端 dev 环境一键安装脚本 +# ============================================================================= +# 作用:一次性给 4 个前端项目装 pnpm 依赖 +# 用法:.\scripts\dev-frontend-install.ps1 +# 前置:已装 pnpm (npm install -g pnpm) +# 输出:每个前端在它自己的目录下创建 node_modules +# 注意:首次安装 3-8 分钟,后续 pnpm install 会很快(增量) +# ============================================================================= + +$ErrorActionPreference = 'Stop' +$ProjectRoot = $PSScriptRoot | Split-Path -Parent +Set-Location $ProjectRoot + +# -------------------------------------------------------------------------- +# 自动把 npm 全局 bin 加进 PATH(只对本脚本生效,不污染系统) +# 原因:某些用户(如 NVM 用户)系统 PATH 缺 npm global bin,pnpm 找不到 +# -------------------------------------------------------------------------- +$NpmGlobalBin = npm config get prefix 2>$null +if ($NpmGlobalBin -and (Test-Path $NpmGlobalBin)) { + if ($env:PATH -notlike "*$NpmGlobalBin*") { + $env:PATH = "$env:PATH;$NpmGlobalBin" + Write-Host " ℹ 已临时把 $NpmGlobalBin 加进 PATH(只对本次脚本生效)" -ForegroundColor DarkGray + } +} + +$Frontends = @('frontend-h5', 'frontend-agent', 'frontend-admin', 'frontend-portal') + +# -------------------------------------------------------------------------- +# 颜色函数 +# -------------------------------------------------------------------------- +function Write-Step($msg) { Write-Host "`n$msg" -ForegroundColor Cyan } +function Write-OK($msg) { Write-Host " ✓ $msg" -ForegroundColor Green } +function Write-Warn($msg) { Write-Host " ⚠ $msg" -ForegroundColor Yellow } +function Write-Err($msg) { Write-Host " ✗ $msg" -ForegroundColor Red } + +# -------------------------------------------------------------------------- +# 前置检查 +# -------------------------------------------------------------------------- +Write-Step "═══ 企微IT智能服务台 — 前端依赖安装 ═══" + +# 检查 pnpm(包含 npm global bin 路径) +$pnpmCmd = Get-Command pnpm -ErrorAction SilentlyContinue +if (-not $pnpmCmd) { + Write-Err "pnpm 没装,请先跑: npm install -g pnpm" + exit 1 +} +$pnpmVer = & $pnpmCmd.Source --version 2>&1 +Write-OK "pnpm 已装: $pnpmVer" + +# 检查 Node 版本(警告,不阻塞) +$nodeVer = node --version +Write-Host " Node 版本: $nodeVer" +if ($nodeVer -notmatch '^v(20\.|21\.|22\.)') { + Write-Warn "前端要求 Node 20.x,你是 $nodeVer。多数情况能跑,出问题再装 Node 20。" +} + +# 检查后端 dev 是否在跑(可选) +try { + $null = Invoke-WebRequest -Uri 'http://localhost:8000/health' -UseBasicParsing -TimeoutSec 3 + Write-OK "后端 dev 在跑 (http://localhost:8000)" +} catch { + Write-Warn "后端 dev 没跑,Vite 反代会连不上后端。先跑 dev-start.ps1 起后端。" +} + +# -------------------------------------------------------------------------- +# 顺序装 4 个前端 +# -------------------------------------------------------------------------- +foreach ($name in $Frontends) { + Write-Step "─── $name ───" + $dir = Join-Path $ProjectRoot $name + if (-not (Test-Path "$dir/package.json")) { + Write-Err "找不到 $dir/package.json,跳过" + continue + } + Set-Location $dir + $sw = [System.Diagnostics.Stopwatch]::StartNew() + try { + pnpm install 2>&1 | Tee-Object -FilePath "$ProjectRoot\.pnpm-install-$name.log" | Out-Host + $sw.Stop() + Write-OK "$name 装完 ($([math]::Round($sw.Elapsed.TotalSeconds))s)" + } catch { + $sw.Stop() + Write-Err "$name 装失败 ($([math]::Round($sw.Elapsed.TotalSeconds))s)" + Write-Host " 详细日志: $ProjectRoot\.pnpm-install-$name.log" -ForegroundColor Yellow + Write-Host " 常见原因:网络抖动 / 镜像源不通 / Node 版本太新" -ForegroundColor Yellow + } +} + +# -------------------------------------------------------------------------- +# 汇总 +# -------------------------------------------------------------------------- +Write-Step "═══ 装完 ═══" +foreach ($name in $Frontends) { + $log = "$ProjectRoot\.pnpm-install-$name.log" + if ((Test-Path "$ProjectRoot/$name/node_modules") -and (Test-Path "$ProjectRoot/$name/package.json")) { + Write-OK "$name 已就绪 (node_modules 存在)" + } else { + Write-Err "$name 没装好,日志: $log" + } +} + +Write-Host "`n下一步:启动前端 dev server" -ForegroundColor Cyan +Write-Host " 单独启动: cd frontend-h5 ; pnpm dev" -ForegroundColor Gray +Write-Host " 一起启动: d:/资料/03-项目开发/wecom_it_smart_desk-claude/scripts/dev-frontend-start.ps1" -ForegroundColor Gray +Write-Host " 全部停止: ...\scripts\dev-frontend-start.ps1 -Stop" -ForegroundColor Gray \ No newline at end of file diff --git a/scripts/dev-frontend-start.ps1 b/scripts/dev-frontend-start.ps1 new file mode 100644 index 0000000..5ac32e5 --- /dev/null +++ b/scripts/dev-frontend-start.ps1 @@ -0,0 +1,302 @@ +# ============================================================================= +# 企微IT智能服务台 — 前端 dev server 一键启动脚本 +# ============================================================================= +# 作用:一次性后台启动 4 个前端 dev server(每个独立窗口 + 独立日志) +# 用法:.\scripts\dev-frontend-start.ps1 +# .\scripts\dev-frontend-start.ps1 -Frontend h5 # 只起一个 +# .\scripts\dev-frontend-start.ps1 -Frontend h5,admin # 起多个 +# .\scripts\dev-frontend-start.ps1 -Stop # 停掉所有 +# 前置:已跑过 dev-frontend-install.ps1(每个前端有 node_modules) +# 输出:日志写到 .pnpm-dev-.log;进程名 pnpm.exe(用 Get-Process pnpm 查) +# 注意:启动顺序 h5 → agent → admin → portal,每个隔 4 秒 +# ============================================================================= + +[CmdletBinding()] +param( + [string]$Frontend = '', # 空 = 全部 4 个;逗号分隔可多选: h5,agent,admin,portal + [switch]$Stop # 切到停止模式 +) + +$ErrorActionPreference = 'Stop' +$ProjectRoot = $PSScriptRoot | Split-Path -Parent +Set-Location $ProjectRoot + +# -------------------------------------------------------------------------- +# 自动把 npm 全局 bin 加进 PATH(只对本脚本生效) +# 原因:某些用户(如 NVM 用户)系统 PATH 缺 npm global bin,pnpm 找不到 +# -------------------------------------------------------------------------- +$NpmGlobalBin = npm config get prefix 2>$null +if ($NpmGlobalBin -and (Test-Path $NpmGlobalBin)) { + if ($env:PATH -notlike "*$NpmGlobalBin*") { + $env:PATH = "$env:PATH;$NpmGlobalBin" + Write-Host " ℹ 已临时把 $NpmGlobalBin 加进 PATH(只对本次脚本生效)" -ForegroundColor DarkGray + } +} + +# -------------------------------------------------------------------------- +# 前置检查 pnpm +# -------------------------------------------------------------------------- +$pnpmCmd = Get-Command pnpm -ErrorAction SilentlyContinue +if (-not $pnpmCmd) { + Write-Host " ✗ pnpm 没装或不在 PATH,请先跑 scripts\dev-frontend-install.ps1" -ForegroundColor Red + exit 1 +} + +# -------------------------------------------------------------------------- +# 4 个前端清单(name -> port) +# 端口必须跟每个前端 vite.config.ts 的 server.port 一致 +# -------------------------------------------------------------------------- +$AllFrontends = @( + @{ Name = 'frontend-h5'; Port = 5174; Label = 'h5' }, + @{ Name = 'frontend-agent'; Port = 5173; Label = 'agent' }, + @{ Name = 'frontend-admin'; Port = 5175; Label = 'admin' }, + @{ Name = 'frontend-portal'; Port = 5176; Label = 'portal' } +) + +# -------------------------------------------------------------------------- +# 颜色函数(跟 dev-frontend-install.ps1 一致) +# -------------------------------------------------------------------------- +function Write-Step($msg) { Write-Host "`n$msg" -ForegroundColor Cyan } +function Write-OK($msg) { Write-Host " ✓ $msg" -ForegroundColor Green } +function Write-Warn($msg) { Write-Host " ⚠ $msg" -ForegroundColor Yellow } +function Write-Err($msg) { Write-Host " ✗ $msg" -ForegroundColor Red } + +# -------------------------------------------------------------------------- +# 工具函数 +# -------------------------------------------------------------------------- +function Get-FrontendByLabel($label) { + foreach ($f in $AllFrontends) { + if ($f.Label -eq $label -or $f.Name -eq $label) { return $f } + } + return $null +} + +function Test-PortListening($port) { + # 优先用 Get-NetTCPConnection(快);失败回退 Test-NetConnection + try { + $conn = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction Stop + return ($null -ne $conn) + } catch { + try { + $tnc = Test-NetConnection -ComputerName 'localhost' -Port $port -InformationLevel Quiet -WarningAction SilentlyContinue + return $tnc + } catch { + return $false + } + } +} + +function Get-PnpmPids() { + # 拿到所有 pnpm 进程 PID(用作"停止所有") + $procs = Get-Process -Name 'pnpm' -ErrorAction SilentlyContinue + if ($null -eq $procs) { return @() } + return @($procs | Select-Object -ExpandProperty Id) +} + +function Get-NodePidsByCwd($dir) { + # 拿到工作目录在 $dir 下的 node.exe 进程(Vite dev server) + $procs = Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" -ErrorAction SilentlyContinue + $pids = @() + foreach ($p in $procs) { + if ($p.CommandLine -and $p.CommandLine.Contains($dir)) { + $pids += $p.ProcessId + } + } + return $pids +} + +# -------------------------------------------------------------------------- +# 模式:停掉所有前端 +# -------------------------------------------------------------------------- +if ($Stop) { + Write-Step "═══ 停掉所有前端 dev server ═══" + + # 先停 node.exe(它们是 Vite dev server 真正的工作进程) + $killed = 0 + foreach ($f in $AllFrontends) { + $dir = Join-Path $ProjectRoot $f.Name + $pids = Get-NodePidsByCwd $dir + foreach ($pid in $pids) { + try { + Stop-Process -Id $pid -Force -ErrorAction Stop + $killed++ + } catch {} + } + } + + # 再停 pnpm 父进程(它会再起 node,所以得反过来先杀子再杀父?实际先父后子都行,这里杀剩的 pnpm) + $pnpmPids = Get-PnpmPids + foreach ($pid in $pnpmPids) { + try { + Stop-Process -Id $pid -Force -ErrorAction Stop + $killed++ + } catch {} + } + + Write-OK "已停掉 $killed 个进程" + + # 兜底:再扫一遍端口确认空 + Start-Sleep -Seconds 1 + foreach ($f in $AllFrontends) { + if (Test-PortListening $f.Port) { + Write-Warn "$($f.Label) 端口 $($f.Port) 还占着,可能不是 pnpm 起的" + } else { + Write-OK "$($f.Label) 端口 $($f.Port) 已释放" + } + } + Write-Host "`n下一步: .\scripts\dev-frontend-start.ps1 重启" -ForegroundColor Gray + exit 0 +} + +# -------------------------------------------------------------------------- +# 解析 -Frontend 参数 +# -------------------------------------------------------------------------- +if ([string]::IsNullOrWhiteSpace($Frontend)) { + $Selected = $AllFrontends +} else { + $Selected = @() + foreach ($label in ($Frontend -split ',')) { + $label = $label.Trim() + $f = Get-FrontendByLabel $label + if ($null -eq $f) { + Write-Err "未知前端: $label(可选: h5, agent, admin, portal)" + exit 1 + } + $Selected += $f + } +} + +# -------------------------------------------------------------------------- +# 前置检查 +# -------------------------------------------------------------------------- +Write-Step "═══ 企微IT智能服务台 — 前端 dev server 启动 ═══" + +# 检查 pnpm +try { + $pnpmVer = pnpm --version + Write-OK "pnpm 已装: $pnpmVer" +} catch { + Write-Err "pnpm 没装,请先跑: npm install -g pnpm" + exit 1 +} + +# 检查 Node 版本(警告,不阻塞) +$nodeVer = node --version +Write-Host " Node 版本: $nodeVer" +if ($nodeVer -notmatch '^v(20\.|21\.|22\.)') { + Write-Warn "前端要求 Node 20.x,你是 $nodeVer。多数情况能跑,出问题再装 Node 20。" +} + +# 检查每个选中的前端是否有 node_modules +foreach ($f in $Selected) { + $dir = Join-Path $ProjectRoot $f.Name + if (-not (Test-Path "$dir/package.json")) { + Write-Err "找不到 $dir/package.json,跳过 $($f.Label)" + # 用占位标记一下,在后面的循环里跳过 + $f | Add-Member -NotePropertyName 'Skip' -NotePropertyValue $true -Force + continue + } + if (-not (Test-Path "$dir/node_modules")) { + Write-Warn "$($f.Label) 没有 node_modules,先跑 dev-frontend-install.ps1" + $f | Add-Member -NotePropertyName 'Skip' -NotePropertyValue $true -Force + } +} + +# -------------------------------------------------------------------------- +# 逐个后台启动 +# -------------------------------------------------------------------------- +$Started = @() + +foreach ($f in $Selected) { + if ($f.Skip) { continue } + + $dir = Join-Path $ProjectRoot $f.Name + $log = Join-Path $ProjectRoot ".pnpm-dev-$($f.Label).log" + $port = $f.Port + $label = $f.Label + + Write-Step "─── $label (port $port) ───" + + # 检查端口是否已被占(被占就跳过,不报错) + if (Test-PortListening $port) { + Write-Warn "端口 $port 已被占,$label 跳过(可能别人/上次没退干净在跑)" + Write-Host " 看进程: Get-NetTCPConnection -LocalPort $port -State Listen" -ForegroundColor Gray + Write-Host " 强制停: .\scripts\dev-frontend-start.ps1 -Stop" -ForegroundColor Gray + continue + } + + # 清空旧日志(每次启动都是干净的一页) + if (Test-Path $log) { Remove-Item $log -Force } + + # 后台启动 Start-Process,WindowStyle Hidden(不弹黑窗) + # 关键:必须用 cmd.exe /c 调 pnpm,因为 pnpm 是 .cmd 不是 .exe + # 直接 Start-Process 'pnpm' 会报 "%1 不是有效的 Win32 应用程序" + try { + $proc = Start-Process -FilePath 'cmd.exe' ` + -ArgumentList '/c', 'pnpm', 'dev' ` + -WorkingDirectory $dir ` + -WindowStyle Hidden ` + -RedirectStandardOutput $log ` + -RedirectStandardError "$log.err" ` + -PassThru ` + -ErrorAction Stop + Write-OK "已起进程 PID=$($proc.Id),日志: $log" + } catch { + Write-Err "启动失败: $_" + continue + } + + # 等 3-5 秒,看端口起来没 + $up = $false + for ($i = 1; $i -le 8; $i++) { + Start-Sleep -Seconds 1 + if (Test-PortListening $port) { + $up = $true + break + } + } + + if ($up) { + Write-OK "$label 端口 $port 已监听" + $Started += $f + } else { + Write-Warn "$label 8 秒内未监听,看日志: $log" + Write-Host " 常见原因: 依赖缺/Node 版本/端口冲突/语法错" -ForegroundColor Yellow + } +} + +# -------------------------------------------------------------------------- +# 汇总表格 +# -------------------------------------------------------------------------- +Write-Step "═══ 启动汇总 ═══" + +if ($Started.Count -eq 0) { + Write-Warn "没前端成功起来" +} else { + # 算每行最大宽度 + $maxLabel = ($Started | ForEach-Object { $_.Label.Length } | Measure-Object -Maximum).Maximum + $maxLabel = [Math]::Max($maxLabel, 5) + + Write-Host (" {0,-$maxLabel} {1,-8} {2}" -f 'NAME', 'PORT', 'URL') -ForegroundColor White + Write-Host (" {0,-$maxLabel} {1,-8} {2}" -f ($maxLabel | ForEach-Object { '-' * $_ }), '----', '---') -ForegroundColor DarkGray + + foreach ($f in $Started) { + $url = "http://localhost:$($f.Port)" + Write-Host (" {0,-$maxLabel} {1,-8} {2}" -f $f.Label, $f.Port, $url) -ForegroundColor Green + } + + Write-Host "`n 日志位置:" -ForegroundColor Cyan + foreach ($f in $Started) { + $log = Join-Path $ProjectRoot ".pnpm-dev-$($f.Label).log" + Write-Host " $log" -ForegroundColor Gray + } + + Write-Host "`n 实时跟日志(另开窗口跑):" -ForegroundColor Cyan + Write-Host " Get-Content .pnpm-dev-h5.log -Wait" -ForegroundColor Gray + Write-Host " Get-Content .pnpm-dev-agent.log -Wait" -ForegroundColor Gray + + Write-Host "`n 停掉所有前端:" -ForegroundColor Cyan + Write-Host " .\scripts\dev-frontend-start.ps1 -Stop" -ForegroundColor Gray +} + +Write-Host "`n下一步:浏览器开上面的 URL 就能用了" -ForegroundColor Cyan