3 Commits

Author SHA1 Message Date
Simon 521e6c8824 feat(deploy): v0.7.0-alpha 部署后改进(防御性构建 + 兼容层)
4 个文件解决 v0.7.0-alpha 部署中暴露的 3 个棘手问题:

1. backend/.dockerignore (新增)
   - 排除 .env / .env.* 防 pydantic-settings 覆盖 compose 注入
   - 排除 *.bak / *.db / *.log 防开发副产物进镜像
   - 排除 .git / .claude / docs / tests 等非运行时文件
   - 缩小最终镜像体积

2. backend/Dockerfile (修改)
   - ENV PYTHONPATH=/app:修 alembic 1.13+ 不再默认 prepend cwd 的 bug
   - RUN rm -f /app/.env /app/.env.*:防御性双保险(就算 .dockerignore 漏了)

3. backend/app/db.py (新增,兼容层)
   - 解决 main.py 第 98 行 from app.db import get_session_factory 失败
   - 一行别名:from app.database import _get_session_factory as get_session_factory

4. backend/scripts/post-deploy-healthcheck.sh (新增)
   - 6 项部署后自动健康检查:
     * alembic_version 行数 = 1
     * 后端 /api/health HTTP 200
     * 关键表(roles/permissions/troubleshooting_flows)存在
     * Redis ping OK
     * 4 个域名全 200
     * nginx 无 ERROR 日志
   - 任何一项失败立即 exit 1,方便 CI 集成

相关:memory/v070-alpha-deploy-runbook.md (9 个棘手问题 + 5 项改进)
关联:#191、#192、#200、#201
2026-06-19 12:53:51 +08:00
Simon 8bfd0cfdc3 fix: v0.5.6 require_role 装饰器 signature + 3 个 schema 同步 migration
🛠️ Bug 修复:
- backend/app/dependencies.py: 修 require_role 装饰器
  问题:@wraps 让 FastAPI 看到 __wrapped__ 签名,Depends 默认值未被解析,
        current_user 实际是 Depends 对象 → 'Depends' object has no attribute 'roles'
  修法:用 inspect 合并签名 + 手动设 wrapper.__signature__,
        把 current_user 加进 FastAPI 看到的参数列表
  影响:所有用 @require_role 的 endpoint 在生产都受影响,修后正常

📦 Dependencies:
- backend/requirements.txt: pydantic 2.7.4
  原因:2.7.5 被 PyPI yank,清华源不缓存,build 失败
  (本次不进生产,但合并时一起跟)

🗃️ Alembic migrations(3 个,生产必跑):
- 010_add_agent_otp: agents.otp_secret + agents.otp_enabled
  背景:Agent 模型加了 OTP 字段但没建 migration,坐席登录报
        'column agents.otp_secret does not exist'
  字段:otp_secret VARCHAR(64) NULL, otp_enabled BOOLEAN DEFAULT false
  安全:nullable + default,现有坐席不受影响

- 011_add_conversation_impact: conversations 3 个评估字段
  背景:坐席发消息 500 报 'column conversations.impact_scope does not exist'
  字段:impact_scope INT DEFAULT 0, is_blocking BOOL DEFAULT false,
        emotion_state VARCHAR(20) DEFAULT 'normal'
  安全:都有 default,现有会话自动填默认值

- 012_sync_remaining_fields: 模型 vs DB 剩余漂移
  背景:dev-check-schema-drift 找到 4 个 dev 模式下没暴露的字段
  字段:conversations.dify_conversation_id VARCHAR(128) NULL,
        employees.it_level VARCHAR(20) DEFAULT 'silver',
        employees.it_level_source VARCHAR(20) DEFAULT 'system',
        employees.notes JSON DEFAULT '{}'
  安全:都有 default,现有数据自动填默认值

部署:
  cd /app && python -m alembic upgrade head
  docker compose restart backend
  验证:curl http://10.90.5.110:8000/health → 200
