Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 521e6c8824 | |||
| 8bfd0cfdc3 | |||
| eee2bcc071 |
@@ -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 之间的"工作交接本"。有问题改这里就行。**
|
||||||
@@ -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
@@ -45,12 +45,21 @@ RUN apt-get update && \
|
|||||||
# 设置工作目录
|
# 设置工作目录
|
||||||
WORKDIR /app
|
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 包
|
# 从构建阶段复制已安装的 Python 包
|
||||||
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
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
|
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||||
|
|
||||||
# 复制项目代码
|
# 复制项目代码(排除 .env 和 .env.*,避免覆盖 docker-compose 注入的环境变量)
|
||||||
COPY . .
|
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
|
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')
|
||||||
@@ -23,7 +23,7 @@ from app.services.token_service import TokenService
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/dev", tags=["dev-mock"])
|
router = APIRouter(prefix="/dev", tags=["dev-mock"])
|
||||||
|
|
||||||
|
|
||||||
def _dev_mode_enabled() -> bool:
|
def _dev_mode_enabled() -> bool:
|
||||||
|
|||||||
@@ -200,9 +200,13 @@ async def send_message(
|
|||||||
# image/file 等非文本消息暂不通过企微推送(仅存储消息记录供坐席查看)
|
# image/file 等非文本消息暂不通过企微推送(仅存储消息记录供坐席查看)
|
||||||
# 跳过 Redis 连��可避免无谓的网络开销,减少截图发送超时
|
# 跳过 Redis 连��可避免无谓的网络开销,减少截图发送超时
|
||||||
if body.msg_type == "text":
|
if body.msg_type == "text":
|
||||||
|
# dev 模式短路:直接跳过企微推送,避免 invalid corpid 噪音
|
||||||
|
from app.config import settings
|
||||||
|
if getattr(settings, 'dev_mode', False):
|
||||||
|
logger.debug(f"[DEV] 跳过企微推送: msg_id={message.id}")
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
import redis.asyncio as aioredis
|
import redis.asyncio as aioredis
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
redis_client = settings.create_redis_client()
|
redis_client = settings.create_redis_client()
|
||||||
wecom_service = WecomService(redis_client)
|
wecom_service = WecomService(redis_client)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
# 3. require_admin: 管理员权限验证
|
# 3. require_admin: 管理员权限验证
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
import inspect
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -225,12 +226,26 @@ def require_role(*required_roles: str):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(func):
|
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)
|
@wraps(func)
|
||||||
async def wrapper(
|
async def wrapper(*args, **kwargs):
|
||||||
*args,
|
# FastAPI 已经把 current_user 注入了 kwargs
|
||||||
current_user: UserInfo = Depends(get_current_user),
|
current_user = kwargs.pop('current_user')
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
# 检查用户是否有任一所需角色
|
# 检查用户是否有任一所需角色
|
||||||
user_roles = set(current_user.roles)
|
user_roles = set(current_user.roles)
|
||||||
required = set(required_roles)
|
required = set(required_roles)
|
||||||
@@ -247,6 +262,8 @@ def require_role(*required_roles: str):
|
|||||||
|
|
||||||
return await func(*args, current_user=current_user, **kwargs)
|
return await func(*args, current_user=current_user, **kwargs)
|
||||||
|
|
||||||
|
# 关键:让 FastAPI 用合并后的签名,这样它能看到 current_user 这个 Depends 参数
|
||||||
|
wrapper.__signature__ = new_sig
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|||||||
+165
-1
@@ -12,12 +12,13 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlalchemy import text
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
# 导入配置(读取环境变量)
|
# 导入配置(读取环境变量)
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -179,6 +180,7 @@ async def _init_default_data():
|
|||||||
3. quick_reply_templates — 快速回复模板
|
3. quick_reply_templates — 快速回复模板
|
||||||
4. approval_links — 审批流程链接
|
4. approval_links — 审批流程链接
|
||||||
5. software_downloads — 软件下载入口
|
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.quick_reply_template import QuickReplyTemplate
|
||||||
from app.models.approval_link import ApprovalLink
|
from app.models.approval_link import ApprovalLink
|
||||||
from app.models.software_download import SoftwareDownload
|
from app.models.software_download import SoftwareDownload
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
async_session_factory = _get_session_factory()
|
async_session_factory = _get_session_factory()
|
||||||
async with async_session_factory() as db:
|
async with async_session_factory() as db:
|
||||||
@@ -207,6 +210,11 @@ async def _init_default_data():
|
|||||||
# 5. 初始化软件下载入口
|
# 5. 初始化软件下载入口
|
||||||
await _init_software_downloads(db, SoftwareDownload)
|
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()
|
await db.commit()
|
||||||
logger.info("默认数据初始化完成")
|
logger.info("默认数据初始化完成")
|
||||||
|
|
||||||
@@ -215,6 +223,162 @@ async def _init_default_data():
|
|||||||
logger.error(f"默认数据初始化失败: {e}")
|
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):
|
async def _init_system_configs(db, SystemConfig):
|
||||||
"""初始化系统配置项。"""
|
"""初始化系统配置项。"""
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ redis==5.0.7
|
|||||||
# 数据验证
|
# 数据验证
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# pydantic: 数据验证和设置管理,FastAPI 的核心依赖
|
# 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: 从环境变量读取配置,支持 .env 文件
|
||||||
pydantic-settings==2.3.4
|
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 "=========================================="
|
||||||
@@ -75,6 +75,10 @@ services:
|
|||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- DEV_MODE=true # 开启 Mock 企微 OAuth
|
- DEV_MODE=true # 开启 Mock 企微 OAuth
|
||||||
- CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176
|
- 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:
|
ports:
|
||||||
- "8000:8000" # 暴露到宿主机
|
- "8000:8000" # 暴露到宿主机
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -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 urlParams = new URLSearchParams(window.location.search)
|
||||||
const token = urlParams.get('token')
|
const token = urlParams.get('token')
|
||||||
const code = urlParams.get('code')
|
const code = urlParams.get('code')
|
||||||
|
|
||||||
// 1. 企微 OAuth2 回调:URL 中有 code 参数
|
// 1. 企微 OAuth2 回调:URL 中有 code 参数
|
||||||
if (code && !token) {
|
if (code && !token) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -197,6 +201,30 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 无 corpId 或获取失败:显示错误(开发环境可 Mock 登录)
|
// 无 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 = '未登录,请通过企业微信工作台访问'
|
error.value = '未登录,请通过企业微信工作台访问'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -218,6 +246,13 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 否则:显示角色选择页面(让用户选择)
|
// 否则:显示角色选择页面(让用户选择)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 钩到 Vue 生命周期:挂载时执行一次
|
||||||
|
*/
|
||||||
|
onMounted(() => {
|
||||||
|
initPortalSession()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ==================== 方法 ====================
|
// ==================== 方法 ====================
|
||||||
@@ -237,7 +272,16 @@ function enterRole(role: string) {
|
|||||||
localStorage.setItem('portal_selected_role', role)
|
localStorage.setItem('portal_selected_role', role)
|
||||||
|
|
||||||
// 跳转到对应的工作台
|
// 跳转到对应的工作台
|
||||||
const roleUrls: Record<string, string> = {
|
// 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/',
|
user: '/itdesk/',
|
||||||
agent: '/itagent/',
|
agent: '/itagent/',
|
||||||
admin: '/itadmin/',
|
admin: '/itadmin/',
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user