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/*.log
.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
> **维护者**: 税友集团 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 配置变更日志
扩展 agents 新增 role角色 skill_tags技能标签字段
@@ -8,16 +8,23 @@ submitted_by(提交人)字段。
Revision ID: 006_admin_ext
Revises: 005_reply_to_id
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
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '006_admin_ext'
down_revision = '005_reply_to_id'
branch_labels = None
depends_on = None
revision: str = '006_admin_ext'
down_revision: Union[str, None] = '005_reply_to_id'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
@@ -113,4 +120,5 @@ def downgrade() -> None:
# 删除 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.table('config_change_logs')
op.drop_table('config_change_logs')
+2 -2
View File
@@ -5,7 +5,7 @@
新增 role_mapping_rules 表(角色映射规则)。
预置三个基础角色:user、agent、admin。
Revision ID: 007_role_sys
Revision ID: 007_role_system
Revises: 006_admin_ext
Create Date: 2026-06-12 23:00:00.000000
"""
@@ -14,7 +14,7 @@ from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '007_role_sys'
revision = '007_role_system'
down_revision = '006_admin_ext'
branch_labels = 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.services.wecom_service import WecomService
from app.utils.response import AppException, ERR_UNAUTHORIZED, success_response
from app.utils.error_codes import ErrorCode
# 速率限制器实例(与 main.py 共享同一配置)
# 移除 env_file=None 参数:slowapi 0.1.9 不支持该参数
@@ -211,30 +212,24 @@ async def agent_login(
if not existing_agent:
# 新坐席注册必须通过企微验证,防止任意 user_id 冒充
raise AppException(
1003,
ErrorCode.AUTH_TOKEN_INVALID,
"企微通讯录验证失败,新坐席注册需要企微身份验证。请稍后重试或联系管理员。"
)
logger.warning(
f"企微API不可达,已注册坐席降级放行: user_id={body.user_id}"
)
# P1 修复: 降级放行时,如果 agent 有 password_hash 则必须验证本地密码
if existing_agent and existing_agent.password_hash:
# P0 修复: 降级放行时,如果 agent 已设置密码则必须验证本地密码
if existing_agent:
if existing_agent.password_hash is None:
# 已注册坐席但未设置密码,要求先设置密码
raise AppException(
ErrorCode.AUTH_PASSWORD_REQUIRED,
"首次登录请先设置密码。管理后台 → 坐席管理 → 设置本地密码"
)
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')):
raise AppException(1011, "本地密码错误")
# 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, "本地密码错误")
raise AppException(ErrorCode.AUTH_PASSWORD_WRONG, "本地密码错误")
# 1. 查找或创建坐席记录
stmt = select(Agent).where(Agent.user_id == body.user_id)
@@ -571,9 +566,11 @@ async def update_agent_password(
# 如果已有旧密码,验证旧密码
if agent.password_hash:
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')):
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')
+24 -11
View File
@@ -16,23 +16,36 @@ 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": "资源申请",
"type": "jump", # 跳转审批
"keywords": ["申请资源", "要资源", "申请"],
},
# 模板122 - 设备申请(API提交)
"Bs7ucTGsPuFhxfk8pn8EydxrWxkVetB4JR8Pb6PHS": {
"id": "Bs7ucTGsPuFhxfk8pn8EydxrWxkVetB4JR8Pb6PHS",
}
if APPROVAL_TEMPLATE_DEVICE:
APPROVAL_TEMPLATES[APPROVAL_TEMPLATE_DEVICE] = {
"id": APPROVAL_TEMPLATE_DEVICE,
"name": "设备申请",
"type": "api", # API提交
"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())
if after_message_id:
# 转换为UUID类型查询,确保和数据库UUID字段类型匹配
# 校验 UUID 格式,然后转字符串(兼容 SQLite/PG 的 String(36) 列,避免类型匹配)
from uuid import UUID as UUIDType
try:
msg_uuid = UUIDType(after_message_id)
UUIDType(after_message_id) # 仅校验
except ValueError:
# 无效的UUID格式返回空列表
# 无效的UUID格式,返回空列表
items = []
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(
Message.id == msg_uuid
Message.id == str(after_message_id)
)
after_result = await db.execute(after_stmt)
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.employees import router as employees_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.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.wecom_jsapi import router as wecom_jsapi_router # v0.5.4 应急页 JS-SDK 签名
# 创建 API 路由器
# 所有子路由都会挂载到这个路由器上
@@ -157,6 +159,14 @@ api_router.include_router(portal_router, tags=["统一入口"])
# DELETE /api/admin/roles/mapping-rules/{id} — 删除映射规则
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
# GET /api/approval/templates — 获取审批模板列表
# GET /api/approval/templates/{id} — 获取审批模板详情
@@ -164,3 +174,7 @@ api_router.include_router(admin_roles_router, tags=["角色管理"])
# POST /api/approval/submit — API提交审批
# GET /api/approval/keywords — 获取审批关键词
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
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from starlette.requests import Request
from app.services.ws_manager import manager as ws_manager
from app.services.cache_service import cache_service
@@ -39,7 +38,6 @@ WS_CLOSE_UNAUTHORIZED = 4001
async def websocket_endpoint(
websocket: WebSocket,
agent_id: str,
request: Request,
) -> None:
"""坐席 WebSocket 端点主循环(含 WS-01 token 认证)。
@@ -61,10 +59,12 @@ async def websocket_endpoint(
- 兼容从 ?token= URL 参数获取(向后兼容)
- 不再将 token 暴露在 URL 中,避免 access_log 泄露
v0.5.1 修复:移除 `request: Request` 参数(部分 Starlette 版本注入 Request 失败,
改用 `websocket.headers` 和 `websocket.query_params` 读取 header/query)
Args:
websocket: FastAPI WebSocket 对象(框架自动注入)
agent_id: 坐席ID(从 URL 路径参数获取)
request: Starlette Request(用于获取 header
"""
# ======================================================================
# WS-01: Token 认证(从 subprotocol / header / query 获取)
@@ -74,17 +74,17 @@ async def websocket_endpoint(
# 格式: Sec-WebSocket-Protocol: bearer.{token}
# 说明: 浏览器原生 WebSocket API 不支持 headers 参数,但支持 subprotocols (第2参数数组)
# 前端用 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."):
token = subprotocol[7:] # 去掉 "bearer." 前缀
else:
# 其次从 Authorization header 获取
auth_header = request.headers.get("Authorization", "")
auth_header = websocket.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:] # 去掉 "Bearer " 前缀
else:
# 向后兼容:从 query param 获取(即将废弃)
token = request.query_params.get("token", "")
token = websocket.query_params.get("token", "")
# 步骤2: 检查 token 是否为空
if not token:
@@ -197,7 +197,6 @@ async def websocket_endpoint(
async def h5_websocket_endpoint(
websocket: WebSocket,
employee_id: str,
request: Request,
) -> None:
"""H5员工 WebSocket 端点主循环(含 token 认证)。
@@ -223,10 +222,12 @@ async def h5_websocket_endpoint(
- (与H5登录 API /api/h5/mock-login 存储格式一致)
- token 缺失、无效、过期、与 employee_id 不匹配均拒绝连接
v0.5.1 修复:移除 `request: Request` 参数(部分 Starlette 版本注入 Request 失败,
改用 `websocket.headers` 和 `websocket.query_params` 读取 header/query)
Args:
websocket: FastAPI WebSocket 对象(框架自动注入)
employee_id: 员工企微 UserID(从 URL 路径参数获取)
request: Starlette Request(用于获取 header
"""
# ======================================================================
# Token 认证(从 subprotocol / header / query 获取)
@@ -234,17 +235,17 @@ async def h5_websocket_endpoint(
# 步骤1: 优先从 Sec-WebSocket-Protocol (subprotocol) 获取 token,其次从 Authorization header,最后从 query(向后兼容)
# 格式: Sec-WebSocket-Protocol: bearer.{token}
subprotocol = request.headers.get("sec-websocket-protocol", "")
subprotocol = websocket.headers.get("sec-websocket-protocol", "")
if subprotocol.startswith("bearer."):
token = subprotocol[7:] # 去掉 "bearer." 前缀
else:
# 其次从 Authorization header 获取
auth_header = request.headers.get("Authorization", "")
auth_header = websocket.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:] # 去掉 "Bearer " 前缀
else:
# 向后兼容:从 query param 获取(即将废弃)
token = request.query_params.get("token", "")
token = websocket.query_params.get("token", "")
# 步骤2: 检查 token 是否为空
if not token:
+44
View File
@@ -99,6 +99,50 @@ class Settings(BaseSettings):
# 是否启用 Mock 登录(默认 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 配置
# ----------------------------------------------------------------------
+146 -8
View File
@@ -15,7 +15,9 @@ import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
# 导入配置(读取环境变量)
from app.config import settings
@@ -35,6 +37,30 @@ logging.basicConfig(
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
links = [
ApprovalLink(category="IT", title="软件安装申请", url="https://审批系统地址/software-install", sort_order=1),
ApprovalLink(category="IT", title="设备报修工单", url="https://审批系统地址/device-repair", sort_order=2),
ApprovalLink(category="IT", title="VPN开通申请", url="https://审批系统地址/vpn-apply", sort_order=3),
ApprovalLink(category="IT", title="权限申请", url="https://审批系统地址/permission-apply", sort_order=4),
ApprovalLink(category="HR", title="入职手续", url="https://审批系统地址/onboarding", sort_order=5),
ApprovalLink(category="HR", title="离职手续", url="https://审批系统地址/offboarding", sort_order=6),
ApprovalLink(category="行政", title="办公用品申领", url="https://审批系统地址/office-supplies", sort_order=7),
ApprovalLink(category="财务", title="报销申请", url="https://审批系统地址/reimbursement", sort_order=8),
# v0.5.2:一站式运维平台真实工单链接(域名 devops.dc.servyou-it.com,已实现企微免登录)
# v0.5.3 更新:去掉 "IT设备升级与硬件维修" (申请单冲突,后续移除)
ApprovalLink(category="IT", title="零信任(原VPN)账号申请",
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",
sort_order=1),
ApprovalLink(category="IT", title="活动与会议技术支持",
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",
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)
@@ -475,6 +516,30 @@ def create_app() -> FastAPI:
# 请求到达后端时 /api/ 已被 strip,因此此处不需要再加 /api 前缀
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 路由
# ----------------------------------------------------------------------
@@ -514,6 +579,79 @@ def create_app() -> FastAPI:
"""
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}")
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 框架
# --------------------------------------------------------------------------
# FastAPI: 高性能异步 Web 框架,自动生成 Swagger API 文档
fastapi==0.111.0
fastapi==0.111.1
# Uvicorn: ASGI 服务器,支持热重载和 WebSocket
uvicorn[standard]==0.30.1
# 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==2.7.4
pydantic==2.7.5
# pydantic-settings: 从环境变量读取配置,支持 .env 文件
pydantic-settings==2.3.4
@@ -78,3 +78,9 @@ passlib[bcrypt]==1.7.4
qrcode[pil]==7.4.2
# pillow: 图片处理(qrcode[pil] 依赖)
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
# =============================================================================
# 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 内存数据库引擎
# =============================================================================
@@ -184,6 +210,70 @@ def mock_redis() -> 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
async def client(db_session: AsyncSession, mock_redis: MockRedis) -> AsyncGenerator[AsyncClient, None]:
"""提供 FastAPI 异步测试客户端。"""
@@ -194,6 +284,9 @@ async def client(db_session: AsyncSession, mock_redis: MockRedis) -> AsyncGenera
async def _override_get_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.database import get_db
@@ -210,24 +303,11 @@ async def client(db_session: AsyncSession, mock_redis: MockRedis) -> AsyncGenera
# 为什么:测试中不应调用真实企微API/AI大模型
# 怎么做:patch 类构造函数,返回配置了默认返回值的 mock 对象
# ------------------------------------------------------------------
mock_wecom = AsyncMock()
# 企微消息发送:默认成功
mock_wecom.send_message.return_value = {"errcode": 0, "errmsg": "ok"}
# 企微通讯录查询:动态返回(根据传入的 user_id 生成对应的名称)
# 为什么:坐席登录时会调用 get_user_info 获取员工姓名
# 如果返回固定名字,登录接口会用 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的模拟回复"
# 使用模块级 mock_wecom_module / mock_ai_module2026-06-15 修复)
# 原因: 模块级 mock 允许测试通过 mock_wecom_instance fixture 改写行为
# 例如降级登录测试改 side_effect = raise Exception("企微不可达")
mock_wecom = mock_wecom_module
mock_ai = mock_ai_module
# Patch WecomService 类(端点函数中会新建实例)
# 注意:只 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.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):
transport = ASGITransport(app=app)
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
> 域名:`itsupport.servyou.com.cn`
+36 -30
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`
> **访问方式**:通过堡垒机 `10.212.189.210:2222`(用户 `sxn`OTP 动态口令认证)
> **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 Compose V2 | 待确认 | `docker compose version` |
| 端口 80 未被占用 | 待确认 | `ss -tlnp \| grep :80` |
@@ -29,17 +29,22 @@
### 2.2 连接方式
```bash
# 方式一:ssh -J 一步跳转(推荐)
# -J 指定跳板机,ssh 会自动帮你跳转
# 堡垒机端口 2222,需要输入 OTP 动态口令
ssh -J sxn@10.212.189.210:2222 sxn@10.80.0.136
**PuTTY 客户端(用户实际使用)**:
- 打开 PuTTY
- Host Name(IP 地址):`10.212.189.210`
- Port:`2222`
- Connection type:SSH
- Saved Sessions:起名(如 `wecom-bastion`)→ Save
- 点 Open
- 用户 `sxn` + 密码
- **堡垒机内再跳目标机**:
```bash
ssh sxn@10.90.5.110
```
# 方式二:先登录堡垒机,再手动跳转
ssh -p 2222 sxn@10.212.189.210
# 输入 OTP 动态口令
> **OpenSSH `ssh -J` 方式不再使用**(用户已确认用 PuTTY,2026-06-15)
# 登录成功后:
ssh sxn@10.80.0.136
ssh sxn@10.90.5.110
```
### 2.3 配置 SSH 快捷方式(推荐)
@@ -53,9 +58,9 @@ Host bastion
Port 2222
User sxn
# IT智能服务台服务器
# 智能IT支持服务台服务器
Host itdesk
HostName 10.80.0.136
HostName 10.90.5.110
User sxn
ProxyJump bastion
```
@@ -78,7 +83,7 @@ scp file itdesk:/opt/ # 文件传输也会自动走堡垒机
# 上传单个文件
scp -o "ProxyJump=sxn@10.212.189.210:2222" \
it-smart-desk-server-deploy.zip \
sxn@10.80.0.136:/opt/
sxn@10.90.5.110:/opt/
# 如果已配置 ~/.ssh/config
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
# 步骤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" \
it-smart-desk-server-deploy.zip \
sxn@10.80.0.136:/tmp/
sxn@10.90.5.110:/tmp/
```
> 上传到 `/tmp/` 而非 `/opt/`,因为普通用户对 `/opt/` 没有写权限
### 步骤 3SSH 登录服务器并解压
### 步骤 3:登录服务器并解压
**PuTTY 登录**(见 §2.2):
- Host:`10.212.189.210`,Port:`2222`,SSH
- 堡垒机内再 `ssh sxn@10.90.5.110`
```bash
# 登录目标服务器
ssh -J sxn@10.212.189.210:2222 sxn@10.80.0.136
# 切换 root(普通用户对 /opt 无写权限)
sudo -i
@@ -237,7 +243,7 @@ docker compose logs --tail 50 postgres
需要联系公司 IT 运维,在公司 DNS 上添加 A 记录:
```
itsupport.servyou.com.cn A 10.80.0.136
itsupport.servyou.com.cn A 10.90.5.110
```
**DNS 未生效前**,可以通过本地 hosts 文件测试:
@@ -246,7 +252,7 @@ itsupport.servyou.com.cn A 10.80.0.136
# Windows: C:\Windows\System32\drivers\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` 清除缓存,或用无痕窗口测试。
@@ -324,11 +330,11 @@ cd frontend-agent && npm run build
# 2. 上传到服务器(通过堡垒机)
scp -o "ProxyJump=sxn@10.212.189.210:2222" \
-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" \
-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(不需要重启整个服务)
ssh itdesk # 如果已配置 SSH 快捷方式
@@ -344,7 +350,7 @@ docker exec wecom_it_nginx nginx -s reload
# 1. 上传新代码到服务器
scp -o "ProxyJump=sxn@10.212.189.210:2222" \
-r backend/ \
sxn@10.80.0.136:/opt/wecom-it-desk/backend/
sxn@10.90.5.110:/opt/wecom-it-desk/backend/
# 2. 重新构建并启动
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
# 如果 DNS 未配置,临时用 IP 直接访问
curl http://10.80.0.136/itdesk/
curl http://10.80.0.136/api/health
curl http://10.90.5.110/itdesk/
curl http://10.90.5.110/api/health
```
### Mock 登录返回 401
@@ -428,7 +434,7 @@ curl -X POST http://localhost/api/h5/mock-login \
### 方式一:公司统一 SSL 终端(推荐)
```
客户端 → HTTPS → 公司SSL终端(F5/网关) → HTTP → 10.80.0.136:80
客户端 → HTTPS → 公司SSL终端(F5/网关,公网 115.236.188.3) → HTTP → 10.90.5.110:80
```
不需要在本服务器上配置证书。联系运维配置 SSL 终端即可。
@@ -441,7 +447,7 @@ curl -X POST http://localhost/api/h5/mock-login \
## 十一、与 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 |
| 外网访问 | Cloudflare Tunnel | 公司 DNS 直连 |
+2 -2
View File
@@ -1,5 +1,5 @@
# =============================================================================
# 企微IT智能服务台 — 打包 + 构建后端镜像 + 部署脚本
# 企微智能IT支持服务台 — 打包 + 构建后端镜像 + 部署脚本
# =============================================================================
# 功能:
# 1. 打包前端构建产物 + nginx配置 + docker-compose.yml + .env
@@ -51,7 +51,7 @@ function Write-Error {
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 企微IT智能服务台 — 打包部署自动化" -ForegroundColor Cyan
Write-Host " 企微智能IT支持服务台 — 打包部署自动化" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 模式:$Mode" -ForegroundColor White
Write-Host ""
+2 -2
View File
@@ -1,5 +1,5 @@
# =============================================================================
# 企微IT智能服务台 — 打包部署脚本
# 企微智能IT支持服务台 — 打包部署脚本
# =============================================================================
# 功能:将所有部署所需文件打包成一个 zip 文件
# 用法:在 PowerShell 中运行此脚本
@@ -19,7 +19,7 @@ $packageDir = "$deployDir\_package"
$zipFile = "$deployDir\it-smart-desk-server-deploy.zip"
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 企微IT智能服务台 — 打包部署文件" -ForegroundColor Cyan
Write-Host " 企微智能IT支持服务台 — 打包部署文件" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# =============================================================================
# IT智能服务台 — RAGFlow 集成部署脚本
# 智能IT支持服务台 — RAGFlow 集成部署脚本
# 目标服务器:10.90.5.110
# 部署路径:/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)"
echo "=========================================="
echo "IT智能服务台 — RAGFlow 集成部署"
echo "智能IT支持服务台 — RAGFlow 集成部署"
echo "时间: $(date)"
echo "=========================================="
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# =============================================================================
# IT智能服务台 — 生产部署脚本
# 智能IT支持服务台 — 生产部署脚本
# 目标服务器:10.90.5.110
# 部署路径:/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)"
echo "=========================================="
echo "IT智能服务台 生产部署"
echo "智能IT支持服务台 生产部署"
echo "时间: $(date)"
echo "=========================================="
+1 -1
View File
@@ -1,5 +1,5 @@
# =============================================================================
# 企微IT智能服务台 — Docker Compose(公司内网服务器版)
# 企微智能IT支持服务台 — Docker Compose(公司内网服务器版)
# =============================================================================
# 目标服务器:10.90.5.110
# 域名:itsupport.servyou.com.cn
+18 -1
View File
@@ -1,5 +1,5 @@
# =============================================================================
# 企微IT智能服务台 — Nginx 配置(公司内网服务器版 + HTTPS)
# 企微智能IT支持服务台 — Nginx 配置(公司内网服务器版 + HTTPS)
# =============================================================================
# 目标服务器:10.90.5.110
# 域名:itsupport.servyou.com.cn
@@ -47,6 +47,23 @@ http {
application/javascript application/xml+rss
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 内部网络)
# =================================================================
+61 -24
View File
@@ -1,5 +1,5 @@
# =============================================================================
# 企微IT智能服务台 — Nginx 配置(公司内网服务器版)
# 企微智能IT支持服务台 — Nginx 配置(公司内网服务器版)
# =============================================================================
# 适用场景:独立域名 itsupport.servyou.com.cn,公司内网 DNS 解析
# 与 NAS 版的区别:
@@ -27,6 +27,21 @@ http {
access_log /var/log/nginx/access.log main;
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
# 如果需要本机 HTTPS,取消下方 server 块注释,并配置证书路径
# =================================================================
# HTTP — 80 端口强制 301 跳 HTTPS
# =================================================================
server {
listen 80;
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-Frame-Options "SAMEORIGIN" 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 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;
deny all;
proxy_pass http://backend_api/;
proxy_pass http://backend_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -183,29 +239,10 @@ http {
# 此路径已包含在 /api/ 的代理规则中,无需单独配置
# ------------------------------------------------------------------
# 默认路径 — 重定向到 H5 员工端
# 默认路径 — 重定向到统一入口
# ------------------------------------------------------------------
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
REM =============================================================================
REM IT智能服务台 — 打包部署脚本(Windows)
REM 智能IT支持服务台 — 打包部署脚本(Windows)
REM 目标:生成部署包,通过堡垒机上传到服务器
REM =============================================================================
echo ==========================================
echo IT智能服务台 部署包打包
echo 智能IT支持服务台 部署包打包
echo 时间: %date% %time%
echo ==========================================
+40 -9
View File
@@ -1,5 +1,5 @@
"""
企微IT智能服务台 — 部署包生成脚本(Windows 兼容版)
企微智能IT支持服务台 — 部署包生成脚本(Windows 兼容版)
=======================================================
功能:
1. 构建前端(H5 + 坐席端)
@@ -40,6 +40,8 @@ INCLUDE_MAP = {
"deploy-server/nginx/nginx.conf": f"{PACKAGE_PREFIX}/nginx/nginx.conf",
"frontend-h5/dist": f"{PACKAGE_PREFIX}/frontend-h5/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",
}
@@ -75,6 +77,9 @@ def run_cmd(cmd: str, cwd: Path | None = None) -> bool:
def should_exclude(path: Path) -> bool:
"""判断文件/目录是否应排除"""
# 路径中任何一段是 uploads 目录就排除(隐私 P0:真实用户上传文件不进部署包)
if "uploads" in path.parts:
return True
name = path.name
if name in {"__pycache__", ".pytest_cache", ".venv", "venv", ".git", ".env", "node_modules"}:
return True
@@ -121,6 +126,32 @@ def build_frontends():
sys.exit(1)
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():
"""创建部署包 zip"""
@@ -163,7 +194,7 @@ def create_package():
def main():
print("=" * 50)
print(" IT智能服务台 — 部署包生成")
print(" 智能IT支持服务台 — 部署包生成")
print("=" * 50)
# 检查是否跳过构建
@@ -181,13 +212,13 @@ def main():
print(" 后续步骤:")
print("=" * 50)
print(f"""
1. 上传部署包到服务器(通过堡垒机):
scp -o "ProxyJump=sxn@10.212.189.210:2222" \\
{ZIP_FILENAME} \\
sxn@10.80.0.136:/tmp/
1. 上传部署包到服务器(通过堡垒机 / PuTTY PSCP):
pscp -load wecom-bastion {ZIP_FILENAME} sxn@10.90.5.110:/tmp/
# 或堡垒机内 scp:
# scp {ZIP_FILENAME} sxn@10.90.5.110:/tmp/
2. SSH 登录服务器(通过堡垒机)
ssh -J sxn@10.212.189.210:2222 sxn@10.80.0.136
2. PuTTY 登录服务器:
- Host 10.212.189.210 Port 2222 → 用户 sxn → 堡垒机内 ssh sxn@10.90.5.110
3. 在服务器上执行:
sudo cp /tmp/{ZIP_FILENAME} /opt/
@@ -201,7 +232,7 @@ def main():
./deploy.sh
4. 配置 DNS(联系 IT 运维):
itsupport.servyou.com.cn → 10.80.0.136
itsupport.servyou.com.cn → 10.90.5.110
5. 浏览器验证:
http://itsupport.servyou.com.cn/itdesk/
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# =============================================================================
# 企微IT智能服务台 — 部署包生成脚本(在开发机上运行)
# 企微智能IT支持服务台 — 部署包生成脚本(在开发机上运行)
# =============================================================================
# 功能:
# 1. 构建前端(H5 + 坐席端)
@@ -28,7 +28,7 @@ PACKAGE_NAME="it-smart-desk-server-deploy"
BUILD_DIR="/tmp/$PACKAGE_NAME"
echo -e "${GREEN}============================================${NC}"
echo -e "${GREEN} IT智能服务台 — 部署包生成${NC}"
echo -e "${GREEN} 智能IT支持服务台 — 部署包生成${NC}"
echo -e "${GREEN}============================================${NC}"
# --- 1. 构建前端 ---
+2 -2
View File
@@ -1,6 +1,6 @@
@echo off
REM =============================================================================
REM 企微IT智能服务台 — 打包部署一键执行
REM 企微智能IT支持服务台 — 打包部署一键执行
REM =============================================================================
REM 功能:
REM 1. 打包前端构建产物 + nginx配置 + docker-compose.yml + .env
@@ -20,7 +20,7 @@ if "%MODE%"=="" set MODE=local
echo.
echo ========================================
echo 企微IT智能服务台 — 打包部署
echo 企微智能IT支持服务台 — 打包部署
echo ========================================
echo 模式: %MODE%
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支持组组长)
> **目标读者**: **管理者 / 架构师 / 运维** — 了解项目全貌、架构决策、部署与运维操作
@@ -570,7 +570,7 @@ docker compose down # 停止新系统所有容器
### TL;DR
企微IT智能服务台第一步(消息接管 + 极简坐席台)全部代码已完成并通过测试,共 **110+ 文件****116/116 测试全部通过**,覆盖后端 API、坐席工作台、用户端 H5 三个子系统。
企微智能IT支持服务台第一步(消息接管 + 极简坐席台)全部代码已完成并通过测试,共 **110+ 文件****116/116 测试全部通过**,覆盖后端 API、坐席工作台、用户端 H5 三个子系统。
### 交付状态
@@ -641,7 +641,7 @@ wecom_it_smart_desk/
├── ARCHITECTURE.md # 系统架构设计(合并版)
├── 01-项目总览与部署手册.md # 管理者视角部署手册
├── 开发交付概览.md # 开发交付状态总览
├── IT智能服务台-项目迁移文档.md # 工作区迁移记录
├── 智能IT支持服务台-项目迁移文档.md # 工作区迁移记录
├── testing/ # 测试报告目录
│ └── QA_COMPREHENSIVE_REPORT.md # 综合 QA 报告
├── diagrams/ # Mermaid 图表
+1 -1
View File
@@ -1,4 +1,4 @@
# IT智能服务台 — 管理后台架构设计文档
# 智能IT支持服务台 — 管理后台架构设计文档
> **文档版本**: v1.0
> **架构师**: 高见远 (Bob)
+2 -2
View File
@@ -1,4 +1,4 @@
# 企微IT智能服务台 — 系统架构设计文档
# 企微智能IT支持服务台 — 系统架构设计文档
> **文档版本**: v0.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。
+1 -1
View File
@@ -1,6 +1,6 @@
# ExternalSystemAdapter 抽象层设计文档
> 版本:V1.0 | 日期:2026-06-11 | 作者:IT智能服务台项目组
> 版本:V1.0 | 日期:2026-06-11 | 作者:智能IT支持服务台项目组
---
@@ -14,7 +14,7 @@
### 1. 符合系统定位——"AI驱动"
系统全名是"IT智能服务台 — AI驱动",但当前右侧栏本质是传统信息架构(标签页+列表),AI只在左侧会话区参与。动态推送让右侧也变成AI能力的延伸,整个产品才能名副其实。
系统全名是"智能IT支持服务台 — AI驱动",但当前右侧栏本质是传统信息架构(标签页+列表),AI只在左侧会话区参与。动态推送让右侧也变成AI能力的延伸,整个产品才能名副其实。
### 2. 降低用户认知负荷
@@ -1,4 +1,4 @@
# IT智能服务台 - 部署修复记录
# 智能IT支持服务台 - 部署修复记录
**日期**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)
2. **应用管理****自建** → **创建应用**
3. 填写:
- 应用名称:`IT智能服务台`
- 应用名称:`智能IT支持服务台`
- 应用logo:上传一个图标
- 可见范围:选择测试部门/人员
+2 -2
View File
@@ -1,4 +1,4 @@
# IT智能服务台 — 管理后台增量 PRD
# 智能IT支持服务台 — 管理后台增量 PRD
> **文档版本**: v1.0
> **创建日期**: 2026-06-16
@@ -28,7 +28,7 @@
| 字段 | 值 |
|------|------|
| 产品名称 | IT智能服务台 — 管理后台 |
| 产品名称 | 智能IT支持服务台 — 管理后台 |
| 项目代号 | `wecom_it_smart_desk`(第三端:admin |
| 编程语言 | 前端: Vue 3 + TypeScript + Element Plus + Pinia · 后端: FastAPI + SQLAlchemy + PostgreSQL + Redis |
| 部署路径 | `/itadmin/`(与 H5 `/itdesk/`、坐席 `/itagent/` 并列) |
@@ -54,7 +54,7 @@
```
┌────────────────────────────────────┐
IT智能服务台 [🔔 人工] │ ← 启用状态(橙色)
│ 智能IT支持服务台 [🔔 人工] │ ← 启用状态(橙色)
│ [▓▓ 人工] │ ← 禁用状态(灰色)
└────────────────────────────────────┘
```
+5 -5
View File
@@ -1,4 +1,4 @@
# 企微IT智能服务台 — 产品需求文档 (PRD)
# 企微智能IT支持服务台 — 产品需求文档 (PRD)
> **文档版本**: v1.0
> **创建日期**: 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` 顶部栏独立) |
---
@@ -1451,7 +1451,7 @@
```
┌─────────────────────────────────────────────────────────────────────┐
│ [IT] IT智能服务台 · 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理 │ ☀️/🌙 │ 坐席: 陈思远 │
│ [IT] 智能IT支持服务台 · 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理 │ ☀️/🌙 │ 坐席: 陈思远 │
├──────────┬──────────────────────────────────┬───────────────────────┤
│ │ 👤 张伟 · 研发一部 🥇黄金 │ 🤖 AI 智能推荐 │
│ 🔍 搜索 │ 😟焦虑 ⏱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 集成) |
| **RAGFlow** | 知识库检索(Dify 通过 RAGFlow 获取知识) | 公司内网 | 0%(Dify 间接调用) |
| **智能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 │
└────────┬────────┘
+1 -1
View File
@@ -1,4 +1,4 @@
# IT智能服务台 · 坐席工作台 v5.3 增量架构设计
# 智能IT支持服务台 · 坐席工作台 v5.3 增量架构设计
> **版本**: v5.3-incremental
> **日期**: 2026-06-06
+3 -3
View File
@@ -1,4 +1,4 @@
# IT智能服务台 · 坐席工作台 v5.3 增量 PRD
# 智能IT支持服务台 · 坐席工作台 v5.3 增量 PRD
> **版本**: v5.3 增量迭代
> **日期**: 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` 顶部栏独立) |
---
@@ -314,7 +314,7 @@
```
┌─────────────────────────────────────────────────────────────────────┐
│ [IT] IT智能服务台 · 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理 │ ☀️/🌙 │ 坐席: 陈思远 │
│ [IT] 智能IT支持服务台 · 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理 │ ☀️/🌙 │ 坐席: 陈思远 │
├──────────┬──────────────────────────────────┬───────────────────────┤
│ │ 👤 张伟 · 研发一部 🥇黄金 │ 🤖 AI 智能推荐 │
│ 🔍 搜索 │ 😟焦虑 ⏱8分32秒 💬6轮 🔁重复 │ ┌─────────────────┐ │
+3 -3
View File
@@ -1,8 +1,8 @@
# 企微IT智能服务台 — 第一步开发交付概览
# 企微智能IT支持服务台 — 第一步开发交付概览
## TL;DR
企微IT智能服务台第一步(消息接管 + 极简坐席台)全部代码已完成并通过测试,共 **110+ 文件****116/116 测试全部通过**,覆盖后端 API、坐席工作台、用户端 H5 三个子系统。
企微智能IT支持服务台第一步(消息接管 + 极简坐席台)全部代码已完成并通过测试,共 **110+ 文件****116/116 测试全部通过**,覆盖后端 API、坐席工作台、用户端 H5 三个子系统。
## 交付状态
@@ -73,7 +73,7 @@ wecom_it_smart_desk/
├── ARCHITECTURE.md # 系统架构设计(合并版)
├── 01-项目总览与部署手册.md # 管理者视角部署手册
├── 开发交付概览.md # 开发交付状态总览
├── IT智能服务台-项目迁移文档.md # 工作区迁移记录
├── 智能IT支持服务台-项目迁移文档.md # 工作区迁移记录
├── testing/ # 测试报告目录
│ └── QA_COMPREHENSIVE_REPORT.md # 综合 QA 报告
├── 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 测试报告,按时间倒序排列(最新在前)。
@@ -1,4 +1,4 @@
# 企微IT智能服务台 — 系统架构、消息收发、知识库迭代说明
# 企微智能IT支持服务台 — 系统架构、消息收发、知识库迭代说明
> **版本**: v1.1 | **日期**: 2026-06-02 | **负责人**: 宋献(IT支持组组长)
> **目标读者**: 运维团队 / 架构团队 / 开发团队
@@ -1,11 +1,11 @@
收件人: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支持在以下方面仍有提升空间:
1. 员工入口体验 — 转人工需另开窗口,AI与人工服务衔接不够流畅,跨企业服务不可达
+1 -1
View File
@@ -1,4 +1,4 @@
# IT智能服务台 - Secret 管理方案
# 智能IT支持服务台 - Secret 管理方案
**版本**: 1.0
**更新日期**: 2026-06-14
+2 -2
View File
@@ -1,4 +1,4 @@
# IT智能服务台 — 安全审计报告
# 智能IT支持服务台 — 安全审计报告
> **编制日期**: 2026-06-14
> **版本**: v1.0
@@ -9,7 +9,7 @@
| 项目 | 说明 |
|------|------|
| 系统名称 | IT智能服务台 |
| 系统名称 | 智能IT支持服务台 |
| 部署环境 | 企业内网 (10.90.5.110) |
| 访问方式 | 企微工作台应用 / HTTPS |
| 用户规模 | ~6000人 |
@@ -1,4 +1,4 @@
# IT智能服务台 - 版本更新说明
# 智能IT支持服务台 - 版本更新说明
**版本**: v1.1.0
**更新日期**: 2026-06-14
@@ -1,4 +1,4 @@
# IT智能服务台 — 项目迁移文档
# 智能IT支持服务台 — 项目迁移文档
**生成时间**2026-06-06
**来源项目**`C:\Users\simon\wecom_it_smart_desk`
**原型文件**`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`
> 分析日期:2026-06-11
> 分析人:IT智能服务台项目组
> 分析人:智能IT支持服务台项目组
---
@@ -557,4 +557,4 @@ IT服务台: employee_id → conversation → 坐席 → 查看安全状态
### 8.3 一句话总结
> 火绒集成是IT智能服务台从「被动响应」走向「主动安全」的关键一步,建议优先推进P0查询能力,2周内可上线见效。
> 火绒集成是智能IT支持服务台从「被动响应」走向「主动安全」的关键一步,建议优先推进P0查询能力,2周内可上线见效。
+5 -5
View File
@@ -1,4 +1,4 @@
# IT智能服务台 — 统一入口(Portal)技术设计文档
# 智能IT支持服务台 — 统一入口(Portal)技术设计文档
**版本**: v1.1
**日期**: 2026-06-13
@@ -25,7 +25,7 @@
### 1.1 背景
当前 IT智能服务台 存在三个独立入口:
当前 智能IT支持服务台 存在三个独立入口:
- **用户端** `/itdesk/` — 员工提交工单、查看进度
- **坐席端** `/itagent/` — IT坐席处理会话、AI辅助
- **管理端** `/itadmin/` — 系统配置、数据分析
@@ -38,7 +38,7 @@
### 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
> 分析日期:2026-06-11
> 分析人:IT智能服务台项目组
> 分析人:智能IT支持服务台项目组
---
@@ -675,7 +675,7 @@ aTrust集成为**P1优先级**(联软P0之后),因为:
```
┌──────────────────────────────────────────────────────────────┐
IT智能服务台 │
│ 智能IT支持服务台 │
│ (统一集成层) │
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
@@ -794,4 +794,4 @@ class UnifiedTerminalInfo:
### 9.3 一句话总结
> 联软是IT智能服务台打通「员工↔终端」映射的关键系统,与火绒形成「管理+安全」双引擎,加上aTrust补全远程办公,三系统集成将实现终端问题排查的360°全景视角。
> 联软是智能IT支持服务台打通「员工↔终端」映射的关键系统,与火绒形成「管理+安全」双引擎,加上aTrust补全远程办公,三系统集成将实现终端问题排查的360°全景视角。
@@ -19,7 +19,7 @@
| `backend/app/api/agents.py` | 改动 | OTP 双因素(otp-bind/otp-verify/otp-unbind) |
| `frontend-h5/src/api/conversation.ts` | 改动 | mapMessage 字段映射(id→message_id) |
| `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 强制评审" 环节
---
+4 -4
View File
@@ -1,4 +1,4 @@
# IT智能服务台 — 调试验证指南
# 智能IT支持服务台 — 调试验证指南
**创建时间**: 2026-06-13
**适用环境**: 正式服务器 10.90.5.10 (itsupport.servyou.com.cn)
@@ -128,7 +128,7 @@
├─────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ IT智能服务台(正式) │ │ IT智能服务台-测试 │ │
│ │ 智能IT支持服务台(正式) │ │ 智能IT支持服务台-测试 │ │
│ │ │ │ │ │
│ │ 可信域名: │ │ 可信域名: │ │
│ │ itsupport.xxx │ │ itdesk.amanzac │ │
@@ -153,7 +153,7 @@
1. 登录 [企微管理后台](https://work.weixin.qq.com/wework_admin/frame)
2. **应用管理****自建** → **创建应用**
3. 填写信息:
- **应用名称**: `IT智能服务台-测试`
- **应用名称**: `智能IT支持服务台-测试`
- **应用logo**: 使用不同颜色(如橙色)区分正式应用
- **应用介绍**: "仅供IT部门测试使用"
- **可见范围**: 选择IT部门 + 测试人员
@@ -209,7 +209,7 @@ VITE_WECOM_CORP_ID=ww_test_xxxxx
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 在企微中找到"IT智能服务台-测试"应用 | 应用显示在工作台 |
| 1 | 在企微中找到"智能IT支持服务台-测试"应用 | 应用显示在工作台 |
| 2 | 点击应用 | 跳转到 `https://itdesk.amanzac.com/itdesk/` |
| 3 | 首次访问 | 跳转企微OAuth2授权页 |
| 4 | 确认授权 | 跳回H5聊天页面 |
+6 -6
View File
@@ -1,8 +1,8 @@
# IT智能服务台 — 资源申请清单
# 智能IT支持服务台 — 资源申请清单
> **📌 使用说明(工作流程)**
>
> 本文档是 IT智能服务台 所有资源申请需求的**统一汇总入口**,适用于:
> 本文档是 智能IT支持服务台 所有资源申请需求的**统一汇总入口**,适用于:
> - 服务器/域名/网络等资源申请
> - 外部系统 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
# ==================== IT智能服务台 — 预生产 ====================
# ==================== 智能IT支持服务台 — 预生产 ====================
# 后端 API
location /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 账户(超管自建)
@@ -307,7 +307,7 @@ IT智能服务台核心目标之一是**打通员工↔终端的映射链路**
- **申请人**:宋献,IT支持组(税友集团),负责终端安全
- **火绒/联软对接人**:宋献(超管权限,自行创建API账户,受安全团队管理)
- **项目**IT智能服务台(IT Smart Desk
- **项目**:智能IT支持服务台(IT Smart Desk
- **紧急程度**:预生产反代配置建议 1-2 个工作日内完成;生产环境 NAS 自建,无需运维介入
- **组织架构说明**
- IT支持组 = 终端安全负责团队(非独立"终端安全团队")
+1 -1
View File
@@ -212,7 +212,7 @@ async def send_invite_card(
"template_card": {
"card_type": "button_interaction",
"source": {
"desc": "IT智能服务台"
"desc": "智能IT支持服务台"
},
"main_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
**报告版本**: v1.0
@@ -185,7 +185,7 @@
2. **构建并部署最新代码**:将今天的 Bug 修复 + UI 风格更新部署到服务器
### 近期安排(P1
3. **创建测试企微应用**:按照双企微应用方案,创建"IT智能服务台-测试"应用
3. **创建测试企微应用**:按照双企微应用方案,创建"智能IT支持服务台-测试"应用
4. **阶段二启动**:排队机制 + 满意度评价设计
5. **aTrust对接**:找信息安全团队获取API密钥
@@ -1,4 +1,4 @@
# IT智能服务台 — 项目开发任务调整建议
# 智能IT支持服务台 — 项目开发任务调整建议
> **文档版本**: V1.0
> **创建日期**: 2026-06-11
+3 -3
View File
@@ -1,4 +1,4 @@
# IT智能服务台 — 风险跟踪表
# 智能IT支持服务台 — 风险跟踪表
**最后更新**: 2026-06-14 18:30
**维护人**: 宋献 + Claude 评审协作
@@ -567,7 +567,7 @@ location /api/ {
## 九、2026-06-14 workbuddy 推送评审新增
**评审依据**: `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 跟进
---
@@ -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)`**
- **处理建议**: 实现该方法 + 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>
<meta charset="UTF-8" />
<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" />
<title>IT智能服务台 - 管理后台</title>
<title>智能IT支持服务台 - 管理后台</title>
</head>
<body>
<div id="app"></div>
+4
View File
@@ -23,6 +23,10 @@
"typescript": "^5.5.0",
"vite": "^5.3.0",
"vue-tsc": "^2.0.0"
},
"engines": {
"node": ">=20.0.0 <21.0.0",
"pnpm": ">=9.0.0"
}
},
"node_modules/@alloc/quick-lru": {
+10 -1
View File
@@ -3,7 +3,9 @@
"version": "1.0.0",
"private": true,
"type": "module",
"description": "企微IT智能服务台 - 管理后台前端",
"description": "企微智能IT支持服务台 - 管理后台前端",
"engines": { "node": ">=20.0.0 <21.0.0", "pnpm": ">=9.0.0" },
"packageManager": "pnpm@9.15.0",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
@@ -11,11 +13,18 @@
"type-check": "vue-tsc --noEmit"
},
"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",
"axios": "^1.7.0",
"codemirror": "^6.0.1",
"element-plus": "^2.7.0",
"pinia": "^2.1.0",
"vue": "^3.4.0",
"vue-codemirror": "^6.0.1",
"vue-json-pretty": "^2.2.4",
"vue-router": "^4.3.0"
},
"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
created_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 // 火绒客户端版本
is_online: boolean // 在线状态
last_connect_time?: number // 最后连接时间(Unix时间戳)
group_id?: number | string // 分组ID_info2 可能返回)
// 硬件信息(可选,_info2 返回)
cpu?: string
memory?: string

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