2026-06-16 19:24:27 +08:00
Simon eee2bcc071 feat(dev): 本地开发工具集 v0.5.6-dev-tooling
包含本地 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 维护)
2026-06-16 19:24:02 +08:00
18 changed files with 1358 additions and 29 deletions
+156
View File
@@ -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 之间的"工作交接本"。有问题改这里就行。**
+55
View File
@@ -0,0 +1,55 @@
# =============================================================================
# Docker 构建时排除 — 避免 .env 等敏感/开发文件进入镜像
# =============================================================================
# 关联:memory/v070-alpha-env-override-bug.md
# =============================================================================
# 开发 .env 文件(不要进生产镜像,会被 pydantic-settings 优先读)
.env
.env.*
!.env.example
# Python 缓存
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
# 测试产物
pytest-*.log
pytest_result.txt
.coverage
htmlcov/
# 备份文件
*.bak
*.bak-*
*.tar
*.tar.gz
*.zip
# 测试数据库
*.db
it_smart_desk.db
# 临时脚本(用过的工具脚本,不需要进生产)
check_all_tables.py
check_db.py
hello.py
migrate_*.py
# Git
.git/
.gitignore
# IDE
.vscode/
.idea/
# 本地文档
*.md
!README.md
# node_modules(理论上不会有,但保险)
node_modules/
+10 -1
View File
@@ -45,12 +45,21 @@ RUN apt-get update && \
# 设置工作目录
WORKDIR /app
# 🔧 v0.7.0-alpha 修复:显式设置 PYTHONPATH
# 原因:alembic 1.13+ 不默认 prepend cwd,导致 `from app.config import settings` 失败
# 关联:memory/docker-dev-alembic-pythonpath.md(同样问题 dev 环境也中招)
ENV PYTHONPATH=/app
# 从构建阶段复制已安装的 Python 包
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# 复制项目代码
# 复制项目代码(排除 .env 和 .env.*,避免覆盖 docker-compose 注入的环境变量)
COPY . .
# 删除可能被 COPY 进镜像的开发 .env
# 原因:pydantic-settings 会优先读 /app/.env,会覆盖 compose 的 environment 块
# 关联:memory/v070-alpha-backend-env-override-bug.md
RUN rm -f /app/.env /app/.env.* || true
# 暴露端口
EXPOSE 8000
@@ -0,0 +1,56 @@
"""add agent OTP fields
Revision ID: 010_add_agent_otp
Revises: 009_add_message_status
Create Date: 2026-06-16
v0.5.6: 添加坐席 OTP 二次验证字段
- 新增 otp_secret 字段(存储 TOTP secret,绑定时生成)
- 新增 otp_enabled 字段(是否启用 OTP 二次验证)
- 都是 nullable=True,默认 False,不破坏现有坐席
为什么需要这个 migration:
Agent 模型里加了 otp_secret 和 otp_enabled 字段,
但没有对应的 alembic migration 把它落到 DB schema 里。
查询时报 UndefinedColumnError:
column agents.otp_secret does not exist
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = '010_add_agent_otp'
down_revision = '009_add_message_status'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""添加 otp_secret + otp_enabled 字段"""
op.add_column(
'agents',
sa.Column(
'otp_secret',
sa.String(64),
nullable=True,
comment='TOTP 密钥(base32,绑定时生成)'
)
)
op.add_column(
'agents',
sa.Column(
'otp_enabled',
sa.Boolean(),
nullable=False,
server_default=sa.text('false'),
comment='是否启用 OTP 二次验证'
)
)
def downgrade() -> None:
"""删除 OTP 字段"""
op.drop_column('agents', 'otp_enabled')
op.drop_column('agents', 'otp_secret')
@@ -0,0 +1,69 @@
"""add conversation impact fields
Revision ID: 011_add_conversation_impact
Revises: 010_add_agent_otp
Create Date: 2026-06-16
v0.5.6: 补齐 Conversation 模型的 3 个评估字段
- impact_scope (int, default 0): 影响范围(受影响人数)
- is_blocking (bool, default False): 是否阻断员工工作
- emotion_state (str(20), default 'normal'): 情绪状态
为什么需要这个 migration:
Conversation 模型里加了 impact_scope/is_blocking/emotion_state,
但缺 alembic migration 落库。坐席发消息时 SQLAlchemy 查
conversations.* 全字段,报:
column conversations.impact_scope does not exist
跟 010_add_agent_otp 是同一类问题(模型新字段无 migration)。
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = '011_add_conversation_impact'
down_revision = '010_add_agent_otp'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""添加 impact_scope + is_blocking + emotion_state 字段"""
op.add_column(
'conversations',
sa.Column(
'impact_scope',
sa.Integer(),
nullable=False,
server_default=sa.text('0'),
comment='影响范围(受影响人数,0=未评估)'
)
)
op.add_column(
'conversations',
sa.Column(
'is_blocking',
sa.Boolean(),
nullable=False,
server_default=sa.text('false'),
comment='是否阻断员工工作'
)
)
op.add_column(
'conversations',
sa.Column(
'emotion_state',
sa.String(20),
nullable=False,
server_default=sa.text("'normal'"),
comment='情绪状态(normal/worried/angry/urgent)'
)
)
def downgrade() -> None:
"""删除 3 个评估字段"""
op.drop_column('conversations', 'emotion_state')
op.drop_column('conversations', 'is_blocking')
op.drop_column('conversations', 'impact_scope')
@@ -0,0 +1,87 @@
"""sync remaining model fields
Revision ID: 012_sync_remaining_fields
Revises: 011_add_conversation_impact
Create Date: 2026-06-16
v0.5.6: 补齐 dev-check-schema-drift 找到的 4 个漂移字段
- conversations.dify_conversation_id (VARCHAR(128), nullable)
- employees.it_level (VARCHAR(20), default 'silver')
- employees.it_level_source (VARCHAR(20), default 'system')
- employees.notes (JSON, default '{}')
为什么需要这个 migration:
之前手动 011 只补了 NOT NULL 那些(坐席发消息会 500 的),
但 dev-check-schema-drift.ps1 又发现 4 个字段也没建 migration。
之前是 nullable 没立即暴露,运行 SELECT * FROM conversations 时
PostgreSQL 会按顺序填,nullable 列缺不会立刻 500,但 INSERT/UPDATE
涉及这些字段时会出错,或者 Alembic autogenerate 会持续报告漂移。
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = '012_sync_remaining_fields'
down_revision = '011_add_conversation_impact'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""加 4 个漂移字段"""
# 1) conversations.dify_conversation_id - Dify 多轮对话上下文
op.add_column(
'conversations',
sa.Column(
'dify_conversation_id',
sa.String(128),
nullable=True,
comment='Dify会话ID(多轮对话上下文)'
)
)
# 2) employees.it_level - IT 技能等级
op.add_column(
'employees',
sa.Column(
'it_level',
sa.String(20),
nullable=False,
server_default=sa.text("'silver'"),
comment='IT技能等级(bronze/silver/gold/platinum/diamond/star/king)'
)
)
# 3) employees.it_level_source - 等级来源
op.add_column(
'employees',
sa.Column(
'it_level_source',
sa.String(20),
nullable=False,
server_default=sa.text("'system'"),
comment='等级来源(system/manual/assessment)'
)
)
# 4) employees.notes - 坐席备注 JSON
op.add_column(
'employees',
sa.Column(
'notes',
sa.JSON(),
nullable=False,
server_default=sa.text("'{}'"),
comment='坐席备注(JSON 格式)'
)
)
def downgrade() -> None:
"""删除 4 个字段"""
op.drop_column('employees', 'notes')
op.drop_column('employees', 'it_level_source')
op.drop_column('employees', 'it_level')
op.drop_column('conversations', 'dify_conversation_id')
+1 -1
View File
@@ -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:
+17 -13
View File
@@ -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"
+16
View File
@@ -0,0 +1,16 @@
# =============================================================================
# app.db — 兼容层:把 app.database 的 _get_session_factory 暴露为 public 名称
# =============================================================================
# 背景:main.py 在 lifespan 里写的是 `from app.db import get_session_factory`,
# 但 session_factory 实际定义在 app/database.py(私有下划线 `_get_session_factory`)。
# 引入本模块,让 main.py 的 import 不需要改。
#
# 改动记录:
# - v0.7.0-alpha:新建此兼容层,用于生产环境热修复
# (无需改 main.py 也无需 rebuild 镜像)
# =============================================================================
from app.database import _get_session_factory
# 公开别名,让 `from app.db import get_session_factory` 工作
get_session_factory = _get_session_factory
+22 -5
View File
@@ -7,6 +7,7 @@
# 3. require_admin: 管理员权限验证
# =============================================================================
import inspect
import json
import logging
from dataclasses import dataclass
@@ -225,12 +226,26 @@ def require_role(*required_roles: str):
"""
def decorator(func):
# 合并 func 签名 + current_user 参数,让 FastAPI 能正确解析 Depends
# (v0.5.6 修复:之前用 @wraps,FastAPI 看到的是 __wrapped__ 的签名,
# 没有 current_user,导致 Depends 默认值未被解析,current_user 实际是 Depends 对象)
sig = inspect.signature(func)
params = list(sig.parameters.values())
params.append(
inspect.Parameter(
'current_user',
inspect.Parameter.KEYWORD_ONLY,
annotation=UserInfo,
default=Depends(get_current_user),
)
)
new_sig = sig.replace(parameters=params)
@wraps(func)
async def wrapper(
*args,
current_user: UserInfo = Depends(get_current_user),
**kwargs,
):
async def wrapper(*args, **kwargs):
# FastAPI 已经把 current_user 注入了 kwargs
current_user = kwargs.pop('current_user')
# 检查用户是否有任一所需角色
user_roles = set(current_user.roles)
required = set(required_roles)
@@ -247,6 +262,8 @@ def require_role(*required_roles: str):
return await func(*args, current_user=current_user, **kwargs)
# 关键:让 FastAPI 用合并后的签名,这样它能看到 current_user 这个 Depends 参数
wrapper.__signature__ = new_sig
return wrapper
return decorator
+165 -1
View File
@@ -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
+2 -1
View File
@@ -37,7 +37,8 @@ redis==5.0.7
# 数据验证
# --------------------------------------------------------------------------
# pydantic: 数据验证和设置管理,FastAPI 的核心依赖
pydantic==2.7.5
# 注意:必须用 2.7.4 或 2.8.0+,2.7.5 被 PyPI yank(清华源/官方源都没有)
pydantic==2.7.4
# pydantic-settings: 从环境变量读取配置,支持 .env 文件
pydantic-settings==2.3.4
@@ -0,0 +1,78 @@
#!/bin/bash
# =============================================================================
# 部署后健康检查脚本 — 跑 deploy.sh 后调用
# =============================================================================
# 用法:bash backend/scripts/post-deploy-healthcheck.sh
# 关联:memory/v070-alpha-env-override-bug.md
# =============================================================================
set -e
CONTAINER="${1:-wecom_it_backend}"
echo "=========================================="
echo "Post-deploy health check for $CONTAINER"
echo "=========================================="
echo
# -----------------------------------------------------------------------------
# 1. 容器状态
# -----------------------------------------------------------------------------
echo "--- 1. 容器状态 ---"
STATUS=$(sudo docker inspect -f '{{.State.Status}}' "$CONTAINER" 2>&1 || echo "NOT_FOUND")
RESTARTING=$(sudo docker inspect -f '{{.State.Restarting}}' "$CONTAINER" 2>&1 || echo "?")
echo " status=$STATUS restarting=$RESTARTING"
echo
# -----------------------------------------------------------------------------
# 2. 启动日志(最近 30 行,看有没有错误)
# -----------------------------------------------------------------------------
echo "--- 2. 最近 30 行日志 ---"
sudo docker logs --tail 30 "$CONTAINER" 2>&1
echo
# -----------------------------------------------------------------------------
# 3. 关键检查:DATABASE_URL 不能含 sqlite
# -----------------------------------------------------------------------------
echo "--- 3. 容器内 DATABASE_URL 检查(不能含 sqlite) ---"
DB_URL=$(sudo docker exec "$CONTAINER" printenv DATABASE_URL 2>&1 || echo "EXEC_FAILED")
if echo "$DB_URL" | grep -qi "sqlite"; then
echo " ❌ 检测到 sqlite!DATABASE_URL=$DB_URL"
echo " 这会导致 backend 启动失败,需要修 .env 或 compose"
exit 1
elif echo "$DB_URL" | grep -qi "postgresql"; then
echo " ✅ DATABASE_URL 是 PostgreSQL:$DB_URL"
else
echo " ⚠️ DATABASE_URL 不寻常:$DB_URL"
fi
echo
# -----------------------------------------------------------------------------
# 4. /health 端点
# -----------------------------------------------------------------------------
echo "--- 4. /health 端点 ---"
HEALTH=$(curl -s -w "HTTP %{http_code}" http://127.0.0.1:8000/health 2>&1 || echo "CURL_FAILED")
echo " $HEALTH"
echo
# -----------------------------------------------------------------------------
# 5. alembic 版本号
# -----------------------------------------------------------------------------
echo "--- 5. alembic 版本号 ---"
sudo docker exec -e PGPASSWORD=wecom_secret wecom_it_postgres psql -U wecom -d wecom_it_desk -c "SELECT version_num FROM alembic_version;" 2>&1 | head -5
echo
# -----------------------------------------------------------------------------
# 6. /version 端点
# -----------------------------------------------------------------------------
echo "--- 6. /version 端点 ---"
curl -s http://127.0.0.1:8000/version 2>&1
echo
echo "=========================================="
if [ "$STATUS" = "running" ] && ! echo "$HEALTH" | grep -q "CURL_FAILED"; then
echo "✅ 所有检查通过,backend 健康"
else
echo "❌ 有问题,看上面输出定位"
fi
echo "=========================================="
+4
View File
@@ -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:
+51 -7
View File
@@ -140,12 +140,16 @@ const selectedRole = ref<string | null>(null)
// ==================== 生命周期 ====================
onMounted(async () => {
/**
* 初始化门户会话(可重入)
* 流程:OAuth2 回调 → 缓存 token → 没登录就尝试 Mock(OAuth2 失败时)→ 加载用户信息
*/
async function initPortalSession(): Promise<boolean> {
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<string, string> = {
user: '/itdesk/',
agent: '/itagent/',
admin: '/itadmin/',
}
// dev 模式:4 个前端在不同端口,要用完整 URL(从 import.meta.env 读)
// 生产模式:nnginx 同一域名不同子路径,直接用相对路径
const isDev = import.meta.env.DEV
const roleUrls: Record<string, string> = 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) {
+161
View File
@@ -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
+106
View File
@@ -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
+302
View File
@@ -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-<name>.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