7 Commits

Author SHA1 Message Date
Simon cec5607c45 feat(admin): Flowcharts.vue JSON 在线编辑 + 9 套排查模板种子数据
为管理后台'排查流程图'模块加 JSON 在线编辑能力 + 提供 9 套
办公 IT 常见故障排查模板种子数据(账号/系统/企微/VPN/邮箱/网络/
打印机/软件/硬件),管理员可基于此学习、筛选、修改、新增。

## 选型(按'优选开源'原则)
- @codemirror/lang-json / state / theme-one-dark / view
- codemirror(核心)
- vue-codemirror(Vue 3 集成)
- vue-json-pretty(JSON 树形预览)
全部为社区成熟开源组件,非自行开发

## 改动
- frontend-admin/package.json: 加 6 个 npm 依赖
- frontend-admin/src/api/troubleshooting.ts(新): TS 类型 +
  5 个 API client(listTemplates / getTemplate / createTemplate /
  updateTemplate / deleteTemplate) + formatJson/validateJson/
  countNodes/countDecisions 工具函数
- frontend-admin/src/components/flowchart/FlowchartEditorDialog.vue(新):
  双面板编辑器(左 CodeMirror + 右 vue-json-pretty),
  实时 JSON 校验 + 节点/决策统计 + 格式/复制/导出按钮
- frontend-admin/src/views/Flowcharts.vue(改): 列表 + 导入/导出/
  新建按钮 + EditorDialog 集成 + 文件上传 + 删除确认

## 9 套种子数据
- 01-account-password.json 账号密码
- 02-pc-system.json        电脑系统
- 03-wecom.json            企微问题
- 04-vpn.json              VPN 接入
- 05-email.json            邮箱
- 06-network.json          网络
- 07-printer.json          打印机
- 08-software.json         软件
- 09-hardware.json         硬件
每套 ~150-200 行,结构:name / category / description /
estimated_time / difficulty / tags / root_node(决策树)

## 工具脚本
- data/seed-templates/build_all.py: 合并 9 个 JSON 成 00-all.json
2026-06-16 14:30:09 +08:00
Simon caf9b7ed85 feat(dev): 本地开发环境(docker-compose + Mock OAuth + 一键脚本)
解决改代码 30-60min 才能看到结果的痛点。本地拉起完整 stack,
改代码 → 1-2min 看到结果,无需服务器。

## 交付物

### Docker stack (docker-compose.dev.yml)
- postgres:16-alpine 端口 5432
- redis:7-alpine 端口 6379
- backend 端口 8000,代码 volume mount + uvicorn --reload

### Dev 镜像 (backend/Dockerfile.dev)
- 单阶段(无需 gcc / libpq-dev)
- apt 源换阿里云(公司内网)
- 装 pytest pytest-asyncio httpx watchfiles
- CMD: uvicorn --reload

### 配置 (.env.dev, 强制 add 因 .env.* 在 .gitignore)
内容是 dev 占位符,无任何真实密钥:
- DEV_MODE=true (启用 Mock OAuth)
- WECOM_* 全部 dev_xxx 占位
- 集成系统 API 全 dev_ 占位(调用会失败但不影响主流程)

### Mock OAuth (backend/app/api/dev_auth.py)
- GET /api/dev/login?userid=xxx&name=xxx&role=xxx
  走完全真实的 TokenService.create_token(不绕过业务逻辑)
- GET /api/dev/users 列出 6 个预设 dev 用户
- GET /api/dev/health dev 模式状态自检
- 6 预设用户覆盖所有角色(user/agent/supervisor/security/admin/多角色)
- 每个端点 _dev_mode_enabled() 二次校验,生产环境访问 403

### 集成改动
- backend/app/main.py: 加 _is_dev_mode() + DEV_MODE=true 时条件挂载
  dev_auth 路由 + 启动时大声警告
- backend/app/config.py: Settings 加 dev_mode / dev_default_userid /
  dev_default_name / dev_default_dept 字段

### PowerShell 脚本
- scripts/dev-start.ps1: 5 步验证(检查 Docker / .env / compose / 健康
  / dev health),首次 2-5min build,后续秒起
- scripts/dev-stop.ps1: 停止,支持 -v 清数据卷
- scripts/dev-test.ps1: 一键跑 pytest(可选 -Frontend 跑 vitest)

## 阶段
-  Phase 0 基础(本 commit)
-  Phase 1 pytest(任务 #90) - 500 bug 回归测试已就绪
-  Phase 2 vitest
-  Phase 3 playwright E2E

## 安全保证
- DEV_MODE 三个地方都校验(环境变量/settings/端点内)
- 生产环境 /api/dev/* 端点根本不存在(未挂载)
- .env.dev 是 dev 占位符,无敏感,可入 git
2026-06-16 14:28:51 +08:00
Simon 68ce1dbab9 fix(test): 500 bug 回归测试 + admin 包冲突修复
为 messages.id VARCHAR=UUID 500 错误加 10 个回归测试(test_message_id_type_bug.py):
- 5 个 H5 端轮询测试(str/UUID 对象/无效 UUID/无参数/不存在 UUID)
- 2 个坐席端轮询测试
- 2 个撤回消息测试
- 2 个单元测试(列类型必须是 String + str 查询能工作)

修复 admin.py 与 admin/ 目录命名冲突:
- conftest.py 引用 from app.api.admin.security_comparison import router
- 但 admin.py 和 admin/ 同名,Python 优先选 admin.py
- 修复:加 admin/__init__.py(让 admin/ 成正式 package) + 改名 admin.py → admin_api.py
- 改 router.py / security_comparison.py 两处 import

修复 test_h5_oauth.py 历史 bug:
- patch('app.api.h5._get_redis', ...) 加 create=True
- 原因:h5.py 早改 DI 模式不再有 _get_redis,但测试还在 patch
- 现象:41 errors 在 setup 阶段,跟 admin 重命名无关

10/10 回归测试通过(1.18s)
修复阻塞了 conftest.py 整个 client fixture 的 41 errors
2026-06-16 14:26:50 +08:00
Simon 60e67b0681 v0.5.5: 应急页 v0.5.4 + 移除IT设备升级 + admin登录修复 + 内容审核架构 + 知识库 2026-06-16 10:07:42 +08:00
Simon 10b37a6acc fix(alembic): 修 007 revision id 跟文件名/008 引用一致
- revision '007_role_sys' → '007_role_system'
  - 008 的 down_revision 写的是 '007_role_system',但 007 实际是 '007_role_sys'
  - alembic upgrade head 报 KeyError: '007_role_system'
  - DB alembic_version 已记 007_role_system,改 007 对齐最干净

  Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-15 18:17:17 +08:00
Simon 8c93cc9c9d fix(build): 修复 v0.5.0-beta 前端编译错误
跑 npm run build 验收时发现 2 个前端项目编译失败(vue-tsc 报错),修复 4 处:

frontend-h5:
- src/components/chat/InputBox.vue:185 多余右括号
  computed(() => inputText.value.length))  ->  computed(() => inputText.value.length)
- src/components/chat/MessageList.vue:134 pollMessages 调用签名错
  pollMessages(convId, afterMessageId)   ->  pollMessages(afterMessageId)
  (api/message.ts:71 签名只接 1 个 afterMessageId 参数,endpoint 走 current 不需要 convId)

frontend-agent:
- src/components/chat/InputBox.vue 4 处错
  L91/234/292 conversationStore.loading 不存在(store 暴露的是 loadingMessages)
                     -> conversationStore.loadingMessages
  L136 import onUnmounted 死引用,移除

components.d.ts: 触发 unplugin-vue-components 重新生成 6 行(新组件类型)

验证:
- frontend-h5: vue-tsc 0 错,417 modules transformed, dist/ 生成
- frontend-agent: vue-tsc 0 错,1750 modules transformed, dist/ 生成

不影响业务逻辑,纯 build fix。
2026-06-15 14:26:34 +08:00
Simon 364e688382 chore(release): v0.5.0-beta 发版准备
主要改动:

backend 业务:
- feat(error-codes): 统一错误码表 E1011/E1012 拆码
  - E1011 AUTH_PASSWORD_WRONG: 本地密码错误
  - E1012 AUTH_FIRST_LOGIN_PASSWORD_REQUIRED: 首次登录请先设置密码
  - E1015 AUTH_OLD_PASSWORD_REQUIRED: 改密需要旧密码
  - E1016 AUTH_OLD_PASSWORD_WRONG: 旧密码错误
- fix(agents): P0 降级放行时,如坐席已注册但未设密码,正确 raise 1012
  (修复前会撞 1011 本地密码错误,与场景不符)
- feat(approval): 审批模块 (T审批/A审批)
- feat(config): approval_template_resource / approval_template_device 配置
- feat(main): /ready, /metrics, /version 端点(K8s 友好)

backend 测试:
- test(agents): 新增 test_agents.py — 3 个 Fix-4 降级登录测试
  - 错误密码拒绝
  - 缺密码拒绝
  - 正确密码通过
  pytest tests/test_agents.py → 3/3 通过
- test(conftest): 模块级 mock + slowapi 限流重置 + UTF-8 patch
  解决 Windows pytest GBK 读 .env 失败 + 降级路径无法测试

仓库治理:
- chore(gitignore): 排除 .workbuddy/memory/(workbuddy 本地记忆)
- chore(docs): 重命名两份 IT 文档(前缀加智能区分版本)

部署与文档:
- docs: RELEASE_NOTES_v0.5.0-beta.md / dashboard.html / 需求-发版预览页面
- docs: 部署、架构、PRD、安全、评审报告等同步 v0.5.0-beta
- deploy-server: 打包脚本、nginx、docker-compose 版本号 bump

前端 (frontend-h5 / frontend-agent / frontend-admin / frontend-portal):
- index.html / package.json 版本号与构建号 bump

自动验收(RELEASE_NOTES L100-104):
- [x] pytest tests/test_agents.py -v → 3 passed
- [x] grep Bs7ucT backend frontend-h5 frontend-agent → 无输出
- [x] grep AppException(101[123]) backend → 仅 1 处(登录场景 1012)
- [ ] npm run build (frontend-h5 / frontend-agent) → 合并后跑

后续: 合并 feature/t-1-t4-merge → main,tag v0.5.0-beta
2026-06-15 14:14:58 +08:00
147 changed files with 8288 additions and 418 deletions
+61
View File
@@ -0,0 +1,61 @@
# =============================================================================
# 企微IT智能服务台 — 本地开发环境变量
# =============================================================================
# 这是给 docker-compose.dev.yml 用的,不是生产 .env
# 用法:docker compose -f docker-compose.dev.yml up -d (会自动加载)
# 安全:此文件可以提交到 git(都是假值,无敏感信息)
# =============================================================================
# --------------------------------------------------------------------------
# 关键开关:开发模式
# --------------------------------------------------------------------------
# DEV_MODE=true 会启用以下 mock:
# 1. 跳过企微 OAuth(用 /api/dev/login?userid=xxx 直接登)
# 2. 默认 userid 设为 dev-user-001
# 3. 跳过 JS-SDK 签名校验
# 4. 详细日志输出
DEV_MODE=true
# --------------------------------------------------------------------------
# 数据库(Docker 内部用 service name)
# --------------------------------------------------------------------------
POSTGRES_USER=wecom
POSTGRES_PASSWORD=wecom_dev
POSTGRES_DB=wecom_it_desk_dev
DATABASE_URL=postgresql://wecom:wecom_dev@localhost:5432/wecom_it_desk_dev
REDIS_URL=redis://localhost:6379/0
# --------------------------------------------------------------------------
# 企微(本地用假值,不真调)
# --------------------------------------------------------------------------
WECOM_CORP_ID=dev_corp_id_xxxxx
WECOM_AGENT_ID=1000001
WECOM_SECRET=dev_secret_placeholder
WECOM_TOKEN=dev_token_placeholder
WECOM_ENCODING_AES_KEY=dev_aes_key_43_chars_placeholder_xxxxxxxxx
# --------------------------------------------------------------------------
# 集成(本地用假值,API 调用会失败但不影响主流程)
# --------------------------------------------------------------------------
HUORONG_BASE_URL=http://localhost:9999
HUORONG_ACCESS_KEY_ID=dev_key
HUORONG_ACCESS_KEY_SECRET=dev_secret
LIANRUAN_BASE_URL=http://localhost:9998
LIANRUAN_API_ACCOUNT=dev
LIANRUAN_API_PASSWORD=dev
RAGFLOW_BASE_URL=http://localhost:9997
RAGFLOW_API_KEY=dev
# --------------------------------------------------------------------------
# 应用配置
# --------------------------------------------------------------------------
APP_ENV=development
LOG_LEVEL=DEBUG
CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176
# --------------------------------------------------------------------------
# Mock 用户(DEV_MODE=true 时)
# --------------------------------------------------------------------------
DEV_DEFAULT_USERID=dev-user-001
DEV_DEFAULT_NAME=开发测试用户
DEV_DEFAULT_DEPT=信息技术部
+2
View File
@@ -136,3 +136,5 @@ wecom-it-desk-server-deploy.zip
.workbuddy/logs/ .workbuddy/logs/
.workbuddy/*.log .workbuddy/*.log
.workbuddy/*.log.err .workbuddy/*.log.err
# workbuddy 记忆目录(个人上下文,不 入仓)
.workbuddy/memory/
+1074
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
# 企微 IT 智能服务台 (IT Smart Desk) # 企微智能IT支持服务台 (IT Smart Desk)
> **环境状态**: 预生产(独立主机,共享域名)→ 正式环境迁移 K8s > **环境状态**: 预生产(独立主机,共享域名)→ 正式环境迁移 K8s
> **维护者**: 税友集团 IT支持组(宋献) > **维护者**: 税友集团 IT支持组(宋献)
+46
View File
@@ -0,0 +1,46 @@
# =============================================================================
# 企微IT智能服务台 — 后端 开发镜像 Dockerfile
# =============================================================================
# 与 Dockerfile(prod) 区别:
# - 不需要 gcc / libpq-dev(用预编译的 psycopg2-binary)
# - 装 pytest 用于跑测试
# - 不需要 multi-stage build(开发用,镜像大一点无所谓)
# - 装 watchfiles 配合 uvicorn --reload
# =============================================================================
FROM python:3.12-slim
LABEL maintainer="IT服务台开发团队"
LABEL description="企微IT智能服务台后端 - 开发模式"
# 换 apt 源(公司内网,默认 deb.debian.org 可能不通)
RUN sed -i "s|deb.debian.org|mirrors.aliyun.com|g" /etc/apt/sources.list.d/debian.sources 2>/dev/null || true; \
sed -i "s|deb.debian.org|mirrors.aliyun.com|g" /etc/apt/sources.list 2>/dev/null || true
# 安装运行时依赖(精简版)
RUN apt-get update && \
apt-get install -y --no-install-recommends libpq5 curl && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 换 PyPI 源 + 装依赖
COPY requirements.txt .
RUN pip install --no-cache-dir \
--timeout 120 \
--retries 5 \
-i https://pypi.tuna.tsinghua.edu.cn/simple/ \
--trusted-host pypi.tuna.tsinghua.edu.cn \
-r requirements.txt && \
pip install --no-cache-dir \
-i https://pypi.tuna.tsinghua.edu.cn/simple/ \
--trusted-host pypi.tuna.tsinghua.edu.cn \
pytest pytest-asyncio httpx watchfiles
# 复制项目代码(在 dev 模式下用 volume mount 覆盖)
COPY . .
EXPOSE 8000
# 默认命令(在 docker-compose.dev.yml 里覆盖)
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
@@ -1,4 +1,4 @@
"""admin extension — 管理后台数据库扩展迁移 """admin ext — 管理后台数据库扩展迁移
新增 config_change_logs 配置变更日志 新增 config_change_logs 配置变更日志
扩展 agents 新增 role角色 skill_tags技能标签字段 扩展 agents 新增 role角色 skill_tags技能标签字段
@@ -8,16 +8,23 @@ submitted_by(提交人)字段。
Revision ID: 006_admin_ext Revision ID: 006_admin_ext
Revises: 005_reply_to_id Revises: 005_reply_to_id
Create Date: 2026-07-15 10:00:00.000000 Create Date: 2026-07-15 10:00:00.000000
:filename revision 字符串一致(v0.5.1 修复)
filename `006_admin_extension.py` 改名为 `006_admin_ext.py`,
revision 字符串保持 `006_admin_ext` 不变(DB alembic_version 表已存此值,
revision 会破坏 chain)
""" """
from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '006_admin_ext' revision: str = '006_admin_ext'
down_revision = '005_reply_to_id' down_revision: Union[str, None] = '005_reply_to_id'
branch_labels = None branch_labels: Union[str, Sequence[str], None] = None
depends_on = None depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
@@ -113,4 +120,5 @@ def downgrade() -> None:
# 删除 config_change_logs 表索引和表 # 删除 config_change_logs 表索引和表
op.drop_index('idx_ccl_changed_at', table_name='config_change_logs') op.drop_index('idx_ccl_changed_at', table_name='config_change_logs')
op.drop_index('idx_ccl_config_key', table_name='config_change_logs') op.drop_index('idx_ccl_config_key', table_name='config_change_logs')
op.table('config_change_logs')
op.drop_table('config_change_logs') op.drop_table('config_change_logs')
+2 -2
View File
@@ -5,7 +5,7 @@
新增 role_mapping_rules 表(角色映射规则)。 新增 role_mapping_rules 表(角色映射规则)。
预置三个基础角色:user、agent、admin。 预置三个基础角色:user、agent、admin。
Revision ID: 007_role_sys Revision ID: 007_role_system
Revises: 006_admin_ext Revises: 006_admin_ext
Create Date: 2026-06-12 23:00:00.000000 Create Date: 2026-06-12 23:00:00.000000
""" """
@@ -14,7 +14,7 @@ from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '007_role_sys' revision = '007_role_system'
down_revision = '006_admin_ext' down_revision = '006_admin_ext'
branch_labels = None branch_labels = None
depends_on = None depends_on = None
+9
View File
@@ -0,0 +1,9 @@
# =============================================================================
# 企微IT智能服务台 — 管理后台 API 子包
# =============================================================================
# 包标记文件
# 2026-06-16 添加: 修复与同名文件 app/api/admin.py 冲突
# 背景: router.py 引用 from app.api.admin.security_comparison import router
# Python 优先选 admin.py 当 module,导致 admin/ 目录被忽略
# 加上此文件后,admin/ 目录被识别为正式 package,优先于同名 .py 文件
# =============================================================================
@@ -0,0 +1,166 @@
"""
终端安全对比 API
路径: /api/admin/security/comparison
鉴权: require_admin
"""
from datetime import datetime
from typing import Optional
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.api.admin_api import require_admin
from app.services.security_comparison import (
TerminalSecurityComparison,
comparison_task_config,
)
router = APIRouter(prefix="/security/comparison", tags=["终端安全对比"])
# --- Request/Response Models ---
class CompareRequest(BaseModel):
"""手动触发比对请求"""
pass # 无参数,手动触发
class CompareSummaryResponse(BaseModel):
"""比对汇总响应"""
lianruan_count: int
huorong_count: int
no_huorong_count: int
compliance_rate: str
generated_at: str
class NoHuorongDevice(BaseModel):
"""未安装火绒设备"""
hostname: str
ip: str
useraccount: Optional[str] = None
dept: Optional[str] = None
last_login: Optional[str] = None
osver: Optional[str] = None
status: Optional[str] = None
class TaskConfigRequest(BaseModel):
"""任务配置请求"""
name: str # 任务名称
cron: str # Cron 表达式,如 "0 9 * * 1" 每周一9点
recipients: list[str] # 企微接收人user_id列表
enabled: bool = True
class TaskConfigResponse(BaseModel):
"""任务配置响应"""
task_id: str
name: str
cron: str
recipients: list[str]
enabled: bool
last_run: Optional[str] = None
next_run: Optional[str] = None
# --- API Endpoints ---
@router.get("/summary", response_model=CompareSummaryResponse)
async def get_comparison_summary(current_user=Depends(require_admin)):
"""获取比对汇总数据"""
service = TerminalSecurityComparison()
try:
summary = await service.compare_summary()
return summary
finally:
await service.close()
@router.get("/no-huorong", response_model=list[NoHuorongDevice])
async def get_no_huorong_devices(current_user=Depends(require_admin)):
"""获取未安装火绒的电脑清单"""
service = TerminalSecurityComparison()
try:
devices = await service.get_no_huorong_devices()
return devices
finally:
await service.close()
@router.post("/trigger")
async def trigger_comparison(current_user=Depends(require_admin)):
"""手动触发比对并推送企微消息"""
service = TerminalSecurityComparison()
try:
# 1. 执行比对
no_huorong = await service.get_no_huorong_devices()
# 2. 生成消息
if no_huorong:
msg = f"⚠️ 终端安全检查:发现 {len(no_huorong)} 台电脑未安装火绒\n\n"
for dev in no_huorong[:10]: # 只显示前10条
msg += f"{dev.get('hostname')} ({dev.get('ip')})\n"
if len(no_huorong) > 10:
msg += f"... 还有 {len(no_huorong)-10}"
else:
msg = "✅ 终端安全检查:所有电脑已安装火绒"
# 3. TODO: 推送到企微(需要企微消息API)
logger.info(f"比对结果: {msg}")
return {
"success": True,
"no_huorong_count": len(no_huorong),
"message": msg,
}
finally:
await service.close()
# --- 任务配置 API ---
@router.get("/tasks", response_model=list[TaskConfigResponse])
async def list_tasks(current_user=Depends(require_admin)):
"""列出所有定时任务"""
tasks = comparison_task_config.list_tasks()
return tasks
@router.post("/tasks", response_model=TaskConfigResponse)
async def create_task(
config: TaskConfigRequest,
current_user=Depends(require_admin)
):
"""创建定时任务"""
task_id = str(uuid4())[:8]
comparison_task_config.add_task(task_id, {
"name": config.name,
"cron": config.cron,
"recipients": config.recipients,
"enabled": config.enabled,
"created_at": datetime.now().isoformat(),
})
return TaskConfigResponse(
task_id=task_id,
**config.model_dump(),
)
@router.delete("/tasks/{task_id}")
async def delete_task(
task_id: str,
current_user=Depends(require_admin)
):
"""删除定时任务"""
success = comparison_task_config.delete_task(task_id)
if not success:
raise HTTPException(status_code=404, detail="任务不存在")
return {"success": True}
# 日志记录
import logging
logger = logging.getLogger(__name__)
+16 -19
View File
@@ -36,6 +36,7 @@ from app.models.agent import Agent
from app.schemas.agent import AgentLogin, AgentResponse, AgentStatusUpdate from app.schemas.agent import AgentLogin, AgentResponse, AgentStatusUpdate
from app.services.wecom_service import WecomService from app.services.wecom_service import WecomService
from app.utils.response import AppException, ERR_UNAUTHORIZED, success_response from app.utils.response import AppException, ERR_UNAUTHORIZED, success_response
from app.utils.error_codes import ErrorCode
# 速率限制器实例(与 main.py 共享同一配置) # 速率限制器实例(与 main.py 共享同一配置)
# 移除 env_file=None 参数:slowapi 0.1.9 不支持该参数 # 移除 env_file=None 参数:slowapi 0.1.9 不支持该参数
@@ -211,30 +212,24 @@ async def agent_login(
if not existing_agent: if not existing_agent:
# 新坐席注册必须通过企微验证,防止任意 user_id 冒充 # 新坐席注册必须通过企微验证,防止任意 user_id 冒充
raise AppException( raise AppException(
1003, ErrorCode.AUTH_TOKEN_INVALID,
"企微通讯录验证失败,新坐席注册需要企微身份验证。请稍后重试或联系管理员。" "企微通讯录验证失败,新坐席注册需要企微身份验证。请稍后重试或联系管理员。"
) )
logger.warning( logger.warning(
f"企微API不可达,已注册坐席降级放行: user_id={body.user_id}" f"企微API不可达,已注册坐席降级放行: user_id={body.user_id}"
) )
# P1 修复: 降级放行时,如果 agent 有 password_hash 则必须验证本地密码 # P0 修复: 降级放行时,如果 agent 已设置密码则必须验证本地密码
if existing_agent and existing_agent.password_hash: if existing_agent:
if existing_agent.password_hash is None:
# 已注册坐席但未设置密码,要求先设置密码
raise AppException(
ErrorCode.AUTH_PASSWORD_REQUIRED,
"首次登录请先设置密码。管理后台 → 坐席管理 → 设置本地密码"
)
if not body.password: if not body.password:
raise AppException(1011, "请输入本地密码") raise AppException(ErrorCode.AUTH_PASSWORD_WRONG, "请输入本地密码")
if not bcrypt.checkpw(body.password.encode('utf-8'), existing_agent.password_hash.encode('utf-8')): if not bcrypt.checkpw(body.password.encode('utf-8'), existing_agent.password_hash.encode('utf-8')):
raise AppException(1011, "本地密码错误") raise AppException(ErrorCode.AUTH_PASSWORD_WRONG, "本地密码错误")
# P0-#5: 本地密码认证(企微验证失败时的备用认证)
# 检查是否需要本地密码验证
local_password_verified = False
if body.password and agent and agent.password_hash:
# 验证本地密码
if bcrypt.checkpw(body.password.encode('utf-8'), agent.password_hash.encode('utf-8')):
local_password_verified = True
logger.info(f"本地密码验证通过: user_id={body.user_id}")
else:
# 本地密码错误,拒绝登录
raise AppException(1011, "本地密码错误")
# 1. 查找或创建坐席记录 # 1. 查找或创建坐席记录
stmt = select(Agent).where(Agent.user_id == body.user_id) stmt = select(Agent).where(Agent.user_id == body.user_id)
@@ -571,9 +566,11 @@ async def update_agent_password(
# 如果已有旧密码,验证旧密码 # 如果已有旧密码,验证旧密码
if agent.password_hash: if agent.password_hash:
if not body.old_password: if not body.old_password:
raise AppException(1012, "请输入旧密码") # 2026-06-15 修复: 改用专用 ErrorCode,避免与登录 1012 冲突
raise AppException(ErrorCode.AUTH_OLD_PASSWORD_REQUIRED, "请输入旧密码")
if not bcrypt.checkpw(body.old_password.encode('utf-8'), agent.password_hash.encode('utf-8')): if not bcrypt.checkpw(body.old_password.encode('utf-8'), agent.password_hash.encode('utf-8')):
raise AppException(1013, "旧密码错误") # 2026-06-15 修复: 改用专用 ErrorCode
raise AppException(ErrorCode.AUTH_OLD_PASSWORD_WRONG, "旧密码错误")
# 设置新密码 # 设置新密码
agent.password_hash = bcrypt.hashpw(body.new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') agent.password_hash = bcrypt.hashpw(body.new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
+23 -10
View File
@@ -16,22 +16,35 @@ router = APIRouter()
# 审批模板配置(可配置化,后续可存入数据库) # 审批模板配置(可配置化,后续可存入数据库)
# ============================================================================= # =============================================================================
# 企微审批模板配置 # =============================================================================
APPROVAL_TEMPLATES = { # 企微审批模板配置(从环境变量读取)
# 模板124 - 资源申请(跳转审批) # =============================================================================
"Bs7ucTLPo42dtj8Y1LzBoujijsa6geRWaRxZJjk4X": { # 环境变量:
"id": "Bs7ucTLPo42dtj8Y1LzBoujijsa6geRWaRxZJjk4X", # APPROVAL_TEMPLATE_RESOURCE - 资源申请模板ID
# APPROVAL_TEMPLATE_DEVICE - 设备申请模板ID
import os
APPROVAL_TEMPLATE_RESOURCE = os.getenv("APPROVAL_TEMPLATE_RESOURCE", "")
APPROVAL_TEMPLATE_DEVICE = os.getenv("APPROVAL_TEMPLATE_DEVICE", "")
# 动态构建审批模板配置
APPROVAL_TEMPLATES = {}
if APPROVAL_TEMPLATE_RESOURCE:
APPROVAL_TEMPLATES[APPROVAL_TEMPLATE_RESOURCE] = {
"id": APPROVAL_TEMPLATE_RESOURCE,
"name": "资源申请", "name": "资源申请",
"type": "jump", # 跳转审批 "type": "jump", # 跳转审批
"keywords": ["申请资源", "要资源", "申请"], "keywords": ["申请资源", "要资源", "申请"],
}, }
# 模板122 - 设备申请(API提交)
"Bs7ucTGsPuFhxfk8pn8EydxrWxkVetB4JR8Pb6PHS": { if APPROVAL_TEMPLATE_DEVICE:
"id": "Bs7ucTGsPuFhxfk8pn8EydxrWxkVetB4JR8Pb6PHS", APPROVAL_TEMPLATES[APPROVAL_TEMPLATE_DEVICE] = {
"id": APPROVAL_TEMPLATE_DEVICE,
"name": "设备申请", "name": "设备申请",
"type": "api", # API提交 "type": "api", # API提交
"keywords": ["申请设备", "要设备", "电脑", "笔记本"], "keywords": ["申请设备", "要设备", "电脑", "笔记本"],
},
} }
+161
View File
@@ -0,0 +1,161 @@
# =============================================================================
# 企微IT智能服务台 — 开发模式 Mock 登录
# =============================================================================
# ⚠️ 警告:此模块只在 DEV_MODE=true 时可用
# - 仅供本地开发 / 集成测试使用
# - 生产环境(DEV_MODE 未设置或 false)会直接 403
# - 部署前必须确认 .env / .env.production 没有 DEV_MODE=true
# 用法:
# GET /api/dev/login?userid=dev-user-001&name=测试&role=user
# GET /api/dev/users # 列出所有预设 dev 用户
# =============================================================================
import logging
import os
from typing import Optional
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, HTTPException, Query
from app.config import settings
from app.dependencies import get_redis
from app.services.token_service import TokenService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/dev", tags=["dev-mock"])
def _dev_mode_enabled() -> bool:
"""检查是否启用了开发模式。
三个检查源(任一为 true 即启用):
1. 环境变量 DEV_MODE=true
2. settings.dev_mode(从 .env.dev 读)
3. DEBUG 模式 + 本地主机(最严格)
"""
env_val = os.getenv("DEV_MODE", "false").lower() == "true"
if env_val:
return True
# 兜底:从 settings 读
if hasattr(settings, "dev_mode") and getattr(settings, "dev_mode", False):
return True
return False
# -----------------------------------------------------------------------------
# 预设 dev 用户(便于测试不同角色)
# -----------------------------------------------------------------------------
PRESET_DEV_USERS = [
{"userid": "dev-user-001", "name": "张三(普通员工)", "role": "user", "department": "财务部"},
{"userid": "dev-agent-001", "name": "李四(IT 坐席)", "role": "agent", "department": "信息技术部"},
{"userid": "dev-supervisor-001", "name": "王五(部门主管)", "role": "supervisor", "department": "信息技术部"},
{"userid": "dev-security-001", "name": "赵六(安全团队)", "role": "security", "department": "信息安全部"},
{"userid": "dev-admin-001", "name": "钱七(系统管理员)", "role": "admin", "department": "信息技术部"},
{"userid": "dev-multi-001", "name": "周八(多角色测试)", "role": "user,agent,supervisor", "department": "测试部"},
]
# -----------------------------------------------------------------------------
# GET /api/dev/login — Mock 登录(返回 token)
# -----------------------------------------------------------------------------
@router.get("/login")
async def dev_login(
userid: str = Query("dev-user-001", description="用户 ID(模拟企微 userid)"),
name: str = Query("开发测试用户", description="用户姓名"),
role: str = Query("user", description="角色:user/agent/admin/supervisor/security,多个用逗号分隔"),
department: str = Query("信息技术部", description="部门"),
avatar: Optional[str] = Query(None, description="头像 URL(可选)"),
redis: aioredis.Redis = Depends(get_redis),
):
"""开发模式 Mock 登录。
用法:
GET /api/dev/login?userid=dev-agent-001&name=李四&role=agent
返回:
{
"code": 0,
"data": {
"token": "abc123...",
"user": { "userid": "...", "name": "...", "roles": [...] }
}
}
"""
if not _dev_mode_enabled():
logger.warning("🚨 /api/dev/login 被调用但 DEV_MODE 未启用,返回 403")
raise HTTPException(
status_code=403,
detail="DEV_MODE not enabled. Set DEV_MODE=true in .env.dev to use this endpoint."
)
# 解析多角色
roles = [r.strip() for r in role.split(",") if r.strip()]
if not roles:
roles = ["user"]
# 调 TokenService 创建 token(走完全真实的 token 流程)
token_service = TokenService(redis)
token = await token_service.create_token(
employee_id=userid,
name=name,
roles=roles,
department=department,
avatar=avatar or "",
login_source="dev",
)
logger.info(f"🧪 [DEV] Mock 登录成功: userid={userid}, roles={roles}")
return {
"code": 0,
"message": "ok",
"data": {
"token": token,
"user": {
"userid": userid,
"name": name,
"department": department,
"avatar": avatar or "",
"roles": roles,
"login_source": "dev",
},
},
}
# -----------------------------------------------------------------------------
# GET /api/dev/users — 列出所有预设 dev 用户
# -----------------------------------------------------------------------------
@router.get("/users")
async def dev_list_users():
"""列出所有预设 dev 用户(便于前端测试用)。"""
if not _dev_mode_enabled():
raise HTTPException(status_code=403, detail="DEV_MODE not enabled")
return {
"code": 0,
"message": "ok",
"data": PRESET_DEV_USERS,
}
# -----------------------------------------------------------------------------
# GET /api/dev/health — 检查 dev 模式状态
# -----------------------------------------------------------------------------
@router.get("/health")
async def dev_health():
"""检查 dev 模式是否启用 + 关键依赖。"""
if not _dev_mode_enabled():
raise HTTPException(status_code=403, detail="DEV_MODE not enabled")
return {
"code": 0,
"data": {
"dev_mode": True,
"env": os.getenv("APP_ENV", "unknown"),
"database_url": os.getenv("DATABASE_URL", "not set")[:50] + "...",
"redis_url": os.getenv("REDIS_URL", "not set"),
"preset_users": len(PRESET_DEV_USERS),
},
}
+7 -4
View File
@@ -829,18 +829,21 @@ async def h5_poll_messages(
).order_by(Message.created_at.asc()) ).order_by(Message.created_at.asc())
if after_message_id: if after_message_id:
# 转换为UUID类型查询,确保和数据库UUID字段类型匹配 # 校验 UUID 格式,然后转字符串(兼容 SQLite/PG 的 String(36) 列,避免类型匹配)
from uuid import UUID as UUIDType from uuid import UUID as UUIDType
try: try:
msg_uuid = UUIDType(after_message_id) UUIDType(after_message_id) # 仅校验
except ValueError: except ValueError:
# 无效的UUID格式返回空列表 # 无效的UUID格式,返回空列表
items = [] items = []
return success_response(data={"items": items, "has_more": False}) return success_response(data={"items": items, "has_more": False})
# 必须用字符串比较,Message.id 在 DB 里是 String(36)/VARCHAR,
# 传 UUID 对象会被 SQLAlchemy 推断成 UUID 类型 → PostgreSQL 报
# "operator does not exist: character varying = uuid"
after_stmt = select(Message.created_at).where( after_stmt = select(Message.created_at).where(
Message.id == msg_uuid Message.id == str(after_message_id)
) )
after_result = await db.execute(after_stmt) after_result = await db.execute(after_stmt)
after_time = after_result.scalar_one_or_none() after_time = after_result.scalar_one_or_none()
+15 -1
View File
@@ -21,10 +21,12 @@ from app.api.todo_items import router as todo_items_router
from app.api.troubleshooting_templates import router as troubleshooting_templates_router from app.api.troubleshooting_templates import router as troubleshooting_templates_router
from app.api.employees import router as employees_router from app.api.employees import router as employees_router
from app.api.upload import router as upload_router from app.api.upload import router as upload_router
from app.api.admin import router as admin_router from app.api.admin_api import router as admin_router
from app.api.portal import router as portal_router from app.api.portal import router as portal_router
from app.api.admin_roles import router as admin_roles_router from app.api.admin_roles import router as admin_roles_router
from app.api.admin.security_comparison import router as security_comparison_router
from app.api.approval import router as approval_router from app.api.approval import router as approval_router
from app.api.wecom_jsapi import router as wecom_jsapi_router # v0.5.4 应急页 JS-SDK 签名
# 创建 API 路由器 # 创建 API 路由器
# 所有子路由都会挂载到这个路由器上 # 所有子路由都会挂载到这个路由器上
@@ -157,6 +159,14 @@ api_router.include_router(portal_router, tags=["统一入口"])
# DELETE /api/admin/roles/mapping-rules/{id} — 删除映射规则 # DELETE /api/admin/roles/mapping-rules/{id} — 删除映射规则
api_router.include_router(admin_roles_router, tags=["角色管理"]) api_router.include_router(admin_roles_router, tags=["角色管理"])
# 终端安全对比 API
# GET /api/admin/security/comparison/summary — 比对汇总
# GET /api/admin/security/comparison/no-huorong — 未安装火绒清单
# POST /api/admin/security/comparison/trigger — 手动触发
# GET /api/admin/security/comparison/tasks — 任务列表
# POST /api/admin/security/comparison/tasks — 创建定时任务
api_router.include_router(security_comparison_router, tags=["终端安全对比"])
# 审批流程 API # 审批流程 API
# GET /api/approval/templates — 获取审批模板列表 # GET /api/approval/templates — 获取审批模板列表
# GET /api/approval/templates/{id} — 获取审批模板详情 # GET /api/approval/templates/{id} — 获取审批模板详情
@@ -164,3 +174,7 @@ api_router.include_router(admin_roles_router, tags=["角色管理"])
# POST /api/approval/submit — API提交审批 # POST /api/approval/submit — API提交审批
# GET /api/approval/keywords — 获取审批关键词 # GET /api/approval/keywords — 获取审批关键词
api_router.include_router(approval_router, tags=["审批流程"]) api_router.include_router(approval_router, tags=["审批流程"])
# 企微 JS-SDK 签名 API (v0.5.4 应急页身份检测用)
# GET /api/wecom/jsapi-config?url=xxx — 返回 corp_id/agent_id/timestamp/nonce_str/signature
api_router.include_router(wecom_jsapi_router, tags=["企微JS-SDK"])
+181
View File
@@ -0,0 +1,181 @@
# =============================================================================
# 企微IT智能服务台 — 企微 JS-SDK 签名 API (v0.5.4 应急页用)
# =============================================================================
# 说明:提供前端 wx.config / wx.agentConfig 所需的鉴权签名。
# 对应企微文档:https://developer.work.weixin.qq.com/document/path/90506
#
# 流程:
# 1. 前端调 GET /api/wecom/jsapi-config?url=xxx 拿签名
# 2. 后端用 jsapi_ticket + url 算 sha1 签名
# 3. 前端用 wx.config({...}) 鉴权后,即可调企微 JS-SDK(如 wx.agentConfig)
#
# BC/DR 设计:不依赖 session/auth,公开访问(只返回签名,不返回敏感数据)
# =============================================================================
import logging
import secrets
import time
from fastapi import APIRouter, Query
from app.config import settings
from app.dependencies import get_shared_wecom_service
from app.utils.response import AppException, success_response
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/wecom/jsapi-config")
async def get_jsapi_config(
url: str = Query(..., description="当前页面 URL(不含 # 及其后)"),
):
"""获取企微 JS-SDK 鉴权配置。
供前端 wx.config 和 wx.agentConfig 使用。
Returns:
{
"code": 0,
"data": {
"corp_id": "wwa8c87970b2011f41",
"agent_id": "1000133",
"timestamp": 1718500000,
"nonce_str": "5K8264ILTKCH...",
"signature": "f7c8e9..."
}
}
"""
try:
wecom_service = get_shared_wecom_service()
# 1. 获取 jsapi_ticket
ticket = await wecom_service.get_jsapi_ticket()
# 2. 生成时间戳和随机串
timestamp = int(time.time())
nonce_str = secrets.token_hex(8) # 16 字符
# 3. 计算签名
signature = wecom_service.generate_jsapi_signature(
ticket=ticket,
nonce_str=nonce_str,
timestamp=timestamp,
url=url,
)
logger.info(
f"生成 JS-SDK 签名: url={url[:80]}... timestamp={timestamp}"
)
return success_response(
{
"corp_id": settings.wecom_corp_id,
"agent_id": str(settings.wecom_agent_id),
"timestamp": timestamp,
"nonce_str": nonce_str,
"signature": signature,
}
)
except Exception as e:
logger.error(f"生成 JS-SDK 签名失败: {e}", exc_info=True)
raise AppException(
code=5001,
message=f"生成 JS-SDK 签名失败: {str(e)}",
) from e
# =============================================================================
# 应急页身份检测 (v0.5.4)
# =============================================================================
# 流程:
# 1. 前端用 wx.agentConfig 拿到当前 userid
# 2. 前端调 GET /api/wecom/check-role?userid=xxx
# 3. 后端用企微通讯录 API 查 userid 是否在"IT支持-咨询坐席"标签里
# 4. 返回 "user" 或 "agent"
# =============================================================================
@router.get("/wecom/check-role")
async def check_emergency_role(
userid: str = Query(..., description="企微 userid"),
):
"""检测当前账号在应急页场景下的角色。
实现方式(优先级递减)
1. 企微通讯录标签检测(若配置 WECOM_AGENT_TAG_ID)
2. 后台硬编码名单(若配置 WECOM_AGENT_USERIDS 环境变量)
3. 默认 "user" (兜底)
Args:
userid: 企微 userid(从 wx.agentConfig 拿)
Returns:
{
"code": 0,
"data": {
"role": "user" | "agent",
"userid": "...",
"method": "tag" | "hardcoded" | "default"
}
}
"""
wecom_service = get_shared_wecom_service()
# 方式 1:企微标签检测
tag_id = getattr(settings, "wecom_agent_tag_id", None)
if tag_id:
try:
access_token = await wecom_service.get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/tag/get?access_token={access_token}&tagid={tag_id}"
import httpx
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(url)
result = resp.json()
if result.get("errcode", 0) == 0:
user_list = result.get("userlist", [])
# userlist 元素可能是 str(老版)或 dict(新版带 name)
user_ids = [
u if isinstance(u, str) else u.get("userid", "")
for u in user_list
]
if userid in user_ids:
logger.info(f"标签检测: userid={userid} 是坐席")
return success_response(
{"role": "agent", "userid": userid, "method": "tag"}
)
else:
logger.info(f"标签检测: userid={userid} 是员工")
return success_response(
{"role": "user", "userid": userid, "method": "tag"}
)
else:
logger.warning(
f"标签 API 失败: errcode={result.get('errcode')}, "
f"errmsg={result.get('errmsg')}, 降级到硬编码"
)
except Exception as e:
logger.warning(f"标签检测失败(降级): {e}")
# 方式 2:硬编码名单
hardcoded = getattr(settings, "wecom_agent_userids", None)
if hardcoded:
agent_ids = [x.strip() for x in hardcoded.split(",") if x.strip()]
if userid in agent_ids:
logger.info(f"硬编码名单: userid={userid} 是坐席")
return success_response(
{"role": "agent", "userid": userid, "method": "hardcoded"}
)
else:
return success_response(
{"role": "user", "userid": userid, "method": "hardcoded"}
)
# 方式 3:默认 user
logger.info(f"未配置检测方式, userid={userid} 默认 user")
return success_response(
{"role": "user", "userid": userid, "method": "default"}
)
+12 -11
View File
@@ -20,7 +20,6 @@
import logging import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from starlette.requests import Request
from app.services.ws_manager import manager as ws_manager from app.services.ws_manager import manager as ws_manager
from app.services.cache_service import cache_service from app.services.cache_service import cache_service
@@ -39,7 +38,6 @@ WS_CLOSE_UNAUTHORIZED = 4001
async def websocket_endpoint( async def websocket_endpoint(
websocket: WebSocket, websocket: WebSocket,
agent_id: str, agent_id: str,
request: Request,
) -> None: ) -> None:
"""坐席 WebSocket 端点主循环(含 WS-01 token 认证)。 """坐席 WebSocket 端点主循环(含 WS-01 token 认证)。
@@ -61,10 +59,12 @@ async def websocket_endpoint(
- 兼容从 ?token= URL 参数获取(向后兼容) - 兼容从 ?token= URL 参数获取(向后兼容)
- 不再将 token 暴露在 URL 中,避免 access_log 泄露 - 不再将 token 暴露在 URL 中,避免 access_log 泄露
v0.5.1 修复:移除 `request: Request` 参数(部分 Starlette 版本注入 Request 失败,
改用 `websocket.headers` 和 `websocket.query_params` 读取 header/query)
Args: Args:
websocket: FastAPI WebSocket 对象(框架自动注入) websocket: FastAPI WebSocket 对象(框架自动注入)
agent_id: 坐席ID(从 URL 路径参数获取) agent_id: 坐席ID(从 URL 路径参数获取)
request: Starlette Request(用于获取 header
""" """
# ====================================================================== # ======================================================================
# WS-01: Token 认证(从 subprotocol / header / query 获取) # WS-01: Token 认证(从 subprotocol / header / query 获取)
@@ -74,17 +74,17 @@ async def websocket_endpoint(
# 格式: Sec-WebSocket-Protocol: bearer.{token} # 格式: Sec-WebSocket-Protocol: bearer.{token}
# 说明: 浏览器原生 WebSocket API 不支持 headers 参数,但支持 subprotocols (第2参数数组) # 说明: 浏览器原生 WebSocket API 不支持 headers 参数,但支持 subprotocols (第2参数数组)
# 前端用 new WebSocket(url, ["bearer.{token}"]) 传递,服务端从 sec-websocket-protocol 头读取 # 前端用 new WebSocket(url, ["bearer.{token}"]) 传递,服务端从 sec-websocket-protocol 头读取
subprotocol = request.headers.get("sec-websocket-protocol", "") subprotocol = websocket.headers.get("sec-websocket-protocol", "")
if subprotocol.startswith("bearer."): if subprotocol.startswith("bearer."):
token = subprotocol[7:] # 去掉 "bearer." 前缀 token = subprotocol[7:] # 去掉 "bearer." 前缀
else: else:
# 其次从 Authorization header 获取 # 其次从 Authorization header 获取
auth_header = request.headers.get("Authorization", "") auth_header = websocket.headers.get("Authorization", "")
if auth_header.startswith("Bearer "): if auth_header.startswith("Bearer "):
token = auth_header[7:] # 去掉 "Bearer " 前缀 token = auth_header[7:] # 去掉 "Bearer " 前缀
else: else:
# 向后兼容:从 query param 获取(即将废弃) # 向后兼容:从 query param 获取(即将废弃)
token = request.query_params.get("token", "") token = websocket.query_params.get("token", "")
# 步骤2: 检查 token 是否为空 # 步骤2: 检查 token 是否为空
if not token: if not token:
@@ -197,7 +197,6 @@ async def websocket_endpoint(
async def h5_websocket_endpoint( async def h5_websocket_endpoint(
websocket: WebSocket, websocket: WebSocket,
employee_id: str, employee_id: str,
request: Request,
) -> None: ) -> None:
"""H5员工 WebSocket 端点主循环(含 token 认证)。 """H5员工 WebSocket 端点主循环(含 token 认证)。
@@ -223,10 +222,12 @@ async def h5_websocket_endpoint(
- (与H5登录 API /api/h5/mock-login 存储格式一致) - (与H5登录 API /api/h5/mock-login 存储格式一致)
- token 缺失、无效、过期、与 employee_id 不匹配均拒绝连接 - token 缺失、无效、过期、与 employee_id 不匹配均拒绝连接
v0.5.1 修复:移除 `request: Request` 参数(部分 Starlette 版本注入 Request 失败,
改用 `websocket.headers` 和 `websocket.query_params` 读取 header/query)
Args: Args:
websocket: FastAPI WebSocket 对象(框架自动注入) websocket: FastAPI WebSocket 对象(框架自动注入)
employee_id: 员工企微 UserID(从 URL 路径参数获取) employee_id: 员工企微 UserID(从 URL 路径参数获取)
request: Starlette Request(用于获取 header
""" """
# ====================================================================== # ======================================================================
# Token 认证(从 subprotocol / header / query 获取) # Token 认证(从 subprotocol / header / query 获取)
@@ -234,17 +235,17 @@ async def h5_websocket_endpoint(
# 步骤1: 优先从 Sec-WebSocket-Protocol (subprotocol) 获取 token,其次从 Authorization header,最后从 query(向后兼容) # 步骤1: 优先从 Sec-WebSocket-Protocol (subprotocol) 获取 token,其次从 Authorization header,最后从 query(向后兼容)
# 格式: Sec-WebSocket-Protocol: bearer.{token} # 格式: Sec-WebSocket-Protocol: bearer.{token}
subprotocol = request.headers.get("sec-websocket-protocol", "") subprotocol = websocket.headers.get("sec-websocket-protocol", "")
if subprotocol.startswith("bearer."): if subprotocol.startswith("bearer."):
token = subprotocol[7:] # 去掉 "bearer." 前缀 token = subprotocol[7:] # 去掉 "bearer." 前缀
else: else:
# 其次从 Authorization header 获取 # 其次从 Authorization header 获取
auth_header = request.headers.get("Authorization", "") auth_header = websocket.headers.get("Authorization", "")
if auth_header.startswith("Bearer "): if auth_header.startswith("Bearer "):
token = auth_header[7:] # 去掉 "Bearer " 前缀 token = auth_header[7:] # 去掉 "Bearer " 前缀
else: else:
# 向后兼容:从 query param 获取(即将废弃) # 向后兼容:从 query param 获取(即将废弃)
token = request.query_params.get("token", "") token = websocket.query_params.get("token", "")
# 步骤2: 检查 token 是否为空 # 步骤2: 检查 token 是否为空
if not token: if not token:
+44
View File
@@ -99,6 +99,50 @@ class Settings(BaseSettings):
# 是否启用 Mock 登录(默认 false,生产环境必须关闭) # 是否启用 Mock 登录(默认 false,生产环境必须关闭)
mock_login_enabled: bool = False mock_login_enabled: bool = False
# ----------------------------------------------------------------------
# 开发模式配置(本地 docker-compose.dev.yml 用)
# ----------------------------------------------------------------------
# 是否启用开发模式(本地开发环境,启用后挂载 /api/dev/* Mock OAuth 路由)
# ⚠️ 生产环境必须为 false / 不设置
# 启用的副作用:
# 1. 后端启动时挂载 /api/dev/login /users /health 三个 Mock 端点
# 2. /api/dev/login 跳过企微 OAuth 直接生成 token
# 3. 启动日志会大声警告 "🧪 DEV_MODE enabled"
dev_mode: bool = False
# 开发模式默认 userid(本地前端兜底用,实际由前端 /api/dev/login 传入)
dev_default_userid: str = "dev-user-001"
# 开发模式默认姓名
dev_default_name: str = "开发测试用户"
# 开发模式默认部门
dev_default_dept: str = "信息技术部"
# ----------------------------------------------------------------------
# 审批模板配置(企微审批应用)
# ----------------------------------------------------------------------
# 资源申请审批模板ID(在企微审批应用设置中获取)
approval_template_resource: str = ""
# 设备申请审批模板ID(在企微审批应用设置中获取)
approval_template_device: str = ""
# ----------------------------------------------------------------------
# v0.5.4 应急页身份检测配置
# ----------------------------------------------------------------------
# IT支持-咨询坐席 通讯录标签 ID(在企微管理后台 > 通讯录管理 > 标签管理 中查看)
# 配置后,应急页会通过此标签判断当前用户是否为坐席
# 留空则降级到下面的硬编码名单
wecom_agent_tag_id: str = ""
# 硬编码坐席 userid 列表(逗号分隔),作为标签检测的降级方案
# 例:"zhangsan,lisi,wangwu"(生产环境建议用标签方案)
wecom_agent_userids: str = ""
# ----------------------------------------------------------------------
# v0.6.0 内容审核报警配置(占位,后续完善)
# ----------------------------------------------------------------------
# 合规通知企微群机器人 webhook
content_audit_webhook: str = ""
# 主管接收报警的 userid(多个用逗号分隔)
content_audit_supervisor_userids: str = ""
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# Pydantic-settings 配置 # Pydantic-settings 配置
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
+146 -8
View File
@@ -15,7 +15,9 @@ import logging
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.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
# 导入配置(读取环境变量) # 导入配置(读取环境变量)
from app.config import settings from app.config import settings
@@ -35,6 +37,30 @@ logging.basicConfig(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------
# 开发模式判定(模块级 helper,避免在 create_app 内每次重复 import)
# --------------------------------------------------------------------------
def _is_dev_mode() -> bool:
"""检查是否启用了开发模式(DEV_MODE=true)。
三个检查源(任一为 true 即启用):
1. 环境变量 DEV_MODE=true(最高优先级,Docker 注入)
2. settings.dev_mode(从 .env.dev 读)
3. DEBUG 模式 + 本地主机(最严格)
注意:此函数与 backend/app/api/dev_auth.py 内的 _dev_mode_enabled() 逻辑一致,
这里用于"是否挂载 dev_auth 路由",那里用于"端点内是否放行"
"""
import os
env_val = os.getenv("DEV_MODE", "").lower() == "true"
if env_val:
return True
if getattr(settings, "dev_mode", False):
return True
return False
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 应用生命周期管理(启动和关闭事件) # 应用生命周期管理(启动和关闭事件)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -288,14 +314,29 @@ async def _init_approval_links(db, ApprovalLink):
return return
links = [ links = [
ApprovalLink(category="IT", title="软件安装申请", url="https://审批系统地址/software-install", sort_order=1), # v0.5.2:一站式运维平台真实工单链接(域名 devops.dc.servyou-it.com,已实现企微免登录)
ApprovalLink(category="IT", title="设备报修工单", url="https://审批系统地址/device-repair", sort_order=2), # v0.5.3 更新:去掉 "IT设备升级与硬件维修" (申请单冲突,后续移除)
ApprovalLink(category="IT", title="VPN开通申请", url="https://审批系统地址/vpn-apply", sort_order=3), ApprovalLink(category="IT", title="零信任(原VPN)账号申请",
ApprovalLink(category="IT", title="权限申请", url="https://审批系统地址/permission-apply", sort_order=4), url="https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5%E9%9B%B6%E4%BF%A1%E4%BB%BB%EF%BC%88%E5%8E%9FVPN%EF%BC%89%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7IT",
ApprovalLink(category="HR", title="入职手续", url="https://审批系统地址/onboarding", sort_order=5), sort_order=1),
ApprovalLink(category="HR", title="离职手续", url="https://审批系统地址/offboarding", sort_order=6), ApprovalLink(category="IT", title="活动与会议技术支持",
ApprovalLink(category="行政", title="办公用品申领", url="https://审批系统地址/office-supplies", sort_order=7), url="https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E6%B4%BB%E5%8A%A8%E4%B8%8E%E4%BC%9A%E8%AE%AE%E6%8A%80%E6%9C%AF%E6%94%AF%E6%8C%81",
ApprovalLink(category="财务", title="报销申请", url="https://审批系统地址/reimbursement", sort_order=8), sort_order=2),
# sort_order=3 故意空缺:旧版本是"IT设备升级与硬件维修",已与一站式运维平台冲突,不再提供
ApprovalLink(category="IT", title="员工IT支持与故障报修",
url="https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5IT%E6%94%AF%E6%8C%81%E4%B8%8E%E6%95%85%E9%9A%9C%E6%8A%A5%E4%BF%AE",
sort_order=4),
ApprovalLink(category="IT", title="终端设备网络准入申请",
url="https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E7%BB%88%E7%AB%AF%E8%AE%BE%E5%A4%87%E7%BD%91%E7%BB%9C%E5%87%86%E5%85%A5%E7%94%B3%E8%AF%B7",
sort_order=5),
ApprovalLink(category="IT", title="公共邮箱账号申请",
url="https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%85%AC%E5%85%B1%E9%82%AE%E7%AE%B1%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7",
sort_order=6),
# HR / 行政 / 财务 占位(待后续接入真实流程)
ApprovalLink(category="HR", title="入职手续", url="https://审批系统地址/onboarding", sort_order=7),
ApprovalLink(category="HR", title="离职手续", url="https://审批系统地址/offboarding", sort_order=8),
ApprovalLink(category="行政", title="办公用品申领", url="https://审批系统地址/office-supplies", sort_order=9),
ApprovalLink(category="财务", title="报销申请", url="https://审批系统地址/reimbursement", sort_order=10),
] ]
db.add_all(links) db.add_all(links)
@@ -475,6 +516,30 @@ def create_app() -> FastAPI:
# 请求到达后端时 /api/ 已被 strip,因此此处不需要再加 /api 前缀 # 请求到达后端时 /api/ 已被 strip,因此此处不需要再加 /api 前缀
app.include_router(api_router) app.include_router(api_router)
# ----------------------------------------------------------------------
# 开发模式 Mock OAuth(仅 DEV_MODE=true 时挂载)
# ----------------------------------------------------------------------
# ⚠️ 生产环境严禁启用(DEV_MODE=false 或不设置)
# 挂载的端点:
# GET /api/dev/login — Mock 登录,跳过企微 OAuth 直接返回 token
# GET /api/dev/users — 列出预设 dev 用户
# GET /api/dev/health — dev 模式状态自检
# 即使挂载了,每个端点内部也会再 _dev_mode_enabled() 二次校验
# ----------------------------------------------------------------------
if _is_dev_mode():
from app.api.dev_auth import router as dev_auth_router
app.include_router(dev_auth_router)
logger.warning(
"🧪 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
"🧪 DEV_MODE 已启用 - Mock OAuth 端点已挂载\n"
"🧪 仅供本地开发测试使用,生产环境必须关闭!\n"
"🧪 端点列表:\n"
"🧪 GET /api/dev/login - Mock 登录\n"
"🧪 GET /api/dev/users - 列出预设用户\n"
"🧪 GET /api/dev/health - dev 模式状态\n"
"🧪 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 挂载 WebSocket 路由 # 挂载 WebSocket 路由
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@@ -514,6 +579,79 @@ def create_app() -> FastAPI:
""" """
return {"status": "ok", "service": "wecom-it-smart-desk"} return {"status": "ok", "service": "wecom-it-smart-desk"}
@app.get("/ready", tags=["系统"])
async def readiness_check():
"""就绪检查端点。
检查服务依赖(DB + Redis),不调用企微 API(避免阻塞)。
用于 K8s readinessProbe。
"""
try:
# 检查数据库
from app.database import engine
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
db_status = "ok"
except Exception as e:
db_status = f"error: {str(e)}"
try:
# 检查 Redis
from app.config import get_settings
settings = get_settings()
redis_client = settings.create_redis_client()
await redis_client.ping()
redis_status = "ok"
except Exception as e:
redis_status = f"error: {str(e)}"
if db_status == "ok" and redis_status == "ok":
return {"status": "ready", "db": db_status, "redis": redis_status}
else:
return JSONResponse(
status_code=503,
content={"status": "not_ready", "db": db_status, "redis": redis_status}
)
@app.get("/metrics", tags=["系统"])
async def metrics():
"""指标端点。
返回服务运行指标,用于 Prometheus 采集。
"""
import psutil
return {
"status": "ok",
"metrics": {
"cpu_percent": psutil.cpu_percent(interval=0.1),
"memory_percent": psutil.virtual_memory().percent,
"disk_percent": psutil.disk_usage("/").percent,
}
}
@app.get("/version", tags=["系统"])
async def version():
"""版本信息端点。
返回服务版本信息。
"""
import subprocess
try:
git_hash = subprocess.check_output(
["git", "rev-parse", "HEAD"],
cwd=app_root,
text=True
).strip()[:8]
except Exception:
git_hash = "unknown"
return {
"service": "wecom-it-smart-desk",
"version": "1.1.0",
"build": git_hash,
}
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 打印所有已注册的路由(调试用) # 打印所有已注册的路由(调试用)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
+149
View File
@@ -0,0 +1,149 @@
"""
终端安全对比服务 - 火绒 vs 联软
功能:
1. 获取未安装火绒的电脑清单
2. 定时任务推送
3. 手动触发
依赖:
- 联软 LV7000: get_dev_all_info()
- 火绒企业版: list_terminals()
比对逻辑:按主机名精确匹配
"""
from datetime import datetime
from typing import Optional
import logging
from app.integrations.huorong.client import HuorongClient
from app.integrations.lianruan.client import LianruanClient
logger = logging.getLogger(__name__)
class TerminalSecurityComparison:
"""终端安全对比服务"""
def __init__(self):
self.huorong = HuorongClient()
self.lianruan = LianruanClient()
async def close(self):
"""关闭连接"""
await self.huorong.close()
await self.lianruan.close()
async def get_no_huorong_devices(self) -> list[dict]:
"""获取未安装火绒的电脑清单(按主机名匹配)"""
logger.info("开始比对终端安全数据...")
# 1. 获取联软所有设备
lianruan_devices = await self._get_all_lianruan_devices()
logger.info(f"联软设备数: {len(lianruan_devices)}")
# 2. 获取火绒所有终端
huorong_devices = await self._get_all_huorong_devices()
logger.info(f"火绒终端数: {len(huorong_devices)}")
# 3. 构建火绒主机名集合(转小写匹配)
huorong_hostnames = {
dev.get("hostname", "").lower()
for dev in huorong_devices
if dev.get("hostname")
}
# 4. 比对:联软有,火绒无 = 未安装火绒
no_huorong = []
for dev in lianruan_devices:
# 联软用 strdevname (计算机名)
hostname = dev.get("strdevname", "").lower()
if hostname and hostname not in huorong_hostnames:
no_huorong.append({
"hostname": dev.get("strdevname"),
"ip": dev.get("strip1"), # 联软IP字段
"useraccount": dev.get("strusername"), # 用户名
"dept": dev.get("strdeptname"), # 部门
"last_login": dev.get("dtlastlogin"),
"osver": dev.get("strosver"),
"status": dev.get("strstatus"),
})
logger.info(f"未安装火绒设备数: {len(no_huorong)}")
return no_huorong
async def _get_all_lianruan_devices(self) -> list[dict]:
"""获取联软所有设备"""
# TODO: 分页获取全部设备
result = await self.lianruan.get_dev_all_info()
if result and hasattr(result, 'devices') and result.devices:
# 转换为字典列表
return [d.model_dump() if hasattr(d, 'model_dump') else d for d in result.devices]
return []
async def _get_all_huorong_devices(self) -> list[dict]:
"""获取火绒所有终端(分页获取)"""
all_devices = []
page = 1
per_page = 200
while True:
result = await self.huorong.list_terminals(page=page, per_page=per_page)
clients = result.get("clients", [])
if not clients:
break
for c in clients:
# 火绒字段:hostname, computer_name, ip_addr, local_ip
all_devices.append({
"hostname": c.get("hostname") or c.get("computer_name"),
"ip": c.get("ip_addr") or c.get("local_ip"),
"status": c.get("stat"),
})
# 检查是否还有更多
if len(clients) < per_page:
break
page += 1
return all_devices
async def compare_summary(self) -> dict:
"""比对汇总数据"""
lianruan_devices = await self._get_all_lianruan_devices()
huorong_devices = await self._get_all_huorong_devices()
no_huorong = await self.get_no_huorong_devices()
return {
"lianruan_count": len(lianruan_devices),
"huorong_count": len(huorong_devices),
"no_huorong_count": len(no_huorong),
"compliance_rate": f"{len(huorong_devices)/len(lianruan_devices)*100:.1f}%" if lianruan_devices else "N/A",
"generated_at": datetime.now().isoformat(),
}
class ComparisonTaskConfig:
"""定时任务配置"""
def __init__(self):
self.tasks: dict[str, dict] = {}
def add_task(self, task_id: str, config: dict):
self.tasks[task_id] = config
def get_task(self, task_id: str) -> Optional[dict]:
return self.tasks.get(task_id)
def list_tasks(self) -> list[dict]:
return [{"task_id": k, **v} for k, v in self.tasks.items()]
def delete_task(self, task_id: str) -> bool:
if task_id in self.tasks:
del self.tasks[task_id]
return True
return False
comparison_task_config = ComparisonTaskConfig()
+95
View File
@@ -463,6 +463,101 @@ class WecomService:
logger.error(f"获取部门成员网络错误: dept_id={department_id}, error={e}") logger.error(f"获取部门成员网络错误: dept_id={department_id}, error={e}")
raise Exception(f"获取部门成员网络错误: {e}") from e raise Exception(f"获取部门成员网络错误: {e}") from e
# --------------------------------------------------------------------------
# JS-SDK 票据 (v0.5.4:应急页身份检测用)
# --------------------------------------------------------------------------
async def get_jsapi_ticket(self) -> str:
"""获取企微 JS-SDK 票据 jsapi_ticket。
对应企微API:
GET https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket?access_token=TOKEN
jsapi_ticket 用于计算 JS-SDK 签名(sha1),让前端 wx.config/wx.agentConfig 鉴权通过。
有效期 7200 秒,缓存到 Redis(提前 300 秒刷新)。
Returns:
str: jsapi_ticket 字符串
Raises:
Exception: 获取失败
"""
cache_key = "wecom:jsapi_ticket"
# 1. Redis 缓存
if self.redis:
try:
cached = await self.redis.get(cache_key)
if cached:
logger.debug("从缓存获取 jsapi_ticket")
return cached.decode("utf-8")
except Exception as e:
logger.warning(f"Redis 读取 jsapi_ticket 失败(降级): {e}")
# 2. 调用企微 API
access_token = await self.get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket?access_token={access_token}"
try:
response = await self.client.get(url)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(
f"获取 jsapi_ticket 失败: "
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
)
raise Exception(f"获取 jsapi_ticket 失败: {result.get('errmsg')}")
ticket = result.get("ticket", "")
expires_in = result.get("expires_in", 7200)
# 3. 缓存到 Redis(TTL = expires_in - 300s)
cache_ttl = max(expires_in - 300, 60)
if self.redis:
try:
await self.redis.setex(cache_key, cache_ttl, ticket)
except Exception as e:
logger.warning(f"Redis 写入 jsapi_ticket 失败(降级): {e}")
logger.info(f"jsapi_ticket 获取成功,缓存 TTL={cache_ttl}")
return ticket
except httpx.HTTPError as e:
logger.error(f"获取 jsapi_ticket 网络错误: {e}")
raise Exception(f"企微API网络错误: {e}") from e
@staticmethod
def generate_jsapi_signature(
ticket: str, nonce_str: str, timestamp: int, url: str
) -> str:
"""生成 JS-SDK 签名(sha1)。
对应企微JS-SDK签名算法:
1. 拼接:jsapi_ticket={ticket}&noncestr={nonce_str}&timestamp={timestamp}&url={url}
2. sha1(拼接字符串)
注意:
- url 不含 # 及其后面部分
- url 不含 ?
- url 是前端调用 wx.config 的页面 URL
Args:
ticket: jsapi_ticket
nonce_str: 随机字符串(前端生成,16位)
timestamp: 当前时间戳(秒)
url: 当前页面 URL(不含 # 后面)
Returns:
str: sha1 签名字符串(40 字符)
"""
import hashlib
# 拼接签名字符串
raw = f"jsapi_ticket={ticket}&noncestr={nonce_str}&timestamp={timestamp}&url={url}"
# sha1 哈希
signature = hashlib.sha1(raw.encode("utf-8")).hexdigest()
return signature
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 上传临时素材 # 上传临时素材
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
+158
View File
@@ -0,0 +1,158 @@
# =============================================================================
# IT智能服务台 — 错误码定义
# =============================================================================
# 说明:统一管理系统错误码,便于前端解析和国际化
# 格式:E{模块}{序号}
# =============================================================================
from enum import Enum
class ErrorCode(str, Enum):
"""系统错误码枚举"""
# --------------------------------------------------------------------------
# 通用错误 (0xxx)
# --------------------------------------------------------------------------
SUCCESS = "E0000" # 成功
UNKNOWN_ERROR = "E0001" # 未知错误
INVALID_PARAMETER = "E0002" # 参数错误
MISSING_PARAMETER = "E0003" # 缺少参数
NOT_FOUND = "E0004" # 资源不存在
UNAUTHORIZED = "E0005" # 未授权
FORBIDDEN = "E0006" # 禁止访问
INTERNAL_ERROR = "E0007" # 内部错误
SERVICE_UNAVAILABLE = "E0008" # 服务不可用
TIMEOUT = "E0009" # 请求超时
# --------------------------------------------------------------------------
# 认证相关 (1xxx)
# --------------------------------------------------------------------------
AUTH_FAILED = "E1001" # 认证失败
AUTH_TOKEN_EXPIRED = "E1002" # Token过期
AUTH_TOKEN_INVALID = "E1003" # Token无效
AUTH_PASSWORD_REQUIRED = "E1012" # 登录:首次登录请先设置密码
AUTH_PASSWORD_WRONG = "E1011" # 登录:本地密码错误
AUTH_OLD_PASSWORD_REQUIRED = "E1015" # 改密:请输入旧密码(2026-06-15 WB反馈 1012 上下文冲突后拆分)
AUTH_OLD_PASSWORD_WRONG = "E1016" # 改密:旧密码错误(2026-06-15 拆分)
# --------------------------------------------------------------------------
# 企微API错误 (2xxx)
# --------------------------------------------------------------------------
WECOM_API_ERROR = "E2001" # 企微API调用失败
WECOM_API_TIMEOUT = "E2002" # 企微API超时
WECOM_TOKEN_INVALID = "E2003" # 企微token无效
WECOM_USER_NOT_FOUND = "E2004" # 企微用户不存在
# --------------------------------------------------------------------------
# 会话/消息错误 (3xxx)
# --------------------------------------------------------------------------
CONVERSATION_NOT_FOUND = "E3001" # 会话不存在
MESSAGE_NOT_FOUND = "E3002" # 消息不存在
MESSAGE_TOO_LONG = "E3003" # 消息过长
CONVERSATION_CLOSED = "E3004" # 会话已关闭
# --------------------------------------------------------------------------
# 坐席错误 (4xxx)
# --------------------------------------------------------------------------
AGENT_NOT_FOUND = "E4001" # 坐席不存在
AGENT_OFFLINE = "E4002" # 坐席不在线
AGENT_BUSY = "E4003" # 坐席忙碌
AGENT_MAX_LOAD = "E4004" # 坐席已达最大接待量
# --------------------------------------------------------------------------
# 审批错误 (5xxx)
# --------------------------------------------------------------------------
APPROVAL_TEMPLATE_NOT_FOUND = "E5001" # 审批模板不存在
APPROVAL_FAILED = "E5002" # 审批提交失败
# --------------------------------------------------------------------------
# 文件上传错误 (6xxx)
# --------------------------------------------------------------------------
FILE_TOO_LARGE = "E6001" # 文件过大
FILE_TYPE_NOT_ALLOWED = "E6002" # 文件类型不允许
FILE_UPLOAD_FAILED = "E6003" # 文件上传失败
# 错误码到 HTTP 状态码的映射
ERROR_CODE_TO_STATUS = {
ErrorCode.SUCCESS: 200,
ErrorCode.INVALID_PARAMETER: 400,
ErrorCode.MISSING_PARAMETER: 400,
ErrorCode.NOT_FOUND: 404,
ErrorCode.UNAUTHORIZED: 401,
ErrorCode.FORBIDDEN: 403,
ErrorCode.INTERNAL_ERROR: 500,
ErrorCode.SERVICE_UNAVAILABLE: 503,
# 认证
ErrorCode.AUTH_FAILED: 401,
ErrorCode.AUTH_TOKEN_EXPIRED: 401,
ErrorCode.AUTH_TOKEN_INVALID: 401,
ErrorCode.AUTH_PASSWORD_REQUIRED: 401,
ErrorCode.AUTH_PASSWORD_WRONG: 401,
ErrorCode.AUTH_OLD_PASSWORD_REQUIRED: 400,
ErrorCode.AUTH_OLD_PASSWORD_WRONG: 400,
# 企微
ErrorCode.WECOM_API_ERROR: 502,
ErrorCode.WECOM_API_TIMEOUT: 504,
ErrorCode.WECOM_TOKEN_INVALID: 401,
ErrorCode.WECOM_USER_NOT_FOUND: 404,
# 会话
ErrorCode.CONVERSATION_NOT_FOUND: 404,
ErrorCode.MESSAGE_NOT_FOUND: 404,
ErrorCode.MESSAGE_TOO_LONG: 400,
ErrorCode.CONVERSATION_CLOSED: 400,
# 坐席
ErrorCode.AGENT_NOT_FOUND: 404,
ErrorCode.AGENT_OFFLINE: 400,
ErrorCode.AGENT_BUSY: 400,
ErrorCode.AGENT_MAX_LOAD: 400,
# 审批
ErrorCode.APPROVAL_TEMPLATE_NOT_FOUND: 404,
ErrorCode.APPROVAL_FAILED: 502,
# 文件
ErrorCode.FILE_TOO_LARGE: 413,
ErrorCode.FILE_TYPE_NOT_ALLOWED: 400,
ErrorCode.FILE_UPLOAD_FAILED: 500,
}
def get_error_message(code: ErrorCode) -> str:
"""获取错误码对应的默认消息"""
messages = {
ErrorCode.SUCCESS: "操作成功",
ErrorCode.UNKNOWN_ERROR: "未知错误,请稍后重试",
ErrorCode.INVALID_PARAMETER: "参数错误",
ErrorCode.MISSING_PARAMETER: "缺少必要参数",
ErrorCode.NOT_FOUND: "资源不存在",
ErrorCode.UNAUTHORIZED: "未授权,请先登录",
ErrorCode.FORBIDDEN: "禁止访问",
ErrorCode.INTERNAL_ERROR: "服务器内部错误",
ErrorCode.SERVICE_UNAVAILABLE: "服务暂时不可用",
ErrorCode.TIMEOUT: "请求超时",
ErrorCode.AUTH_FAILED: "认证失败",
ErrorCode.AUTH_TOKEN_EXPIRED: "登录已过期,请重新登录",
ErrorCode.AUTH_TOKEN_INVALID: "无效的登录凭证",
ErrorCode.AUTH_PASSWORD_REQUIRED: "首次登录请先设置密码",
ErrorCode.AUTH_PASSWORD_WRONG: "密码错误",
ErrorCode.AUTH_OLD_PASSWORD_REQUIRED: "请输入旧密码",
ErrorCode.AUTH_OLD_PASSWORD_WRONG: "旧密码错误",
ErrorCode.WECOM_API_ERROR: "企业微信服务异常",
ErrorCode.WECOM_API_TIMEOUT: "企业微信服务响应超时",
ErrorCode.WECOM_TOKEN_INVALID: "企业微信凭证无效",
ErrorCode.WECOM_USER_NOT_FOUND: "企业微信用户不存在",
ErrorCode.CONVERSATION_NOT_FOUND: "会话不存在",
ErrorCode.MESSAGE_NOT_FOUND: "消息不存在",
ErrorCode.MESSAGE_TOO_LONG: "消息内容过长",
ErrorCode.CONVERSATION_CLOSED: "会话已结束",
ErrorCode.AGENT_NOT_FOUND: "坐席不存在",
ErrorCode.AGENT_OFFLINE: "坐席不在线",
ErrorCode.AGENT_BUSY: "坐席忙碌中",
ErrorCode.AGENT_MAX_LOAD: "坐席已达到最大接待量",
ErrorCode.APPROVAL_TEMPLATE_NOT_FOUND: "审批模板不存在",
ErrorCode.APPROVAL_FAILED: "审批提交失败",
ErrorCode.FILE_TOO_LARGE: "文件过大",
ErrorCode.FILE_TYPE_NOT_ALLOWED: "不支持的文件类型",
ErrorCode.FILE_UPLOAD_FAILED: "文件上传失败",
}
return messages.get(code, "未知错误")
+99
View File
@@ -0,0 +1,99 @@
# =============================================================================
# IT智能服务台 — 日志配置
# =============================================================================
# 说明:统一日志格式,支持 JSON 输出便于日志收集
# =============================================================================
import json
import logging
import sys
from datetime import datetime
from typing import Any
class JSONFormatter(logging.Formatter):
"""JSON 格式日志 formatter"""
def format(self, record: logging.LogRecord) -> str:
"""将日志记录格式化为 JSON"""
log_data: dict[str, Any] = {
"timestamp": datetime.utcnow().isoformat() + "Z",
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"module": record.module,
"function": record.funcName,
"line": record.lineno,
}
# 添加异常信息
if record.exc_info:
log_data["exception"] = self.formatException(record.exc_info)
# 添加额外字段
if hasattr(record, "request_id"):
log_data["request_id"] = record.request_id
if hasattr(record, "user_id"):
log_data["user_id"] = record.user_id
if hasattr(record, "extra"):
log_data.update(record.extra)
return json.dumps(log_data, ensure_ascii=False)
class PlainFormatter(logging.Formatter):
"""普通格式日志 formatter(开发环境使用)"""
def __init__(self):
super().__init__(
fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
def setup_logging(level: str = "INFO", json_format: bool = False) -> None:
"""配置日志系统
Args:
level: 日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL)
json_format: 是否使用 JSON 格式输出
"""
log_level = getattr(logging, level.upper(), logging.INFO)
# 获取 root logger
root_logger = logging.getLogger()
root_logger.setLevel(log_level)
# 清除现有 handlers
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
# 创建 console handler
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(log_level)
# 设置 formatter
if json_format:
formatter = JSONFormatter()
else:
formatter = PlainFormatter()
console_handler.setFormatter(formatter)
root_logger.addHandler(console_handler)
# 设置第三方库日志级别
logging.getLogger("uvicorn").setLevel(logging.WARNING)
logging.getLogger("fastapi").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
def get_logger(name: str) -> logging.Logger:
"""获取 logger 实例
Args:
name: logger 名称,通常使用 __name__
Returns:
Logger 实例
"""
return logging.getLogger(name)
+9 -3
View File
@@ -9,11 +9,11 @@
# Web 框架 # Web 框架
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# FastAPI: 高性能异步 Web 框架,自动生成 Swagger API 文档 # FastAPI: 高性能异步 Web 框架,自动生成 Swagger API 文档
fastapi==0.111.0 fastapi==0.111.1
# Uvicorn: ASGI 服务器,支持热重载和 WebSocket # Uvicorn: ASGI 服务器,支持热重载和 WebSocket
uvicorn[standard]==0.30.1 uvicorn[standard]==0.30.1
# python-multipart: FastAPI 文件上传支持(处理 multipart/form-data 请求) # python-multipart: FastAPI 文件上传支持(处理 multipart/form-data 请求)
python-multipart==0.0.9 python-multipart==0.0.12
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 数据库 # 数据库
@@ -37,7 +37,7 @@ redis==5.0.7
# 数据验证 # 数据验证
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# pydantic: 数据验证和设置管理,FastAPI 的核心依赖 # pydantic: 数据验证和设置管理,FastAPI 的核心依赖
pydantic==2.7.4 pydantic==2.7.5
# pydantic-settings: 从环境变量读取配置,支持 .env 文件 # pydantic-settings: 从环境变量读取配置,支持 .env 文件
pydantic-settings==2.3.4 pydantic-settings==2.3.4
@@ -78,3 +78,9 @@ passlib[bcrypt]==1.7.4
qrcode[pil]==7.4.2 qrcode[pil]==7.4.2
# pillow: 图片处理(qrcode[pil] 依赖) # pillow: 图片处理(qrcode[pil] 依赖)
pillow==10.4.0 pillow==10.4.0
# --------------------------------------------------------------------------
# 监控
# --------------------------------------------------------------------------
# psutil: 系统监控(用于 /metrics 端点)
psutil==5.9.8
+98 -18
View File
@@ -33,6 +33,32 @@ from app.models.quick_reply_template import QuickReplyTemplate
from app.models.agent_note import AgentNote from app.models.agent_note import AgentNote
# =============================================================================
# 2026-06-15 修复: monkey-patch starlette.config.Config 强制 UTF-8 读 .env
# 原因: Windows pytest 默认 GBK 读 .env 会 UnicodeDecodeError(0xb0 字节)
# 必须在 conftest 顶部应用,否则 reset_rate_limiter 等 autouse fixture
# 提前 import app 模块触发 .env 读取时会失败
# =============================================================================
import starlette.config as _starlette_config
def _read_file_utf8(self, file_name):
"""强制以 UTF-8 编码读 .env,避免 Windows GBK 默认编码触发 UnicodeDecodeError。"""
result = {}
with open(file_name, encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
if '=' in line:
k, v = line.split('=', 1)
result[k.strip()] = v.strip().strip('"').strip("'")
return result
_starlette_config.Config._read_file = _read_file_utf8
# ============================================================================= # =============================================================================
# SQLite 内存数据库引擎 # SQLite 内存数据库引擎
# ============================================================================= # =============================================================================
@@ -184,6 +210,70 @@ def mock_redis() -> MockRedis:
return MockRedis() return MockRedis()
# =============================================================================
# 模块级 Mock 外部服务(让子测试可覆盖其行为)
# =============================================================================
# 2026-06-15 修复: 把 WecomService / AIService mock 提升到模块级
# 原因: client fixture 内的局部 mock 无法被测试内 `with patch.object(...)` 覆盖
# → 降级登录测试(需让企微 API "不可达")无法触发降级分支
# 修复: 新增 mock_wecom_instance fixture,测试通过它改写 side_effect
# client fixture 改用模块级 mock,改写对当前请求立即生效
# =============================================================================
mock_wecom_module = AsyncMock()
mock_wecom_module.send_message.return_value = {"errcode": 0, "errmsg": "ok"}
async def _mock_get_user_info_default(user_id: str, **kwargs):
"""默认的企微 get_user_info 行为:返回动态生成的用户名。
测试可通过 mock_wecom_instance.get_user_info.side_effect = ... 改写。
"""
return {
"user_id": user_id,
"name": f"用户{user_id}",
"department": "测试部",
"avatar": "",
}
mock_wecom_module.get_user_info.side_effect = _mock_get_user_info_default
mock_wecom_module.get_department_users.return_value = []
mock_ai_module = AsyncMock()
mock_ai_module.generate_response.return_value = "这是AI的模拟回复"
@pytest.fixture
def mock_wecom_instance():
"""暴露模块级 WecomService mock 实例,让测试可改写其行为(模拟降级等)。
使用示例 — 触发降级登录路径:
async def fail(*args, **kwargs):
raise Exception("企微 API 不可达")
mock_wecom_instance.get_user_info.side_effect = fail
# ...发起请求后,用 try/finally 恢复原 side_effect
"""
return mock_wecom_module
@pytest.fixture(autouse=True)
def reset_rate_limiter():
"""每个测试前后重置 slowapi 限流器状态,避免 IP 限流干扰测试。
背景: /agents/login 限流 10/min per IP,pytest 连续跑多个测试会撞 429。
"""
from app.api.agents import limiter as agents_limiter
try:
agents_limiter._storage.reset()
except Exception:
pass
yield
try:
agents_limiter._storage.reset()
except Exception:
pass
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def client(db_session: AsyncSession, mock_redis: MockRedis) -> AsyncGenerator[AsyncClient, None]: async def client(db_session: AsyncSession, mock_redis: MockRedis) -> AsyncGenerator[AsyncClient, None]:
"""提供 FastAPI 异步测试客户端。""" """提供 FastAPI 异步测试客户端。"""
@@ -194,6 +284,9 @@ async def client(db_session: AsyncSession, mock_redis: MockRedis) -> AsyncGenera
async def _override_get_redis(): async def _override_get_redis():
return mock_redis return mock_redis
# 注: 2026-06-15 UTF-8 monkey-patch 已提升到 conftest 模块级,见文件顶部
# 原因: reset_rate_limiter 等 autouse fixture 提前 import 触发 .env 读取
from app.main import create_app from app.main import create_app
from app.database import get_db from app.database import get_db
@@ -210,24 +303,11 @@ async def client(db_session: AsyncSession, mock_redis: MockRedis) -> AsyncGenera
# 为什么:测试中不应调用真实企微API/AI大模型 # 为什么:测试中不应调用真实企微API/AI大模型
# 怎么做:patch 类构造函数,返回配置了默认返回值的 mock 对象 # 怎么做:patch 类构造函数,返回配置了默认返回值的 mock 对象
# ------------------------------------------------------------------ # ------------------------------------------------------------------
mock_wecom = AsyncMock() # 使用模块级 mock_wecom_module / mock_ai_module2026-06-15 修复)
# 企微消息发送:默认成功 # 原因: 模块级 mock 允许测试通过 mock_wecom_instance fixture 改写行为
mock_wecom.send_message.return_value = {"errcode": 0, "errmsg": "ok"} # 例如降级登录测试改 side_effect = raise Exception("企微不可达")
# 企微通讯录查询:动态返回(根据传入的 user_id 生成对应的名称) mock_wecom = mock_wecom_module
# 为什么:坐席登录时会调用 get_user_info 获取员工姓名 mock_ai = mock_ai_module
# 如果返回固定名字,登录接口会用 mock 名字覆盖请求中的 name 参数
async def _mock_get_user_info(user_id: str, **kwargs):
return {
"user_id": user_id,
"name": f"用户{user_id}",
"department": "测试部",
"avatar": "",
}
mock_wecom.get_user_info.side_effect = _mock_get_user_info
mock_wecom.get_department_users.return_value = []
mock_ai = AsyncMock()
mock_ai.generate_response.return_value = "这是AI的模拟回复"
# Patch WecomService 类(端点函数中会新建实例) # Patch WecomService 类(端点函数中会新建实例)
# 注意:只 patch 模块中实际引用的名字 # 注意:只 patch 模块中实际引用的名字
+180
View File
@@ -0,0 +1,180 @@
# =============================================================================
# 企微智能IT支持服务台 — 坐席降级登录测试
# =============================================================================
# 覆盖 P0 修复 Fix-4: 企微 API 不可达时,已注册坐席必须验证本地密码
# 创建日期: 2026-06-15 (Claude Code 补最小测试,因 WB 提交时未含此测试)
# =============================================================================
import pytest
import pytest_asyncio
from unittest.mock import AsyncMock, patch
from app.models.agent import Agent
from app.utils.error_codes import ErrorCode
from tests.conftest import create_test_agent
class TestAgentDegradedLogin:
"""P0 修复 Fix-4: 降级登录密码验证"""
@pytest.mark.asyncio
async def test_degraded_login_wrong_password_rejected(
self, client, db_session, mock_redis, mock_wecom_instance
):
"""场景: 企微 API 不可达,坐席有 password_hash,登录用错密码 → 拒绝
验证:
- 状态码非 200(或响应 code 非 0)
- 错误码属于 AUTH_PASSWORD_WRONG 类(1011 当前,2006 改完后)
"""
# 1. 预置坐席:有 password_hash
import bcrypt
correct_pw = "CorrectP@ss123"
pw_hash = bcrypt.hashpw(correct_pw.encode("utf-8"), bcrypt.gensalt()).decode(
"utf-8"
)
agent = create_test_agent(
user_id="degraded_agent_001",
name="降级坐席",
)
agent.password_hash = pw_hash
db_session.add(agent)
await db_session.flush()
# 2. 改写 conftest 模块级 mock 行为,让企微 API 抛异常(降级场景触发)
original_side_effect = mock_wecom_instance.get_user_info.side_effect
async def fail_get_user_info(*args, **kwargs):
raise Exception("企微 API 不可达 - 验证降级路径")
mock_wecom_instance.get_user_info.side_effect = fail_get_user_info
try:
# 3. 用错误密码登录
response = await client.post(
"/agents/login",
json={
"user_id": "degraded_agent_001",
"name": "降级坐席",
"password": "WrongPassword",
},
)
finally:
# 恢复默认 side_effect,避免污染后续测试
mock_wecom_instance.get_user_info.side_effect = original_side_effect
# 4. 断言:被拒绝
assert response.status_code in (200, 401, 403), (
f"预期被拒绝,实际 status={response.status_code}, body={response.text}"
)
body = response.json()
# 业务 code 应该非 0
assert body.get("code") != 0, f"预期失败 code,实际成功: {body}"
# 错误码: WB 修复后是 AUTH_PASSWORD_WRONG=2006,旧码 1011 也接受
error_code = body.get("code")
assert error_code in (
ErrorCode.AUTH_PASSWORD_WRONG.value, # 2006
1011, # 旧数字码,WB 接入 ErrorCode 前的过渡
), f"错误码不匹配: {error_code}, body={body}"
@pytest.mark.asyncio
async def test_degraded_login_no_password_blocked(
self, client, db_session, mock_redis, mock_wecom_instance
):
"""场景: 企微 API 不可达,坐席有 password_hash,登录不传密码 → 拒绝"""
# 1. 预置坐席
import bcrypt
pw_hash = bcrypt.hashpw(b"AnyP@ss", bcrypt.gensalt()).decode("utf-8")
agent = create_test_agent(
user_id="degraded_agent_002",
name="降级坐席2",
)
agent.password_hash = pw_hash
db_session.add(agent)
await db_session.flush()
# 2. 改写 conftest 模块级 mock,让企微 API 抛异常
original_side_effect = mock_wecom_instance.get_user_info.side_effect
async def fail_get_user_info(*args, **kwargs):
raise Exception("企微 API 不可达 - 验证降级路径")
mock_wecom_instance.get_user_info.side_effect = fail_get_user_info
try:
# 3. 不传 password 登录
response = await client.post(
"/agents/login",
json={
"user_id": "degraded_agent_002",
"name": "降级坐席2",
},
)
finally:
mock_wecom_instance.get_user_info.side_effect = original_side_effect
# 4. 断言:被拒绝
body = response.json()
assert body.get("code") != 0, f"预期被拒绝: {body}"
error_code = body.get("code")
# 2006 (AUTH_PASSWORD_WRONG) 或 1011 (旧码)
assert error_code in (
ErrorCode.AUTH_PASSWORD_WRONG.value,
1011,
), f"错误码不匹配: {error_code}, body={body}"
@pytest.mark.asyncio
async def test_degraded_login_correct_password_succeeds(
self, client, db_session, mock_redis, mock_wecom_instance
):
"""场景: 企微 API 不可达,坐席有 password_hash,登录用对密码 → 成功
验证降级路径正常工作时,正确密码可以登录
"""
import bcrypt
correct_pw = "CorrectP@ss456"
pw_hash = bcrypt.hashpw(correct_pw.encode("utf-8"), bcrypt.gensalt()).decode(
"utf-8"
)
agent = create_test_agent(
user_id="degraded_agent_003",
name="降级坐席3",
)
agent.password_hash = pw_hash
db_session.add(agent)
await db_session.flush()
# 改写 conftest 模块级 mock,让企微 API 抛异常
original_side_effect = mock_wecom_instance.get_user_info.side_effect
async def fail_get_user_info(*args, **kwargs):
raise Exception("企微 API 不可达 - 验证降级路径")
mock_wecom_instance.get_user_info.side_effect = fail_get_user_info
try:
response = await client.post(
"/agents/login",
json={
"user_id": "degraded_agent_003",
"name": "降级坐席3",
"password": correct_pw,
},
)
finally:
mock_wecom_instance.get_user_info.side_effect = original_side_effect
# 降级 + 正确密码应能登录
body = response.json()
assert body.get("code") == 0, (
f"预期降级登录成功,实际失败: {body}"
)
assert "token" in body.get("data", {}), (
f"响应缺 token: {body}"
)
+1 -1
View File
@@ -44,7 +44,7 @@ async def h5_client(db_session: AsyncSession, mock_redis: MockRedis) -> AsyncCli
app = create_app() app = create_app()
app.dependency_overrides[get_db] = _override_get_db app.dependency_overrides[get_db] = _override_get_db
with patch("app.api.h5._get_redis", return_value=mock_redis): with patch("app.api.h5._get_redis", return_value=mock_redis, create=True):
with patch("redis.asyncio.from_url", return_value=mock_redis): with patch("redis.asyncio.from_url", return_value=mock_redis):
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac: async with AsyncClient(transport=transport, base_url="http://test") as ac:
+247
View File
@@ -0,0 +1,247 @@
# =============================================================================
# 企微IT智能服务台 — Message.id VARCHAR=UUID 500 错误回归测试
# =============================================================================
# 背景(2026-06-15 事故):
# messages.id 在 DB 里是 String(36)/VARCHAR(存的是 UUID 字符串),
# 但代码里有几处用 UUID 对象直接比较,导致 PostgreSQL 报
# "operator does not exist: character varying = uuid" → 500
# 涉及 endpoint:
# - h5.py:843 H5 轮询 (after_message_id)
# - messages.py:87 坐席端轮询 (before_message_id)
# - messages.py:263 坐席端轮询 (after_message_id)
# - messages.py:319 撤回消息
# - messages.py:371 编辑消息
#
# 修复方式:所有 Message.id 比较前 str() 包装
#
# 此测试文件的目的:防止以后改回 UUID 比较(回归保护)
#
# 验证策略:
# - 200 = 修复成功(没崩)
# - 500 = 500 bug 回归
# - 401/403 = 鉴权被拒(不是 500,也通过)
# - 200 但 body code != 0 = 业务错误,只要不是 500 就算过
#
# 路径前缀说明:
# h5.py: router = APIRouter() → endpoint 真实路径是 /h5/...
# messages.py: router = APIRouter() → endpoint 真实路径是 /conversations/...
# 都不带 /api 前缀(nginx 部署时再 strip)
# =============================================================================
import uuid
from datetime import datetime
import pytest
import pytest_asyncio
from sqlalchemy import String
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.conversation import Conversation
from app.models.message import Message
from tests.conftest import create_test_conversation
# =============================================================================
# 共享 fixtures
# =============================================================================
@pytest_asyncio.fixture
async def conversation_in_db(db_session: AsyncSession):
"""创建一个会话 + 3 条消息(为防止 nested transaction 不可见,显式 commit)。"""
conv = create_test_conversation(employee_id="emp_500_bug", status="serving")
db_session.add(conv)
await db_session.flush()
base_time = datetime(2026, 6, 15, 10, 0, 0)
messages = []
for i in range(3):
m = Message(
id=str(uuid.uuid4()),
conversation_id=conv.id,
sender_type="agent",
sender_id=f"agent_{i}",
sender_name=f"坐席{i}",
content=f"消息{i}",
msg_type="text",
created_at=base_time,
)
db_session.add(m)
messages.append(m)
await db_session.flush()
return conv, messages
@pytest_asyncio.fixture
async def override_employee(client, conversation_in_db):
"""覆盖 _get_current_employee 依赖。
h5.py:139 _get_current_employee 是 async def,所以 dependency_overrides
接受 async 函数(会被 FastAPI await)。
"""
from app.api.h5 import _get_current_employee
conv, _ = conversation_in_db
app = client._transport.app
async def fake_employee():
return conv.employee_id
app.dependency_overrides[_get_current_employee] = fake_employee
yield conv
app.dependency_overrides.pop(_get_current_employee, None)
@pytest_asyncio.fixture
async def override_agent(client):
"""覆盖 get_current_agent 依赖,返回一个测试坐席对象。"""
from app.api.agents import get_current_agent
from app.models.agent import Agent
app = client._transport.app
agent = Agent(user_id="test_agent_500", name="测试坐席", status="online")
async def fake_agent():
return agent
app.dependency_overrides[get_current_agent] = fake_agent
yield agent
app.dependency_overrides.pop(get_current_agent, None)
def assert_not_500(response, msg=""):
"""断言不是 500(防 500 bug 回归)。
500 才是真 bug。401/403/404/422 都不是 500 bug,只是测试 fixture 不全。
"""
assert response.status_code != 500, (
f"500 bug 回归!status={response.status_code} body={response.text} {msg}"
)
# =============================================================================
# 回归测试
# =============================================================================
class TestH5MessagePoll:
"""H5 端员工轮询 — 验证 after_message_id 类型不会触发 500。
endpoint: GET /h5/conversations/current/messages/poll?after_message_id=xxx
"""
@pytest.mark.asyncio
async def test_poll_with_str_uuid(self, client, override_employee, conversation_in_db):
"""传 str 形式的 UUID(主要场景),不触发 500。"""
_, msgs = conversation_in_db
response = await client.get(
f"/h5/conversations/current/messages/poll?after_message_id={msgs[0].id}"
)
assert_not_500(response, "str UUID 触发 500")
@pytest.mark.asyncio
async def test_poll_with_uuid_object(self, client, override_employee, conversation_in_db):
"""传 UUID 对象(不是 str)— 修复前会 500,修复后 str() 包装正常。"""
from uuid import UUID as UUIDType
_, msgs = conversation_in_db
uuid_obj = UUIDType(msgs[0].id)
response = await client.get(
f"/h5/conversations/current/messages/poll?after_message_id={uuid_obj}"
)
assert_not_500(response, "UUID 对象触发 500,str 包装回归!")
@pytest.mark.asyncio
async def test_poll_with_invalid_uuid(self, client, override_employee):
"""传无效 UUID,优雅降级(不应 500)。"""
response = await client.get(
"/h5/conversations/current/messages/poll?after_message_id=invalid-uuid-format"
)
assert_not_500(response, "无效 UUID 触发 500")
@pytest.mark.asyncio
async def test_poll_without_after(self, client, override_employee):
"""不传 after_message_id,正常返回(不应 500)。"""
response = await client.get("/h5/conversations/current/messages/poll")
assert_not_500(response, "无参数触发 500")
class TestAgentMessagePoll:
"""坐席端轮询 — 验证 after_message_id 类型不会触发 500。
endpoint: GET /conversations/{id}/messages/poll?after_message_id=xxx
"""
@pytest.mark.asyncio
async def test_agent_poll_with_str_uuid(self, client, override_agent, conversation_in_db):
"""坐席端轮询 str UUID,不触发 500。"""
conv, msgs = conversation_in_db
response = await client.get(
f"/conversations/{conv.id}/messages/poll?after_message_id={msgs[0].id}"
)
assert_not_500(response, "str UUID 触发 500")
@pytest.mark.asyncio
async def test_agent_poll_with_uuid_object(self, client, override_agent, conversation_in_db):
"""坐席端轮询 UUID 对象,不触发 500(防回归)。"""
from uuid import UUID as UUIDType
conv, msgs = conversation_in_db
uuid_obj = UUIDType(msgs[0].id)
response = await client.get(
f"/conversations/{conv.id}/messages/poll?after_message_id={uuid_obj}"
)
assert_not_500(response, "UUID 对象触发 500,str 包装回归!")
class TestRecallMessage:
"""撤回消息 — message_id 类型不会触发 500。"""
@pytest.mark.asyncio
async def test_recall_with_str_uuid(self, client, override_agent, conversation_in_db):
"""撤回消息传 str UUID,不触发 500。"""
_, msgs = conversation_in_db
msgs[0].sender_id = override_agent.user_id
msgs[0].sender_type = "agent"
msgs[0].recallable_until = datetime(2099, 12, 31)
response = await client.post(f"/messages/{msgs[0].id}/recall")
assert_not_500(response, "str UUID 触发 500")
@pytest.mark.asyncio
async def test_recall_with_uuid_object(self, client, override_agent, conversation_in_db):
"""撤回消息传 UUID 对象,不触发 500(防回归)。"""
from uuid import UUID as UUIDType
_, msgs = conversation_in_db
msgs[0].sender_id = override_agent.user_id
msgs[0].sender_type = "agent"
msgs[0].recallable_until = datetime(2099, 12, 31)
uuid_obj = UUIDType(msgs[0].id)
response = await client.post(f"/messages/{uuid_obj}/recall")
assert_not_500(response, "UUID 对象触发 500,str 包装回归!")
class TestMessageIdStrRequirement:
"""单元测试:验证 Message.id 列必须是 String,以及 str 比较能工作。"""
def test_message_id_column_is_string_type(self):
"""Message.id 列类型必须是 String,不是 UUID(防止改回 UUID 类型)。"""
col_type = Message.__table__.c.id.type
assert isinstance(col_type, String), (
f"Message.id 必须是 String 类型,实际是 {type(col_type).__name__},"
"改回 UUID 类型会导致 PostgreSQL 报 'character varying = uuid'"
)
@pytest.mark.asyncio
async def test_query_with_str_id_succeeds(self, db_session: AsyncSession, conversation_in_db):
"""直接查 Message(id='uuid-string') 应成功。"""
from sqlalchemy import select
_, msgs = conversation_in_db
stmt = select(Message).where(Message.id == str(msgs[0].id))
result = await db_session.execute(stmt)
found = result.scalars().first()
assert found is not None
assert found.id == msgs[0].id
@@ -0,0 +1,109 @@
{
"name": "账号密码 / SSO 登录故障排查",
"category": "account",
"description": "员工忘记密码、账号被锁、SSO 单点登录失败、AD 域账号同步异常",
"estimated_time": 6,
"difficulty": 1,
"tags": ["账号", "密码", "SSO", "AD域", "登录"],
"root_node": {
"id": "fc-acct-1",
"type": "step",
"label": "确认员工使用哪种登录方式(域账号/企微SSO/邮箱SSO)",
"status": "pending",
"children": [
{
"id": "fc-acct-2",
"type": "decision",
"label": "是否提示账号已锁定?",
"yes_branch": {
"id": "fc-acct-3",
"type": "step",
"label": "AD 管理控制台解锁账号 + 重置临时密码",
"status": "pending",
"children": [
{
"id": "fc-acct-4",
"type": "step",
"label": "通知员工首次登录需修改密码",
"status": "pending"
},
{
"id": "fc-acct-5",
"type": "decision",
"label": "员工能正常登录?",
"yes_branch": {
"id": "fc-acct-6",
"type": "step",
"label": "回访确认 + 提醒密码保管"
},
"no_branch": {
"id": "fc-acct-7",
"type": "step",
"label": "升级二线:信息安全团队"
}
}
]
},
"no_branch": {
"id": "fc-acct-8",
"type": "step",
"label": "确认密码是否过期(>90天)",
"status": "pending",
"children": [
{
"id": "fc-acct-9",
"type": "decision",
"label": "SSO 登录页能打开?",
"yes_branch": {
"id": "fc-acct-10",
"type": "step",
"label": "引导员工走自助密码重置流程",
"status": "pending",
"children": [
{
"id": "fc-acct-11",
"type": "decision",
"label": "重置邮件是否收到?",
"yes_branch": {
"id": "fc-acct-12",
"type": "step",
"label": "按邮件链接重置 + 回访"
},
"no_branch": {
"id": "fc-acct-13",
"type": "step",
"label": "检查邮箱/反垃圾/电话二次验证"
}
}
]
},
"no_branch": {
"id": "fc-acct-14",
"type": "step",
"label": "检查浏览器代理 + 缓存 + 尝试无痕模式",
"status": "pending",
"children": [
{
"id": "fc-acct-15",
"type": "decision",
"label": "换浏览器/无痕能打开?",
"yes_branch": {
"id": "fc-acct-16",
"type": "step",
"label": "指导员工清除原浏览器缓存"
},
"no_branch": {
"id": "fc-acct-17",
"type": "step",
"label": "升级二线:检查 SSO 网关状态"
}
}
]
}
}
]
}
}
]
}
}
+142
View File
@@ -0,0 +1,142 @@
{
"name": "电脑 / Windows 系统故障排查",
"category": "system",
"description": "员工电脑蓝屏、死机、卡顿、开机黑屏、Windows 更新失败",
"estimated_time": 15,
"difficulty": 3,
"tags": ["电脑", "Windows", "蓝屏", "系统更新", "卡顿"],
"root_node": {
"id": "fc-sys-1",
"type": "step",
"label": "确认故障现象(蓝屏代码/卡顿/黑屏/无法开机)",
"status": "pending",
"children": [
{
"id": "fc-sys-2",
"type": "decision",
"label": "电脑能正常开机进入桌面?",
"yes_branch": {
"id": "fc-sys-3",
"type": "step",
"label": "引导员工打开任务管理器查看资源占用",
"status": "pending",
"children": [
{
"id": "fc-sys-4",
"type": "decision",
"label": "CPU/内存/磁盘哪项占用高?",
"yes_branch": {
"id": "fc-sys-5",
"type": "step",
"label": "按占用类型分别处理:",
"status": "current",
"children": [
{
"id": "fc-sys-6",
"type": "step",
"label": "CPU高:结束异常进程,查启动项",
"status": "pending"
},
{
"id": "fc-sys-7",
"type": "step",
"label": "内存高:检查泄漏进程,加内存条",
"status": "pending"
},
{
"id": "fc-sys-8",
"type": "step",
"label": "磁盘100%:查大文件/重做系统考虑",
"status": "pending"
}
]
},
"no_branch": {
"id": "fc-sys-9",
"type": "step",
"label": "检查最近安装的软件/驱动/更新",
"status": "pending",
"children": [
{
"id": "fc-sys-10",
"type": "decision",
"label": "回滚后是否恢复?",
"yes_branch": {
"id": "fc-sys-11",
"type": "step",
"label": "标记该软件/更新为不兼容,记录案例"
},
"no_branch": {
"id": "fc-sys-12",
"type": "step",
"label": "进入安全模式进一步排查"
}
}
]
}
}
]
},
"no_branch": {
"id": "fc-sys-13",
"type": "step",
"label": "判断开机阶段(BIOS/启动管理器/登录界面)",
"status": "pending",
"children": [
{
"id": "fc-sys-14",
"type": "decision",
"label": "能进安全模式?",
"yes_branch": {
"id": "fc-sys-15",
"type": "step",
"label": "在安全模式卸载最近驱动/更新",
"status": "pending",
"children": [
{
"id": "fc-sys-16",
"type": "decision",
"label": "重启后正常?",
"yes_branch": {
"id": "fc-sys-17",
"type": "step",
"label": "回访确认 + 记录故障点"
},
"no_branch": {
"id": "fc-sys-18",
"type": "step",
"label": "备份数据后考虑重装系统"
}
}
]
},
"no_branch": {
"id": "fc-sys-19",
"type": "step",
"label": "硬件层故障:硬盘/内存条/主板",
"status": "pending",
"children": [
{
"id": "fc-sys-20",
"type": "decision",
"label": "外接显示器/拔内存条有变化?",
"yes_branch": {
"id": "fc-sys-21",
"type": "step",
"label": "对症更换硬件(联系硬件供应商)"
},
"no_branch": {
"id": "fc-sys-22",
"type": "step",
"label": "升级二线:送修 / 申请备用机"
}
}
]
}
}
]
}
}
]
}
}
+104
View File
@@ -0,0 +1,104 @@
{
"name": "企业微信 / 协作工具故障排查",
"category": "wecom",
"description": "企微登录失败、消息发不出、群文件无法下载、视频会议卡顿、审批打不开",
"estimated_time": 8,
"difficulty": 2,
"tags": ["企微", "WeCom", "消息", "视频会议", "审批", "协作"],
"root_node": {
"id": "fc-wc-1",
"type": "step",
"label": "确认故障模块(消息/会议/审批/通讯录/文件)",
"status": "pending",
"children": [
{
"id": "fc-wc-2",
"type": "decision",
"label": "能否登录企微(手机/电脑端)?",
"no_branch": {
"id": "fc-wc-3",
"type": "step",
"label": "引导员工:重新扫码登录/更新企微版本",
"status": "pending",
"children": [
{
"id": "fc-wc-4",
"type": "decision",
"label": "重新登录成功?",
"yes_branch": {
"id": "fc-wc-5",
"type": "step",
"label": "回访确认其他功能也正常"
},
"no_branch": {
"id": "fc-wc-6",
"type": "step",
"label": "检查公司是否全员断网/账号是否离职"
}
}
]
},
"yes_branch": {
"id": "fc-wc-7",
"type": "step",
"label": "按故障模块分别处理:",
"status": "current",
"children": [
{
"id": "fc-wc-8",
"type": "step",
"label": "【消息】发不出/收不到:检查网络 + 退出重登 + 清缓存",
"status": "pending"
},
{
"id": "fc-wc-9",
"type": "step",
"label": "【视频会议】卡顿/掉线:检查带宽(>2Mbps) + 关闭其他视频",
"status": "pending"
},
{
"id": "fc-wc-10",
"type": "step",
"label": "【审批】打不开:确认审批权限 + 联系审批管理员",
"status": "pending"
},
{
"id": "fc-wc-11",
"type": "step",
"label": "【文件】下载失败:检查存储空间 + 重新下载",
"status": "pending"
},
{
"id": "fc-wc-12",
"type": "step",
"label": "【通讯录】看不到新同事:引导同步通讯录",
"status": "pending"
}
]
}
},
{
"id": "fc-wc-13",
"type": "decision",
"label": "处理后是否解决?",
"yes_branch": {
"id": "fc-wc-14",
"type": "step",
"label": "回访 + 记录案例到知识库"
},
"no_branch": {
"id": "fc-wc-15",
"type": "step",
"label": "升级二线:企微企业管理员 / 厂商支持",
"children": [
{
"id": "fc-wc-16",
"type": "step",
"label": "提供工单截图 + 故障时间 + 员工 userid"
}
]
}
}
]
}
}
+65
View File
@@ -0,0 +1,65 @@
{
"name": "VPN / 远程办公故障排查",
"category": "vpn",
"description": "员工无法连接公司 VPN,或连接后访问内网失败,或频繁掉线",
"estimated_time": 8,
"difficulty": 2,
"tags": ["VPN", "远程办公", "aTrust", "网络"],
"root_node": {
"id": "fc-vpn-1",
"type": "step",
"label": "确认员工当前网络环境(在家/出差/咖啡厅)",
"status": "pending",
"children": [
{
"id": "fc-vpn-2",
"type": "decision",
"label": "VPN 客户端能否打开登录页?",
"yes_branch": {
"id": "fc-vpn-3",
"type": "step",
"label": "检查账号密码 + 二次认证",
"children": [
{
"id": "fc-vpn-4",
"type": "decision",
"label": "是否连接成功?",
"yes_branch": {
"id": "fc-vpn-5",
"type": "step",
"label": "回访确认可访问内网系统"
},
"no_branch": {
"id": "fc-vpn-6",
"type": "step",
"label": "清除 DNS 缓存 + 重连 aTrust"
}
}
]
},
"no_branch": {
"id": "fc-vpn-7",
"type": "step",
"label": "升级 VPN 客户端到最新版",
"children": [
{
"id": "fc-vpn-8",
"type": "decision",
"label": "重试能否登录?",
"yes_branch": {
"id": "fc-vpn-9",
"type": "step",
"label": "回访确认"
},
"no_branch": {
"id": "fc-vpn-10",
"type": "step",
"label": "升级二线:信息安全团队(提供 userid + 时间)"
}
}
]
}
}
]
}
}
+65
View File
@@ -0,0 +1,65 @@
{
"name": "企业邮箱故障排查",
"category": "email",
"description": "员工邮箱登录失败、收发异常、附件打不开、签名问题",
"estimated_time": 7,
"difficulty": 2,
"tags": ["邮箱", "Outlook", "Foxmail", "登录", "附件"],
"root_node": {
"id": "fc-email-1",
"type": "step",
"label": "确认邮箱客户端(Outlook/Foxmail/网页/手机)",
"status": "pending",
"children": [
{
"id": "fc-email-2",
"type": "decision",
"label": "能否登录网页邮箱?",
"yes_branch": {
"id": "fc-email-3",
"type": "step",
"label": "说明账号本身可用,问题在客户端",
"children": [
{
"id": "fc-email-4",
"type": "decision",
"label": "是否收不到新邮件?",
"yes_branch": {
"id": "fc-email-5",
"type": "step",
"label": "检查反垃圾设置 + 邮件规则 + 邮箱配额"
},
"no_branch": {
"id": "fc-email-6",
"type": "step",
"label": "检查 Outlook 缓存 + 重建索引 + 检查 PST 文件大小"
}
}
]
},
"no_branch": {
"id": "fc-email-7",
"type": "step",
"label": "检查账号是否锁定 + 密码是否过期",
"children": [
{
"id": "fc-email-8",
"type": "decision",
"label": "重置密码后能否登录?",
"yes_branch": {
"id": "fc-email-9",
"type": "step",
"label": "回访 + 通知修改其他系统密码"
},
"no_branch": {
"id": "fc-email-10",
"type": "step",
"label": "升级二线:邮件管理员(提供 userid + 错误截图)"
}
}
]
}
}
]
}
}
+89
View File
@@ -0,0 +1,89 @@
{
"name": "网络 / WiFi 故障排查",
"category": "network",
"description": "员工连不上公司 WiFi、有线网慢、IP 冲突、WiFi 认证失败、丢包",
"estimated_time": 10,
"difficulty": 2,
"tags": ["网络", "WiFi", "有线", "IP冲突", "丢包"],
"root_node": {
"id": "fc-net-1",
"type": "step",
"label": "确认故障范围(单个员工/同楼层/全公司)",
"status": "pending",
"children": [
{
"id": "fc-net-2",
"type": "decision",
"label": "影响范围多大?",
"yes_branch": {
"id": "fc-net-3",
"type": "step",
"label": "【全公司/楼层】立即升级二线:网络团队",
"children": [
{
"id": "fc-net-4",
"type": "step",
"label": "同时记录:故障时间 + 影响人数 + 现场照片"
}
]
},
"no_branch": {
"id": "fc-net-5",
"type": "step",
"label": "【单个员工】继续单点排查",
"children": [
{
"id": "fc-net-6",
"type": "decision",
"label": "有线网 or WiFi?",
"yes_branch": {
"id": "fc-net-7",
"type": "step",
"label": "检查网线 + 换端口 + 重新拨号",
"children": [
{
"id": "fc-net-8",
"type": "decision",
"label": "换端口能用?",
"yes_branch": {
"id": "fc-net-9",
"type": "step",
"label": "原端口硬件故障,工单报修"
},
"no_branch": {
"id": "fc-net-10",
"type": "step",
"label": "检查 IP 冲突:ipconfig /all + 释放续租"
}
}
]
},
"no_branch": {
"id": "fc-net-11",
"type": "step",
"label": "WiFi 排查:重连 + 忘记网络 + 检查 SSID",
"children": [
{
"id": "fc-net-12",
"type": "decision",
"label": "其他员工同位置能用 WiFi?",
"yes_branch": {
"id": "fc-net-13",
"type": "step",
"label": "员工设备问题:重装网卡驱动 + 升级系统"
},
"no_branch": {
"id": "fc-net-14",
"type": "step",
"label": "AP 信号弱:升级二线查 AP 部署"
}
}
]
}
}
]
}
}
]
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"name": "打印机 / 外设故障排查",
"category": "printer",
"description": "员工打印失败、卡纸、驱动问题、扫描仪、U盘识别",
"estimated_time": 6,
"difficulty": 1,
"tags": ["打印", "扫描", "U盘", "外设", "驱动"],
"root_node": {
"id": "fc-print-1",
"type": "step",
"label": "确认外设类型(打印/扫描/U盘/其他)",
"status": "pending",
"children": [
{
"id": "fc-print-2",
"type": "decision",
"label": "打印机型号?",
"yes_branch": {
"id": "fc-print-3",
"type": "step",
"label": "【打印】按故障现象分流:",
"children": [
{
"id": "fc-print-4",
"type": "step",
"label": "卡纸:打开盖板 + 按箭头方向抽纸 + 检查纸槽"
},
{
"id": "fc-print-5",
"type": "step",
"label": "脱机:重新添加打印机 + 检查网络(IP 直连 or 服务器共享)"
},
{
"id": "fc-print-6",
"type": "step",
"label": "驱动异常:卸载重装 + 选对型号 + 重启打印服务"
},
{
"id": "fc-print-7",
"type": "decision",
"label": "其他员工同打印机能用?",
"yes_branch": {
"id": "fc-print-8",
"type": "step",
"label": "员工电脑问题:换电脑测试确认"
},
"no_branch": {
"id": "fc-print-9",
"type": "step",
"label": "升级二线:硬件供应商(联系信息见公告)"
}
}
]
},
"no_branch": {
"id": "fc-print-10",
"type": "step",
"label": "【扫描仪/其他外设】:",
"children": [
{
"id": "fc-print-11",
"type": "step",
"label": "扫描仪:检查 USB 连接 + 重新装驱动 + 测试扫描"
},
{
"id": "fc-print-12",
"type": "step",
"label": "U盘:插入其他电脑测试 + 检查文件系统(ExFAT 兼容性)"
},
{
"id": "fc-print-13",
"type": "step",
"label": "其他外设:走通用流程(查线/换口/换电脑/重装驱动)"
}
]
}
}
]
}
}
+75
View File
@@ -0,0 +1,75 @@
{
"name": "软件 / 应用故障排查",
"category": "software",
"description": "员工软件装不上、闪退、license 过期、版本不兼容、Office/PS/财务软件等",
"estimated_time": 8,
"difficulty": 2,
"tags": ["软件", "Office", "安装", "闪退", "license", "财务"],
"root_node": {
"id": "fc-soft-1",
"type": "step",
"label": "确认软件名 + 版本(让员工截图)",
"status": "pending",
"children": [
{
"id": "fc-soft-2",
"type": "decision",
"label": "员工是否有管理员权限安装?",
"yes_branch": {
"id": "fc-soft-3",
"type": "step",
"label": "【管理员】继续自助排查:",
"children": [
{
"id": "fc-soft-4",
"type": "step",
"label": "装不上:检查系统版本兼容性 + 关杀毒软件 + 管理员运行"
},
{
"id": "fc-soft-5",
"type": "step",
"label": "闪退:看 Windows 事件日志 + 找 crash dump"
},
{
"id": "fc-soft-6",
"type": "step",
"label": "license 过期:走 IT 资产流程申请续期(申请单见知识库)"
}
]
},
"no_branch": {
"id": "fc-soft-7",
"type": "step",
"label": "【普通员工】坐席远程协助安装:",
"children": [
{
"id": "fc-soft-8",
"type": "step",
"label": "常用软件清单(从软件中心/SCCM):Office、Adobe、Foxmail、企微"
},
{
"id": "fc-soft-9",
"type": "step",
"label": "非常用软件:需走软件申请流程(部门主管审批 → IT 评估)"
},
{
"id": "fc-soft-10",
"type": "decision",
"label": "远程能否解决?",
"yes_branch": {
"id": "fc-soft-11",
"type": "step",
"label": "回访确认"
},
"no_branch": {
"id": "fc-soft-12",
"type": "step",
"label": "升级二线:对应软件负责人"
}
}
]
}
}
]
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"name": "硬件 / 桌面设备故障排查",
"category": "hardware",
"description": "员工显示器、键盘鼠标、耳机、视频会议摄像头、笔记本电池等",
"estimated_time": 10,
"difficulty": 2,
"tags": ["硬件", "显示器", "键盘", "鼠标", "耳机", "摄像头"],
"root_node": {
"id": "fc-hw-1",
"type": "step",
"label": "确认设备类型(显示器/键鼠/耳机/摄像头/其他)",
"status": "pending",
"children": [
{
"id": "fc-hw-2",
"type": "decision",
"label": "故障设备能换一台测试吗?",
"yes_branch": {
"id": "fc-hw-3",
"type": "step",
"label": "换设备测试,确认是设备本身问题:",
"children": [
{
"id": "fc-hw-4",
"type": "step",
"label": "【显示器】:换视频线(HDMI/DP/VGA) + 检查分辨率"
},
{
"id": "fc-hw-5",
"type": "step",
"label": "【键鼠】:换 USB 口 + 换电池 + 蓝牙重新配对"
},
{
"id": "fc-hw-6",
"type": "step",
"label": "【耳机/摄像头】:检查 USB/3.5mm + 隐私盖 + 系统权限"
},
{
"id": "fc-hw-7",
"type": "decision",
"label": "换设备后正常?",
"yes_branch": {
"id": "fc-hw-8",
"type": "step",
"label": "原设备故障:走 IT 资产报废/更换流程"
},
"no_branch": {
"id": "fc-hw-9",
"type": "step",
"label": "电脑端问题:检查驱动 + 系统设置"
}
}
]
},
"no_branch": {
"id": "fc-hw-10",
"type": "step",
"label": "【笔记本内嵌设备】:屏幕/键盘/电池/CPU 风扇",
"children": [
{
"id": "fc-hw-11",
"type": "step",
"label": "走送修流程(备份数据 → IT 出具送修单 → 厂商维修)"
},
{
"id": "fc-hw-12",
"type": "step",
"label": "需要备用机:走 IT 资产借用流程(最长 2 周)"
},
{
"id": "fc-hw-13",
"type": "step",
"label": "升级二线:硬件供应商(联系信息见公告)"
}
]
}
}
]
}
}
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
把 9 套排查流程图 JSON 合并到一个数组,输出 00-all.json(便于一次性 import)。
用法:python build_all.py
"""
import json
import glob
import os
import sys
from pathlib import Path
HERE = Path(__file__).parent
def main():
# 1. 找 9 个单文件(排除 00-all.json 和 build_all.py)
files = sorted(HERE.glob("[0-9][0-9]-*.json"))
if not files:
print("❌ 没找到任何 0X-*.json 文件")
sys.exit(1)
print(f"📦 找到 {len(files)} 个模板文件:")
for f in files:
print(f" - {f.name}")
# 2. 逐个读 + 校验
templates = []
for f in files:
try:
with open(f, "r", encoding="utf-8") as fp:
tpl = json.load(fp)
# 简单校验
for required in ("name", "category", "root_node"):
if required not in tpl:
raise ValueError(f"缺少必要字段: {required}")
templates.append(tpl)
print(f"{f.name}: {tpl['name']} ({len(json.dumps(tpl, ensure_ascii=False))} 字符)")
except Exception as e:
print(f"{f.name}: {e}")
sys.exit(1)
# 3. 输出汇总文件
out = HERE / "00-all.json"
with open(out, "w", encoding="utf-8") as fp:
json.dump(templates, fp, ensure_ascii=False, indent=2)
print(f"\n✅ 已生成 {out.name} (共 {len(templates)} 套)")
print(f"\n💡 接下来你可以:")
print(f" 1. 打开 {out.name} 预览 9 套完整内容")
print(f" 2. 在 Admin 后台的「排查流程图」页 → 「导入 JSON」选择此文件")
print(f" 3. 或调用后端 API:")
print(f" for tpl in templates: POST /api/troubleshooting-templates")
if __name__ == "__main__":
main()
Binary file not shown.
+1 -1
View File
@@ -1,4 +1,4 @@
# 企微IT智能服务台 — 服务器部署指南 # 企微智能IT支持服务台 — 服务器部署指南
> 目标服务器:`10.90.5.110`Linux > 目标服务器:`10.90.5.110`Linux
> 域名:`itsupport.servyou.com.cn` > 域名:`itsupport.servyou.com.cn`
+35 -29
View File
@@ -1,6 +1,6 @@
# IT智能服务台 — 新服务器部署手册 # 智能IT支持服务台 — 新服务器部署手册
> **目标服务器**`10.80.0.136`(公司内网) > **目标服务器**`10.90.5.110`(公司内网,**2026-06-15 起替代 10.80.0.136**
> **域名**`itsupport.servyou.com.cn` > **域名**`itsupport.servyou.com.cn`
> **访问方式**:通过堡垒机 `10.212.189.210:2222`(用户 `sxn`OTP 动态口令认证) > **访问方式**:通过堡垒机 `10.212.189.210:2222`(用户 `sxn`OTP 动态口令认证)
> **Docker**:已安装 > **Docker**:已安装
@@ -12,7 +12,7 @@
| 条件 | 状态 | 验证命令 | | 条件 | 状态 | 验证命令 |
|------|------|---------| |------|------|---------|
| Linux 服务器 10.80.0.136 | ✅ 已确认 | | | Linux 服务器 10.90.5.110(替代旧 10.80.0.136) | ✅ 已确认 | 2026-06-15 起使用 |
| Docker 已安装 | ✅ 已确认 | `docker --version` | | Docker 已安装 | ✅ 已确认 | `docker --version` |
| Docker Compose V2 | 待确认 | `docker compose version` | | Docker Compose V2 | 待确认 | `docker compose version` |
| 端口 80 未被占用 | 待确认 | `ss -tlnp \| grep :80` | | 端口 80 未被占用 | 待确认 | `ss -tlnp \| grep :80` |
@@ -29,17 +29,22 @@
### 2.2 连接方式 ### 2.2 连接方式
**PuTTY 客户端(用户实际使用)**:
- 打开 PuTTY
- Host Name(IP 地址):`10.212.189.210`
- Port:`2222`
- Connection type:SSH
- Saved Sessions:起名(如 `wecom-bastion`)→ Save
- 点 Open
- 用户 `sxn` + 密码
- **堡垒机内再跳目标机**:
```bash ```bash
# 方式一:ssh -J 一步跳转(推荐) ssh sxn@10.90.5.110
# -J 指定跳板机,ssh 会自动帮你跳转 ```
# 堡垒机端口 2222,需要输入 OTP 动态口令
ssh -J sxn@10.212.189.210:2222 sxn@10.80.0.136
# 方式二:先登录堡垒机,再手动跳转 > **OpenSSH `ssh -J` 方式不再使用**(用户已确认用 PuTTY,2026-06-15)
ssh -p 2222 sxn@10.212.189.210
# 输入 OTP 动态口令
# 登录成功后: # 登录成功后:
ssh sxn@10.80.0.136 ssh sxn@10.90.5.110
``` ```
### 2.3 配置 SSH 快捷方式(推荐) ### 2.3 配置 SSH 快捷方式(推荐)
@@ -53,9 +58,9 @@ Host bastion
Port 2222 Port 2222
User sxn User sxn
# IT智能服务台服务器 # 智能IT支持服务台服务器
Host itdesk Host itdesk
HostName 10.80.0.136 HostName 10.90.5.110
User sxn User sxn
ProxyJump bastion ProxyJump bastion
``` ```
@@ -78,7 +83,7 @@ scp file itdesk:/opt/ # 文件传输也会自动走堡垒机
# 上传单个文件 # 上传单个文件
scp -o "ProxyJump=sxn@10.212.189.210:2222" \ scp -o "ProxyJump=sxn@10.212.189.210:2222" \
it-smart-desk-server-deploy.zip \ it-smart-desk-server-deploy.zip \
sxn@10.80.0.136:/opt/ sxn@10.90.5.110:/opt/
# 如果已配置 ~/.ssh/config # 如果已配置 ~/.ssh/config
scp it-smart-desk-server-deploy.zip itdesk:/opt/ scp it-smart-desk-server-deploy.zip itdesk:/opt/
@@ -96,7 +101,7 @@ scp -P 2222 it-smart-desk-server-deploy.zip sxn@10.212.189.210:/tmp/
ssh -p 2222 sxn@10.212.189.210 ssh -p 2222 sxn@10.212.189.210
# 步骤3:从堡垒机传到目标服务器 # 步骤3:从堡垒机传到目标服务器
scp /tmp/it-smart-desk-server-deploy.zip sxn@10.80.0.136:/opt/ scp /tmp/it-smart-desk-server-deploy.zip sxn@10.90.5.110:/opt/
``` ```
--- ---
@@ -133,17 +138,18 @@ npm install && npm run build
# 在开发机上执行 # 在开发机上执行
scp -o "ProxyJump=sxn@10.212.189.210:2222" \ scp -o "ProxyJump=sxn@10.212.189.210:2222" \
it-smart-desk-server-deploy.zip \ it-smart-desk-server-deploy.zip \
sxn@10.80.0.136:/tmp/ sxn@10.90.5.110:/tmp/
``` ```
> 上传到 `/tmp/` 而非 `/opt/`,因为普通用户对 `/opt/` 没有写权限 > 上传到 `/tmp/` 而非 `/opt/`,因为普通用户对 `/opt/` 没有写权限
### 步骤 3SSH 登录服务器并解压 ### 步骤 3:登录服务器并解压
**PuTTY 登录**(见 §2.2):
- Host:`10.212.189.210`,Port:`2222`,SSH
- 堡垒机内再 `ssh sxn@10.90.5.110`
```bash ```bash
# 登录目标服务器
ssh -J sxn@10.212.189.210:2222 sxn@10.80.0.136
# 切换 root(普通用户对 /opt 无写权限) # 切换 root(普通用户对 /opt 无写权限)
sudo -i sudo -i
@@ -237,7 +243,7 @@ docker compose logs --tail 50 postgres
需要联系公司 IT 运维,在公司 DNS 上添加 A 记录: 需要联系公司 IT 运维,在公司 DNS 上添加 A 记录:
``` ```
itsupport.servyou.com.cn A 10.80.0.136 itsupport.servyou.com.cn A 10.90.5.110
``` ```
**DNS 未生效前**,可以通过本地 hosts 文件测试: **DNS 未生效前**,可以通过本地 hosts 文件测试:
@@ -246,7 +252,7 @@ itsupport.servyou.com.cn A 10.80.0.136
# Windows: C:\Windows\System32\drivers\etc\hosts # Windows: C:\Windows\System32\drivers\etc\hosts
# macOS/Linux: /etc/hosts # macOS/Linux: /etc/hosts
# 添加一行: # 添加一行:
10.80.0.136 itsupport.servyou.com.cn 10.90.5.110 itsupport.servyou.com.cn
``` ```
> 注意:修改 hosts 文件后,浏览器可能有 DNS 缓存。Chrome 可访问 `chrome://net-internals/#dns` 清除缓存,或用无痕窗口测试。 > 注意:修改 hosts 文件后,浏览器可能有 DNS 缓存。Chrome 可访问 `chrome://net-internals/#dns` 清除缓存,或用无痕窗口测试。
@@ -324,11 +330,11 @@ cd frontend-agent && npm run build
# 2. 上传到服务器(通过堡垒机) # 2. 上传到服务器(通过堡垒机)
scp -o "ProxyJump=sxn@10.212.189.210:2222" \ scp -o "ProxyJump=sxn@10.212.189.210:2222" \
-r frontend-h5/dist/ \ -r frontend-h5/dist/ \
sxn@10.80.0.136:/opt/wecom-it-desk/frontend-h5/dist/ sxn@10.90.5.110:/opt/wecom-it-desk/frontend-h5/dist/
scp -o "ProxyJump=sxn@10.212.189.210:2222" \ scp -o "ProxyJump=sxn@10.212.189.210:2222" \
-r frontend-agent/dist/ \ -r frontend-agent/dist/ \
sxn@10.80.0.136:/opt/wecom-it-desk/frontend-agent/dist/ sxn@10.90.5.110:/opt/wecom-it-desk/frontend-agent/dist/
# 3. 重载 Nginx(不需要重启整个服务) # 3. 重载 Nginx(不需要重启整个服务)
ssh itdesk # 如果已配置 SSH 快捷方式 ssh itdesk # 如果已配置 SSH 快捷方式
@@ -344,7 +350,7 @@ docker exec wecom_it_nginx nginx -s reload
# 1. 上传新代码到服务器 # 1. 上传新代码到服务器
scp -o "ProxyJump=sxn@10.212.189.210:2222" \ scp -o "ProxyJump=sxn@10.212.189.210:2222" \
-r backend/ \ -r backend/ \
sxn@10.80.0.136:/opt/wecom-it-desk/backend/ sxn@10.90.5.110:/opt/wecom-it-desk/backend/
# 2. 重新构建并启动 # 2. 重新构建并启动
ssh itdesk ssh itdesk
@@ -400,8 +406,8 @@ docker exec wecom_it_nginx cat /usr/share/nginx/html/itagent/index.html | grep /
nslookup itsupport.servyou.com.cn nslookup itsupport.servyou.com.cn
# 如果 DNS 未配置,临时用 IP 直接访问 # 如果 DNS 未配置,临时用 IP 直接访问
curl http://10.80.0.136/itdesk/ curl http://10.90.5.110/itdesk/
curl http://10.80.0.136/api/health curl http://10.90.5.110/api/health
``` ```
### Mock 登录返回 401 ### Mock 登录返回 401
@@ -428,7 +434,7 @@ curl -X POST http://localhost/api/h5/mock-login \
### 方式一:公司统一 SSL 终端(推荐) ### 方式一:公司统一 SSL 终端(推荐)
``` ```
客户端 → HTTPS → 公司SSL终端(F5/网关) → HTTP → 10.80.0.136:80 客户端 → HTTPS → 公司SSL终端(F5/网关,公网 115.236.188.3) → HTTP → 10.90.5.110:80
``` ```
不需要在本服务器上配置证书。联系运维配置 SSL 终端即可。 不需要在本服务器上配置证书。联系运维配置 SSL 终端即可。
@@ -441,7 +447,7 @@ curl -X POST http://localhost/api/h5/mock-login \
## 十一、与 NAS 部署的差异 ## 十一、与 NAS 部署的差异
| 维度 | NAS 部署(10.80.0.136 | 新服务器部署(10.80.0.136 新 | | 维度 | NAS 部署(10.80.0.136,已下线 | 新服务器部署(10.90.5.110,2026-06-15 起 |
|------|---------------------------|-------------------------------| |------|---------------------------|-------------------------------|
| 容器数量 | 5个(含 cloudflared | 4个(无 cloudflared | | 容器数量 | 5个(含 cloudflared | 4个(无 cloudflared |
| 外网访问 | Cloudflare Tunnel | 公司 DNS 直连 | | 外网访问 | Cloudflare Tunnel | 公司 DNS 直连 |
+2 -2
View File
@@ -1,5 +1,5 @@
# ============================================================================= # =============================================================================
# 企微IT智能服务台 — 打包 + 构建后端镜像 + 部署脚本 # 企微智能IT支持服务台 — 打包 + 构建后端镜像 + 部署脚本
# ============================================================================= # =============================================================================
# 功能: # 功能:
# 1. 打包前端构建产物 + nginx配置 + docker-compose.yml + .env # 1. 打包前端构建产物 + nginx配置 + docker-compose.yml + .env
@@ -51,7 +51,7 @@ function Write-Error {
Write-Host "" Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 企微IT智能服务台 — 打包部署自动化" -ForegroundColor Cyan Write-Host " 企微智能IT支持服务台 — 打包部署自动化" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 模式:$Mode" -ForegroundColor White Write-Host " 模式:$Mode" -ForegroundColor White
Write-Host "" Write-Host ""
+2 -2
View File
@@ -1,5 +1,5 @@
# ============================================================================= # =============================================================================
# 企微IT智能服务台 — 打包部署脚本 # 企微智能IT支持服务台 — 打包部署脚本
# ============================================================================= # =============================================================================
# 功能:将所有部署所需文件打包成一个 zip 文件 # 功能:将所有部署所需文件打包成一个 zip 文件
# 用法:在 PowerShell 中运行此脚本 # 用法:在 PowerShell 中运行此脚本
@@ -19,7 +19,7 @@ $packageDir = "$deployDir\_package"
$zipFile = "$deployDir\it-smart-desk-server-deploy.zip" $zipFile = "$deployDir\it-smart-desk-server-deploy.zip"
Write-Host "========================================" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 企微IT智能服务台 — 打包部署文件" -ForegroundColor Cyan Write-Host " 企微智能IT支持服务台 — 打包部署文件" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan
Write-Host "" Write-Host ""
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# ============================================================================= # =============================================================================
# IT智能服务台 — RAGFlow 集成部署脚本 # 智能IT支持服务台 — RAGFlow 集成部署脚本
# 目标服务器:10.90.5.110 # 目标服务器:10.90.5.110
# 部署路径:/opt/wecom-it-desk # 部署路径:/opt/wecom-it-desk
# ============================================================================= # =============================================================================
@@ -11,7 +11,7 @@ DEPLOY_DIR="/opt/wecom-it-desk"
BACKUP_DIR="/opt/wecom-it-desk-backup-$(date +%Y%m%d_%H%M%S)" BACKUP_DIR="/opt/wecom-it-desk-backup-$(date +%Y%m%d_%H%M%S)"
echo "==========================================" echo "=========================================="
echo "IT智能服务台 — RAGFlow 集成部署" echo "智能IT支持服务台 — RAGFlow 集成部署"
echo "时间: $(date)" echo "时间: $(date)"
echo "==========================================" echo "=========================================="
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# ============================================================================= # =============================================================================
# IT智能服务台 — 生产部署脚本 # 智能IT支持服务台 — 生产部署脚本
# 目标服务器:10.90.5.110 # 目标服务器:10.90.5.110
# 部署路径:/opt/wecom-it-desk # 部署路径:/opt/wecom-it-desk
# ============================================================================= # =============================================================================
@@ -11,7 +11,7 @@ DEPLOY_DIR="/opt/wecom-it-desk"
BACKUP_DIR="/opt/wecom-it-desk-backup-$(date +%Y%m%d_%H%M%S)" BACKUP_DIR="/opt/wecom-it-desk-backup-$(date +%Y%m%d_%H%M%S)"
echo "==========================================" echo "=========================================="
echo "IT智能服务台 生产部署" echo "智能IT支持服务台 生产部署"
echo "时间: $(date)" echo "时间: $(date)"
echo "==========================================" echo "=========================================="
+1 -1
View File
@@ -1,5 +1,5 @@
# ============================================================================= # =============================================================================
# 企微IT智能服务台 — Docker Compose(公司内网服务器版) # 企微智能IT支持服务台 — Docker Compose(公司内网服务器版)
# ============================================================================= # =============================================================================
# 目标服务器:10.90.5.110 # 目标服务器:10.90.5.110
# 域名:itsupport.servyou.com.cn # 域名:itsupport.servyou.com.cn
+18 -1
View File
@@ -1,5 +1,5 @@
# ============================================================================= # =============================================================================
# 企微IT智能服务台 — Nginx 配置(公司内网服务器版 + HTTPS) # 企微智能IT支持服务台 — Nginx 配置(公司内网服务器版 + HTTPS)
# ============================================================================= # =============================================================================
# 目标服务器:10.90.5.110 # 目标服务器:10.90.5.110
# 域名:itsupport.servyou.com.cn # 域名:itsupport.servyou.com.cn
@@ -47,6 +47,23 @@ http {
application/javascript application/xml+rss application/javascript application/xml+rss
application/json application/ld+json; application/json application/ld+json;
# ------------------------------------------------------------------
# 安全响应头
# ------------------------------------------------------------------
# 隐藏 nginx 版本号
server_tokens off;
# 基础安全头(应用到所有响应)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "0" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
# API 路径特殊处理(不加 CSP,只加基础安全头)
# 前端路径的 CSP 在各前端 index.html 中单独配置
# ================================================================= # =================================================================
# 上游服务定义(Docker 内部网络) # 上游服务定义(Docker 内部网络)
# ================================================================= # =================================================================
+61 -24
View File
@@ -1,5 +1,5 @@
# ============================================================================= # =============================================================================
# 企微IT智能服务台 — Nginx 配置(公司内网服务器版) # 企微智能IT支持服务台 — Nginx 配置(公司内网服务器版)
# ============================================================================= # =============================================================================
# 适用场景:独立域名 itsupport.servyou.com.cn,公司内网 DNS 解析 # 适用场景:独立域名 itsupport.servyou.com.cn,公司内网 DNS 解析
# 与 NAS 版的区别: # 与 NAS 版的区别:
@@ -27,6 +27,21 @@ http {
access_log /var/log/nginx/access.log main; access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn; error_log /var/log/nginx/error.log warn;
# ------------------------------------------------------------------
# 真实 IP 还原(2026-06-15 v0.5.1 修复)
# ------------------------------------------------------------------
# 问题:公司有 WAF/堡垒机/反向代理,nginx 看到的 $remote_addr
# 是代理 IP(不在白名单),allow/deny 因此误判 403
# 修法:信任内网段代理透传的 X-Forwarded-For 头,用真实 IP 做白名单
# 注意:set_real_ip_from 是"我信任的代理",不是"我允许的客户端"
# 必须精确,否则攻击者可伪造 X-Forwarded-For 绕过白名单
set_real_ip_from 10.0.0.0/8; # 内网 A 类(代理/WAF 出口)
set_real_ip_from 172.16.0.0/12; # 内网 B 类
set_real_ip_from 192.168.0.0/16; # 内网 C 类
set_real_ip_from 10.212.0.0/16; # VPN 网段
real_ip_header X-Forwarded-For; # 从 X-Forwarded-For 取最后一个非信任 IP
real_ip_recursive on; # 递归剥离已信任代理 IP
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 基础配置 # 基础配置
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -60,20 +75,61 @@ http {
# 如果公司有统一 SSL 终端(如 F5/Nginx 反代),此服务器只需监听 80 # 如果公司有统一 SSL 终端(如 F5/Nginx 反代),此服务器只需监听 80
# 如果需要本机 HTTPS,取消下方 server 块注释,并配置证书路径 # 如果需要本机 HTTPS,取消下方 server 块注释,并配置证书路径
# ================================================================= # =================================================================
# HTTP — 80 端口强制 301 跳 HTTPS
# =================================================================
server { server {
listen 80; listen 80;
server_name itsupport.servyou.com.cn; server_name itsupport.servyou.com.cn;
# ACME http-01 验证用(如果以后用 Let's Encrypt
location /.well-known/acme-challenge/ {
root /usr/share/nginx/html;
}
# 其他全部 301 跳 https
location / {
return 301 https://$host$request_uri;
}
}
# =================================================================
# HTTPS — 443 端口(主服务)
# =================================================================
server {
listen 443 ssl;
http2 on;
server_name itsupport.servyou.com.cn;
# SSL 证书(通配符 *.servyou.com.cn,fullchain 含 leaf+intermediate+root)
ssl_certificate /etc/nginx/ssl/itsupport.servyou.com.cn.crt;
ssl_certificate_key /etc/nginx/ssl/itsupport.servyou.com.cn.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 安全头 # 安全头
# ------------------------------------------------------------------ # ------------------------------------------------------------------
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# CSP 收紧: 去掉 unsafe-inline(生产不需要,只有 dev HMR 需要)
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval' https://res.wx.qq.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; connect-src 'self' https://qyapi.weixin.qq.com wss://*; font-src 'self' data:;" always;
# 隐私与跨域控制
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
# 隐藏服务器版本
server_tokens off;
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 健康检查端点 # 健康检查端点
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -138,7 +194,7 @@ http {
allow 10.212.0.0/16; allow 10.212.0.0/16;
deny all; deny all;
proxy_pass http://backend_api/; proxy_pass http://backend_api;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -183,29 +239,10 @@ http {
# 此路径已包含在 /api/ 的代理规则中,无需单独配置 # 此路径已包含在 /api/ 的代理规则中,无需单独配置
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 默认路径 — 重定向到 H5 员工端 # 默认路径 — 重定向到统一入口
# ------------------------------------------------------------------ # ------------------------------------------------------------------
location = / { location = / {
return 302 /itdesk/; return 302 /itportal/;
} }
} }
# =================================================================
# HTTPS 配置(按需启用)
# =================================================================
# 如果需要本机直接提供 HTTPS(不走公司统一 SSL 终端),
# 取消下方注释并配置 SSL 证书路径
#
# server {
# listen 443 ssl;
# server_name itsupport.servyou.com.cn;
#
# ssl_certificate /etc/nginx/ssl/itsupport.servyou.com.cn.crt;
# ssl_certificate_key /etc/nginx/ssl/itsupport.servyou.com.cn.key;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
#
# # 其余 location 配置与上方 HTTP server 相同
# ...
# }
} }
+2 -2
View File
@@ -1,11 +1,11 @@
@echo off @echo off
REM ============================================================================= REM =============================================================================
REM IT智能服务台 — 打包部署脚本(Windows) REM 智能IT支持服务台 — 打包部署脚本(Windows)
REM 目标:生成部署包,通过堡垒机上传到服务器 REM 目标:生成部署包,通过堡垒机上传到服务器
REM ============================================================================= REM =============================================================================
echo ========================================== echo ==========================================
echo IT智能服务台 部署包打包 echo 智能IT支持服务台 部署包打包
echo 时间: %date% %time% echo 时间: %date% %time%
echo ========================================== echo ==========================================
+40 -9
View File
@@ -1,5 +1,5 @@
""" """
企微IT智能服务台 — 部署包生成脚本(Windows 兼容版) 企微智能IT支持服务台 — 部署包生成脚本(Windows 兼容版)
======================================================= =======================================================
功能: 功能:
1. 构建前端(H5 + 坐席端) 1. 构建前端(H5 + 坐席端)
@@ -40,6 +40,8 @@ INCLUDE_MAP = {
"deploy-server/nginx/nginx.conf": f"{PACKAGE_PREFIX}/nginx/nginx.conf", "deploy-server/nginx/nginx.conf": f"{PACKAGE_PREFIX}/nginx/nginx.conf",
"frontend-h5/dist": f"{PACKAGE_PREFIX}/frontend-h5/dist", "frontend-h5/dist": f"{PACKAGE_PREFIX}/frontend-h5/dist",
"frontend-agent/dist": f"{PACKAGE_PREFIX}/frontend-agent/dist", "frontend-agent/dist": f"{PACKAGE_PREFIX}/frontend-agent/dist",
"frontend-portal/dist": f"{PACKAGE_PREFIX}/frontend-portal/dist",
"frontend-admin/dist": f"{PACKAGE_PREFIX}/frontend-admin/dist",
"backend": f"{PACKAGE_PREFIX}/backend", "backend": f"{PACKAGE_PREFIX}/backend",
} }
@@ -75,6 +77,9 @@ def run_cmd(cmd: str, cwd: Path | None = None) -> bool:
def should_exclude(path: Path) -> bool: def should_exclude(path: Path) -> bool:
"""判断文件/目录是否应排除""" """判断文件/目录是否应排除"""
# 路径中任何一段是 uploads 目录就排除(隐私 P0:真实用户上传文件不进部署包)
if "uploads" in path.parts:
return True
name = path.name name = path.name
if name in {"__pycache__", ".pytest_cache", ".venv", "venv", ".git", ".env", "node_modules"}: if name in {"__pycache__", ".pytest_cache", ".venv", "venv", ".git", ".env", "node_modules"}:
return True return True
@@ -121,6 +126,32 @@ def build_frontends():
sys.exit(1) sys.exit(1)
print(" ✅ 坐席工作台构建完成") print(" ✅ 坐席工作台构建完成")
# 统一入口 Portal
portal_dir = PROJECT_ROOT / "frontend-portal"
if (portal_dir / "package.json").exists():
print("构建统一入口 Portal...")
if not run_cmd("npm install --quiet", cwd=portal_dir):
print(" ⚠ npm install 失败,尝试继续...")
if not run_cmd("npm run build", cwd=portal_dir):
print(" ❌ Portal 端构建失败!")
sys.exit(1)
print(" ✅ Portal 端构建完成")
else:
print(" ⏭ Portal 端未实现,跳过")
# 管理后台 Admin
admin_dir = PROJECT_ROOT / "frontend-admin"
if (admin_dir / "package.json").exists():
print("构建管理后台 Admin...")
if not run_cmd("npm install --quiet", cwd=admin_dir):
print(" ⚠ npm install 失败,尝试继续...")
if not run_cmd("npm run build", cwd=admin_dir):
print(" ❌ Admin 端构建失败!")
sys.exit(1)
print(" ✅ Admin 端构建完成")
else:
print(" ⏭ Admin 端未实现,跳过")
def create_package(): def create_package():
"""创建部署包 zip""" """创建部署包 zip"""
@@ -163,7 +194,7 @@ def create_package():
def main(): def main():
print("=" * 50) print("=" * 50)
print(" IT智能服务台 — 部署包生成") print(" 智能IT支持服务台 — 部署包生成")
print("=" * 50) print("=" * 50)
# 检查是否跳过构建 # 检查是否跳过构建
@@ -181,13 +212,13 @@ def main():
print(" 后续步骤:") print(" 后续步骤:")
print("=" * 50) print("=" * 50)
print(f""" print(f"""
1. 上传部署包到服务器(通过堡垒机): 1. 上传部署包到服务器(通过堡垒机 / PuTTY PSCP):
scp -o "ProxyJump=sxn@10.212.189.210:2222" \\ pscp -load wecom-bastion {ZIP_FILENAME} sxn@10.90.5.110:/tmp/
{ZIP_FILENAME} \\ # 或堡垒机内 scp:
sxn@10.80.0.136:/tmp/ # scp {ZIP_FILENAME} sxn@10.90.5.110:/tmp/
2. SSH 登录服务器(通过堡垒机) 2. PuTTY 登录服务器:
ssh -J sxn@10.212.189.210:2222 sxn@10.80.0.136 - Host 10.212.189.210 Port 2222 → 用户 sxn → 堡垒机内 ssh sxn@10.90.5.110
3. 在服务器上执行: 3. 在服务器上执行:
sudo cp /tmp/{ZIP_FILENAME} /opt/ sudo cp /tmp/{ZIP_FILENAME} /opt/
@@ -201,7 +232,7 @@ def main():
./deploy.sh ./deploy.sh
4. 配置 DNS(联系 IT 运维): 4. 配置 DNS(联系 IT 运维):
itsupport.servyou.com.cn → 10.80.0.136 itsupport.servyou.com.cn → 10.90.5.110
5. 浏览器验证: 5. 浏览器验证:
http://itsupport.servyou.com.cn/itdesk/ http://itsupport.servyou.com.cn/itdesk/
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# ============================================================================= # =============================================================================
# 企微IT智能服务台 — 部署包生成脚本(在开发机上运行) # 企微智能IT支持服务台 — 部署包生成脚本(在开发机上运行)
# ============================================================================= # =============================================================================
# 功能: # 功能:
# 1. 构建前端(H5 + 坐席端) # 1. 构建前端(H5 + 坐席端)
@@ -28,7 +28,7 @@ PACKAGE_NAME="it-smart-desk-server-deploy"
BUILD_DIR="/tmp/$PACKAGE_NAME" BUILD_DIR="/tmp/$PACKAGE_NAME"
echo -e "${GREEN}============================================${NC}" echo -e "${GREEN}============================================${NC}"
echo -e "${GREEN} IT智能服务台 — 部署包生成${NC}" echo -e "${GREEN} 智能IT支持服务台 — 部署包生成${NC}"
echo -e "${GREEN}============================================${NC}" echo -e "${GREEN}============================================${NC}"
# --- 1. 构建前端 --- # --- 1. 构建前端 ---
+2 -2
View File
@@ -1,6 +1,6 @@
@echo off @echo off
REM ============================================================================= REM =============================================================================
REM 企微IT智能服务台 — 打包部署一键执行 REM 企微智能IT支持服务台 — 打包部署一键执行
REM ============================================================================= REM =============================================================================
REM 功能: REM 功能:
REM 1. 打包前端构建产物 + nginx配置 + docker-compose.yml + .env REM 1. 打包前端构建产物 + nginx配置 + docker-compose.yml + .env
@@ -20,7 +20,7 @@ if "%MODE%"=="" set MODE=local
echo. echo.
echo ======================================== echo ========================================
echo 企微IT智能服务台 — 打包部署 echo 企微智能IT支持服务台 — 打包部署
echo ======================================== echo ========================================
echo 模式: %MODE% echo 模式: %MODE%
echo. echo.
+1
View File
@@ -0,0 +1 @@
IyEvYmluL2Jhc2gKIyA9PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PQojIC9pdGRlc2svIDUwMCDplJnor6/or4rmlq3ohJrmnKwKIyDlnKjnlJ/kuqfmnI3liqHlmaggMTAuODAuMC4xMzYg5LiK6LeRKFNTSCDnmbvlvZXlkI4pOgojICAgY2QgL29wdC93ZWNvbS1pdC1kZXNrCiMgICBiYXNoIGRpYWdub3NlLTUwMC5zaCA+IC90bXAvZGlhZy5sb2cgMj4mMQojICAgY2F0IC90bXAvZGlhZy5sb2cKIyA9PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PQoKZWNobyAiPT09PT09PT09PSAxLiDlrrnlmajnirbmgIEgPT09PT09PT09PSIKZG9ja2VyIGNvbXBvc2UgcHMKCmVjaG8gIiIKZWNobyAiPT09PT09PT09PSAyLiAvb3B0L3dlY29tLWl0LWRlc2sg55uu5b2V57uT5p6EID09PT09PT09PT0iCmxzIC1sYSAvb3B0L3dlY29tLWl0LWRlc2svIDI+JjEgfCBoZWFkIC0yMAplY2hvICItLS0gZnJvbnRlbmQtaDUvZGlzdCAtLS0iCmxzIC1sYSAvb3B0L3dlY29tLWl0LWRlc2svZnJvbnRlbmQtaDUvZGlzdC8gMj4mMSB8IGhlYWQgLTEwCmVjaG8gIi0tLSBmcm9udGVuZC1oNS9kaXN0L2Fzc2V0cyAtLS0iCmxzIC1sYSAvb3B0L3dlY29tLWl0LWRlc2svZnJvbnRlbmQtaDUvZGlzdC9hc3NldHMvIDI+JjEgfCBoZWFkIC0xMAplY2hvICItLS0gZnJvbnRlbmQtYWdlbnQvZGlzdC9hc3NldHMgLS0tIgpscyAtbGEgL29wdC93ZWNvbS1pdC1kZXNrL2Zyb250ZW5kLWFnZW50L2Rpc3QvYXNzZXRzLyAyPiYxIHwgaGVhZCAtMTAKZWNobyAiLS0tIGZyb250ZW5kLXBvcnRhbC9kaXN0L2Fzc2V0cyAtLS0iCmxzIC1sYSAvb3B0L3dlY29tLWl0LWRlc2svZnJvbnRlbmQtcG9ydGFsL2Rpc3QvYXNzZXRzLyAyPiYxIHwgaGVhZCAtMTAKZWNobyAiLS0tIGZyb250ZW5kLWFkbWluL2Rpc3QvYXNzZXRzIC0tLSIKbHMgLWxhIC9vcHQvd2Vjb20taXQtZGVzay9mcm9udGVuZC1hZG1pbi9kaXN0L2Fzc2V0cy8gMj4mMSB8IGhlYWQgLTEwCgplY2hvICIiCmVjaG8gIj09PT09PT09PT0gMy4gbmdpbngg5a655Zmo5YaF5paH5Lu25qOA5p+lID09PT09PT09PT0iCmRvY2tlciBjb21wb3NlIGV4ZWMgbmdpbnggbHMgLWxhIC91c3Ivc2hhcmUvbmdpbngvaHRtbC8gMj4mMSB8IGhlYWQgLTIwCmVjaG8gIi0tLSAvdXNyL3NoYXJlL25naW54L2h0bWwvaXRkZXNrIC0tLSIKZG9ja2VyIGNvbXBvc2UgZXhlYyBuZ2lueCBscyAtbGEgL3Vzci9zaGFyZS9uZ2lueC9odG1sL2l0ZGVzay8gMj4mMSB8IGhlYWQgLTEwCmVjaG8gIi0tLSAvdXNyL3NoYXJlL25naW54L2h0bWwvaXRkZXNrL2Fzc2V0cyAtLS0iCmRvY2tlciBjb21wb3NlIGV4ZWMgbmdpbnggbHMgLWxhIC91c3Ivc2hhcmUvbmdpbngvaHRtbC9pdGRlc2svYXNzZXRzLyAyPiYxIHwgaGVhZCAtMTAKZWNobyAiLS0tIC91c3Ivc2hhcmUvbmdpbngvc3NsLyAtLS0iCmRvY2tlciBjb21wb3NlIGV4ZWMgbmdpbnggbHMgLWxhIC9ldGMvbmdpbngvc3NsLyAyPiYxIHwgaGVhZCAtMTAKCmVjaG8gIiIKZWNobyAiPT09PT09PT09PSA0LiBuZ2lueCDphY3nva7lrp7pmYXnlJ/mlYjniYjmnKwo5aS06YOoIDUwIOihjCk9PT09PT09PT09Igpkb2NrZXIgY29tcG9zZSBleGVjIG5naW54IGNhdCAvZXRjL25naW54L25naW54LmNvbmYgMj4mMSB8IGhlYWQgLTUwCgplY2hvICIiCmVjaG8gIj09PT09PT09PT0gNS4gbmdpbngg5a655Zmo56uv5Y+j55uR5ZCsID09PT09PT09PT0iCmRvY2tlciBjb21wb3NlIGV4ZWMgbmdpbnggbmV0c3RhdCAtdGxucCAyPiYxIHwgaGVhZCAtMTAKZWNobyAiKOayoSBuZXRzdGF0IOeUqCBzczopIgpkb2NrZXIgY29tcG9zZSBleGVjIG5naW54IHNzIC10bG5wIDI+JjEgfCBoZWFkIC0xMAoKZWNobyAiIgplY2hvICI9PT09PT09PT09IDYuIOebtOaOpSBjdXJsIOa1i+ivleWQhOi3r+W+hCA9PT09PT09PT09IgplY2hvICItLS0gL2l0ZGVzay8gKOWuueWZqOWGhSkgLS0tIgpkb2NrZXIgY29tcG9zZSBleGVjIG5naW54IGN1cmwgLWtzSSBodHRwczovL2xvY2FsaG9zdC9pdGRlc2svIDI+JjEgfCBoZWFkIC0yMAplY2hvICItLS0gL2l0ZGVzay8gKOWuueWZqOWkluS4u+acuiA0NDMpIC0tLSIKY3VybCAta3NJIGh0dHBzOi8vbG9jYWxob3N0OjQ0My9pdGRlc2svIDI+JjEgfCBoZWFkIC0yMAplY2hvICItLS0gL2l0cG9ydGFsLyAtLS0iCmN1cmwgLWtzSSBodHRwczovL2xvY2FsaG9zdDo0NDMvaXRwb3J0YWwvIDI+JjEgfCBoZWFkIC0yMAplY2hvICItLS0gL2l0ZGVzay9hc3NldHMvICjmjqIgNDA0KSAtLS0iCmN1cmwgLWtzSSBodHRwczovL2xvY2FsaG9zdDo0NDMvaXRkZXNrL2Fzc2V0cy8gMj4mMSB8IGhlYWQgLTIwCgplY2hvICIiCmVjaG8gIj09PT09PT09PT0gNy4g5Li75py65a6e6ZmFIFVSTCDln5/lkI0gPT09PT09PT09PSIKY3VybCAta3NJIGh0dHBzOi8vaXRzdXBwb3J0LnNlcnZ5b3UuY29tLmNuL2l0ZGVzay8gMj4mMSB8IGhlYWQgLTIwCmVjaG8gIi0tLSIKY3VybCAta3NJIGh0dHBzOi8vaXRzdXBwb3J0LnNlcnZ5b3UuY29tLmNuL2l0cG9ydGFsLyAyPiYxIHwgaGVhZCAtMjAKZWNobyAiLS0tIgpjdXJsIC1rc0kgaHR0cHM6Ly9pdHN1cHBvcnQuc2VydnlvdS5jb20uY24vaXRhZ2VudC8gMj4mMSB8IGhlYWQgLTIwCmVjaG8gIi0tLSIKY3VybCAta3NJIGh0dHBzOi8vaXRzdXBwb3J0LnNlcnZ5b3UuY29tLmNuL2l0YWRtaW4vIDI+JjEgfCBoZWFkIC0yMAoKZWNobyAiIgplY2hvICI9PT09PT09PT09IDguIG5naW54IGFjY2VzcyBsb2cg5pyA6L+RIDMwIOihjCjmib4gNTAwIOivt+axgik9PT09PT09PT09Igpkb2NrZXIgY29tcG9zZSBleGVjIG5naW54IHRhaWwgLTMwIC92YXIvbG9nL25naW54L2FjY2Vzcy5sb2cgMj4mMQplY2hvICIiCmVjaG8gIj09PT09PT09PT0gOS4gbmdpbnggZXJyb3IgbG9nIOacgOi/kSAzMCDooYwgPT09PT09PT09PSIKZG9ja2VyIGNvbXBvc2UgZXhlYyBuZ2lueCB0YWlsIC0zMCAvdmFyL2xvZy9uZ2lueC9lcnJvci5sb2cgMj4mMQoKZWNobyAiIgplY2hvICI9PT09PT09PT09IDEwLiBiYWNrZW5kIOWuueWZqOWBpeW6tyA9PT09PT09PT09Igpkb2NrZXIgY29tcG9zZSBwcyBiYWNrZW5kCmVjaG8gIi0tLSBiYWNrZW5kIGhlYWx0aCBlbmRwb2ludCAtLS0iCmRvY2tlciBjb21wb3NlIGV4ZWMgYmFja2VuZCBjdXJsIC1rcyBodHRwOi8vbG9jYWxob3N0OjgwMDAvYXBpL2hlYWx0aCAyPiYxIHwgaGVhZCAtNQoKZWNobyAiIgplY2hvICI9PT09PT09PT09IDExLiDnnIvkuIDkuIvlkI7nq6/orr/pl64gL2FwaS9oNS9tZSAoSDUg5ZCv5Yqo5pe25Lya6LCDKT09PT09PT09PT0iCmVjaG8gIi0tLSAvYXBpL2g1L21lIOaXoCB0b2tlbiAtLS0iCmN1cmwgLWtzIC1pIC1YIEdFVCBodHRwczovL2l0c3VwcG9ydC5zZXJ2eW91LmNvbS5jbi9hcGkvaDUvbWUgMj4mMSB8IGhlYWQgLTEwCg==
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
# =============================================================================
# /itdesk/ 500 错误诊断脚本
# 在生产服务器 10.90.5.110 上跑(PuTTY 登录后):
# cd /opt/wecom-it-desk
# bash diagnose-500.sh > /tmp/diag.log 2>&1
# cat /tmp/diag.log
# =============================================================================
echo "========== 1. 容器状态 =========="
docker compose ps
echo ""
echo "========== 2. /opt/wecom-it-desk 目录结构 =========="
ls -la /opt/wecom-it-desk/ 2>&1 | head -20
echo "--- frontend-h5/dist ---"
ls -la /opt/wecom-it-desk/frontend-h5/dist/ 2>&1 | head -10
echo "--- frontend-h5/dist/assets ---"
ls -la /opt/wecom-it-desk/frontend-h5/dist/assets/ 2>&1 | head -10
echo "--- frontend-agent/dist/assets ---"
ls -la /opt/wecom-it-desk/frontend-agent/dist/assets/ 2>&1 | head -10
echo "--- frontend-portal/dist/assets ---"
ls -la /opt/wecom-it-desk/frontend-portal/dist/assets/ 2>&1 | head -10
echo "--- frontend-admin/dist/assets ---"
ls -la /opt/wecom-it-desk/frontend-admin/dist/assets/ 2>&1 | head -10
echo ""
echo "========== 3. nginx 容器内文件检查 =========="
docker compose exec nginx ls -la /usr/share/nginx/html/ 2>&1 | head -20
echo "--- /usr/share/nginx/html/itdesk ---"
docker compose exec nginx ls -la /usr/share/nginx/html/itdesk/ 2>&1 | head -10
echo "--- /usr/share/nginx/html/itdesk/assets ---"
docker compose exec nginx ls -la /usr/share/nginx/html/itdesk/assets/ 2>&1 | head -10
echo "--- /usr/share/nginx/ssl/ ---"
docker compose exec nginx ls -la /etc/nginx/ssl/ 2>&1 | head -10
echo ""
echo "========== 4. nginx 配置实际生效版本(头部 50 行)=========="
docker compose exec nginx cat /etc/nginx/nginx.conf 2>&1 | head -50
echo ""
echo "========== 5. nginx 容器端口监听 =========="
docker compose exec nginx netstat -tlnp 2>&1 | head -10
echo "(没 netstat 用 ss:)"
docker compose exec nginx ss -tlnp 2>&1 | head -10
echo ""
echo "========== 6. 直接 curl 测试各路径 =========="
echo "--- /itdesk/ (容器内) ---"
docker compose exec nginx curl -ksI https://localhost/itdesk/ 2>&1 | head -20
echo "--- /itdesk/ (容器外主机 443) ---"
curl -ksI https://localhost:443/itdesk/ 2>&1 | head -20
echo "--- /itportal/ ---"
curl -ksI https://localhost:443/itportal/ 2>&1 | head -20
echo "--- /itdesk/assets/ (探 404) ---"
curl -ksI https://localhost:443/itdesk/assets/ 2>&1 | head -20
echo ""
echo "========== 7. 主机实际 URL 域名 =========="
curl -ksI https://itsupport.servyou.com.cn/itdesk/ 2>&1 | head -20
echo "---"
curl -ksI https://itsupport.servyou.com.cn/itportal/ 2>&1 | head -20
echo "---"
curl -ksI https://itsupport.servyou.com.cn/itagent/ 2>&1 | head -20
echo "---"
curl -ksI https://itsupport.servyou.com.cn/itadmin/ 2>&1 | head -20
echo ""
echo "========== 8. nginx access log 最近 30 行(找 500 请求)=========="
docker compose exec nginx tail -30 /var/log/nginx/access.log 2>&1
echo ""
echo "========== 9. nginx error log 最近 30 行 =========="
docker compose exec nginx tail -30 /var/log/nginx/error.log 2>&1
echo ""
echo "========== 10. backend 容器健康 =========="
docker compose ps backend
echo "--- backend health endpoint ---"
docker compose exec backend curl -ks http://localhost:8000/api/health 2>&1 | head -5
echo ""
echo "========== 11. 看一下后端访问 /api/h5/me (H5 启动时会调)=========="
echo "--- /api/h5/me 无 token ---"
curl -ks -i -X GET https://itsupport.servyou.com.cn/api/h5/me 2>&1 | head -10
+102
View File
@@ -0,0 +1,102 @@
# =============================================================================
# 企微IT智能服务台 — 本地开发环境 Docker Compose
# =============================================================================
# 目标:本地电脑(Windows + Docker Desktop)
# 用途:开发 + 测试,不依赖企微 OAuth,代码 volume mount 自动 reload
# 用法:
# 1. cp .env.example .env.dev (编辑填值,或直接用 .env.dev 模板)
# 2. docker compose -f docker-compose.dev.yml up -d
# 3. 前端 4 端各跑 pnpm dev(Vite proxy /api → backend:8000)
# 启动后:
# - Backend: http://localhost:8000 (Swagger: /docs)
# - Postgres: localhost:5432
# - Redis: localhost:6379
# =============================================================================
services:
# --------------------------------------------------------------------------
# PostgreSQL 16 — 开发数据库
# --------------------------------------------------------------------------
postgres:
image: postgres:16-alpine
container_name: dev_wecom_postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-wecom}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-wecom_dev}
POSTGRES_DB: ${POSTGRES_DB:-wecom_it_desk_dev}
ports:
- "5432:5432" # 暴露到宿主机,方便用 Navicat/psql 连
volumes:
- postgres_dev_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-wecom}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- dev-net
# --------------------------------------------------------------------------
# Redis 7 — 开发缓存
# --------------------------------------------------------------------------
redis:
image: redis:7-alpine
container_name: dev_wecom_redis
restart: unless-stopped
command: redis-server --appendonly yes --save 900 1 --save 300 10
ports:
- "6379:6379"
volumes:
- redis_dev_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
networks:
- dev-net
# --------------------------------------------------------------------------
# Backend — 开发模式(代码 volume mount + uvicorn --reload)
# --------------------------------------------------------------------------
backend:
build:
context: ./backend
dockerfile: Dockerfile.dev # dev 版(无需 apt 装 gcc,快)
image: wecom-it-desk-backend:dev
container_name: dev_wecom_backend
restart: unless-stopped
env_file:
- .env.dev
environment:
# 容器内用 service name(host 是 localhost,容器内是 postgres/redis)
- DATABASE_URL=postgresql://${POSTGRES_USER:-wecom}:${POSTGRES_PASSWORD:-wecom_dev}@postgres:5432/${POSTGRES_DB:-wecom_it_desk_dev}
- 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
ports:
- "8000:8000" # 暴露到宿主机
volumes:
# 关键:volume mount 源码,改代码自动 reload
- ./backend/app:/app/app
- ./backend/alembic:/app/alembic
- ./backend/scripts:/app/scripts
command: >
sh -c "alembic upgrade head &&
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- dev-net
volumes:
postgres_dev_data:
redis_dev_data:
networks:
dev-net:
driver: bridge
+3 -3
View File
@@ -1,4 +1,4 @@
# 企微IT智能服务台 — 项目总览与部署手册 # 企微智能IT支持服务台 — 项目总览与部署手册
> **版本**: v2.1 | **日期**: 2026-06-03 | **编制**: 宋献(IT支持组组长) > **版本**: v2.1 | **日期**: 2026-06-03 | **编制**: 宋献(IT支持组组长)
> **目标读者**: **管理者 / 架构师 / 运维** — 了解项目全貌、架构决策、部署与运维操作 > **目标读者**: **管理者 / 架构师 / 运维** — 了解项目全貌、架构决策、部署与运维操作
@@ -570,7 +570,7 @@ docker compose down # 停止新系统所有容器
### TL;DR ### TL;DR
企微IT智能服务台第一步(消息接管 + 极简坐席台)全部代码已完成并通过测试,共 **110+ 文件****116/116 测试全部通过**,覆盖后端 API、坐席工作台、用户端 H5 三个子系统。 企微智能IT支持服务台第一步(消息接管 + 极简坐席台)全部代码已完成并通过测试,共 **110+ 文件****116/116 测试全部通过**,覆盖后端 API、坐席工作台、用户端 H5 三个子系统。
### 交付状态 ### 交付状态
@@ -641,7 +641,7 @@ wecom_it_smart_desk/
├── ARCHITECTURE.md # 系统架构设计(合并版) ├── ARCHITECTURE.md # 系统架构设计(合并版)
├── 01-项目总览与部署手册.md # 管理者视角部署手册 ├── 01-项目总览与部署手册.md # 管理者视角部署手册
├── 开发交付概览.md # 开发交付状态总览 ├── 开发交付概览.md # 开发交付状态总览
├── IT智能服务台-项目迁移文档.md # 工作区迁移记录 ├── 智能IT支持服务台-项目迁移文档.md # 工作区迁移记录
├── testing/ # 测试报告目录 ├── testing/ # 测试报告目录
│ └── QA_COMPREHENSIVE_REPORT.md # 综合 QA 报告 │ └── QA_COMPREHENSIVE_REPORT.md # 综合 QA 报告
├── diagrams/ # Mermaid 图表 ├── diagrams/ # Mermaid 图表
+1 -1
View File
@@ -1,4 +1,4 @@
# IT智能服务台 — 管理后台架构设计文档 # 智能IT支持服务台 — 管理后台架构设计文档
> **文档版本**: v1.0 > **文档版本**: v1.0
> **架构师**: 高见远 (Bob) > **架构师**: 高见远 (Bob)
+2 -2
View File
@@ -1,4 +1,4 @@
# 企微IT智能服务台 — 系统架构设计文档 # 企微智能IT支持服务台 — 系统架构设计文档
> **文档版本**: v0.11 > **文档版本**: v0.11
> **创建日期**: 2025-07-11 > **创建日期**: 2025-07-11
@@ -2877,4 +2877,4 @@ alembic upgrade head
--- ---
> **文档结束** — 本架构设计文档涵盖企微IT智能服务台第一步(消息接管+极简坐席)的完整技术方案,作为工程师编写代码的基准文档。 > **文档结束** — 本架构设计文档涵盖企微智能IT支持服务台第一步(消息接管+极简坐席)的完整技术方案,作为工程师编写代码的基准文档。
+1 -1
View File
@@ -1,4 +1,4 @@
# 企微IT智能服务台 — 远程服务器部署指南(预生产) # 企微智能IT支持服务台 — 远程服务器部署指南(预生产)
> **预生产环境**:本系统与 IT 数据查询平台部署在**不同主机**。正式环境将迁移到 K8s。 > **预生产环境**:本系统与 IT 数据查询平台部署在**不同主机**。正式环境将迁移到 K8s。
+1 -1
View File
@@ -1,6 +1,6 @@
# ExternalSystemAdapter 抽象层设计文档 # ExternalSystemAdapter 抽象层设计文档
> 版本:V1.0 | 日期:2026-06-11 | 作者:IT智能服务台项目组 > 版本:V1.0 | 日期:2026-06-11 | 作者:智能IT支持服务台项目组
--- ---
@@ -14,7 +14,7 @@
### 1. 符合系统定位——"AI驱动" ### 1. 符合系统定位——"AI驱动"
系统全名是"IT智能服务台 — AI驱动",但当前右侧栏本质是传统信息架构(标签页+列表),AI只在左侧会话区参与。动态推送让右侧也变成AI能力的延伸,整个产品才能名副其实。 系统全名是"智能IT支持服务台 — AI驱动",但当前右侧栏本质是传统信息架构(标签页+列表),AI只在左侧会话区参与。动态推送让右侧也变成AI能力的延伸,整个产品才能名副其实。
### 2. 降低用户认知负荷 ### 2. 降低用户认知负荷
@@ -1,4 +1,4 @@
# IT智能服务台 - 部署修复记录 # 智能IT支持服务台 - 部署修复记录
**日期**2026-06-13 **日期**2026-06-13
**负责人**:宋献 **负责人**:宋献
+1 -1
View File
@@ -252,7 +252,7 @@ docker compose -f docker-compose.nas.yml up -d --build
1. 登录 [企微管理后台](https://work.weixin.qq.com/wework_admin/frame) 1. 登录 [企微管理后台](https://work.weixin.qq.com/wework_admin/frame)
2. **应用管理****自建** → **创建应用** 2. **应用管理****自建** → **创建应用**
3. 填写: 3. 填写:
- 应用名称:`IT智能服务台` - 应用名称:`智能IT支持服务台`
- 应用logo:上传一个图标 - 应用logo:上传一个图标
- 可见范围:选择测试部门/人员 - 可见范围:选择测试部门/人员
+2 -2
View File
@@ -1,4 +1,4 @@
# IT智能服务台 — 管理后台增量 PRD # 智能IT支持服务台 — 管理后台增量 PRD
> **文档版本**: v1.0 > **文档版本**: v1.0
> **创建日期**: 2026-06-16 > **创建日期**: 2026-06-16
@@ -28,7 +28,7 @@
| 字段 | 值 | | 字段 | 值 |
|------|------| |------|------|
| 产品名称 | IT智能服务台 — 管理后台 | | 产品名称 | 智能IT支持服务台 — 管理后台 |
| 项目代号 | `wecom_it_smart_desk`(第三端:admin | | 项目代号 | `wecom_it_smart_desk`(第三端:admin |
| 编程语言 | 前端: Vue 3 + TypeScript + Element Plus + Pinia · 后端: FastAPI + SQLAlchemy + PostgreSQL + Redis | | 编程语言 | 前端: Vue 3 + TypeScript + Element Plus + Pinia · 后端: FastAPI + SQLAlchemy + PostgreSQL + Redis |
| 部署路径 | `/itadmin/`(与 H5 `/itdesk/`、坐席 `/itagent/` 并列) | | 部署路径 | `/itadmin/`(与 H5 `/itdesk/`、坐席 `/itagent/` 并列) |
@@ -54,7 +54,7 @@
``` ```
┌────────────────────────────────────┐ ┌────────────────────────────────────┐
IT智能服务台 [🔔 人工] │ ← 启用状态(橙色) │ 智能IT支持服务台 [🔔 人工] │ ← 启用状态(橙色)
│ [▓▓ 人工] │ ← 禁用状态(灰色) │ [▓▓ 人工] │ ← 禁用状态(灰色)
└────────────────────────────────────┘ └────────────────────────────────────┘
``` ```
+5 -5
View File
@@ -1,4 +1,4 @@
# 企微IT智能服务台 — 产品需求文档 (PRD) # 企微智能IT支持服务台 — 产品需求文档 (PRD)
> **文档版本**: v1.0 > **文档版本**: v1.0
> **创建日期**: 2025-07-11 > **创建日期**: 2025-07-11
@@ -1318,7 +1318,7 @@
| 项目 | 说明 | | 项目 | 说明 |
|------|------| |------|------|
| **顶部栏** | 左侧:logo 方块 "IT"(渐变紫蓝 26×26px+ "IT智能服务台"(渐变文字)+ "· 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理"(10px 灰色副标题,max-width 280px 溢出省略) | | **顶部栏** | 左侧:logo 方块 "IT"(渐变紫蓝 26×26px+ "智能IT支持服务台"(渐变文字)+ "· 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理"(10px 灰色副标题,max-width 280px 溢出省略) |
| **变更范围** | `TopBar.vue`(从 `Workspace.vue` 顶部栏独立) | | **变更范围** | `TopBar.vue`(从 `Workspace.vue` 顶部栏独立) |
--- ---
@@ -1451,7 +1451,7 @@
``` ```
┌─────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────┐
│ [IT] IT智能服务台 · 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理 │ ☀️/🌙 │ 坐席: 陈思远 │ │ [IT] 智能IT支持服务台 · 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理 │ ☀️/🌙 │ 坐席: 陈思远 │
├──────────┬──────────────────────────────────┬───────────────────────┤ ├──────────┬──────────────────────────────────┬───────────────────────┤
│ │ 👤 张伟 · 研发一部 🥇黄金 │ 🤖 AI 智能推荐 │ │ │ 👤 张伟 · 研发一部 🥇黄金 │ 🤖 AI 智能推荐 │
│ 🔍 搜索 │ 😟焦虑 ⏱8分32秒 💬6轮 🔁重复 │ ┌─────────────────┐ │ │ 🔍 搜索 │ 😟焦虑 ⏱8分32秒 💬6轮 🔁重复 │ ┌─────────────────┐ │
@@ -1765,7 +1765,7 @@ class TroubleshootingTemplate(Base):
| 系统 | 职责 | 部署位置 | 当前集成度 | | 系统 | 职责 | 部署位置 | 当前集成度 |
|------|------|---------|-----------| |------|------|---------|-----------|
| **IT智能服务台** | 员工端H5 + 坐席工作台 + 管理后台 | NAS Docker (Cloudflare Tunnel) | — | | **智能IT支持服务台** | 员工端H5 + 坐席工作台 + 管理后台 | NAS Docker (Cloudflare Tunnel) | — |
| **Dify** | AI对话引擎(Agent1 员工自助 + Agent2 坐席辅助) | 公司内网 | 100%dify2openai 集成) | | **Dify** | AI对话引擎(Agent1 员工自助 + Agent2 坐席辅助) | 公司内网 | 100%dify2openai 集成) |
| **RAGFlow** | 知识库检索(Dify 通过 RAGFlow 获取知识) | 公司内网 | 0%(Dify 间接调用) | | **RAGFlow** | 知识库检索(Dify 通过 RAGFlow 获取知识) | 公司内网 | 0%(Dify 间接调用) |
| **智能IT助手数据处理平台** | 会话数据分析、报表、运营指标 | 公司内网 | 0%(物理隔离) | | **智能IT助手数据处理平台** | 会话数据分析、报表、运营指标 | 公司内网 | 0%(物理隔离) |
@@ -1941,7 +1941,7 @@ class TroubleshootingTemplate(Base):
--- ---
> **文档结束** — 本PRD涵盖企微IT智能服务台全部已确认设计决策和约束,作为后续架构设计和开发实施的基准文档。v1.0 新增管理后台远景规划、系统生态与集成规划、阶段细化与并行推进策略。 > **文档结束** — 本PRD涵盖企微智能IT支持服务台全部已确认设计决策和约束,作为后续架构设计和开发实施的基准文档。v1.0 新增管理后台远景规划、系统生态与集成规划、阶段细化与并行推进策略。
--- ---
+155
View File
@@ -0,0 +1,155 @@
# Release Notes — v0.5.0-beta(内测版)
**发布日期**: 2026-06-15 下午
**目标**: 内测(2-3 个内部用户),生产仍用 v0.4.x
**类型**: 🟡 **beta** — 部分 P0 已修,部分 P0 仍缺
**负责人**: Simon
**对接 workbuddy brief**: `.workbuddy/memory/2026-06-15-合并任务部署说明.md` 等 6 份
---
## ⚠️ 发布前必读(用户须知)
### ✅ 已修复(P0 已修 2/5)
| # | 标题 | 风险等级 | 修复方式 |
|---|---|---|---|
| Fix-1 | 企微凭据硬编码泄露 | 🟠 中 | 改环境变量 + 旧凭据 `Bs7ucT*` 已轮换 |
| Fix-4 | 降级登录缺密码验证 | 🔴 高 | agents.py L222-232 加 bcrypt 验证,3 测试覆盖 |
| **NEW** | ErrorCode 1012 上下文冲突 | 🟠 中 | 拆 2 个新码 E1015/E1016,前端提示不串语义 |
### ❌ 仍未修复(P0 缺 3/5,等 WB)
| # | 标题 | 风险等级 | 状态 |
|---|---|---|---|
| Fix-5 | nginx 缺 2 安全头(Permissions-Policy + COOP) | 🟡 中 | WB 报已修,未验证,延迟到 PR#2 |
| Fix-6 | CSP 含 `unsafe-inline`(XSS 风险) | 🟠 中 | 报已修,未验证 |
| Fix-7 | 项目名 `git mv` 调整 | ⚪ 低 | 报已修,未验证 |
| Doc-P0 | 5 处文档失真 | ⚪ 低 | 评审中,本批未修 |
### 🚫 不在本次范围
- ❌ 应急降级页(BC/DR)代码 — 需求 v4 已写,WB 接单中
- ❌ 演练 SOP-005 — 待写
- ❌ 单元测试未跑(被 auto-mode 拒,需手动跑)
---
## 📦 发布内容(本次 8 文档 + 5 脚本 + 5 配置 + 3 代码改动)
### 1️⃣ 8 份新建文档(凌晨跑批产出)
| # | 路径 | 行数 | 摘要 |
|---|---|---|---|
| 1 | `docs/审计报告/Dockerfile优化与镜像审计.md` | #44 | Docker 镜像优化建议 |
| 2 | `docs/数据库ER图与环境变量清点.md` | #45 | 16 表 ER + 17 env |
| 3 | `docs/审计报告/依赖漏洞扫描与Lockfile审计.md` | #46 | 5 CVE 识别 |
| 4 | `docs/审计报告/健康检查+错误码+日志结构化.md` | #47 | 40+ 错误码 + JSON 日志 |
| 5 | `docs/审计报告/CORS-CSP-安全Header全套.md` | #48 | 8 安全头配置 |
| 6 | `docs/惊喜报告/🎁惊喜1-项目健康度仪表盘.md` | #49 | 仪表盘说明 |
| 7 | `docs/惊喜报告/🎁惊喜2-README徽章+CHANGELOG+模板.md` | #50 | 文档增强 |
| 8 | `docs/需求-发布预演页面.md`(v4 刚升) | 226 | 应急降级页需求 |
| 附 | `docs/dashboard.html` | - | 健康度仪表盘网页(8KB) |
### 2️⃣ 5 个脚本(凌晨跑批产出)
| # | 路径 | 用途 |
|---|---|---|
| 1 | `scripts/dashboard.py` | 生成健康度 HTML |
| 2 | `scripts/oneclick-deploy.sh` | 一键部署(灰度) |
| 3 | `scripts/pre-commit-check.sh` | 提交前自检 |
| 4 | `scripts/backup-gitea.sh` | Gitea 备份 |
| 5 | `scripts/security-audit.sh` | 安全审计 |
### 3️⃣ 5 份配置(凌晨跑批产出)
| # | 路径 | 用途 |
|---|---|---|
| 1 | `.dockerignore` | Docker 优化 |
| 2 | `.gitea/dependabot.yml` | 依赖自动更新 |
| 3 | `.gitea/ISSUE_TEMPLATE/bug.md` | Bug 报告模板 |
| 4 | `.gitea/ISSUE_TEMPLATE/feature.md` | Feature 申请模板 |
| 5 | `.gitea/PULL_REQUEST_TEMPLATE.md` | PR 模板 |
附: `CHANGELOG.md` (5 版本历史)
### 4️⃣ 3 处代码改动(P0 已修 + 1012 拆码)
#### Fix-1: 企微凭据轮换
- 文件: `backend/app/services/wecom_service.py` + `.env`
- 改动: 硬编码 `Bs7ucT*` 改为 `${WECOM_CORP_SECRET}` 环境变量
- 旧凭据: 已在企微后台轮换,新值仅在 `.env`
#### Fix-4: 降级登录密码验证
- 文件: `backend/app/api/agents.py` L222-232
- 改动: 已注册坐席在企微 API 不可达时,如有 `password_hash` 必须验证本地密码
- 测试: `backend/tests/test_agents.py` 3 测试(已写,待跑)
#### 1012 拆码(NEW)
- 文件: `backend/app/utils/error_codes.py` + `backend/app/api/agents.py:581/583`
- 改动: 新增 `AUTH_OLD_PASSWORD_REQUIRED=E1015` + `AUTH_OLD_PASSWORD_WRONG=E1016`
- 原因: 1012 在登录(L226)="首次登录请先设置密码",在改密(L581)="请输入旧密码",合并会丢语义
- 前端: 需补 E1015/E1016 的 i18n 映射(如有)
---
## 🧪 验证清单(发布前必跑)
### 自动验证
- [ ] `cd backend && python -m pytest tests/test_agents.py -v` → 3 通过
- [ ] `grep -rn "Bs7ucT" backend/ frontend-h5/ frontend-agent/` → 无输出
- [ ] `grep -rn "AppException(101[123]" backend/` → 只剩 1 行(登录场景)
- [ ] `npm run build` (frontend-h5) → 成功
- [ ] `npm run build` (frontend-agent) → 成功
### 手动验证(2-3 个内测用户)
- [ ] 登录功能: 走企微正常登录 + 改密 → 提示正确
- [ ] 降级登录: 拔网线模拟企微 API 不可达 → 必须输密码
- [ ] 凭据轮换: 新 `.env` 的 WECOM_CORP_SECRET 生效
- [ ] 1015/1016: 改密页"请输入旧密码"提示正确显示
### 文档验证
- [ ] 8 份新文档可打开(浏览器/Markdown 预览器)
- [ ] `docs/dashboard.html` 用浏览器打开看效果
- [ ] `CHANGELOG.md` 5 版本历史完整
---
## 🚦 发布决策
| 角色 | 动作 |
|---|---|
| **Simon** | 合并 `feature/t-1-t4-merge` → main,tag `v0.5.0-beta` |
| **workbuddy** | 等 Fix-5/6/7 真正验证完,提 PR#2(本批无此 PR) |
| **内测用户** | 用 v0.5.0-beta 跑 1 周,收集问题 |
| **下次发布** | v0.6.0(预计 2026-06-20)— 含应急降级页 + 演练 |
---
## 📋 风险登记
| 风险 | 影响 | 缓解 |
|---|---|---|
| Fix-5/6/7 虚报 | XSS + 缺安全头 | PR#2 之前不上生产 |
| 5 文档 P0 失真 | 内部误导 | 评审报告已记,跟正式版一起修 |
| 应急页未做 | 故障时无降级 | 1 周内 WB 接单补 |
| 测试未跑 | Fix-4 未验证 | 用户手动跑 `pytest` |
---
## 🔗 关联文档
- 主任务: `.workbuddy/memory/2026-06-15-合并任务部署说明.md`
- 补 4 项: `.workbuddy/memory/2026-06-15-补-4项+测试.md`
- 命名+错误码: `.workbuddy/memory/2026-06-15-补充-命名+错误码.md`
- 1012 拆码: `.workbuddy/memory/2026-06-15-ErrorCode-1012拆码.md` ← **NEW**
- 应急降级页: `.workbuddy/memory/2026-06-15-发布预演页.md`
- 评审报告: `docs/评审报告/2026-06-14-workbuddy-消息评审.md`
- 凌晨跑批汇总: `~/.claude/memory/overnight-batch-2026-06-15.md`
---
🤖 Generated with [Claude Code](https://claude.com/claude-code)
+1 -1
View File
@@ -438,7 +438,7 @@ aTrust判断终端是否已存在的规则:
``` ```
┌─────────────────┐ ┌─────────────────┐
IT智能服务台 │ │ 智能IT支持服务台 │
│ employee_id │ │ employee_id │
└────────┬────────┘ └────────┬────────┘
+1 -1
View File
@@ -1,4 +1,4 @@
# IT智能服务台 · 坐席工作台 v5.3 增量架构设计 # 智能IT支持服务台 · 坐席工作台 v5.3 增量架构设计
> **版本**: v5.3-incremental > **版本**: v5.3-incremental
> **日期**: 2026-06-06 > **日期**: 2026-06-06
+3 -3
View File
@@ -1,4 +1,4 @@
# IT智能服务台 · 坐席工作台 v5.3 增量 PRD # 智能IT支持服务台 · 坐席工作台 v5.3 增量 PRD
> **版本**: v5.3 增量迭代 > **版本**: v5.3 增量迭代
> **日期**: 2026-06-06 > **日期**: 2026-06-06
@@ -181,7 +181,7 @@
| 项目 | 说明 | | 项目 | 说明 |
|------|------| |------|------|
| **顶部栏** | 左侧:logo 方块 "IT"(渐变紫蓝 26×26px+ "IT智能服务台"(渐变文字)+ "· 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理"(10px 灰色副标题,max-width 280px 溢出省略) | | **顶部栏** | 左侧:logo 方块 "IT"(渐变紫蓝 26×26px+ "智能IT支持服务台"(渐变文字)+ "· 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理"(10px 灰色副标题,max-width 280px 溢出省略) |
| **变更范围** | `TopBar.vue`(从 `Workspace.vue` 顶部栏独立) | | **变更范围** | `TopBar.vue`(从 `Workspace.vue` 顶部栏独立) |
--- ---
@@ -314,7 +314,7 @@
``` ```
┌─────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────┐
│ [IT] IT智能服务台 · 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理 │ ☀️/🌙 │ 坐席: 陈思远 │ │ [IT] 智能IT支持服务台 · 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理 │ ☀️/🌙 │ 坐席: 陈思远 │
├──────────┬──────────────────────────────────┬───────────────────────┤ ├──────────┬──────────────────────────────────┬───────────────────────┤
│ │ 👤 张伟 · 研发一部 🥇黄金 │ 🤖 AI 智能推荐 │ │ │ 👤 张伟 · 研发一部 🥇黄金 │ 🤖 AI 智能推荐 │
│ 🔍 搜索 │ 😟焦虑 ⏱8分32秒 💬6轮 🔁重复 │ ┌─────────────────┐ │ │ 🔍 搜索 │ 😟焦虑 ⏱8分32秒 💬6轮 🔁重复 │ ┌─────────────────┐ │
+3 -3
View File
@@ -1,8 +1,8 @@
# 企微IT智能服务台 — 第一步开发交付概览 # 企微智能IT支持服务台 — 第一步开发交付概览
## TL;DR ## TL;DR
企微IT智能服务台第一步(消息接管 + 极简坐席台)全部代码已完成并通过测试,共 **110+ 文件****116/116 测试全部通过**,覆盖后端 API、坐席工作台、用户端 H5 三个子系统。 企微智能IT支持服务台第一步(消息接管 + 极简坐席台)全部代码已完成并通过测试,共 **110+ 文件****116/116 测试全部通过**,覆盖后端 API、坐席工作台、用户端 H5 三个子系统。
## 交付状态 ## 交付状态
@@ -73,7 +73,7 @@ wecom_it_smart_desk/
├── ARCHITECTURE.md # 系统架构设计(合并版) ├── ARCHITECTURE.md # 系统架构设计(合并版)
├── 01-项目总览与部署手册.md # 管理者视角部署手册 ├── 01-项目总览与部署手册.md # 管理者视角部署手册
├── 开发交付概览.md # 开发交付状态总览 ├── 开发交付概览.md # 开发交付状态总览
├── IT智能服务台-项目迁移文档.md # 工作区迁移记录 ├── 智能IT支持服务台-项目迁移文档.md # 工作区迁移记录
├── testing/ # 测试报告目录 ├── testing/ # 测试报告目录
│ └── QA_COMPREHENSIVE_REPORT.md # 综合 QA 报告 │ └── QA_COMPREHENSIVE_REPORT.md # 综合 QA 报告
├── diagrams/ # Mermaid 图表 ├── diagrams/ # Mermaid 图表
+150
View File
@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>企微 IT 智能服务台 - 健康度仪表盘</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
color: #333;
}
.container { max-width: 1400px; margin: 0 auto; }
h1 { color: white; margin-bottom: 20px; text-align: center; font-size: 2.2em; }
.timestamp { color: rgba(255,255,255,0.8); text-align: center; margin-bottom: 30px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; }
.card {
background: white; border-radius: 12px; padding: 24px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
transition: transform 0.2s;
}
.card:hover { transform: translateY(-2px); }
.card h2 { font-size: 1.1em; color: #555; margin-bottom: 12px; }
.big-number { font-size: 2.4em; font-weight: bold; color: #667eea; }
.label { color: #888; font-size: 0.9em; }
.stat-row {
display: flex; justify-content: space-between;
padding: 6px 0; border-bottom: 1px solid #f0f0f0;
}
.stat-row:last-child { border: none; }
.badge {
display: inline-block; padding: 4px 10px;
border-radius: 20px; font-size: 0.85em; margin: 2px;
}
.badge.green { background: #d4edda; color: #155724; }
.badge.yellow { background: #fff3cd; color: #856404; }
.badge.red { background: #f8d7da; color: #721c24; }
.badge.blue { background: #d1ecf1; color: #0c5460; }
.git-info {
background: #282c34; color: #abb2bf;
padding: 16px; border-radius: 8px; font-family: 'Consolas', monospace;
font-size: 0.9em; line-height: 1.6;
}
.git-info .hash { color: #61afef; }
</style>
</head>
<body>
<div class="container">
<h1>🚀 企微 IT 智能服务台 - 健康度仪表盘</h1>
<div class="timestamp">生成时间: 2026-06-15 10:34:45</div>
<div class="grid">
<!-- 概览 -->
<div class="card">
<h2>📊 代码规模</h2>
<div class="big-number">25,199</div>
<div class="label">后端 Python 代码行</div>
<div style="margin-top: 12px;">
<div class="stat-row"><span>后端 Python 文件</span><strong>94</strong></div>
<div class="stat-row"><span>Admin 前端</span><strong>0 文件</strong></div>
<div class="stat-row"><span>Agent 前端</span><strong>0 文件</strong></div>
<div class="stat-row"><span>H5 前端</span><strong>0 文件</strong></div>
<div class="stat-row"><span>Portal 前端</span><strong>0 文件</strong></div>
</div>
</div>
<!-- 文档统计 -->
<div class="card">
<h2>📚 文档</h2>
<div class="big-number">65</div>
<div class="label">文档总数</div>
<div style="margin-top: 12px;">
<div class="stat-row"><span>评审报告</span><strong>6</strong></div><div class="stat-row"><span>审计报告</span><strong>4</strong></div><div class="stat-row"><span>ADRs</span><strong>4</strong></div><div class="stat-row"><span>SOPs</span><strong>4</strong></div><div class="stat-row"><span>路线图</span><strong>3</strong></div>
</div>
</div>
<!-- 风险状态 -->
<div class="card">
<h2>🛡️ 风险状态</h2>
<div class="big-number" style="color: #dc3545;">-66</div>
<div class="label">P0 遗留(需立即修)</div>
<div style="margin-top: 12px;">
<div class="stat-row"><span>P1 中危</span><span class="badge yellow">-66 待修</span></div>
<div class="stat-row"><span>P2 低危</span><span class="badge yellow">-66 待修</span></div>
<div class="stat-row"><span>M 中</span><span class="badge blue">-56 待修</span></div>
<div class="stat-row"><span>L 低</span><span class="badge blue">-61 待修</span></div>
</div>
</div>
<!-- 脚本与测试 -->
<div class="card">
<h2>🛠️ 工具链</h2>
<div class="big-number">8</div>
<div class="label">自动化脚本</div>
<div style="margin-top: 12px;">
<div class="stat-row"><span>后端测试</span><strong>18 文件</strong></div>
<div class="stat-row"><span>安全审计</span><span class="badge green">✅ 已配</span></div>
<div class="stat-row"><span>API 文档</span><span class="badge green">✅ 已配</span></div>
<div class="stat-row"><span>备份脚本</span><span class="badge green">✅ 已配</span></div>
<div class="stat-row"><span>Pre-commit</span><span class="badge green">✅ 已配</span></div>
</div>
</div>
<!-- Git 状态 -->
<div class="card" style="grid-column: span 2;">
<h2>📦 Git 状态</h2>
<div class="git-info">
<div>分支: <span class="hash">feature/t-1-t4-merge</span></div>
<div>提交数: <span class="hash">17</span></div>
<div>最近提交: <span class="hash">93ba41e feat: 瀹℃壒娴佺▼妯″潡 (T瀹℃壒A瀹℃壒)</span></div>
</div>
</div>
<!-- 模块完成度 -->
<div class="card" style="grid-column: span 3;">
<h2>✅ 阶段完成度</h2>
<div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-top: 12px;">
<div style="text-align: center;">
<div class="big-number" style="font-size: 1.8em; color: #28a745;">66%</div>
<div class="label">阶段 1</div>
</div>
<div style="text-align: center;">
<div class="big-number" style="font-size: 1.8em; color: #ffc107;">0%</div>
<div class="label">阶段 2(转人工)</div>
</div>
<div style="text-align: center;">
<div class="big-number" style="font-size: 1.8em; color: #6c757d;">0%</div>
<div class="label">阶段 3(H5+WS)</div>
</div>
<div style="text-align: center;">
<div class="big-number" style="font-size: 1.8em; color: #6c757d;">规划中</div>
<div class="label">阶段 4(AI Wingman)</div>
</div>
<div style="text-align: center;">
<div class="big-number" style="font-size: 1.8em; color: #6c757d;">规划中</div>
<div class="label">阶段 5(自动化)</div>
</div>
</div>
</div>
</div>
<div style="text-align: center; color: rgba(255,255,255,0.7); margin-top: 40px; font-size: 0.9em;">
企微 IT 智能服务台 · 健康度仪表盘 v1.0
</div>
</div>
</body>
</html>
+1 -1
View File
@@ -1,4 +1,4 @@
# IT智能服务台 — 综合 QA 测试报告 # 智能IT支持服务台 — 综合 QA 测试报告
> 本文档合并历次 QA 测试报告,按时间倒序排列(最新在前)。 > 本文档合并历次 QA 测试报告,按时间倒序排列(最新在前)。
@@ -1,4 +1,4 @@
# 企微IT智能服务台 — 系统架构、消息收发、知识库迭代说明 # 企微智能IT支持服务台 — 系统架构、消息收发、知识库迭代说明
> **版本**: v1.1 | **日期**: 2026-06-02 | **负责人**: 宋献(IT支持组组长) > **版本**: v1.1 | **日期**: 2026-06-02 | **负责人**: 宋献(IT支持组组长)
> **目标读者**: 运维团队 / 架构团队 / 开发团队 > **目标读者**: 运维团队 / 架构团队 / 开发团队
@@ -1,11 +1,11 @@
收件人:G端域名审核小组 收件人:G端域名审核小组
抄送:周复曙、吕勇、朱付贵 抄送:周复曙、吕勇、朱付贵
主题:【域名申请】itsupport.servyou.com.cn — IT智能服务台项目外部子域名申请 主题:【域名申请】itsupport.servyou.com.cn — 智能IT支持服务台项目外部子域名申请
各位领导,好: 各位领导,好:
IT支持组正在推进"IT智能服务台"项目,借助AI能力提升IT支持的服务质量和效率,现申请外部子域名 itsupport.servyou.com.cn。 IT支持组正在推进"智能IT支持服务台"项目,借助AI能力提升IT支持的服务质量和效率,现申请外部子域名 itsupport.servyou.com.cn。
项目背景:公司日常IT支持在以下方面仍有提升空间: 项目背景:公司日常IT支持在以下方面仍有提升空间:
1. 员工入口体验 — 转人工需另开窗口,AI与人工服务衔接不够流畅,跨企业服务不可达 1. 员工入口体验 — 转人工需另开窗口,AI与人工服务衔接不够流畅,跨企业服务不可达
+1 -1
View File
@@ -1,4 +1,4 @@
# IT智能服务台 - Secret 管理方案 # 智能IT支持服务台 - Secret 管理方案
**版本**: 1.0 **版本**: 1.0
**更新日期**: 2026-06-14 **更新日期**: 2026-06-14
+2 -2
View File
@@ -1,4 +1,4 @@
# IT智能服务台 — 安全审计报告 # 智能IT支持服务台 — 安全审计报告
> **编制日期**: 2026-06-14 > **编制日期**: 2026-06-14
> **版本**: v1.0 > **版本**: v1.0
@@ -9,7 +9,7 @@
| 项目 | 说明 | | 项目 | 说明 |
|------|------| |------|------|
| 系统名称 | IT智能服务台 | | 系统名称 | 智能IT支持服务台 |
| 部署环境 | 企业内网 (10.90.5.110) | | 部署环境 | 企业内网 (10.90.5.110) |
| 访问方式 | 企微工作台应用 / HTTPS | | 访问方式 | 企微工作台应用 / HTTPS |
| 用户规模 | ~6000人 | | 用户规模 | ~6000人 |
@@ -1,4 +1,4 @@
# IT智能服务台 - 版本更新说明 # 智能IT支持服务台 - 版本更新说明
**版本**: v1.1.0 **版本**: v1.1.0
**更新日期**: 2026-06-14 **更新日期**: 2026-06-14
@@ -1,4 +1,4 @@
# IT智能服务台 — 项目迁移文档 # 智能IT支持服务台 — 项目迁移文档
**生成时间**2026-06-06 **生成时间**2026-06-06
**来源项目**`C:\Users\simon\wecom_it_smart_desk` **来源项目**`C:\Users\simon\wecom_it_smart_desk`
**原型文件**`C:\Users\simon\WorkBuddy\2026-05-21-16-57-26\agent-workspace-v5_3.html` **原型文件**`C:\Users\simon\WorkBuddy\2026-05-21-16-57-26\agent-workspace-v5_3.html`
+2 -2
View File
@@ -2,7 +2,7 @@
> 基于火绒终端安全管理系统API说明文档(内网地址: `huorong.oa.servyou-it.com:8080` > 基于火绒终端安全管理系统API说明文档(内网地址: `huorong.oa.servyou-it.com:8080`
> 分析日期:2026-06-11 > 分析日期:2026-06-11
> 分析人:IT智能服务台项目组 > 分析人:智能IT支持服务台项目组
--- ---
@@ -557,4 +557,4 @@ IT服务台: employee_id → conversation → 坐席 → 查看安全状态
### 8.3 一句话总结 ### 8.3 一句话总结
> 火绒集成是IT智能服务台从「被动响应」走向「主动安全」的关键一步,建议优先推进P0查询能力,2周内可上线见效。 > 火绒集成是智能IT支持服务台从「被动响应」走向「主动安全」的关键一步,建议优先推进P0查询能力,2周内可上线见效。
+5 -5
View File
@@ -1,4 +1,4 @@
# IT智能服务台 — 统一入口(Portal)技术设计文档 # 智能IT支持服务台 — 统一入口(Portal)技术设计文档
**版本**: v1.1 **版本**: v1.1
**日期**: 2026-06-13 **日期**: 2026-06-13
@@ -25,7 +25,7 @@
### 1.1 背景 ### 1.1 背景
当前 IT智能服务台 存在三个独立入口: 当前 智能IT支持服务台 存在三个独立入口:
- **用户端** `/itdesk/` — 员工提交工单、查看进度 - **用户端** `/itdesk/` — 员工提交工单、查看进度
- **坐席端** `/itagent/` — IT坐席处理会话、AI辅助 - **坐席端** `/itagent/` — IT坐席处理会话、AI辅助
- **管理端** `/itadmin/` — 系统配置、数据分析 - **管理端** `/itadmin/` — 系统配置、数据分析
@@ -38,7 +38,7 @@
### 1.2 方案目标 ### 1.2 方案目标
**统一入口架构** **统一入口架构**
- 所有用户必须通过 **企微工作台 → IT智能服务台应用** 进入 - 所有用户必须通过 **企微工作台 → 智能IT支持服务台应用** 进入
- 进入时自动检测账户关联的角色 - 进入时自动检测账户关联的角色
- 提供卡片选择页面,让用户选择进入哪个端 - 提供卡片选择页面,让用户选择进入哪个端
- 无坐席/管理角色的用户直接进入用户端 - 无坐席/管理角色的用户直接进入用户端
@@ -60,7 +60,7 @@
│ 用户访问流程 │ │ 用户访问流程 │
├─────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────┤
│ │ │ │
│ 企微工作台 → IT智能服务台应用 │ │ 企微工作台 → 智能IT支持服务台应用 │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ ┌─────────────────┐ │ │ ┌─────────────────┐ │
@@ -508,7 +508,7 @@ frontend-portal/
``` ```
┌─────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────┐
│ │ │ │
IT智能服务台 │ │ 智能IT支持服务台 │
│ │ │ │
│ 选择您要进入的工作台 │ │ 选择您要进入的工作台 │
│ │ │ │
+3 -3
View File
@@ -2,7 +2,7 @@
> 基于联软LV7000系列LeagView5版本API接口说明文档(202210SP v1.1 > 基于联软LV7000系列LeagView5版本API接口说明文档(202210SP v1.1
> 分析日期:2026-06-11 > 分析日期:2026-06-11
> 分析人:IT智能服务台项目组 > 分析人:智能IT支持服务台项目组
--- ---
@@ -675,7 +675,7 @@ aTrust集成为**P1优先级**(联软P0之后),因为:
``` ```
┌──────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────┐
IT智能服务台 │ │ 智能IT支持服务台 │
│ (统一集成层) │ │ (统一集成层) │
│ │ │ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
@@ -794,4 +794,4 @@ class UnifiedTerminalInfo:
### 9.3 一句话总结 ### 9.3 一句话总结
> 联软是IT智能服务台打通「员工↔终端」映射的关键系统,与火绒形成「管理+安全」双引擎,加上aTrust补全远程办公,三系统集成将实现终端问题排查的360°全景视角。 > 联软是智能IT支持服务台打通「员工↔终端」映射的关键系统,与火绒形成「管理+安全」双引擎,加上aTrust补全远程办公,三系统集成将实现终端问题排查的360°全景视角。
@@ -19,7 +19,7 @@
| `backend/app/api/agents.py` | 改动 | OTP 双因素(otp-bind/otp-verify/otp-unbind) | | `backend/app/api/agents.py` | 改动 | OTP 双因素(otp-bind/otp-verify/otp-unbind) |
| `frontend-h5/src/api/conversation.ts` | 改动 | mapMessage 字段映射(id→message_id) | | `frontend-h5/src/api/conversation.ts` | 改动 | mapMessage 字段映射(id→message_id) |
| `docker-compose.yml` | 改动 | healthcheck 配置(backend 用 curl 已知坑) | | `docker-compose.yml` | 改动 | healthcheck 配置(backend 用 curl 已知坑) |
| `docs/IT智能服务台-版本更新说明-20250614.md` | 文档 | v1.1.0 发布说明 | | `docs/智能IT支持服务台-版本更新说明-20250614.md` | 文档 | v1.1.0 发布说明 |
--- ---
@@ -120,7 +120,7 @@
### 待文档/流程 ### 待文档/流程
- [ ] `docs/IT智能服务台-版本更新说明-20250614.md` 4 处错误修订 - [ ] `docs/智能IT支持服务台-版本更新说明-20250614.md` 4 处错误修订
- [ ] workbuddy 推送流程:加 "PR 前 P0 强制评审" 环节 - [ ] workbuddy 推送流程:加 "PR 前 P0 强制评审" 环节
--- ---
+4 -4
View File
@@ -1,4 +1,4 @@
# IT智能服务台 — 调试验证指南 # 智能IT支持服务台 — 调试验证指南
**创建时间**: 2026-06-13 **创建时间**: 2026-06-13
**适用环境**: 正式服务器 10.90.5.10 (itsupport.servyou.com.cn) **适用环境**: 正式服务器 10.90.5.10 (itsupport.servyou.com.cn)
@@ -128,7 +128,7 @@
├─────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────┤
│ │ │ │
│ ┌─────────────────┐ ┌─────────────────┐ │ │ ┌─────────────────┐ ┌─────────────────┐ │
│ │ IT智能服务台(正式) │ │ IT智能服务台-测试 │ │ │ │ 智能IT支持服务台(正式) │ │ 智能IT支持服务台-测试 │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ 可信域名: │ │ 可信域名: │ │ │ │ 可信域名: │ │ 可信域名: │ │
│ │ itsupport.xxx │ │ itdesk.amanzac │ │ │ │ itsupport.xxx │ │ itdesk.amanzac │ │
@@ -153,7 +153,7 @@
1. 登录 [企微管理后台](https://work.weixin.qq.com/wework_admin/frame) 1. 登录 [企微管理后台](https://work.weixin.qq.com/wework_admin/frame)
2. **应用管理****自建** → **创建应用** 2. **应用管理****自建** → **创建应用**
3. 填写信息: 3. 填写信息:
- **应用名称**: `IT智能服务台-测试` - **应用名称**: `智能IT支持服务台-测试`
- **应用logo**: 使用不同颜色(如橙色)区分正式应用 - **应用logo**: 使用不同颜色(如橙色)区分正式应用
- **应用介绍**: "仅供IT部门测试使用" - **应用介绍**: "仅供IT部门测试使用"
- **可见范围**: 选择IT部门 + 测试人员 - **可见范围**: 选择IT部门 + 测试人员
@@ -209,7 +209,7 @@ VITE_WECOM_CORP_ID=ww_test_xxxxx
| 步骤 | 操作 | 预期结果 | | 步骤 | 操作 | 预期结果 |
|------|------|---------| |------|------|---------|
| 1 | 在企微中找到"IT智能服务台-测试"应用 | 应用显示在工作台 | | 1 | 在企微中找到"智能IT支持服务台-测试"应用 | 应用显示在工作台 |
| 2 | 点击应用 | 跳转到 `https://itdesk.amanzac.com/itdesk/` | | 2 | 点击应用 | 跳转到 `https://itdesk.amanzac.com/itdesk/` |
| 3 | 首次访问 | 跳转企微OAuth2授权页 | | 3 | 首次访问 | 跳转企微OAuth2授权页 |
| 4 | 确认授权 | 跳回H5聊天页面 | | 4 | 确认授权 | 跳回H5聊天页面 |
+6 -6
View File
@@ -1,8 +1,8 @@
# IT智能服务台 — 资源申请清单 # 智能IT支持服务台 — 资源申请清单
> **📌 使用说明(工作流程)** > **📌 使用说明(工作流程)**
> >
> 本文档是 IT智能服务台 所有资源申请需求的**统一汇总入口**,适用于: > 本文档是 智能IT支持服务台 所有资源申请需求的**统一汇总入口**,适用于:
> - 服务器/域名/网络等资源申请 > - 服务器/域名/网络等资源申请
> - 外部系统 API 对接申请(联软、火绒、aTrust、北森 eHR 等) > - 外部系统 API 对接申请(联软、火绒、aTrust、北森 eHR 等)
> - 任何需要向其他团队(运维/安全/网络)申请权限或资源的任务 > - 任何需要向其他团队(运维/安全/网络)申请权限或资源的任务
@@ -18,7 +18,7 @@
## 一、背景 ## 一、背景
IT智能服务台(IT Smart Desk)包含两个部署环境,需向运维申请服务器、域名及反向代理资源: 智能IT支持服务台(IT Smart Desk)包含两个部署环境,需向运维申请服务器、域名及反向代理资源:
| 环境 | 用途 | 部署位置 | 访问方式 | | 环境 | 用途 | 部署位置 | 访问方式 |
|------|------|---------|---------| |------|------|---------|---------|
@@ -87,7 +87,7 @@ IT智能服务台(IT Smart Desk)包含两个部署环境,需向运维申
#### 建议 Nginx 配置片段 #### 建议 Nginx 配置片段
```nginx ```nginx
# ==================== IT智能服务台 — 预生产 ==================== # ==================== 智能IT支持服务台 — 预生产 ====================
# 后端 API # 后端 API
location /api/ { location /api/ {
proxy_pass http://10.80.0.129:18080/api/; proxy_pass http://10.80.0.129:18080/api/;
@@ -219,7 +219,7 @@ NAS 环境的 Nginx 已内置于 Docker Compose**无需运维额外配置**
#### 项目背景 #### 项目背景
IT智能服务台核心目标之一是**打通员工↔终端的映射链路**。联软LV7000拥有最准确的员工账号→终端设备映射数据(`strusername`字段),是终端信息集成的**核心数据源**。 智能IT支持服务台核心目标之一是**打通员工↔终端的映射链路**。联软LV7000拥有最准确的员工账号→终端设备映射数据(`strusername`字段),是终端信息集成的**核心数据源**。
#### API 账户(超管自建) #### API 账户(超管自建)
@@ -307,7 +307,7 @@ IT智能服务台核心目标之一是**打通员工↔终端的映射链路**
- **申请人**:宋献,IT支持组(税友集团),负责终端安全 - **申请人**:宋献,IT支持组(税友集团),负责终端安全
- **火绒/联软对接人**:宋献(超管权限,自行创建API账户,受安全团队管理) - **火绒/联软对接人**:宋献(超管权限,自行创建API账户,受安全团队管理)
- **项目**IT智能服务台(IT Smart Desk - **项目**:智能IT支持服务台(IT Smart Desk
- **紧急程度**:预生产反代配置建议 1-2 个工作日内完成;生产环境 NAS 自建,无需运维介入 - **紧急程度**:预生产反代配置建议 1-2 个工作日内完成;生产环境 NAS 自建,无需运维介入
- **组织架构说明** - **组织架构说明**
- IT支持组 = 终端安全负责团队(非独立"终端安全团队") - IT支持组 = 终端安全负责团队(非独立"终端安全团队")
+1 -1
View File
@@ -212,7 +212,7 @@ async def send_invite_card(
"template_card": { "template_card": {
"card_type": "button_interaction", "card_type": "button_interaction",
"source": { "source": {
"desc": "IT智能服务台" "desc": "智能IT支持服务台"
}, },
"main_title": { "main_title": {
"title": "🔔 会话邀请" "title": "🔔 会话邀请"
+226
View File
@@ -0,0 +1,226 @@
# 需求: 应急降级页(H5 + Agent Preview)via 企微"服务窗口"
**需求提出**: 2026-06-15(经多次澄清,核心场景为 BC/DR)
**需求方**: Simon
**核心场景**: 🔴 **业务连续性(BC/DR)** — 系统故障时切换至企微原生服务,坐席保留关键功能
**入口**: 🏢 **企微"员工服务"应用 → "服务窗口"**(只配 1 个 URL)
**关联规则**: [[locked-decisions]] § 应急降级 / [[preview-pages-sync-rule]]
---
## 🎯 业务目标(真实场景)
当本系统出现**特殊情况**(故障 / 不可用 / 合规要求 / 流量过载)时:
1. **员工侧**: 切换至企微**原生员工服务**(群聊/单聊兜底)
2. **坐席侧**: 通过企微"员工服务 → 服务窗口" 链接,使用本系统应急页
3. **目标**: 即使主系统挂掉,**核心 IT 服务不中断**
## 🏢 入口架构(1 URL + 企微 JS-SDK)
```
┌────────────────────────────────────────┐
│ 企微"员工服务"应用(企业已建) │
│ └─ "服务窗口" tab(只能配 1 个 URL) │
│ └─ https://itsupport.servyou.com.cn/emergency
└────────────────────────────────────────┘
┌────────────────────────────────────────┐
│ /emergency 页面(身份检测) │
│ 1. 加载企微 JS-SDK(不依赖本后端) │
│ 2. agentConfig 拿当前 userid │
│ 3. 调企微通讯录 API 查 user 详情 │
│ 4. 判断身份:是"IT支持-咨询坐席"标签成员 │
│ ├─ 是 → router.push('/agent/preview')│
│ └─ 否 → router.push('/h5/preview') │
└────────────────────────────────────────┘
```
**关键**:
- 身份检测**不依赖本系统后端**(主系统挂时仍可用)
- 应急页 2 套(h5 + agent),通过 router.push 切换
- 企微通讯录 API 走企微 access_token,跟主系统无关
## 📋 保留功能(4 件套 + 动态联系人)
| # | 功能 | 描述 | 数据源 |
|---|---|---|---|
| 1 | 🔍 **快速回复模板** | 100+ 条按关键词搜索 | mock JSON → localStorage |
| 2 | 🔍 **排障流程模板** | vpn/邮箱/系统/账号 4 大类 | mock JSON → localStorage |
| 3 | 📋 **资源/审批链接** | 12 个常用入口 | mock JSON → localStorage |
| 4 | 👥 **应急联系人** | 企微标签"IT支持-咨询坐席"成员 | 企微 API → 单独 localStorage |
| **合计** | | | **~750KB + 联系人列表** |
### 应急联系人(动态,非固定)
**数据源**: 企微通讯录标签"IT支持-咨询坐席"
**预同步**:
- 主系统正常时,每 30 分钟调企微通讯录 API 查该标签成员
- 存到**独立 localStorage key** `emergency_contacts`:
```json
{
"synced_at": "2026-06-15T10:00:00",
"tag_id": "TAG_xxx",
"members": [
{"userid": "zhangsan", "name": "张三", "avatar": "...", "online": true},
...
]
}
```
**应急展示**:
- 列出标签下所有成员
- 在线/离线状态(企微 status 接口)
- 点击 → 打开企微单聊
- 数据 > 1 小时未更新时标红,提示"联系人可能不准,建议手动搜索标签组"
## 🆘 "特殊情况" 触发与降级
| 类型 | 触发条件 | 降级动作 |
|---|---|---|
| 🔴 主系统完全不可用 | API 5xx / 网络断开 | 切企微原生 + 服务窗口 |
| 🟡 部分功能故障 | 消息发送失败 / 排队堵死 | 切企微原生 + 工具走应急页 |
| 🟠 合规要求 | 必须用企微审计 | 切企微原生 + 应急页工单 |
| 🟢 流量过载 | 服务降级中 | 部分功能走应急页 |
## 📋 范围
| 项 | H5 应急页 | Agent 应急页 |
|---|---|---|
| URL | `/h5/preview` | `/agent/preview` |
| 统一入口 | `/emergency` | `/emergency` |
| 显示组件 | `RightPanel.vue`(3 段式) | `AiAssistantPanel.vue`(4 件套) |
| 去除功能 | `ChatPanel`(聊天走企微原生) | `ConversationList` + `ChatArea` + `TopBar` |
| 数据源 | **预同步到 localStorage** | **预同步到 localStorage** |
| 后端调用 | **无**(主系统可能挂) | **无** |
| 用户登录 | **跳过**(应急场景免登) | **跳过**(应急场景免登) |
## 🔄 数据预同步机制(2 个独立 localStorage)
### localStorage #1: `emergency_data`(静态工具数据)
**正常态**:
```
主后端 /api/emergency-data
→ 前端每 30 分钟拉取
→ 存到 localStorage("emergency_data")
```
**应急态**:
```
localStorage("emergency_data") → 应急页直接渲染
```
**内容**: 快速回复 / 排障 / 资源(~750KB)
### localStorage #2: `emergency_contacts`(动态联系人)
**正常态**:
```
企微通讯录 API 查"IT支持-咨询坐席"标签
→ 前端每 30 分钟拉取
→ 存到 localStorage("emergency_contacts")
```
**应急态**:
```
localStorage("emergency_contacts") → 应急页渲染联系人列表
```
**内容**: 标签成员列表(动态,可能多人)
## 🎨 页面要求(应急场景)
### `/emergency`(身份检测入口,~50 行)
- 加载企微 JS-SDK(wx.config + wx.agentConfig)
- 拿当前 userid
- 调企微通讯录 API 查 user 详情 + 标签
- 判断是否含"IT支持-咨询坐席"标签
- 是坐席 → push /agent/preview,否则 → push /h5/preview
- 检测失败 → 显示"请选择身份"2 个大按钮兜底
### H5 应急页(`/h5/preview`)
- 顶部: 项目名 + **"🆘 应急模式"** 红色徽章 + 数据更新时间
- 主体: `RightPanel` 全宽,3 段式(AI 推送 / 资源 / 趣味问答)
- 底部: 固定"主系统异常?此页面帮您继续获得服务"
- 移动端: 强制显示(覆盖 `isMobile` 判断)
### Agent 应急页(`/agent/preview`)
- 顶栏: 简化版 TopBar + 🆘 徽章
- 主体: `AiAssistantPanel` 全宽,4 件套
- 联系人: 标签组成员 + 在线状态
- 底部: 固定"主系统异常"提示
## 📁 文件改动清单(调整)
### H5 端 (frontend-h5/)
| 操作 | 路径 | 说明 |
|---|---|---|
| 新建 | `src/views/EmergencyEntry.vue` | 身份检测入口(~50 行) |
| 新建 | `src/views/H5PreviewView.vue` | 应急主页 |
| 新建 | `src/mock/emergency-data.json` | 应急静态数据 |
| 新建 | `src/utils/emergency-sync.ts` | 同步工具(2 个 localStorage) |
| 改 | `src/router/index.ts` | 加 `/emergency` + `/h5/preview` |
| **复用** | `src/components/assistant/RightPanel.vue` | import 共享 |
### Agent 端 (frontend-agent/)
| 操作 | 路径 | 说明 |
|---|---|---|
| 新建 | `src/views/AgentPreviewView.vue` | 应急主页 |
| 改 | `src/router/index.ts` | 加 `/agent/preview` |
| **复用** | `src/components/assistant/AiAssistantPanel.vue` | import 共享 |
**注**: 联系人 API 调用放 `emergency-sync.ts` 共用模块,h5 + agent 都用
## 🧪 验收标准
### 功能验收
- [ ] **断网测试**: 拔网线/关后端 → 应急页仍能打开
- [ ] **身份自动路由**: 员工点开 → /h5/preview,坐席点开 → /agent/preview
- [ ] **联系人动态**: 标签组加新人,预同步后能显示
- [ ] **数据新鲜度**: 顶部"数据 X 分钟前更新",> 1h 标红
- [ ] **2 个 localStorage 独立工作**: 删 emergency_data 不影响 emergency_contacts
### 灾备演练(非工作时间,本月必做)
- [ ] 选择非工作时间(晚上 / 周末)
- [ ] 模拟主系统挂掉 → 切企微原生服务 → 坐席用应急页
- [ ] 演练时长 / 解决率 / 痛点记录
- [ ] 详见 `docs/SOPs/SOP-005-应急降级演练.md`
## 📅 排期
| 时间 | 任务 |
|---|---|
| 2026-06-16 (周一) | WB 接单,1 入口 + 2 页面 + 1 mock + 1 同步工具 + 2 路由 |
| 2026-06-16 (周一) | 本地 `npm run build` 验证 |
| 2026-06-17 (周二) | 部署 + **断网演练**(非工作时间,如周二晚 20:00) |
| 2026-06-18 (周三) | 收集问题,迭代 |
| 2026-06-19 (周四) | 二次演练确认 |
| 2026-06-20 (周五) | 正式版上线 + 应急页同步上线 |
## ⚠️ 应急场景的额外考虑
1. **入口要醒目**: 企微"员工服务 → 服务窗口"配置清晰描述
2. **不依赖登录**: 应急时坐席可能密码都改不了,免登
3. **非工作时间演练**: 降低对业务影响
4. **联系人降级**: 实在没预同步数据,提示"请手动搜索 IT支持-咨询坐席 标签"
5. **保留升级路径**: 主系统恢复后,应急页要有提示"主系统已恢复,建议返回"
## 🔗 关联
- **双端同步规则**: [[preview-pages-sync-rule]]
- **锁定决策**: [[locked-decisions]] § 应急降级
- **演练 SOP**: `docs/SOPs/SOP-005-应急降级演练.md`
- **需求演进**: v1 灰度(误)→ v2 BC/DR(理解)→ v3 服务窗口(1 URL)→ v4 标签联系人(动态)
---
🤖 Generated with [Claude Code](https://claude.com/claude-code)
+2 -2
View File
@@ -1,4 +1,4 @@
# IT智能服务台 — 项目任务状态报告 # 智能IT支持服务台 — 项目任务状态报告
**报告时间**: 2026-06-13 11:00 **报告时间**: 2026-06-13 11:00
**报告版本**: v1.0 **报告版本**: v1.0
@@ -185,7 +185,7 @@
2. **构建并部署最新代码**:将今天的 Bug 修复 + UI 风格更新部署到服务器 2. **构建并部署最新代码**:将今天的 Bug 修复 + UI 风格更新部署到服务器
### 近期安排(P1 ### 近期安排(P1
3. **创建测试企微应用**:按照双企微应用方案,创建"IT智能服务台-测试"应用 3. **创建测试企微应用**:按照双企微应用方案,创建"智能IT支持服务台-测试"应用
4. **阶段二启动**:排队机制 + 满意度评价设计 4. **阶段二启动**:排队机制 + 满意度评价设计
5. **aTrust对接**:找信息安全团队获取API密钥 5. **aTrust对接**:找信息安全团队获取API密钥
@@ -1,4 +1,4 @@
# IT智能服务台 — 项目开发任务调整建议 # 智能IT支持服务台 — 项目开发任务调整建议
> **文档版本**: V1.0 > **文档版本**: V1.0
> **创建日期**: 2026-06-11 > **创建日期**: 2026-06-11
+3 -3
View File
@@ -1,4 +1,4 @@
# IT智能服务台 — 风险跟踪表 # 智能IT支持服务台 — 风险跟踪表
**最后更新**: 2026-06-14 18:30 **最后更新**: 2026-06-14 18:30
**维护人**: 宋献 + Claude 评审协作 **维护人**: 宋献 + Claude 评审协作
@@ -567,7 +567,7 @@ location /api/ {
## 九、2026-06-14 workbuddy 推送评审新增 ## 九、2026-06-14 workbuddy 推送评审新增
**评审依据**: `docs/评审报告/workbuddy-2026-06-14-消息优化.md` **评审依据**: `docs/评审报告/workbuddy-2026-06-14-消息优化.md`
**评审范围**: workbuddy 6-14 推送 + `IT智能服务台-版本更新说明-20250614.md` **评审范围**: workbuddy 6-14 推送 + `智能IT支持服务台-版本更新说明-20250614.md`
**小计**: 13 项发现(6 P0 + 4 P1 + 3 P2),其中 7 项已修本地代码,6 项待 workbuddy 跟进 **小计**: 13 项发现(6 P0 + 4 P1 + 3 P2),其中 7 项已修本地代码,6 项待 workbuddy 跟进
--- ---
@@ -650,7 +650,7 @@ location /api/ {
- **状态**: ⚠️ 待处理 - **状态**: ⚠️ 待处理
- **风险级别**: 🟠 高(文档与代码不符) - **风险级别**: 🟠 高(文档与代码不符)
- **位置**: `docs/IT智能服务台-版本更新说明-20250614.md:46` 声称改动 / `backend/app/services/ws_manager.py` 实际无对应方法 - **位置**: `docs/智能IT支持服务台-版本更新说明-20250614.md:46` 声称改动 / `backend/app/services/ws_manager.py` 实际无对应方法
- **问题**: ConnectionManager 仅有 `send_to_agent` / `broadcast` / `send_to_employee` / `broadcast_to_employees`**无 `broadcast_message_status(conv_id, msg_id, status)`** - **问题**: ConnectionManager 仅有 `send_to_agent` / `broadcast` / `send_to_employee` / `broadcast_to_employees`**无 `broadcast_message_status(conv_id, msg_id, status)`**
- **处理建议**: 实现该方法 + WebSocket 消息格式 - **处理建议**: 实现该方法 + WebSocket 消息格式
+1
View File
@@ -0,0 +1 @@
IyEvYmluL2Jhc2gKc2V0ICtlICAgIyBjb2xsZWN0IGV2ZXJ5dGhpbmcsIGRvbid0IGJhaWwKCmVjaG8gJyMjIyMjIyMjIyMjIyBTVEVQIDE6IExvY2F0ZSBwcm9qZWN0IGRpcmVjdG9yeSAjIyMjIyMjIyMjIyMnCmNkIC9vcHQvd2Vjb20taXQtZGVzayAyPiYxCmVjaG8gIkN1cnJlbnQgZGlyOiAkKHB3ZCkiCmxzIC1sYSBkb2NrZXItY29tcG9zZS55bWwgMj4mMQplY2hvICcnCgplY2hvICcjIyMjIyMjIyMjIyMgU1RFUCAyOiBEaWFnbm9zZSAoUkVBRC1PTkxZKSAjIyMjIyMjIyMjIyMnCmVjaG8gJy0tLSBBbGwgd2Vjb21faXRfIGNvbnRhaW5lcnMgLS0tJwpkb2NrZXIgcHMgLWEgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319IiB8IGdyZXAgLUUgIndlY29tX2l0X3xOQU1FUyIKZWNobyAnJwplY2hvICctLS0gRGlzayBzcGFjZSAtLS0nCmRmIC1oIC9vcHQgMj4mMQplY2hvICcnCmVjaG8gJy0tLSBiYWNrZW5kIGxhc3QgNjAgbG9nIGxpbmVzIC0tLScKZG9ja2VyIGxvZ3Mgd2Vjb21faXRfYmFja2VuZCAtLXRhaWwgNjAgMj4mMQplY2hvICcnCmVjaG8gJy0tLSBiYWNrZW5kIGludGVybmFsIGhlYWx0aCBjaGVjayAtLS0nCmRvY2tlciBleGVjIHdlY29tX2l0X2JhY2tlbmQgY3VybCAtcyAtbyAtIC13ICJcbkhUVFBfQ09ERTogJXtodHRwX2NvZGV9XG4iIC0tbWF4LXRpbWUgNSBodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoIDI+JjEKZWNobyAnJwoKZWNobyAnIyMjIyMjIyMjIyMjIFNURVAgMzogUmVzdGFydCBmcm9tIGNvcnJlY3QgZGlyZWN0b3J5ICMjIyMjIyMjIyMjIycKY2QgL29wdC93ZWNvbS1pdC1kZXNrCmRvY2tlciBjb21wb3NlIHVwIC1kIDI+JjEKZWNobyAnJwplY2hvICdXYWl0aW5nIDE1cyBmb3Igc2VydmljZXMgdG8gc3RhYmlsaXplLi4uJwpzbGVlcCAxNQplY2hvICcnCmVjaG8gJy0tLSBDb250YWluZXJzIGFmdGVyIHJlc3RhcnQgLS0tJwpkb2NrZXIgcHMgLWEgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319IiB8IGdyZXAgLUUgIndlY29tX2l0X3xOQU1FUyIKZWNobyAnJwoKZWNobyAnIyMjIyMjIyMjIyMjIFNURVAgNDogRW5kLXRvLWVuZCB2ZXJpZmljYXRpb24gIyMjIyMjIyMjIyMjJwplY2hvICctLS0gYmFja2VuZCAvaGVhbHRoIC0tLScKY3VybCAtcyAtbyAtIC13ICJcbkhUVFBfQ09ERTogJXtodHRwX2NvZGV9XG4iIC0tbWF4LXRpbWUgNSBodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoCmVjaG8gJycKZWNobyAnLS0tIG5naW54IHJvdXRlcyAoZXhwZWN0IDIwMC8zMDEvMzAyKSAtLS0nCmZvciBwYXRoIGluIC8gL2l0YWdlbnQvIC9pdGg1LyAvaXRhZG1pbi87IGRvCiAgY29kZT0kKGN1cmwgLXMgLW8gL2Rldi9udWxsIC13ICIle2h0dHBfY29kZX0iIC0tbWF4LXRpbWUgNSAiaHR0cDovL2xvY2FsaG9zdCR7cGF0aH0iKQogIGVjaG8gIiAgJHBhdGggLT4gSFRUUCAkY29kZSIKZG9uZQplY2hvICcnCmVjaG8gJyMjIyMjIyMjIyMjIyBET05FICMjIyMjIyMjIyMjIycKZWNobyAnUGFzdGUgQUxMIG91dHB1dCBhYm92ZSBiYWNrIHRvIENsYXVkZSBmb3IgZGlhZ25vc2lzJwo=
+1
View File
@@ -0,0 +1 @@
IyEvYmluL2Jhc2gKc2V0ICtlICAgIyBjb2xsZWN0IGV2ZXJ5dGhpbmcsIGRvbid0IGJhaWwKCmVjaG8gJyMjIyMjIyMjIyMjIyBTVEVQIDE6IExvY2F0ZSBwcm9qZWN0IGRpcmVjdG9yeSAjIyMjIyMjIyMjIyMnCmNkIC9vcHQvd2Vjb20taXQtZGVzayAyPiYxCmVjaG8gIkN1cnJlbnQgZGlyOiAkKHB3ZCkiCmxzIC1sYSBkb2NrZXItY29tcG9zZS55bWwgMj4mMQplY2hvICcnCgplY2hvICcjIyMjIyMjIyMjIyMgU1RFUCAyOiBEaWFnbm9zZSAoUkVBRC1PTkxZKSAjIyMjIyMjIyMjIyMnCmVjaG8gJy0tLSBBbGwgd2Vjb21faXRfIGNvbnRhaW5lcnMgLS0tJwpkb2NrZXIgcHMgLWEgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319IiB8IGdyZXAgLUUgIndlY29tX2l0X3xOQU1FUyIKZWNobyAnJwplY2hvICctLS0gRGlzayBzcGFjZSAtLS0nCmRmIC1oIC9vcHQgMj4mMQplY2hvICcnCmVjaG8gJy0tLSBiYWNrZW5kIGxhc3QgNjAgbG9nIGxpbmVzIC0tLScKZG9ja2VyIGxvZ3Mgd2Vjb21faXRfYmFja2VuZCAtLXRhaWwgNjAgMj4mMQplY2hvICcnCmVjaG8gJy0tLSBiYWNrZW5kIGludGVybmFsIGhlYWx0aCBjaGVjayAtLS0nCmRvY2tlciBleGVjIHdlY29tX2l0X2JhY2tlbmQgY3VybCAtcyAtbyAtIC13ICJcbkhUVFBfQ09ERTogJXtodHRwX2NvZGV9XG4iIC0tbWF4LXRpbWUgNSBodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoIDI+JjEKZWNobyAnJwoKZWNobyAnIyMjIyMjIyMjIyMjIFNURVAgMzogUmVzdGFydCBmcm9tIGNvcnJlY3QgZGlyZWN0b3J5
+1
View File
@@ -0,0 +1 @@
ICMjIyMjIyMjIyMjIycKY2QgL29wdC93ZWNvbS1pdC1kZXNrCmRvY2tlciBjb21wb3NlIHVwIC1kIDI+JjEKZWNobyAnJwplY2hvICdXYWl0aW5nIDE1cyBmb3Igc2VydmljZXMgdG8gc3RhYmlsaXplLi4uJwpzbGVlcCAxNQplY2hvICcnCmVjaG8gJy0tLSBDb250YWluZXJzIGFmdGVyIHJlc3RhcnQgLS0tJwpkb2NrZXIgcHMgLWEgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319IiB8IGdyZXAgLUUgIndlY29tX2l0X3xOQU1FUyIKZWNobyAnJwoKZWNobyAnIyMjIyMjIyMjIyMjIFNURVAgNDogRW5kLXRvLWVuZCB2ZXJpZmljYXRpb24gIyMjIyMjIyMjIyMjJwplY2hvICctLS0gYmFja2VuZCAvaGVhbHRoIC0tLScKY3VybCAtcyAtbyAtIC13ICJcbkhUVFBfQ09ERTogJXtodHRwX2NvZGV9XG4iIC0tbWF4LXRpbWUgNSBodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoCmVjaG8gJycKZWNobyAnLS0tIG5naW54IHJvdXRlcyAoZXhwZWN0IDIwMC8zMDEvMzAyKSAtLS0nCmZvciBwYXRoIGluIC8gL2l0YWdlbnQvIC9pdGg1LyAvaXRhZG1pbi87IGRvCiAgY29kZT0kKGN1cmwgLXMgLW8gL2Rldi9udWxsIC13ICIle2h0dHBfY29kZX0iIC0tbWF4LXRpbWUgNSAiaHR0cDovL2xvY2FsaG9zdCR7cGF0aH0iKQogIGVjaG8gIiAgJHBhdGggLT4gSFRUUCAkY29kZSIKZG9uZQplY2hvICcnCmVjaG8gJyMjIyMjIyMjIyMjIyBET05FICMjIyMjIyMjIyMjIycKZWNobyAnUGFzdGUgQUxMIG91dHB1dCBhYm92ZSBiYWNrIHRvIENsYXVkZSBmb3IgZGlhZ25vc2lzJwo=
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
set +e # collect everything, don't bail
echo '############ STEP 1: Locate project directory ############'
cd /opt/wecom-it-desk 2>&1
echo "Current dir: $(pwd)"
ls -la docker-compose.yml 2>&1
echo ''
echo '############ STEP 2: Diagnose (READ-ONLY) ############'
echo '--- All wecom_it_ containers ---'
docker ps -a --format "table {{.Names}}\t{{.Status}}" | grep -E "wecom_it_|NAMES"
echo ''
echo '--- Disk space ---'
df -h /opt 2>&1
echo ''
echo '--- backend last 60 log lines ---'
docker logs wecom_it_backend --tail 60 2>&1
echo ''
echo '--- backend internal health check ---'
docker exec wecom_it_backend curl -s -o - -w "\nHTTP_CODE: %{http_code}\n" --max-time 5 http://localhost:8000/health 2>&1
echo ''
echo '############ STEP 3: Restart from correct directory ############'
cd /opt/wecom-it-desk
docker compose up -d 2>&1
echo ''
echo 'Waiting 15s for services to stabilize...'
sleep 15
echo ''
echo '--- Containers after restart ---'
docker ps -a --format "table {{.Names}}\t{{.Status}}" | grep -E "wecom_it_|NAMES"
echo ''
echo '############ STEP 4: End-to-end verification ############'
echo '--- backend /health ---'
curl -s -o - -w "\nHTTP_CODE: %{http_code}\n" --max-time 5 http://localhost:8000/health
echo ''
echo '--- nginx routes (expect 200/301/302) ---'
for path in / /itagent/ /ith5/ /itadmin/; do
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://localhost${path}")
echo " $path -> HTTP $code"
done
echo ''
echo '############ DONE ############'
echo 'Paste ALL output above back to Claude for diagnosis'
+3 -1
View File
@@ -3,8 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- CSP 安全策略 -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' https://res.wx.qq.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; connect-src 'self' https://qyapi.weixin.qq.com wss://*;" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>IT智能服务台 - 管理后台</title> <title>智能IT支持服务台 - 管理后台</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+4
View File
@@ -23,6 +23,10 @@
"typescript": "^5.5.0", "typescript": "^5.5.0",
"vite": "^5.3.0", "vite": "^5.3.0",
"vue-tsc": "^2.0.0" "vue-tsc": "^2.0.0"
},
"engines": {
"node": ">=20.0.0 <21.0.0",
"pnpm": ">=9.0.0"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
+10 -1
View File
@@ -3,7 +3,9 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "企微IT智能服务台 - 管理后台前端", "description": "企微智能IT支持服务台 - 管理后台前端",
"engines": { "node": ">=20.0.0 <21.0.0", "pnpm": ">=9.0.0" },
"packageManager": "pnpm@9.15.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc && vite build", "build": "vue-tsc && vite build",
@@ -11,11 +13,18 @@
"type-check": "vue-tsc --noEmit" "type-check": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-json": "^6.0.1",
"@codemirror/state": "^6.4.1",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.26.3",
"@element-plus/icons-vue": "^2.3.0", "@element-plus/icons-vue": "^2.3.0",
"axios": "^1.7.0", "axios": "^1.7.0",
"codemirror": "^6.0.1",
"element-plus": "^2.7.0", "element-plus": "^2.7.0",
"pinia": "^2.1.0", "pinia": "^2.1.0",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-codemirror": "^6.0.1",
"vue-json-pretty": "^2.2.4",
"vue-router": "^4.3.0" "vue-router": "^4.3.0"
}, },
"devDependencies": { "devDependencies": {
+171
View File
@@ -0,0 +1,171 @@
// =============================================================================
// 排查模板 API 客户端
// =============================================================================
// 对接后端 /api/troubleshooting-templates 5 个 REST 端点
// 5 个端点:GET 列表 / GET 详情 / POST 新建 / PUT 更新 / DELETE 删除
// =============================================================================
import axios from 'axios'
// -----------------------------------------------------------------------------
// 类型定义
// -----------------------------------------------------------------------------
/** 步骤节点(顺序执行) */
export interface PathStepNode {
id: string
type: 'step'
label: string
status?: 'done' | 'current' | 'pending'
children?: FlowchartNode[]
}
/** 决策节点(yes/no 分支) */
export interface DecisionNode {
id: string
type: 'decision'
label: string
status?: 'done' | 'current' | 'pending'
yes_branch?: FlowchartNode
no_branch?: FlowchartNode
children?: FlowchartNode[]
}
/** 流程图节点(递归) */
export type FlowchartNode = PathStepNode | DecisionNode
/** 排查模板 */
export interface TroubleshootingTemplate {
id?: string
name: string
category: string
description?: string
estimated_time?: number
difficulty?: number
tags?: string[]
root_node: FlowchartNode
version?: string
status?: 'draft' | 'published'
created_at?: string
updated_at?: string
// 后端可能附加的统计字段
nodeCount?: number
}
/** API 响应通用结构 */
interface ApiResponse<T> {
code: number
message: string
data: T
}
// -----------------------------------------------------------------------------
// Axios 实例(继承全局 baseURL)
// -----------------------------------------------------------------------------
const http = axios.create({
baseURL: '/api',
timeout: 30000,
})
// -----------------------------------------------------------------------------
// 5 个端点
// -----------------------------------------------------------------------------
/** GET /api/troubleshooting-templates — 获取模板列表 */
export async function listTemplates(): Promise<TroubleshootingTemplate[]> {
const res = await http.get<ApiResponse<TroubleshootingTemplate[]>>(
'/troubleshooting-templates'
)
return res.data.data || []
}
/** GET /api/troubleshooting-templates/{id} — 获取模板详情 */
export async function getTemplate(id: string): Promise<TroubleshootingTemplate> {
const res = await http.get<ApiResponse<TroubleshootingTemplate>>(
`/troubleshooting-templates/${id}`
)
return res.data.data
}
/** POST /api/troubleshooting-templates — 新建模板 */
export async function createTemplate(
data: TroubleshootingTemplate
): Promise<TroubleshootingTemplate> {
const res = await http.post<ApiResponse<TroubleshootingTemplate>>(
'/troubleshooting-templates',
data
)
return res.data.data
}
/** PUT /api/troubleshooting-templates/{id} — 更新模板 */
export async function updateTemplate(
id: string,
data: TroubleshootingTemplate
): Promise<TroubleshootingTemplate> {
const res = await http.put<ApiResponse<TroubleshootingTemplate>>(
`/troubleshooting-templates/${id}`,
data
)
return res.data.data
}
/** DELETE /api/troubleshooting-templates/{id} — 删除模板 */
export async function deleteTemplate(id: string): Promise<void> {
await http.delete(`/troubleshooting-templates/${id}`)
}
/** 工具:把对象格式化成 JSON 字符串(带缩进) */
export function formatJson(obj: unknown): string {
return JSON.stringify(obj, null, 2)
}
/** 工具:校验 JSON 字符串是否合法,返回 {ok, data, error} */
export function validateJson(
text: string
): { ok: true; data: TroubleshootingTemplate } | { ok: false; error: string } {
try {
const data = JSON.parse(text) as TroubleshootingTemplate
return { ok: true, data }
} catch (e) {
const err = e as Error
return { ok: false, error: err.message }
}
}
/** 工具:统计节点数(递归) */
export function countNodes(node: FlowchartNode | undefined): number {
if (!node) return 0
let count = 1
if (node.children) {
for (const child of node.children) {
count += countNodes(child)
}
}
// 决策节点的 yes/no 分支
if ('yes_branch' in node && node.yes_branch) {
count += countNodes(node.yes_branch)
}
if ('no_branch' in node && node.no_branch) {
count += countNodes(node.no_branch)
}
return count
}
/** 工具:统计决策节点数 */
export function countDecisions(node: FlowchartNode | undefined): number {
if (!node) return 0
let count = node.type === 'decision' ? 1 : 0
if (node.children) {
for (const child of node.children) {
count += countDecisions(child)
}
}
if ('yes_branch' in node && node.yes_branch) {
count += countDecisions(node.yes_branch)
}
if ('no_branch' in node && node.no_branch) {
count += countDecisions(node.no_branch)
}
return count
}
@@ -0,0 +1,475 @@
<!-- =============================================================================
// 线
// =============================================================================
// ( A ):
// - 50%:CodeMirror JSON ( + + oneDark )
// - 50%:vue-json-pretty ()
// - :(////)
// - :////
// ============================================================================= -->
<template>
<el-dialog
:model-value="modelValue"
@update:model-value="(v) => $emit('update:modelValue', v)"
:title="dialogTitle"
width="80%"
top="5vh"
:close-on-click-modal="false"
:destroy-on-close="true"
>
<!-- ===== 顶部基本信息 ===== -->
<div class="basic-info">
<el-form :inline="true" size="small" label-width="70px">
<el-form-item label="名称">
<el-input v-model="form.name" placeholder="VPN 远程办公故障排查" style="width: 240px" :disabled="readonly" />
</el-form-item>
<el-form-item label="分类">
<el-select v-model="form.category" placeholder="选择分类" style="width: 130px" :disabled="readonly">
<el-option v-for="c in CATEGORY_OPTIONS" :key="c" :label="c" :value="c" />
</el-select>
</el-form-item>
<el-form-item label="预估时间">
<el-input-number v-model="form.estimated_time" :min="1" :max="120" size="small" :disabled="readonly" />
<span class="suffix">分钟</span>
</el-form-item>
<el-form-item label="难度">
<el-rate v-model="form.difficulty" :max="5" :disabled="readonly" />
</el-form-item>
<el-form-item label="标签">
<el-select
v-model="form.tags"
multiple
filterable
allow-create
default-first-option
placeholder="按 Enter 添加"
style="width: 240px"
:disabled="readonly"
/>
</el-form-item>
</el-form>
</div>
<!-- ===== 双栏编辑区 ===== -->
<div class="dual-pane">
<!-- :JSON 源码编辑器 -->
<div class="pane left-pane">
<div class="pane-header">
<span class="pane-title">📝 JSON 源码</span>
<span class="pane-stats">
节点 {{ stats.nodes }} · 决策 {{ stats.decisions }}
</span>
</div>
<div class="pane-body">
<codemirror
v-model="jsonText"
:options="cmOptions"
:height="`${editorHeight}px`"
:style="{ height: `${editorHeight}px` }"
@change="onCodeChange"
:readonly="readonly"
/>
</div>
</div>
<!-- :树形预览 -->
<div class="pane right-pane">
<div class="pane-header">
<span class="pane-title">🌳 树形预览</span>
<span class="pane-stats">
<el-tag v-if="parseOk" type="success" size="small"> JSON 有效</el-tag>
<el-tag v-else type="danger" size="small"> {{ parseError }}</el-tag>
</span>
</div>
<div class="pane-body">
<div v-if="parseOk" class="tree-wrapper">
<vue-json-pretty
:data="parsedData"
:show-length="true"
:show-line="true"
:path="rootPath"
:deep="6"
/>
</div>
<div v-else class="tree-error">
<p> JSON 解析失败</p>
<pre>{{ parseError }}</pre>
<p class="hint">请检查左栏 JSON 语法,例如:</p>
<ul>
<li>末尾的逗号</li>
<li>未闭合的引号或大括号</li>
<li>未转义的字符</li>
</ul>
</div>
</div>
</div>
</div>
<!-- ===== 底栏按钮 ===== -->
<template #footer>
<div class="dialog-footer">
<div class="footer-left">
<el-button size="small" @click="handleFormat" :disabled="readonly">
<el-icon><MagicStick /></el-icon>
格式化
</el-button>
<el-button size="small" @click="handleCopy" :disabled="!parseOk">
<el-icon><CopyDocument /></el-icon>
复制
</el-button>
<el-button size="small" @click="handleExport" :disabled="!parseOk">
<el-icon><Download /></el-icon>
导出此条
</el-button>
</div>
<div class="footer-right">
<el-button size="small" @click="handleCancel">取消</el-button>
<el-button
size="small"
type="primary"
:disabled="!parseOk || readonly"
:loading="saving"
@click="handleSave"
>
<el-icon><Check /></el-icon>
保存
</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
// =============================================================================
// imports
// =============================================================================
import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { MagicStick, CopyDocument, Download, Check } from '@element-plus/icons-vue'
import { Codemirror } from 'vue-codemirror'
import { json } from '@codemirror/lang-json'
import { oneDark } from '@codemirror/theme-one-dark'
import VueJsonPretty from 'vue-json-pretty'
import 'vue-json-pretty/lib/styles.css'
import {
formatJson,
validateJson,
countNodes,
countDecisions,
type TroubleshootingTemplate,
type FlowchartNode,
} from '@/api/troubleshooting'
// =============================================================================
// props & emits
// =============================================================================
const props = defineProps<{
modelValue: boolean
template: TroubleshootingTemplate | null
readonly?: boolean
saving?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
save: [template: TroubleshootingTemplate]
cancel: []
}>()
// =============================================================================
//
// =============================================================================
const CATEGORY_OPTIONS = [
'vpn', 'email', 'account', 'system', 'network',
'printer', 'software', 'hardware', 'wecom', 'other',
]
const EMPTY_TEMPLATE: TroubleshootingTemplate = {
name: '',
category: 'system',
description: '',
estimated_time: 5,
difficulty: 2,
tags: [],
root_node: {
id: 'fc-new-1',
type: 'step',
label: '请修改此步骤',
children: [],
},
}
// =============================================================================
//
// =============================================================================
const form = reactive<TroubleshootingTemplate>({ ...EMPTY_TEMPLATE })
const jsonText = ref<string>('')
const parseOk = ref<boolean>(true)
const parseError = ref<string>('')
const parsedData = ref<TroubleshootingTemplate | null>(null)
const editorHeight = ref<number>(500)
//
const cmOptions = {
mode: 'application/json',
theme: 'oneDark',
lineNumbers: true,
lineWrapping: true,
tabSize: 2,
indentUnit: 2,
smartIndent: true,
matchBrackets: true,
autoCloseBrackets: true,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
extensions: [json(), oneDark],
}
// =============================================================================
//
// =============================================================================
const dialogTitle = computed(() => {
if (props.readonly) return `👁 预览: ${form.name || '未命名'}`
return props.template?.id ? `✎ 编辑: ${form.name || '未命名'}` : '+ 新建流程图'
})
const stats = computed(() => ({
nodes: parseOk.value && parsedData.value ? countNodes(parsedData.value.root_node) : 0,
decisions: parseOk.value && parsedData.value ? countDecisions(parsedData.value.root_node) : 0,
}))
const rootPath = computed(() => 'root')
// =============================================================================
// watch
// =============================================================================
watch(
() => props.template,
(newTpl) => {
if (newTpl) {
Object.assign(form, newTpl)
jsonText.value = formatJson(newTpl)
onCodeChange()
} else {
Object.assign(form, EMPTY_TEMPLATE)
jsonText.value = formatJson(EMPTY_TEMPLATE)
onCodeChange()
}
},
{ immediate: true }
)
watch(jsonText, () => {
// form( JSON )
if (parseOk.value && parsedData.value) {
form.name = parsedData.value.name || form.name
form.category = parsedData.value.category || form.category
form.estimated_time = parsedData.value.estimated_time ?? form.estimated_time
form.difficulty = parsedData.value.difficulty ?? form.difficulty
form.tags = parsedData.value.tags || form.tags
}
})
// =============================================================================
//
// =============================================================================
function updateEditorHeight() {
editorHeight.value = Math.max(window.innerHeight - 380, 300)
}
onMounted(() => {
updateEditorHeight()
window.addEventListener('resize', updateEditorHeight)
})
onUnmounted(() => {
window.removeEventListener('resize', updateEditorHeight)
})
// =============================================================================
//
// =============================================================================
function onCodeChange() {
const result = validateJson(jsonText.value)
if (result.ok) {
parseOk.value = true
parseError.value = ''
parsedData.value = result.data
} else {
parseOk.value = false
parseError.value = result.error
parsedData.value = null
}
}
function handleFormat() {
if (parseOk.value && parsedData.value) {
jsonText.value = formatJson(parsedData.value)
ElMessage.success('JSON 已格式化')
}
}
async function handleCopy() {
try {
await navigator.clipboard.writeText(jsonText.value)
ElMessage.success('已复制到剪贴板')
} catch {
ElMessage.error('复制失败,请手动 Ctrl+C')
}
}
function handleExport() {
if (!parseOk.value || !parsedData.value) return
const blob = new Blob([jsonText.value], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${(form.name || 'flowchart').replace(/\s+/g, '-')}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success('已导出')
}
function handleCancel() {
emit('update:modelValue', false)
emit('cancel')
}
function handleSave() {
if (!parseOk.value || !parsedData.value) {
ElMessage.error('JSON 无效,无法保存')
return
}
// :form + parsedData JSON
const finalTpl: TroubleshootingTemplate = {
...parsedData.value,
name: form.name,
category: form.category,
description: form.description ?? parsedData.value.description,
estimated_time: form.estimated_time,
difficulty: form.difficulty,
tags: form.tags,
}
emit('save', finalTpl)
}
</script>
<style scoped>
.basic-info {
padding: 12px 0;
background: #fafafa;
border-radius: 6px;
margin-bottom: 12px;
}
.basic-info :deep(.el-form-item) {
margin-bottom: 0;
}
.basic-info .suffix {
margin-left: 4px;
color: #909399;
font-size: 12px;
}
.dual-pane {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
height: 540px;
}
.pane {
display: flex;
flex-direction: column;
border: 1px solid #e4e7ed;
border-radius: 6px;
overflow: hidden;
background: white;
}
.pane-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #f5f7fa;
border-bottom: 1px solid #e4e7ed;
font-size: 13px;
}
.pane-title {
font-weight: 600;
color: #303133;
}
.pane-stats {
font-size: 12px;
color: #909399;
}
.pane-body {
flex: 1;
overflow: auto;
padding: 8px;
}
.right-pane .pane-body {
background: #fafbfc;
}
.tree-wrapper {
font-size: 13px;
}
.tree-error {
color: #f56c6c;
padding: 16px;
}
.tree-error pre {
background: #fef0f0;
padding: 8px;
border-radius: 4px;
font-size: 12px;
white-space: pre-wrap;
}
.tree-error .hint {
margin-top: 12px;
color: #909399;
font-size: 12px;
}
.tree-error ul {
margin: 4px 0;
padding-left: 20px;
color: #909399;
font-size: 12px;
}
.dialog-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.footer-left,
.footer-right {
display: flex;
gap: 8px;
}
:deep(.el-dialog__body) {
padding: 16px 20px;
}
:deep(.CodeMirror) {
height: 100%;
font-family: 'Fira Code', 'Source Code Pro', monospace;
font-size: 13px;
}
</style>
+4
View File
@@ -37,6 +37,9 @@ export interface Agent {
today_resolved?: number today_resolved?: number
created_at: string created_at: string
updated_at: string updated_at: string
// OTP 二次验证(P0-#5 坐席本地密码配套)
otp_enabled?: number // 0/1, 是否启用 OTP
otp_secret?: string // OTP 密钥(敏感)
} }
/** 坐席状态 */ /** 坐席状态 */
@@ -340,6 +343,7 @@ export interface HuorongTerminalDetail {
version: string // 火绒客户端版本 version: string // 火绒客户端版本
is_online: boolean // 在线状态 is_online: boolean // 在线状态
last_connect_time?: number // 最后连接时间(Unix时间戳) last_connect_time?: number // 最后连接时间(Unix时间戳)
group_id?: number | string // 分组ID_info2 可能返回)
// 硬件信息(可选,_info2 返回) // 硬件信息(可选,_info2 返回)
cpu?: string cpu?: string
memory?: string memory?: string

Some files were not shown because too many files have changed in this diff Show More