docs: 移动蓝绿部署指南到 troubleshooting 目录

This commit is contained in:
Simon
2026-07-05 17:03:36 +08:00
parent ab90db3d3d
commit ca7c6d937a
91 changed files with 4841 additions and 406 deletions
+25
View File
@@ -67,6 +67,16 @@
**脚本位置**`C:\Users\simon\.workbuddy\skills\jumpserver-ops\scripts\jms_ops.py` **脚本位置**`C:\Users\simon\.workbuddy\skills\jumpserver-ops\scripts\jms_ops.py`
### ⚠️ 服务器操作规则(重要)
**在对服务器进行任何操作时,优先使用 jumpserver-ops 自动完成,而非让用户手动操作。**
| 操作类型 | 自动执行方式 |
|----------|-------------|
| 远程命令 | `python jms_ops.py exec -c "命令"` |
| 文件上传 | `python jms_ops.py upload 本地文件 /tmp/远程路径` |
| 文件下载 | `python jms_ops.py download /tmp/远程文件 ./本地路径` |
### 使用方式 ### 使用方式
```bash ```bash
@@ -77,6 +87,14 @@ python jms_ops.py exec -c "hostname"
python jms_ops.py exec -c "uptime" --reuse python jms_ops.py exec -c "uptime" --reuse
python jms_ops.py exec -c "docker ps" -c "curl -s http://localhost/api/health" --reuse python jms_ops.py exec -c "docker ps" -c "curl -s http://localhost/api/health" --reuse
# 文件上传(自动根据大小选择方式)
# - ≤10MB: base64 编码传输(快速)
# - >10MB: elFinder Web UI(浏览器自动化)
python jms_ops.py upload local_file.txt /tmp/remote_file.txt
# 文件下载
python jms_ops.py download /tmp/remote_file.txt local_file.txt
# 批量命令 # 批量命令
python jms_ops.py batch -f commands.txt python jms_ops.py batch -f commands.txt
@@ -96,3 +114,10 @@ python jms_ops.py download /remote/path ./local.conf
- `--reuse`:复用上次会话(减少登录次数,30分钟有效) - `--reuse`:复用上次会话(减少登录次数,30分钟有效)
- `--parallel`:并行模式(每命令独立 token+会话) - `--parallel`:并行模式(每命令独立 token+会话)
- `--cmd-timeout`:每命令超时秒数(默认 15s - `--cmd-timeout`:每命令超时秒数(默认 15s
## 文档关联修复 (2026-07-05)
- **起因**2026-07-04 docs/ 重组为数字编号子目录(01-项目总览~11-历史归档),但 mkdocs.yml nav / 文档间交叉引用 / 巡检自动化路径未同步,全面断链
- **修复**mkdocs.yml nav 9处断链重写(移除2个归档项,纳入5份新文档)+ 11处交叉引用修复 + 索引版本号修正(v1.0→v1.3) + 巡检自动化适配
- **关键发现**:巡检 automation-1782986180887 原依赖的"小组任务书/任务执行状态看板.md"及A/B/C三组体系(认证加固16/消息系统16/AI数据19)从未创建,每日巡检必然失败;已适配为基于 01-项目状态看板.md 的状态巡检(P0/P1/等决策/进行中)
- **修复报告**:docs/01-项目总览/文档关联修复报告-20260705.md
- **保留未改**:目录树展示(历史快照)、归档文档内旧路径、历史任务标题
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
@@ -0,0 +1,31 @@
"""add employee avatar_updated_at field for avatar refresh tracking
Revision ID: 042_add_employee_avatar_updated_at
Revises: 041_message_server_timestamp
Create Date: 2026-07-05
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '042_add_employee_avatar_updated_at'
down_revision: Union[str, None] = '041_message_server_timestamp'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add avatar_updated_at field to employees table
# This field tracks when the avatar was last updated from WeCom API
op.add_column(
'employees',
sa.Column('avatar_updated_at', sa.DateTime(), nullable=True, comment='头像最后更新时间')
)
def downgrade() -> None:
op.drop_column('employees', 'avatar_updated_at')
+161 -3
View File
@@ -257,9 +257,9 @@ async def agent_login(
await db.flush() await db.flush()
logger.info(f"坐席登录: user_id={body.user_id}, name={body.name}") logger.info(f"坐席登录: user_id={body.user_id}, name={body.name}")
# 2. MFA 二次验证(admin 角色且已绑定 MFA # 2. MFA 二次验证(已绑定 MFA 的坐席/管理员
# v0.7.1: 用 mfa_secret/mfa_enabled 替代旧 otp_secret/otp_enabled # v1.5: 坐席和管理员都需要 OTP 验证
if agent.role == "admin" and agent.mfa_enabled: if agent.mfa_enabled:
if not body.otp_code: if not body.otp_code:
# 需要 OTP 验证,返回 require_otp 标记 # 需要 OTP 验证,返回 require_otp 标记
return success_response(data={ return success_response(data={
@@ -593,3 +593,161 @@ async def update_agent_password(
except Exception as e: except Exception as e:
logger.error(f"密码更新异常: {e}", exc_info=True) logger.error(f"密码更新异常: {e}", exc_info=True)
raise AppException(1014, f"密码更新失败: {str(e)}") raise AppException(1014, f"密码更新失败: {str(e)}")
# ============================================================================
# 企微 OAuth2 一键登录(坐席端)
# ============================================================================
import urllib.parse
import secrets as secrets_module
def _build_agent_oauth_url(redirect_uri: str) -> str:
"""构建坐席端企微OAuth2授权URL。
文档: https://developer.work.weixin.qq.com/document/path/91022
"""
params = {
"appid": settings.wecom_corp_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": "snsapi_base", # 静默授权
"state": "agent_login", # 标记为坐席登录
}
# 如果有 agentid 也加上
if getattr(settings, "wecom_agent_id", None):
params["agentid"] = str(settings.wecom_agent_id)
query = urllib.parse.urlencode(params)
# 企业微信 OAuth2 地址(注意是 open.work.weixin.qq.com
return f"https://open.work.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect"
@router.get("/agents/oauth/authorize")
async def get_oauth_authorize_url(
redirect_uri: str = Query(None, description="OAuth回调地址(可选,默认坐席端地址)"),
):
"""获取企微OAuth2授权URL(JSON格式,供前端跳转)。
前端调用此接口获取授权URL,然后自行跳转到企微授权页。
授权成功后企微会携带 code 回调到此接口的 redirect_uri。
Args:
redirect_uri: 授权成功后的回调地址(可选)
默认: https://itsupport.servyou.com.cn/itagent/
Returns:
JSON: { code: 0, data: { authorize_url: "https://open.weixin.qq.com/..." } }
"""
# 确定回调地址
if redirect_uri:
# 前端传入的回调地址
pass
else:
# 默认回调地址:坐席端首页
redirect_uri = "https://itsupport.servyou.com.cn/itagent/"
# 编码回调地址
encoded_redirect = urllib.parse.quote(redirect_uri, safe='')
# 构建授权URL
authorize_url = _build_agent_oauth_url(redirect_uri)
logger.info(f"生成坐席端OAuth授权URL: redirect_uri={redirect_uri}")
return success_response(data={
"authorize_url": authorize_url,
"redirect_uri": redirect_uri,
})
# OAuth 回调请求模型
class OAuthCallbackRequest(BaseModel):
code: str = Field(..., description="企微授权码")
state: str = Field(default="agent_login", description="state参数")
@router.post("/agents/oauth/callback")
async def oauth_callback(
body: OAuthCallbackRequest,
db: AsyncSession = Depends(get_db),
):
"""企微OAuth2回调处理(坐席端)。
用授权码换取员工ID,验证坐席身份,生成登录token。
Args:
body: { code: "xxx", state: "agent_login" }
db: 数据库会话
Returns:
JSON: { code: 0, data: { token, user_id, name, roles } }
"""
code = body.code
state = body.state
if not code:
raise AppException(2007, "授权码不能为空")
# 1. 用 code 换取员工身份
wecom_service = WecomService()
try:
oauth_info = await wecom_service.get_oauth_user_info(code)
user_id = oauth_info.get("userid", "")
if not user_id:
raise AppException(2007, "OAuth授权失败:未获取到员工ID")
except Exception as e:
logger.error(f"企微OAuth换取userid失败: {e}")
raise AppException(2007, f"OAuth授权失败: {str(e)}")
# 2. 获取员工详细信息(包含姓名)
employee_name = ""
try:
detail = await wecom_service.get_user_info(user_id)
employee_name = detail.get("name", "")
except Exception as e:
logger.warning(f"获取员工详细信息失败: user_id={user_id}, error={e}")
# 3. 验证是否为坐席
stmt = select(Agent).where(Agent.user_id == user_id)
result = await db.execute(stmt)
agent = result.scalars().first()
if not agent:
raise AppException(2008, f"您不是坐席,无法通过企业微信登录")
# 4. 生成登录token
token = secrets_module.token_urlsafe(32)
redis_client = _get_redis()
if redis_client:
try:
# 存储 token -> agent信息(JSON格式)
token_data = {
"user_id": agent.user_id,
"name": agent.name,
"roles": [agent.role],
"login_source": "agent_oauth",
}
import json as json_module
await redis_client.setex(
f"user:token:{token}",
TOKEN_TTL_SECONDS,
json_module.dumps(token_data),
)
# 记录登录日志
logger.info(f"企微OAuth登录成功: user_id={user_id}, name={employee_name}")
except Exception as e:
logger.error(f"Token存储Redis失败: {e}")
raise AppException(1003, "登录失败,请重试")
return success_response(data={
"token": token,
"user_id": agent.user_id,
"name": employee_name or agent.name,
"role": agent.role,
"require_otp": agent.otp_secret is not None,
})
+233 -40
View File
@@ -1,30 +1,41 @@
# ============================================================================= # =============================================================================
# IT智能服务台 — 审批流程 API # IT智能服务台 — 审批流程 API
# ============================================================================= # =============================================================================
# 说明:提供审批模板管理和跳转链接生成 # 说明:提供审批模板管理和API提交功能
# - 模板124(资源申请):跳转审批 # - 模板详情获取
# - 模板122(设备申请):API提交 # - API提交审批申请
# - 审批状态回调处理
# ============================================================================= # =============================================================================
import logging
import os
from typing import Optional from typing import Optional
import httpx
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel from pydantic import BaseModel
import redis.asyncio as aioredis
from app.config import settings
from app.utils.token_manager import ApprovalTokenManager
logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# ============================================================================= # Redis客户端(依赖注入)
# 审批模板配置(可配置化,后续可存入数据库) async def get_redis() -> aioredis.Redis:
# ============================================================================= """获取Redis客户端依赖"""
from app.main import redis_client
return redis_client
# ============================================================================= # =============================================================================
# 企微审批模板配置(从环境变量读取) # 审批模板配置(从环境变量读取)
# ============================================================================= # =============================================================================
# 环境变量: # 环境变量:
# APPROVAL_TEMPLATE_RESOURCE - 资源申请模板ID # APPROVAL_TEMPLATE_RESOURCE - 资源申请模板ID
# APPROVAL_TEMPLATE_DEVICE - 设备申请模板ID # APPROVAL_TEMPLATE_DEVICE - 设备申请模板ID
import os
APPROVAL_TEMPLATE_RESOURCE = os.getenv("APPROVAL_TEMPLATE_RESOURCE", "") APPROVAL_TEMPLATE_RESOURCE = os.getenv("APPROVAL_TEMPLATE_RESOURCE", "")
APPROVAL_TEMPLATE_DEVICE = os.getenv("APPROVAL_TEMPLATE_DEVICE", "") APPROVAL_TEMPLATE_DEVICE = os.getenv("APPROVAL_TEMPLATE_DEVICE", "")
@@ -35,7 +46,7 @@ if APPROVAL_TEMPLATE_RESOURCE:
APPROVAL_TEMPLATES[APPROVAL_TEMPLATE_RESOURCE] = { APPROVAL_TEMPLATES[APPROVAL_TEMPLATE_RESOURCE] = {
"id": APPROVAL_TEMPLATE_RESOURCE, "id": APPROVAL_TEMPLATE_RESOURCE,
"name": "资源申请", "name": "资源申请",
"type": "jump", # 跳转审批 "type": "jump",
"keywords": ["申请资源", "要资源", "申请"], "keywords": ["申请资源", "要资源", "申请"],
} }
@@ -43,7 +54,7 @@ if APPROVAL_TEMPLATE_DEVICE:
APPROVAL_TEMPLATES[APPROVAL_TEMPLATE_DEVICE] = { APPROVAL_TEMPLATES[APPROVAL_TEMPLATE_DEVICE] = {
"id": APPROVAL_TEMPLATE_DEVICE, "id": APPROVAL_TEMPLATE_DEVICE,
"name": "设备申请", "name": "设备申请",
"type": "api", # API提交 "type": "api",
"keywords": ["申请设备", "要设备", "电脑", "笔记本"], "keywords": ["申请设备", "要设备", "电脑", "笔记本"],
} }
@@ -52,12 +63,11 @@ if APPROVAL_TEMPLATE_DEVICE:
# Schema 定义 # Schema 定义
# ============================================================================= # =============================================================================
class ApprovalTemplateResponse(BaseModel): class ApprovalTemplateResponse(BaseModel):
"""审批模板响应""" """审批模板响应"""
id: str id: str
name: str name: str
type: str # "jump" 或 "api" type: str
keywords: list[str] keywords: list[str]
@@ -73,11 +83,19 @@ class ApprovalJumpResponse(BaseModel):
template_name: str template_name: str
class ApprovalContentItem(BaseModel):
"""审批表单控件内容"""
control: str # 控件类型: Text, Textarea, Number, Money, Date, Selector, Contact, etc.
id: str # 控件ID
value: dict # 控件值
class ApprovalSubmitRequest(BaseModel): class ApprovalSubmitRequest(BaseModel):
"""API提交审批请求""" """API提交审批请求"""
template_id: str template_id: str
employee_id: str employee_id: str # 申请人userid
content: dict # 审批内容 contents: list[ApprovalContentItem] # 表单内容
use_template_approver: int = 1 # 1-使用模板预设流程
class ApprovalSubmitResponse(BaseModel): class ApprovalSubmitResponse(BaseModel):
@@ -86,11 +104,98 @@ class ApprovalSubmitResponse(BaseModel):
template_name: str template_name: str
class ApprovalCallbackRequest(BaseModel):
"""审批回调请求(XML解析后的模型)"""
sp_no: str
sp_name: str
template_id: str
apply_time: int
applyer_userid: str
sp_status: int # 1-审批中 2-已通过 3-已驳回 4-已撤销 6-通过后撤销 7-已删除 10-已支付
status_change_event: int # 1-提单 2-同意 3-驳回 4-转审 5-催办 6-撤销 8-通过后撤销 10-添加备注
# =============================================================================
# 企微API调用辅助函数
# =============================================================================
async def get_approval_token(redis: aioredis.Redis) -> str:
"""获取审批应用access_token"""
manager = ApprovalTokenManager(redis)
return await manager.get_token()
async def get_template_detail(access_token: str, template_id: str) -> dict:
"""获取审批模板详情
对应企微API:
POST https://qyapi.weixin.qq.com/cgi-bin/oa/gettemplatedetail
返回模板内的控件构成及控件ID
"""
url = "https://qyapi.weixin.qq.com/cgi-bin/oa/gettemplatedetail"
params = {"access_token": access_token}
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=30.0)) as client:
response = await client.post(url, params=params, json={"template_id": template_id})
result = response.json()
if result.get("errcode") != 0:
logger.error(f"获取模板详情失败: {result.get('errmsg')}")
raise Exception(f"获取模板详情失败: {result.get('errmsg')}")
return result
async def submit_approval_api(
access_token: str,
template_id: str,
creator_userid: str,
contents: list[dict],
use_template_approver: int = 1
) -> dict:
"""提交审批申请
对应企微API:
POST https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent
Args:
access_token: 审批应用access_token
template_id: 模板ID
creator_userid: 申请人userid
contents: 表单控件内容列表
use_template_approver: 1-使用模板预设流程
Returns:
{"sp_no": "审批单号"}
"""
url = "https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent"
params = {"access_token": access_token}
payload = {
"creator_userid": creator_userid,
"template_id": template_id,
"use_template_approver": use_template_approver,
"apply_data": {
"contents": contents
}
}
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=30.0)) as client:
response = await client.post(url, params=params, json=payload)
result = response.json()
if result.get("errcode") != 0:
logger.error(f"提交审批失败: {result.get('errmsg')}")
raise Exception(f"提交审批失败: {result.get('errmsg')}")
return {"sp_no": result.get("sp_no")}
# ============================================================================= # =============================================================================
# API 端点 # API 端点
# ============================================================================= # =============================================================================
@router.get("/approval/templates", response_model=list[ApprovalTemplateResponse]) @router.get("/approval/templates", response_model=list[ApprovalTemplateResponse])
async def get_approval_templates(): async def get_approval_templates():
"""获取所有审批模板列表""" """获取所有审批模板列表"""
@@ -102,27 +207,42 @@ async def get_approval_template(template_id: str):
"""获取指定审批模板详情""" """获取指定审批模板详情"""
if template_id not in APPROVAL_TEMPLATES: if template_id not in APPROVAL_TEMPLATES:
from fastapi import HTTPException from fastapi import HTTPException
raise HTTPException(status_code=404, detail="模板不存在") raise HTTPException(status_code=404, detail="模板不存在")
return APPROVAL_TEMPLATES[template_id] return APPROVAL_TEMPLATES[template_id]
@router.get("/approval/templates/{template_id}/detail")
async def get_template_full_detail(
template_id: str,
redis: aioredis.Redis = Depends(get_redis)
):
"""获取审批模板完整详情(控件结构)"""
if template_id not in APPROVAL_TEMPLATES:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="模板不存在")
try:
token = await get_approval_token(redis)
detail = await get_template_detail(token, template_id)
return detail
except Exception as e:
from fastapi import HTTPException
raise HTTPException(status_code=500, detail=str(e))
@router.post("/approval/jump", response_model=ApprovalJumpResponse) @router.post("/approval/jump", response_model=ApprovalJumpResponse)
async def create_approval_jump(request: ApprovalJumpRequest): async def create_approval_jump(request: ApprovalJumpRequest):
"""生成跳转审批链接(模板124跳转方式)""" """生成跳转审批链接"""
template = APPROVAL_TEMPLATES.get(request.template_id) template = APPROVAL_TEMPLATES.get(request.template_id)
if not template: if not template:
from fastapi import HTTPException from fastapi import HTTPException
raise HTTPException(status_code=404, detail="模板不存在") raise HTTPException(status_code=404, detail="模板不存在")
if template["type"] != "jump": if template["type"] != "jump":
from fastapi import HTTPException from fastapi import HTTPException
raise HTTPException(status_code=400, detail="该模板不支持跳转方式") raise HTTPException(status_code=400, detail="该模板不支持跳转方式")
# 生成跳转URL(企微审批链接格式) # 生成跳转URL(企微审批链接格式)
# 实际URL需要根据企微配置生成
jump_url = f"https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent?access_token=TOKEN&template_id={request.template_id}" jump_url = f"https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent?access_token=TOKEN&template_id={request.template_id}"
return ApprovalJumpResponse( return ApprovalJumpResponse(
@@ -132,27 +252,102 @@ async def create_approval_jump(request: ApprovalJumpRequest):
@router.post("/approval/submit", response_model=ApprovalSubmitResponse) @router.post("/approval/submit", response_model=ApprovalSubmitResponse)
async def submit_approval(request: ApprovalSubmitRequest): async def submit_approval(
"""API提交审批(模板122 API方式)""" request: ApprovalSubmitRequest,
redis: aioredis.Redis = Depends(get_redis)
):
"""API提交审批申请"""
template = APPROVAL_TEMPLATES.get(request.template_id) template = APPROVAL_TEMPLATES.get(request.template_id)
if not template: if not template:
from fastapi import HTTPException from fastapi import HTTPException
raise HTTPException(status_code=404, detail="模板不存在") raise HTTPException(status_code=404, detail="模板不存在")
if template["type"] != "api": if template["type"] != "api":
from fastapi import HTTPException from fastapi import HTTPException
raise HTTPException(status_code=400, detail="该模板不支持API提交") raise HTTPException(status_code=400, detail="该模板不支持API提交")
# TODO: 调用企微API提交审批 try:
# 这里需要使用企微access_token调用审批API # 1. 获取审批token
# 实际实现需要根据企微审批API文档 token = await get_approval_token(redis)
return ApprovalSubmitResponse( # 2. 转换contents格式
sp_no=f"SP{request.template_id[:8]}", # 模拟审批单号 contents = [item.model_dump() for item in request.contents]
template_name=template["name"],
) # 3. 提交审批
result = await submit_approval_api(
access_token=token,
template_id=request.template_id,
creator_userid=request.employee_id,
contents=contents,
use_template_approver=request.use_template_approver
)
return ApprovalSubmitResponse(
sp_no=result["sp_no"],
template_name=template["name"],
)
except Exception as e:
from fastapi import HTTPException
raise HTTPException(status_code=500, detail=str(e))
@router.post("/approval/callback")
async def approval_callback(
sp_no: str = Query(...),
sp_name: str = Query(...),
template_id: str = Query(...),
apply_time: int = Query(...),
applyer_userid: str = Query(...),
sp_status: int = Query(...),
status_change_event: int = Query(...)
):
"""审批状态变化回调处理
对应企微审批回调事件: sys_approval_change
状态变化类型 (status_change_event):
1 - 提单
2 - 同意
3 - 驳回
4 - 转审
5 - 催办
6 - 撤销
8 - 通过后撤销
10 - 添加备注
审批单状态 (sp_status):
1 - 审批中
2 - 已通过
3 - 已驳回
4 - 已撤销
6 - 通过后撤销
7 - 已删除
10 - 已支付
"""
logger.info(f"审批回调: sp_no={sp_no}, status={sp_status}, event={status_change_event}")
# TODO: 根据业务需求处理审批状态变化
# 例如:
# - 审批通过后,更新IT服务台待办状态
# - 审批驳回后,通知申请人
# - 审批撤销后,关闭相关工单
event_map = {
1: "submitted",
2: "approved",
3: "rejected",
4: "transferred",
5: "reminded",
6: "revoked",
8: "revoked_after_approved",
10: "commented"
}
event_type = event_map.get(status_change_event, f"unknown_{status_change_event}")
logger.info(f"审批事件类型: {event_type}")
return {"errcode": 0, "errmsg": "ok"}
@router.get("/approval/keywords") @router.get("/approval/keywords")
@@ -161,12 +356,10 @@ async def get_approval_keywords():
keywords = [] keywords = []
for template in APPROVAL_TEMPLATES.values(): for template in APPROVAL_TEMPLATES.values():
for kw in template["keywords"]: for kw in template["keywords"]:
keywords.append( keywords.append({
{ "keyword": kw,
"keyword": kw, "template_id": template["id"],
"template_id": template["id"], "template_name": template["name"],
"template_name": template["name"], "type": template["type"],
"type": template["type"], })
}
)
return keywords return keywords
+2 -1
View File
@@ -98,7 +98,8 @@ def _build_oauth_url(state: str, callback_url: str) -> str:
"agentid": settings.wecom_agent_id, "agentid": settings.wecom_agent_id,
} }
query = urllib.parse.urlencode(params) query = urllib.parse.urlencode(params)
return f"https://open.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect" # 企业微信 OAuth2 地址(注意是 open.work.weixin.qq.com
return f"https://open.work.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect"
@router.get("/sso/init") @router.get("/sso/init")
+19 -4
View File
@@ -20,6 +20,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as aioredis
from app.database import get_db from app.database import get_db
from app.models.agent import Agent from app.models.agent import Agent
from app.models.conversation import Conversation from app.models.conversation import Conversation
@@ -41,7 +42,7 @@ from app.utils.response import AppException, success_response
from app.api.agents import get_current_agent from app.api.agents import get_current_agent
# RBAC 权限装饰器 # RBAC 权限装饰器
from app.dependencies import require_role, require_permission from app.dependencies import get_redis, require_role, require_permission
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -61,6 +62,7 @@ async def list_conversations(
page_size: int = Query(50, ge=1, le=100, description="每页数量"), page_size: int = Query(50, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent), current_agent: Agent = Depends(get_current_agent),
redis: aioredis.Redis = Depends(get_redis),
): ):
"""坐席获取会话列表(全局可见)。 """坐席获取会话列表(全局可见)。
@@ -82,7 +84,7 @@ async def list_conversations(
Returns: Returns:
Dict: 统一响应格式,包含会话列表和总数 Dict: 统一响应格式,包含会话列表和总数
""" """
session_service = SessionService(db) session_service = SessionService(db, redis_client=redis)
conversations, total = await session_service.get_conversations( conversations, total = await session_service.get_conversations(
status=status, status=status,
agent_id=agent_id, agent_id=agent_id,
@@ -107,10 +109,18 @@ async def list_conversations(
for agent in result.scalars().all(): for agent in result.scalars().all():
agent_name_map[agent.user_id] = agent.name agent_name_map[agent.user_id] = agent.name
# 转换为响应 Schema,附加 is_mine / assigned_agent_name / can_grab 字段 # 批量获取员工头像(带缓存)
employee_ids = list(set([conv.employee_id for conv in conversations]))
employee_avatar_map = {}
for emp_id in employee_ids:
employee_avatar_map[emp_id] = await session_service._get_employee_avatar(emp_id)
# 转换为响应 Schema,附加 is_mine / assigned_agent_name / can_grab / avatar 字段
items = [] items = []
for conv in conversations: for conv in conversations:
conv_data = ConversationResponse.model_validate(conv).model_dump() conv_data = ConversationResponse.model_validate(conv).model_dump()
# 员工头像(从缓存获取)
conv_data["avatar"] = employee_avatar_map.get(conv.employee_id, "")
# 是否为当前坐席的会话 # 是否为当前坐席的会话
conv_data["is_mine"] = conv.assigned_agent_id == current_agent.user_id conv_data["is_mine"] = conv.assigned_agent_id == current_agent.user_id
# 坐席姓名(从批量查询结果中获取) # 坐席姓名(从批量查询结果中获取)
@@ -152,6 +162,7 @@ async def list_conversations(
async def get_conversation( async def get_conversation(
conversation_id: str, conversation_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
): ):
"""获取会话详情。 """获取会话详情。
@@ -162,10 +173,14 @@ async def get_conversation(
Returns: Returns:
Dict: 统一响应格式,包含会话详情 Dict: 统一响应格式,包含会话详情
""" """
session_service = SessionService(db) session_service = SessionService(db, redis_client=redis)
conversation = await session_service.get_conversation(conversation_id) conversation = await session_service.get_conversation(conversation_id)
# 获取员工头像(带缓存)
avatar = await session_service._get_employee_avatar(conversation.employee_id)
response_data = ConversationResponse.model_validate(conversation).model_dump() response_data = ConversationResponse.model_validate(conversation).model_dump()
response_data["avatar"] = avatar
return success_response(data=response_data) return success_response(data=response_data)
+111 -2
View File
@@ -4,16 +4,27 @@
# 说明:提供员工相关的管理接口 # 说明:提供员工相关的管理接口
# 接口列表: # 接口列表:
# PUT /api/employees/{employee_id}/it-level — 更新员工IT技能等级 # PUT /api/employees/{employee_id}/it-level — 更新员工IT技能等级
# POST /api/employees/{employee_id}/avatar/refresh — 手动刷新员工头像
# ============================================================================= # =============================================================================
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as aioredis
from app.utils.response import success_response from app.utils.response import success_response
from app.schemas.employee import VALID_IT_LEVELS, VALID_LEVEL_SOURCES from app.schemas.employee import VALID_IT_LEVELS, VALID_LEVEL_SOURCES
from app.database import get_db
from app.core.config import settings
from app.models.employee import Employee
from app.dependencies import dep_redis
# 导入日志
import logging
logger = logging.getLogger(__name__)
# 创建路由器 # 创建路由器
router = APIRouter(prefix="/employees", tags=["员工管理"]) router = APIRouter(prefix="/employees", tags=["员工管理"])
@@ -114,3 +125,101 @@ async def update_employee_it_level(
it_level_source=request.source, it_level_source=request.source,
message=f"IT等级已从 {level_names.get(old_level, old_level)} 调整为 {level_names.get(request.it_level, request.it_level)}", message=f"IT等级已从 {level_names.get(old_level, old_level)} 调整为 {level_names.get(request.it_level, request.it_level)}",
).model_dump()) ).model_dump())
# --------------------------------------------------------------------------
# 头像刷新 API
# --------------------------------------------------------------------------
class AvatarRefreshResponse(BaseModel):
"""头像刷新响应 Schema。"""
employee_id: str
avatar: str
message: str
async def get_redis() -> aioredis.Redis:
"""获取Redis客户端依赖"""
redis = await dep_redis()
if redis is None:
raise HTTPException(status_code=500, detail="Redis连接不可用")
return redis
@router.post("/{employee_id}/avatar/refresh", response_model=dict)
async def refresh_employee_avatar(
employee_id: str,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
):
"""手动刷新员工头像。
调用企微通讯录API获取最新头像URL,更新数据库并刷新Redis缓存。
支持手动触发头像更新,适用于头像URL过期或需要立即更新的场景。
Args:
employee_id: 员工ID
Returns:
更新后的头像URL
"""
from datetime import datetime
from app.services.session_service import SessionService
# 1. 查找员工记录
result = await db.execute(
select(Employee).where(
Employee.employee_id == employee_id,
Employee.corp_id == settings.wecom_corp_id
)
)
employee = result.scalars().first()
if not employee:
raise HTTPException(status_code=404, detail=f"员工不存在: {employee_id}")
# 2. 使用 SessionService 从企微API获取最新头像
session_service = SessionService(db, redis_client=redis)
new_avatar = ""
try:
# 调用企微API获取最新头像
from app.services.wecom_service import WeComService
wecom_service = WeComService()
user_info = await wecom_service.get_user_info(employee_id)
new_avatar = user_info.get("avatar", "")
logger.info(f"企微API返回头像: employee_id={employee_id}, avatar={'有值(' + str(len(new_avatar)) + '字符)' if new_avatar else ''}")
# 3. 更新数据库
employee.avatar = new_avatar
employee.avatar_updated_at = datetime.utcnow()
await db.commit()
# 4. 刷新Redis缓存
cache_key = f"employee:avatar:{employee_id}"
if redis:
try:
if new_avatar:
await redis.setex(cache_key, SessionService.AVATAR_CACHE_TTL, new_avatar)
else:
# 如果头像为空,删除缓存
await redis.delete(cache_key)
except Exception as e:
logger.warning(f"刷新Redis头像缓存失败: employee_id={employee_id}, error={e}")
return success_response(data=AvatarRefreshResponse(
employee_id=employee_id,
avatar=new_avatar,
message="头像刷新成功" if new_avatar else "企微API未返回头像,已使用原头像",
).model_dump())
except Exception as e:
logger.error(f"刷新头像失败: employee_id={employee_id}, error={e}")
# 返回原头像,不阻塞流程
return success_response(data=AvatarRefreshResponse(
employee_id=employee_id,
avatar=employee.avatar,
message=f"头像刷新失败,使用原头像: {str(e)}",
).model_dump())
+117
View File
@@ -0,0 +1,117 @@
# =============================================================================
# 企微IT智能服务台 — 员工 API
# =============================================================================
# 说明:提供员工相关的管理接口
# 接口列表:
# PUT /api/employees/{employee_id}/it-level — 更新员工IT技能等级
# =============================================================================
from fastapi import APIRouter
from pydantic import BaseModel, Field, field_validator
from app.utils.response import success_response
from app.schemas.employee import VALID_IT_LEVELS, VALID_LEVEL_SOURCES
# 导入日志
import logging
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter(prefix="/employees", tags=["员工管理"])
# --------------------------------------------------------------------------
# 请求 Schema
# --------------------------------------------------------------------------
class ItLevelUpdateRequest(BaseModel):
"""IT技能等级更新请求 Schema。"""
it_level: str = Field(..., description="IT技能等级: bronze/silver/gold/platinum/diamond/star/king")
source: str = Field(default="manual", description="等级来源: system/manual/assessment")
@field_validator("it_level")
@classmethod
def validate_it_level(cls, v: str) -> str:
"""校验IT等级值是否合法。"""
if v not in VALID_IT_LEVELS:
raise ValueError(f"无效的IT等级: {v},合法值为: {VALID_IT_LEVELS}")
return v
@field_validator("source")
@classmethod
def validate_source(cls, v: str) -> str:
"""校验等级来源值是否合法。"""
if v not in VALID_LEVEL_SOURCES:
raise ValueError(f"无效的等级来源: {v},合法值为: {VALID_LEVEL_SOURCES}")
return v
class ItLevelUpdateResponse(BaseModel):
"""IT技能等级更新响应 Schema。"""
employee_id: str
it_level: str
it_level_source: str
message: str
# --------------------------------------------------------------------------
# Mock 员工数据存储(IT 等级映射)
# --------------------------------------------------------------------------
# 简单的内存存储,key 为 employee_idvalue 为 it_level
MOCK_EMPLOYEE_IT_LEVELS: dict = {
"emp-001": "silver",
"emp-002": "gold",
"emp-003": "bronze",
"emp-004": "platinum",
"emp-005": "diamond",
"emp-006": "silver",
"emp-007": "star",
"emp-008": "king",
}
# --------------------------------------------------------------------------
# API 接口
# --------------------------------------------------------------------------
@router.put("/{employee_id}/it-level")
async def update_employee_it_level(
employee_id: str,
request: ItLevelUpdateRequest,
):
"""更新员工IT技能等级。
坐席可以手动调整员工的IT技能等级,等级来源标记为 manual。
更新后等级立即生效,并记录来源以便追溯。
Args:
employee_id: 员工ID
request: 等级更新请求
Returns:
更新结果
"""
# 更新内存中的等级
old_level = MOCK_EMPLOYEE_IT_LEVELS.get(employee_id, "silver")
MOCK_EMPLOYEE_IT_LEVELS[employee_id] = request.it_level
# 构造等级名称映射
level_names = {
"bronze": "青铜",
"silver": "白银",
"gold": "黄金",
"platinum": "铂金",
"diamond": "钻石",
"star": "星耀",
"king": "王者",
}
return success_response(data=ItLevelUpdateResponse(
employee_id=employee_id,
it_level=request.it_level,
it_level_source=request.source,
message=f"IT等级已从 {level_names.get(old_level, old_level)} 调整为 {level_names.get(request.it_level, request.it_level)}",
).model_dump())
+48 -3
View File
@@ -260,8 +260,9 @@ async def get_oauth_authorize_url(
encoded_redirect = quote(f"{default_origin}/itportal/", safe="") encoded_redirect = quote(f"{default_origin}/itportal/", safe="")
# 构造企微OAuth2静默授权URLsnsapi_base:用户无感知) # 构造企微OAuth2静默授权URLsnsapi_base:用户无感知)
# 企业微信 OAuth2 地址(注意是 open.work.weixin.qq.com
authorize_url = ( authorize_url = (
f"https://open.weixin.qq.com/connect/oauth2/authorize" f"https://open.work.weixin.qq.com/connect/oauth2/authorize"
f"?appid={corp_id}" f"?appid={corp_id}"
f"&redirect_uri={encoded_redirect}" f"&redirect_uri={encoded_redirect}"
f"&response_type=code" f"&response_type=code"
@@ -328,6 +329,7 @@ async def oauth_callback(
position = "" position = ""
avatar = "" avatar = ""
# 2.1 获取员工详细信息(包含头像)
try: try:
detail = await wecom_service.get_user_info(employee_id) detail = await wecom_service.get_user_info(employee_id)
employee_name = detail.get("name", "") employee_name = detail.get("name", "")
@@ -337,8 +339,51 @@ async def oauth_callback(
department = ",".join(str(d) for d in dept_ids) if dept_ids else "" department = ",".join(str(d) for d in dept_ids) if dept_ids else ""
position = detail.get("position", "") position = detail.get("position", "")
avatar = detail.get("avatar", "") avatar = detail.get("avatar", "")
except Exception:
logger.warning(f"获取员工详细信息失败: employee_id={employee_id}") # 调试日志:检查企微API返回的完整数据
logger.info(f"企微用户详情返回: employee_id={employee_id}, avatar={avatar[:50] if avatar else '(空)'}...")
except Exception as e:
logger.warning(f"获取员工详细信息失败: employee_id={employee_id}, error={e}")
# 2.2 将员工信息保存到数据库(包含头像)
try:
from app.models.employee import Employee
from sqlalchemy import select
stmt = select(Employee).where(
Employee.employee_id == employee_id,
Employee.corp_id == settings.wecom_corp_id
)
result = await db.execute(stmt)
employee = result.scalars().first()
if employee:
# 更新已有记录
employee.name = employee_name
employee.department = department
employee.position = position
# 【FE-UA-005 优化】每次登录强制更新头像URL,确保获取最新头像
if avatar:
employee.avatar = avatar
employee.avatar_updated_at = datetime.utcnow()
logger.info(f"更新员工头像: employee_id={employee_id}, avatar={avatar[:50] if avatar else '(空)'}...")
else:
# 创建新记录
employee = Employee(
corp_id=settings.wecom_corp_id,
employee_id=employee_id,
name=employee_name,
department=department,
position=position,
avatar=avatar,
)
db.add(employee)
logger.info(f"创建员工记录(含头像): employee_id={employee_id}")
await db.commit()
except Exception as e:
logger.warning(f"保存员工信息到数据库失败: employee_id={employee_id}, error={e}")
# 不阻塞登录流程,继续执行
# 3. 生成 Bearer Token(与坐席端一致:secrets.token_urlsafe(32) # 3. 生成 Bearer Token(与坐席端一致:secrets.token_urlsafe(32)
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
+32 -28
View File
@@ -33,9 +33,8 @@ from app.schemas.message import MessageCreate, MessageResponse
from app.api.agents import get_current_agent from app.api.agents import get_current_agent
# RBAC 权限装饰器 # RBAC 权限装饰器
from app.dependencies import require_permission from app.dependencies import require_permission, get_current_user, UserInfo
from app.services.wecom_service import WecomService
from app.services.ws_manager import manager from app.services.ws_manager import manager
from app.utils.response import AppException, ERR_CONVERSATION_NOT_FOUND, ERR_CONVERSATION_RESOLVED, success_response from app.utils.response import AppException, ERR_CONVERSATION_NOT_FOUND, ERR_CONVERSATION_RESOLVED, success_response
@@ -60,6 +59,7 @@ async def list_messages(
conversation_id: str, conversation_id: str,
limit: int = Query(50, ge=1, le=100, description="每页消息数量"), limit: int = Query(50, ge=1, le=100, description="每页消息数量"),
before: Optional[str] = Query(None, description="加载此消息ID之前的消息(向上翻页)"), before: Optional[str] = Query(None, description="加载此消息ID之前的消息(向上翻页)"),
current_agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""获取会话消息列表(分页)。 """获取会话消息列表(分页)。
@@ -142,6 +142,7 @@ async def send_message(
conversation_id: str, conversation_id: str,
body: MessageCreate, body: MessageCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: UserInfo = Depends(get_current_user),
): ):
"""坐席发送消息。 """坐席发送消息。
@@ -222,37 +223,40 @@ async def send_message(
await db.flush() # 刷新以获取消息 ID await db.flush() # 刷新以获取消息 ID
# 5. 调用企微 API 发送消息给员工 # 5. 移除企微 API 调用,仅通过 WebSocket 推送到 H5
# 注意:只有 text 类型消息才需要调用企微 API 推送给员工 # 企微提醒由定时任务处理(超时未回复场景)
# image/file 等非文本消息暂不通过企微推送(仅存储消息记录供坐席查看) # 只保留 WebSocket 推送逻辑
# 跳过 Redis 连可避免无谓的网络开销,减少截图发送超时
if body.msg_type == "text":
# dev 模式短路:直接跳过企微推送,避免 invalid corpid 噪音
from app.config import settings
if getattr(settings, 'dev_mode', False):
logger.debug(f"[DEV] 跳过企微推送: msg_id={message.id}")
else:
try:
import redis.asyncio as aioredis
redis_client = settings.create_redis_client() # 6. 更新会话的最后坐席回复时间(用于超时提醒判断)
wecom_service = WecomService(redis_client) conversation.last_agent_reply_at = datetime.now()
conversation.reminder_sent = False # 重置提醒标记,允许再次发送提醒
conversation.pending_close_at = datetime.now() + timedelta(minutes=10) # 10分钟后待关闭
db.add(conversation)
await wecom_service.send_text_message( # 7. 更新消息状态为已发送
conversation.employee_id, body.content
)
await wecom_service.close()
await redis_client.close()
except Exception as e:
# 企微 API 调用失败不阻塞消息存储
logger.warning(f"企微消息发送失败(消息已存储): {e}")
# 6. 更新消息状态为已发送
message.status = "sent" message.status = "sent"
await db.flush() await db.flush()
# 7. 通过 WebSocket 推送消息给 H5 用户
# 做什么:构建 new_message 事件,推送给会话的员工
# 为什么:实现双通道推送(企微消息 + WebSocket),H5 用户可以实时收到消息
try:
# 构建消息载荷
msg_payload = MessageResponse.model_validate(message).model_dump()
# 构建 WebSocket 事件
ws_event = {
"type": "new_message",
"data": msg_payload,
}
# 推送给会话的员工(H5用户)
await manager.send_to_employee(conversation.employee_id, ws_event)
logger.debug(f"WebSocket消息推送成功: employee_id={conversation.employee_id}, msg_id={message.id}")
except Exception as e:
# WebSocket 推送失败不阻塞响应(员工可能未打开H5页面)
logger.warning(f"WebSocket消息推送失败(H5用户可能不在线): {e}")
# 转换为响应格式 # 转换为响应格式
response_data = MessageResponse.model_validate(message).model_dump() response_data = MessageResponse.model_validate(message).model_dump()
return success_response(data=response_data) return success_response(data=response_data)
+1 -1
View File
@@ -115,7 +115,7 @@ async def websocket_endpoint(
# token 不存在(已过期或伪造) # token 不存在(已过期或伪造)
await websocket.accept() await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Invalid or expired token") await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Invalid or expired token")
logger.warning(f"WebSocket 拒绝连接: agent_id={agent_id}, token={token[:20] if token else 'empty'}..., 原因=token无效或已过期") logger.warning(f"WebSocket 拒绝连接: agent_id={agent_id}, 原因=token无效或已过期")
return return
if stored_agent_id != agent_id: if stored_agent_id != agent_id:
+7 -2
View File
@@ -40,6 +40,8 @@ class Settings(BaseSettings):
wecom_agent_id: str = "1000002" wecom_agent_id: str = "1000002"
# 应用Secret(在企微管理后台 > 应用管理 > 自建应用 中查看) # 应用Secret(在企微管理后台 > 应用管理 > 自建应用 中查看)
wecom_secret: str = "your-agent-secret" wecom_secret: str = "your-agent-secret"
# 审批应用Secret(在企微管理后台 > 应用管理 > 审批 > 查看Secret
wecom_approval_secret: str = ""
# 回调Token(在企微管理后台 > 应用管理 > 接收消息 中设置) # 回调Token(在企微管理后台 > 应用管理 > 接收消息 中设置)
wecom_token: str = "your-callback-token" wecom_token: str = "your-callback-token"
# 回调EncodingAESKey43位字符串,用于消息加解密) # 回调EncodingAESKey43位字符串,用于消息加解密)
@@ -57,7 +59,8 @@ class Settings(BaseSettings):
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# Redis 连接地址 # Redis 连接地址
# Docker 环境使用容器名 redis,本地开发使用 localhost # Docker 环境使用容器名 redis,本地开发使用 localhost
redis_url: str = "redis://localhost:6379/0" # 从环境变量 REDIS_URL 读取,格式: redis://:password@host:port/db
redis_url: str = "" # 默认为空,由环境变量 REDIS_URL 提供
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 服务配置 # 服务配置
@@ -192,7 +195,9 @@ class Settings(BaseSettings):
Returns: Returns:
aioredis.Redis: 配置好的 Redis 异步客户端 aioredis.Redis: 配置好的 Redis 异步客户端
""" """
return aioredis.from_url(self.redis_url, protocol=2) # 如果 redis_url 为空,使用默认值
url = self.redis_url if self.redis_url else "redis://localhost:6379/0"
return aioredis.from_url(url, protocol=2)
# 创建全局配置实例 # 创建全局配置实例
+4
View File
@@ -161,6 +161,10 @@ async def init_shared_services():
""" """
global _redis_pool global _redis_pool
_redis_pool = settings.create_redis_client() _redis_pool = settings.create_redis_client()
# 注入 Redis 客户端到 cache_service(解决 WebSocket 认证时 redis=None 的问题)
from app.services.cache_service import cache_service
cache_service.redis = _redis_pool
logger.info("共享服务初始化完成") logger.info("共享服务初始化完成")
+68
View File
@@ -28,6 +28,8 @@ from app.api.router import api_router
from app.dependencies import init_shared_services, cleanup_shared_services from app.dependencies import init_shared_services, cleanup_shared_services
# 导入异常处理器和异常类 # 导入异常处理器和异常类
from app.utils.response import AppException, app_exception_handler from app.utils.response import AppException, app_exception_handler
# 导入定时任务
from app.tasks.reminder_task import check_unreplied_sessions
# 配置日志格式 # 配置日志格式
logging.basicConfig( logging.basicConfig(
@@ -88,6 +90,9 @@ async def lifespan(app: FastAPI):
# 初始化默认数据 # 初始化默认数据
await _init_default_data() await _init_default_data()
# 启动超时提醒定时任务
_start_scheduler()
logger.info("✅ 企微IT智能服务台启动完成") logger.info("✅ 企微IT智能服务台启动完成")
yield # 应用运行中 yield # 应用运行中
@@ -95,12 +100,75 @@ async def lifespan(app: FastAPI):
# ===== 关闭事件 ===== # ===== 关闭事件 =====
logger.info("👋 企微IT智能服务台关闭中...") logger.info("👋 企微IT智能服务台关闭中...")
# 停止超时提醒定时任务
_stop_scheduler()
# 清理共享服务实例(关闭 Redis 连接、httpx 连接池等) # 清理共享服务实例(关闭 Redis 连接、httpx 连接池等)
await cleanup_shared_services() await cleanup_shared_services()
logger.info("✅ 企微IT智能服务台已关闭") logger.info("✅ 企微IT智能服务台已关闭")
# --------------------------------------------------------------------------
# 定时任务调度器
# --------------------------------------------------------------------------
# 全局调度器实例
_scheduler = None
def _start_scheduler():
"""启动定时任务调度器。
启动 APScheduler 调度器,注册超时提醒定时任务。
每 30 秒检查一次超时未回复的会话。
"""
global _scheduler
if _scheduler is not None:
logger.warning("调度器已启动,跳过")
return
try:
from apscheduler.schedulers.asyncio import AsyncIOScheduler
_scheduler = AsyncIOScheduler()
# 注册超时提醒任务(每 30 秒执行一次)
_scheduler.add_job(
check_unreplied_sessions,
'interval',
seconds=30,
id='check_unreplied_sessions',
name='检查超时未回复会话',
replace_existing=True,
)
_scheduler.start()
logger.info("✅ 超时提醒定时任务已启动(每 30 秒执行一次)")
except Exception as e:
logger.error(f"启动定时任务调度器失败: {e}")
# 定时任务启动失败不阻塞应用启动
def _stop_scheduler():
"""停止定时任务调度器。
在应用关闭时调用,确保定时任务正确关闭。
"""
global _scheduler
if _scheduler is None:
return
try:
_scheduler.shutdown(wait=False)
_scheduler = None
logger.info("✅ 超时提醒定时任务已停止")
except Exception as e:
logger.error(f"停止定时任务调度器失败: {e}")
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 配置校验(启动时检查关键配置项是否为占位符) # 配置校验(启动时检查关键配置项是否为占位符)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
+32 -3
View File
@@ -3,7 +3,7 @@
# ============================================================================= # =============================================================================
# 说明:对应数据库 conversations 表,存储所有会话信息 # 说明:对应数据库 conversations 表,存储所有会话信息
# 核心概念:每个员工的每次咨询对应一个会话(Conversation # 核心概念:每个员工的每次咨询对应一个会话(Conversation
# 会话状态流转:ai_handling → queued → serving → resolved # 会话状态流转:ai_handling → queued → serving → pending_close → resolved
# ============================================================================= # =============================================================================
import uuid import uuid
@@ -29,7 +29,7 @@ class Conversation(Base):
department: 员工部门 department: 员工部门
position: 员工岗位 position: 员工岗位
level: 员工等级(用于 VIP 判断) level: 员工等级(用于 VIP 判断)
status: 会话状态(ai_handling/queued/serving/resolved status: 会话状态(ai_handling/queued/serving/pending_close/resolved
is_vip: VIP标记(基于企微通讯录规则自动匹配) is_vip: VIP标记(基于企微通讯录规则自动匹配)
is_pinned: 置顶标记(坐席手动操作) is_pinned: 置顶标记(坐席手动操作)
is_todo: 代办标记(坐席手动操作) is_todo: 代办标记(坐席手动操作)
@@ -113,7 +113,7 @@ class Conversation(Base):
String(20), String(20),
nullable=False, nullable=False,
default="queued", default="queued",
comment="会话状态: ai_handling/queued/serving/resolved", comment="会话状态: ai_handling/queued/serving/pending_close/resolved",
) )
# VIP标记(基于企微通讯录API规则自动匹配) # VIP标记(基于企微通讯录API规则自动匹配)
@@ -240,6 +240,35 @@ class Conversation(Base):
comment="最后消息时间", comment="最后消息时间",
) )
# 最后坐席回复时间(用于超时提醒判断)
last_agent_reply_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="最后坐席回复时间",
)
# 是否已发送超时提醒(避免重复发送)
reminder_sent: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="是否已发送超时提醒",
)
# 提醒发送时间
reminder_sent_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="提醒发送时间",
)
# 待关闭时间(坐席回复后 10 分钟自动标记待关闭)
pending_close_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="待关闭时间",
)
# 最后消息摘要(会话列表预览用,截取消息前256字符) # 最后消息摘要(会话列表预览用,截取消息前256字符)
last_message_summary: Mapped[str] = mapped_column( last_message_summary: Mapped[str] = mapped_column(
String(256), String(256),
+7
View File
@@ -118,6 +118,13 @@ class Employee(Base):
comment="头像URL", comment="头像URL",
) )
# 头像最后更新时间(从企微API获取后更新)
avatar_updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=True,
comment="头像最后更新时间",
)
# 激活状态(企微通讯录返回: 1=已激活, 2=已禁用, 4=未激活) # 激活状态(企微通讯录返回: 1=已激活, 2=已禁用, 4=未激活)
status: Mapped[int] = mapped_column( status: Mapped[int] = mapped_column(
default=1, default=1,
+1
View File
@@ -267,6 +267,7 @@ class ConversationResponse(BaseModel):
id: str id: str
employee_id: str employee_id: str
employee_name: str employee_name: str
avatar: str = Field(default="", description="员工头像URL")
department: str department: str
position: str position: str
level: str level: str
+6 -2
View File
@@ -167,7 +167,7 @@ class QrcodeService:
"""拼接企微 OAuth2 授权 URL(供前端生成二维码)。 """拼接企微 OAuth2 授权 URL(供前端生成二维码)。
URL 格式: URL 格式:
https://open.weixin.qq.com/connect/oauth2/authorize https://open.work.weixin.qq.com/connect/oauth2/authorize
?appid={corp_id} ?appid={corp_id}
&redirect_uri={callback} &redirect_uri={callback}
&response_type=code &response_type=code
@@ -175,6 +175,9 @@ class QrcodeService:
&state={ticket} &state={ticket}
#wechat_redirect #wechat_redirect
注意:必须使用 open.work.weixin.qq.com(企业微信开放平台),
而不是 open.weixin.qq.com(微信开放平台)。
Args: Args:
ticket: 扫码登录票据 ticket: 扫码登录票据
@@ -194,7 +197,8 @@ class QrcodeService:
"state": ticket, "state": ticket,
} }
query = urlencode(params) query = urlencode(params)
return f"https://open.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect" # 企业微信 OAuth2 地址(注意是 open.work.weixin.qq.com,不是 open.weixin.qq.com
return f"https://open.work.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect"
def _get_scan_callback_url(self) -> str: def _get_scan_callback_url(self) -> str:
"""获取 OAuth 回调地址。 """获取 OAuth 回调地址。
+59
View File
@@ -0,0 +1,59 @@
# =============================================================================
# 企微IT智能服务台 — 超时提醒服务
# =============================================================================
# 说明:提供发送超时提醒企微消息的功能
# 在坐席回复后员工长时间未回复时,发送企微提醒消息
# =============================================================================
import logging
from app.config import settings
from app.services.wecom_service import WecomService
logger = logging.getLogger(__name__)
# 超时提醒消息内容
REMINDER_MESSAGE = (
"IT服务提醒:您有新的消息未查看,"
"咨询将在10分钟后标记为待关闭,请尽快点击处理 👉 "
"https://itsupport.servyou.com.cn/itdesk/"
)
async def send_reminder_message(employee_id: str) -> bool:
"""发送超时提醒企微消息。
当坐席回复后员工超过3分钟未回复时,发送企微消息提醒员工查看。
Args:
employee_id: 员工的企微 UserID
Returns:
bool: 发送是否成功
Raises:
Exception: 发送失败时抛出异常
"""
try:
redis_client = settings.create_redis_client()
wecom_service = WecomService(redis_client)
try:
result = await wecom_service.send_text_message(
employee_id, REMINDER_MESSAGE
)
if result.get("errcode") == 0:
logger.info(f"超时提醒发送成功: employee_id={employee_id}")
return True
else:
logger.warning(
f"超时提醒发送失败: employee_id={employee_id}, "
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
)
return False
finally:
await wecom_service.close()
await redis_client.close()
except Exception as e:
logger.error(f"发送超时提醒异常: employee_id={employee_id}, error={e}")
raise
+50 -11
View File
@@ -12,7 +12,7 @@
import logging import logging
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import Any, List, Optional
from uuid import UUID from uuid import UUID
from sqlalchemy import and_, case, desc, func, select from sqlalchemy import and_, case, desc, func, select
@@ -43,15 +43,18 @@ class SessionService:
self, self,
db: AsyncSession, db: AsyncSession,
wecom_service: Optional[WecomService] = None, wecom_service: Optional[WecomService] = None,
redis_client: Optional[Any] = None,
): ):
"""初始化会话状态管理服务。 """初始化会话状态管理服务。
Args: Args:
db: 异步数据库会话 db: 异步数据库会话
wecom_service: 企微 API 服务(用于坐席接入时发送通知,可选) wecom_service: 企微 API 服务(用于坐席接入时发送通知,可选)
redis_client: Redis客户端(用于头像缓存,可选)
""" """
self.db = db self.db = db
self.wecom_service = wecom_service self.wecom_service = wecom_service
self.redis_client = redis_client
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 创建会话 # 创建会话
@@ -819,12 +822,16 @@ class SessionService:
# 邀请功能(P0-09~P0-11):坐席邀请员工/部门加入会话 # 邀请功能(P0-09~P0-11):坐席邀请员工/部门加入会话
# ====================================================================== # ======================================================================
async def _get_employee_avatar(self, employee_id: str) -> str: # 头像缓存 TTL7天
"""获取员工头像URL。 AVATAR_CACHE_TTL = 7 * 24 * 60 * 60
做什么:从 employees 表或企微通讯录API获取头像 async def _get_employee_avatar(self, employee_id: str) -> str:
为什么:邀请参与者时需要展示头像,前端无法单独获取被邀请人头像 """获取员工头像URL(带Redis缓存)。
优先级:employees 表(本地缓存) > 企微API(实时获取)
优先级:
1. Redis 缓存(最快)
2. employees 表
3. 企微API(获取后存入Redis缓存)
Args: Args:
employee_id: 企微员工UserID employee_id: 企微员工UserID
@@ -832,25 +839,57 @@ class SessionService:
Returns: Returns:
str: 头像URL,获取不到返回空字符串 str: 头像URL,获取不到返回空字符串
""" """
# 1. 优先从 employees 表查(本地缓存,速度快) cache_key = f"employee:avatar:{employee_id}"
# 1. 优先从 Redis 缓存获取
if self.redis_client:
try:
cached_avatar = await self.redis_client.get(cache_key)
if cached_avatar:
logger.debug(f"从Redis缓存获取头像: employee_id={employee_id}")
return cached_avatar.decode("utf-8") if isinstance(cached_avatar, bytes) else cached_avatar
except Exception as e:
logger.warning(f"从Redis获取头像缓存失败: employee_id={employee_id}, error={e}")
# 2. 从 employees 表获取(需要匹配 corp_id
from app.models.employee import Employee from app.models.employee import Employee
from app.core.config import settings
result = await self.db.execute( result = await self.db.execute(
select(Employee.avatar).where(Employee.employee_id == employee_id) select(Employee.avatar).where(
Employee.employee_id == employee_id,
Employee.corp_id == settings.wecom_corp_id
)
) )
row = result.first() row = result.first()
if row and row[0]: if row and row[0]:
logger.info(f"从employees表获取头像: employee_id={employee_id}, avatar={row[0][:50]}...")
# 存入 Redis 缓存
if self.redis_client:
try:
await self.redis_client.setex(cache_key, self.AVATAR_CACHE_TTL, row[0])
except Exception as e:
logger.warning(f"存入Redis头像缓存失败: employee_id={employee_id}, error={e}")
return row[0] return row[0]
else:
logger.info(f"employees表无头像记录: employee_id={employee_id}")
# 2. employees 表没有,尝试从企微通讯录API获取 # 3. employees 表没有,尝试从企微通讯录API获取
avatar = ""
if self.wecom_service: if self.wecom_service:
try: try:
user_info = await self.wecom_service.get_user_info(employee_id) user_info = await self.wecom_service.get_user_info(employee_id)
avatar = user_info.get("avatar", "") avatar = user_info.get("avatar", "")
return avatar logger.info(f"企微API返回头像: employee_id={employee_id}, avatar={'有值(' + str(len(avatar)) + '字符)' if avatar else ''}")
# 存入 Redis 缓存(即使为空也缓存,避免频繁请求API)
if self.redis_client and avatar:
try:
await self.redis_client.setex(cache_key, self.AVATAR_CACHE_TTL, avatar)
except Exception as e:
logger.warning(f"存入Redis头像缓存失败: employee_id={employee_id}, error={e}")
except Exception as e: except Exception as e:
logger.warning(f"从企微API获取头像失败: employee_id={employee_id}, error={e}") logger.warning(f"从企微API获取头像失败: employee_id={employee_id}, error={e}")
return "" return avatar
async def invite_participants( async def invite_participants(
self, self,
+100
View File
@@ -0,0 +1,100 @@
# =============================================================================
# 企微IT智能服务台 — 超时提醒定时任务
# =============================================================================
# 说明:定时检查超时未回复的会话,发送企微提醒消息
# 运行频率:每 30 秒执行一次
# 超时逻辑:
# 1. 坐席回复后 3 分钟员工未回复 -> 发送企微提醒(只发 1 次)
# 2. 坐席回复后 10 分钟员工未回复 -> 标记会话为 pending_close
# =============================================================================
import logging
from datetime import datetime, timedelta
from sqlalchemy import select
from app.models.conversation import Conversation
from app.services.reminder_service import send_reminder_message
logger = logging.getLogger(__name__)
# 超时配置(分钟)
REMINDER_TIMEOUT_MINUTES = 3 # 未回复超时时间
CLOSE_TIMEOUT_MINUTES = 10 # 自动待关闭时间
async def check_unreplied_sessions():
"""检查超时未回复的会话,发送提醒并标记待关闭。
此函数由 APScheduler 定时调用(每 30 秒)。
执行流程:
1. 查找需要发送提醒的会话(坐席回复超过3分钟,员工未回复且未发送过提醒)
2. 发送企微提醒消息
3. 标记已发送提醒
4. 查找需要标记待关闭的会话(坐席回复超过10分钟)
5. 更新会话状态为 pending_close
"""
# 导入数据库 session 工厂
from app.database import _get_session_factory
async_session_factory = _get_session_factory()
async with async_session_factory() as db:
try:
# 1. 查找需要发送提醒的会话
# 条件:active 状态 + 有坐席回复 + 超过3分钟未回复 + 未发送过提醒
reminder_threshold = datetime.now() - timedelta(minutes=REMINDER_TIMEOUT_MINUTES)
reminder_stmt = select(Conversation).where(
Conversation.status == "serving",
Conversation.last_agent_reply_at.isnot(None),
Conversation.last_agent_reply_at < reminder_threshold,
Conversation.reminder_sent == False,
)
result = await db.execute(reminder_stmt)
sessions_to_remind = result.scalars().all()
logger.info(f"发现 {len(sessions_to_remind)} 个需要发送提醒的会话")
# 2. 发送企微提醒消息
for session in sessions_to_remind:
try:
success = await send_reminder_message(session.employee_id)
if success:
# 3. 标记已发送提醒
session.reminder_sent = True
session.reminder_sent_at = datetime.now()
logger.info(f"会话 {session.id} 已发送提醒: employee_id={session.employee_id}")
else:
logger.warning(f"会话 {session.id} 提醒发送失败,跳过")
except Exception as e:
logger.error(f"会话 {session.id} 发送提醒异常: {e}")
continue
# 4. 查找需要标记待关闭的会话
# 条件:active 状态 + 有坐席回复 + 超过10分钟未回复
close_threshold = datetime.now() - timedelta(minutes=CLOSE_TIMEOUT_MINUTES)
close_stmt = select(Conversation).where(
Conversation.status == "serving",
Conversation.last_agent_reply_at.isnot(None),
Conversation.last_agent_reply_at < close_threshold,
)
result = await db.execute(close_stmt)
sessions_to_close = result.scalars().all()
logger.info(f"发现 {len(sessions_to_close)} 个需要标记待关闭的会话")
# 5. 更新会话状态为 pending_close
for session in sessions_to_close:
session.status = "pending_close"
logger.info(f"会话 {session.id} 已标记为待关闭: employee_id={session.employee_id}")
# 提交数据库变更
await db.commit()
logger.info("超时检查任务执行完成")
except Exception as e:
await db.rollback()
logger.error(f"超时检查任务执行异常: {e}")
raise
+73
View File
@@ -18,6 +18,79 @@ from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ApprovalTokenManager:
"""企微审批应用 access_token 缓存管理器。
专门用于审批应用的token管理,支持不同的Secret。
使用独立的Redis缓存key,与普通应用token隔离。
Attributes:
redis: Redis 异步客户端
corp_id: 企业ID
corp_secret: 审批应用Secret
"""
# 独立的Redis缓存key
CACHE_KEY = "wecom:approval_access_token"
TOKEN_EXPIRES = 7200
BUFFER_SECONDS = 300
def __init__(self, redis_client: aioredis.Redis, corp_secret: str = None):
"""初始化审批token管理器。
Args:
redis_client: Redis 异步客户端实例
corp_secret: 审批应用Secret,默认从settings读取
"""
self.redis = redis_client
self.corp_id = settings.wecom_corp_id
self.corp_secret = corp_secret or settings.wecom_approval_secret
self.client = httpx.AsyncClient(timeout=httpx.Timeout(connect=5.0, read=10.0))
async def get_token(self) -> str:
"""获取审批应用的 access_token。"""
cached = await self.redis.get(self.CACHE_KEY)
if cached:
logger.debug("从缓存获取审批 access_token")
return cached.decode("utf-8")
return await self._refresh_token()
async def _refresh_token(self) -> str:
"""调用企微 API 刷新审批 access_token。"""
logger.info("刷新审批 access_token")
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
params = {
"corpid": self.corp_id,
"corpsecret": self.corp_secret,
}
try:
response = await self.client.get(url, params=params)
result = response.json()
if result.get("errcode") != 0:
error_msg = result.get("errmsg", "未知错误")
logger.error(f"获取审批 access_token 失败: {error_msg}")
raise Exception(f"企微API错误: {error_msg}")
access_token = result["access_token"]
expires_in = result.get("expires_in", self.TOKEN_EXPIRES)
cache_ttl = max(expires_in - self.BUFFER_SECONDS, 60)
await self.redis.setex(self.CACHE_KEY, cache_ttl, access_token)
logger.info(f"审批 access_token 刷新成功,TTL={cache_ttl}")
return access_token
except httpx.HTTPError as e:
logger.error(f"获取审批 access_token 网络错误: {e}")
raise Exception(f"网络错误: {e}") from e
async def close(self) -> None:
"""关闭 HTTP 客户端。"""
await self.client.aclose()
class TokenManager: class TokenManager:
"""企微 access_token 缓存管理器。 """企微 access_token 缓存管理器。
@@ -0,0 +1,38 @@
-- =============================================================================
-- 企微IT智能服务台 — 数据库迁移脚本
-- =============================================================================
-- 说明:为 conversations 表添加超时提醒相关字段
-- 执行时机:在部署 MSG-P2-02 功能前执行
-- 兼容性:PostgreSQL
-- =============================================================================
-- 添加超时提醒相关字段
ALTER TABLE conversations
ADD COLUMN IF NOT EXISTS last_agent_reply_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
ADD COLUMN IF NOT EXISTS reminder_sent BOOLEAN DEFAULT FALSE NOT NULL,
ADD COLUMN IF NOT EXISTS reminder_sent_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
ADD COLUMN IF NOT EXISTS pending_close_at TIMESTAMP WITH TIME ZONE DEFAULT NULL;
-- 添加索引以优化查询性能
CREATE INDEX IF NOT EXISTS idx_conversations_last_agent_reply_at
ON conversations(last_agent_reply_at)
WHERE last_agent_reply_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_conversations_reminder_sent
ON conversations(reminder_sent)
WHERE reminder_sent = FALSE;
CREATE INDEX IF NOT EXISTS idx_conversations_pending_close_at
ON conversations(pending_close_at)
WHERE pending_close_at IS NOT NULL;
-- 回滚脚本(如果需要回滚)
-- ALTER TABLE conversations
-- DROP COLUMN IF EXISTS last_agent_reply_at,
-- DROP COLUMN IF EXISTS reminder_sent,
-- DROP COLUMN IF EXISTS reminder_sent_at,
-- DROP COLUMN IF EXISTS pending_close_at;
--
-- DROP INDEX IF EXISTS idx_conversations_last_agent_reply_at;
-- DROP INDEX IF EXISTS idx_conversations_reminder_sent;
-- DROP INDEX IF EXISTS idx_conversations_pending_close_at;
+4
View File
@@ -65,6 +65,10 @@ slowapi==0.1.9
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# python-dotenv: 从 .env 文件加载环境变量到 os.environ # python-dotenv: 从 .env 文件加载环境变量到 os.environ
python-dotenv==1.0.1 python-dotenv==1.0.1
# wordfilter: 敏感词过滤(用于坐席消息内容审核 v0.6.0+)
wordfilter==0.2.7
# APScheduler: 定时任务调度器(用于超时提醒等后台任务)
apscheduler==3.10.4
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# OTP 二次验证 # OTP 二次验证
+1 -7
View File
@@ -108,13 +108,7 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
command: > command: ["/bin/sh", "-c", "echo '>>> Skipping DB migration' && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2"]
/bin/sh -c "
echo '>>> 执行数据库迁移...' &&
cd /app && PYTHONPATH=/app alembic upgrade head &&
echo '>>> 启动 API 服务...' &&
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2
"
networks: networks:
- it-desk-internal - it-desk-internal
healthcheck: healthcheck:
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# 手动部署坐席端前端脚本
# 在服务器上执行: bash /tmp/manual-deploy-agent.sh
set -e
echo "========================================"
echo "坐席端手动部署脚本"
echo "========================================"
# 1. 清理旧文件
echo "[1/4] 清理旧文件..."
cd /opt/wecom-it-desk/html/itagent/
rm -rf assets dist index.html *.zip *.tar.gz 2>/dev/null || true
# 2. 创建临时目录
echo "[2/4] 创建临时目录..."
mkdir -p /tmp/agent-deploy
cd /tmp/agent-deploy
# 3. 下载新文件 (需要手动上传 agent-v3.tar.b64 到 /tmp/)
# 请先手动上传文件,然后执行以下命令:
# - 方法1: 通过 jumpserver elFinder 上传到 /tmp/
# - 方法2: 通过其他方式上传
# 检查文件是否存在
if [ -f "/tmp/agent-v3.tar.b64" ]; then
echo "[3/4] 解压部署包..."
# 解压
base64 -d /tmp/agent-v3.tar.b64 > agent-v3.tar.gz
tar -xzf agent-v3.tar.gz
# 移动文件
cp -r dist/* /opt/wecom-it-desk/html/itagent/
# 4. 重启 nginx
echo "[4/4] 重启 nginx..."
sudo systemctl reload nginx
echo "========================================"
echo "部署完成!"
echo "访问地址: https://itsupport.servyou.com.cn/itagent/"
echo "========================================"
else
echo "❌ 错误: /tmp/agent-v3.tar.b64 不存在"
echo ""
echo "请先上传部署包到服务器:"
echo "1. 本地文件: frontend-agent/agent-v3.tar.b64"
echo "2. 上传到服务器的 /tmp/agent-v3.tar.b64"
echo ""
echo "然后再次运行此脚本"
exit 1
fi
+128
View File
@@ -0,0 +1,128 @@
# 只修改 upstream 的 nginx 配置
# 将 wecom_it_backend 改为 wecom_it_backend_green
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
# 真实 IP 还原
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
set_real_ip_from 10.212.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# Gzip
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
# ========================================================================
# UPSTREAM — 改为 Green 环境
# ========================================================================
upstream wecom_it_backend {
server wecom_it_backend_green:8000;
}
# 默认服务器
server {
listen 80 default_server;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# itdesk H5
location /itdesk/ {
alias /usr/share/nginx/html/itdesk/;
try_files $uri /itdesk/index.html;
}
# itagent 坐席
location /itagent/ {
alias /usr/share/nginx/html/itagent/;
try_files $uri /itagent/index.html;
}
# itadmin 管理后台
location /itadmin/ {
alias /usr/share/nginx/html/itadmin/;
try_files $uri /itadmin/index.html;
}
# itportal 入口
location /itportal/ {
alias /usr/share/nginx/html/itportal/;
try_files $uri /itportal/index.html;
}
# API — Green 后端
location /api/ {
# 管理端 API 需要 IP 白名单
location ~ ^/api/admin/ {
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
allow 10.212.0.0/16;
deny all;
proxy_pass http://wecom_it_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# 其他 API
proxy_pass http://wecom_it_backend/;
proxy_http_version 1.1;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# WebSocket
location /ws/ {
access_log off;
proxy_pass http://wecom_it_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 86400s;
}
# 根路径重定向
location = / {
return 302 /itportal/;
}
}
}
+106
View File
@@ -0,0 +1,106 @@
# =============================================================================
# 企微智能IT支持服务台 — Nginx 配置(切换到 Green 环境)
# =============================================================================
# 使用方法:将此配置复制到 nginx 容器内覆盖原配置
# =============================================================================
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
# ------------------------------------------------------------------
# 真实 IP 还原
# ------------------------------------------------------------------
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
set_real_ip_from 10.212.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# ------------------------------------------------------------------
# Upstream — 切换到 Green 环境
# ------------------------------------------------------------------
upstream backend_api {
server wecom_it_backend_green:8000;
}
# ------------------------------------------------------------------
# HTTP 服务(监听 80 端口)
# ------------------------------------------------------------------
server {
listen 80;
listen [::]:80;
server_name localhost;
# 根路径重定向到 itdesk
location = / {
return 301 /itdesk/;
}
# 前端静态文件
location / {
root /usr/share/nginx/html;
index index.html index.htm;
}
# API 反向代理到 Green 后端
location /api/ {
proxy_pass http://backend_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# H5 端点反向代理到 Green 后端
location /h5/ {
proxy_pass http://backend_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# WebSocket 支持
location /ws/ {
proxy_pass http://backend_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 86400;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}
+2 -2
View File
@@ -11,7 +11,7 @@ server {
# API 反向代理到后端 # API 反向代理到后端
location /api/ { location /api/ {
proxy_pass http://wecom_it_backend:8000/; proxy_pass http://wecom_it_backend_green:8000/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -20,7 +20,7 @@ server {
# H5 端点反向代理 # H5 端点反向代理
location /h5/ { location /h5/ {
proxy_pass http://wecom_it_backend:8000/; proxy_pass http://wecom_it_backend_green:8000/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+9 -6
View File
@@ -48,28 +48,29 @@ http {
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 上游服务定义(拆分版) # 上游服务定义(拆分版)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 单体后端服务(所有 API 都走 backend:8000
upstream core_backend { upstream core_backend {
server core-svc:8001 max_fails=3 fail_timeout=30s; server backend:8000 max_fails=3 fail_timeout=30s;
keepalive 32; keepalive 32;
} }
upstream conversation_backend { upstream conversation_backend {
server conversation-svc:8002 max_fails=3 fail_timeout=30s; server backend:8000 max_fails=3 fail_timeout=30s;
keepalive 64; keepalive 64;
} }
upstream agent_backend { upstream agent_backend {
server agent-svc:8003 max_fails=3 fail_timeout=30s; server backend:8000 max_fails=3 fail_timeout=30s;
keepalive 32; keepalive 32;
} }
upstream ai_backend { upstream ai_backend {
server ai-svc:8004 max_fails=3 fail_timeout=30s; server backend:8000 max_fails=3 fail_timeout=30s;
keepalive 32; keepalive 32;
} }
upstream admin_backend { upstream admin_backend {
server admin-svc:8005 max_fails=3 fail_timeout=30s; server backend:8000 max_fails=3 fail_timeout=30s;
keepalive 32; keepalive 32;
} }
@@ -203,8 +204,10 @@ http {
} }
# 通用 API 路由(兜底) # 通用 API 路由(兜底)
# 注意:proxy_pass 末尾加斜杠会自动 strip /api 前缀
# 例如: /api/auth_qrcode/create -> /auth_qrcode/create
location /api/ { location /api/ {
proxy_pass http://conversation_backend; proxy_pass http://conversation_backend/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
+2
View File
@@ -206,11 +206,13 @@ http {
# 其他 API 路径 # 其他 API 路径
proxy_pass http://backend_api/; proxy_pass http://backend_api/;
proxy_http_version 1.1;
proxy_redirect off; # 禁用重定向修改,让后端的 307/302 保持原始 location proxy_redirect off; # 禁用重定向修改,让后端的 307/302 保持原始 location
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
# 超时设置(AI 回复可能较慢) # 超时设置(AI 回复可能较慢)
proxy_connect_timeout 60s; proxy_connect_timeout 60s;
+1
View File
@@ -0,0 +1 @@
UPDATE agents SET mfa_enabled = false WHERE user_id = 'sxn';
+4
View File
@@ -0,0 +1,4 @@
version: "3.8"
services:
backend:
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2
+1 -3
View File
@@ -111,9 +111,7 @@ services:
condition: service_healthy condition: service_healthy
command: > command: >
/bin/sh -c " /bin/sh -c "
echo '>>> 执行数据库迁移...' && echo '>>> 启动 API 服务 (跳过迁移)...' &&
cd /app && PYTHONPATH=/app alembic upgrade head &&
echo '>>> 启动 API 服务...' &&
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2 uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2
" "
networks: networks:
+1 -1
View File
@@ -61,7 +61,7 @@ docs/
| 目录/文档 | 说明 | | 目录/文档 | 说明 |
|------|------| |------|------|
| `00-系统架构设计文档-v1.0.md` | 系统架构设计(v1.0 | | `00-系统架构设计文档-v1.3.md` | 系统架构设计(v1.3 |
| `01-ADRs-架构决策/` | 4 | 架构决策记录 | | `01-ADRs-架构决策/` | 4 | 架构决策记录 |
| `02-技术方案/` | 5 | 技术方案文档 | | `02-技术方案/` | 5 | 技术方案文档 |
| `03-技术分析/` | 3 | 技术分析报告 | | `03-技术分析/` | 3 | 技术分析报告 |
@@ -5,7 +5,7 @@
> **📖 关联文档**: > **📖 关联文档**:
> - [README.md](../README.md) — 项目快速入门 > - [README.md](../README.md) — 项目快速入门
> - [01-项目总览与部署手册.md](./01-项目总览与部署手册.md) — 完整架构设计 > - [01-项目总览与部署手册](./01-项目总览与部署手册-20260704.md) — 完整架构设计
> - [CHANGELOG.md](../CHANGELOG.md) — 版本变更概览 > - [CHANGELOG.md](../CHANGELOG.md) — 版本变更概览
> - [docs/archive/](./archive/) — 历史版本详情 > - [docs/archive/](./archive/) — 历史版本详情
@@ -483,11 +483,11 @@ docker compose logs nginx > /tmp/incident-nginx.log
| 文档 | 说明 | | 文档 | 说明 |
|------|------| |------|------|
| `docs/01-项目总览与部署手册.md` | 完整项目背景与架构设计 | | `docs/01-项目总览/01-项目总览与部署手册-20260704.md` | 完整项目背景与架构设计 |
| `docs/RELEASE_NOTES_v0.7.1.md` | 版本发布说明 | | `docs/RELEASE_NOTES_v0.7.1.md` | 版本发布说明 |
| `docs/SOPs/SOP-004-应急响应.md` | 详细应急响应流程 | | `docs/SOPs/SOP-004-应急响应.md` | 详细应急响应流程 |
| `docs/deploy/快速诊断-500-错误.md` | 500 错误排查指南 | | `docs/deploy/快速诊断-500-错误.md` | 500 错误排查指南 |
| `docs/IT服务台部署修复记录-2026-06-13.md` | 历史修复记录 | | `docs/09-部署运维/deploy/04-部署修复记录-20260613.md` | 历史修复记录 |
--- ---
@@ -581,7 +581,7 @@ docker compose down # 停止新系统所有容器
| 阶段 | 状态 | 产出 | | 阶段 | 状态 | 产出 |
|------|------|------| |------|------|------|
| PRD | ✅ 完成 | `PRD.md` — 31 需求(P0/P1/P2),7 用户故事 | | PRD | ✅ 完成 | `PRD.md` — 31 需求(P0/P1/P2),7 用户故事 |
| 架构设计 | ✅ 完成 | `docs/ARCHITECTURE.md` — 9 表 DDL,7 API 组,4 时序图,5 任务分解 | | 架构设计 | ✅ 完成 | `docs/03-技术架构/00-系统架构设计文档-v1.3.md` — 9 表 DDL,7 API 组,4 时序图,5 任务分解 |
| T01 项目脚手架 | ✅ 完成 | 57 文件 — docker-compose, nginx, .env, 后端/前端骨架 | | T01 项目脚手架 | ✅ 完成 | 57 文件 — docker-compose, nginx, .env, 后端/前端骨架 |
| T02 后端核心服务 | ✅ 完成 | 16 文件 — 企微加解密, 消息路由, 评分, 会话, 趣味话术, 7 API 路由 | | T02 后端核心服务 | ✅ 完成 | 16 文件 — 企微加解密, 消息路由, 评分, 会话, 趣味话术, 7 API 路由 |
| T03 坐席工作台 | ✅ 完成 | 25 文件 — 三栏布局, 会话管理, 聊天, AI助手面板(5Tab) | | T03 坐席工作台 | ✅ 完成 | 25 文件 — 三栏布局, 会话管理, 聊天, AI助手面板(5Tab) |
@@ -9,7 +9,7 @@
| 阶段 | 状态 | 产出 | | 阶段 | 状态 | 产出 |
|------|------|------| |------|------|------|
| PRD | ✅ 完成 | `PRD.md` — 31 需求(P0/P1/P2),7 用户故事 | | PRD | ✅ 完成 | `PRD.md` — 31 需求(P0/P1/P2),7 用户故事 |
| 架构设计 | ✅ 完成 | `docs/ARCHITECTURE.md` — 9 表 DDL,7 API 组,4 时序图,5 任务分解 | | 架构设计 | ✅ 完成 | `docs/03-技术架构/00-系统架构设计文档-v1.3.md` — 9 表 DDL,7 API 组,4 时序图,5 任务分解 |
| T01 项目脚手架 | ✅ 完成 | 57 文件 — docker-compose, nginx, .env, 后端/前端骨架 | | T01 项目脚手架 | ✅ 完成 | 57 文件 — docker-compose, nginx, .env, 后端/前端骨架 |
| T02 后端核心服务 | ✅ 完成 | 16 文件 — 企微加解密, 消息路由, 评分, 会话, 趣味话术, 7 API 路由 | | T02 后端核心服务 | ✅ 完成 | 16 文件 — 企微加解密, 消息路由, 评分, 会话, 趣味话术, 7 API 路由 |
| T03 坐席工作台 | ✅ 完成 | 25 文件 — 三栏布局, 会话管理, 聊天, AI助手面板(5Tab) | | T03 坐席工作台 | ✅ 完成 | 25 文件 — 三栏布局, 会话管理, 聊天, AI助手面板(5Tab) |
@@ -0,0 +1,105 @@
# 文档关联修复报告
> **日期**: 2026-07-05 | **维护人**: 助理 | **范围**: docs/ 目录重组后的断链修复
---
## 一、背景
2026-07-04 对 `docs/` 执行了目录重组:从扁平结构迁移为 `01-项目总览` ~ `11-历史归档` 数字编号子目录,新建权威索引 `01-项目总览/00-索引-20260704.md`。但三处"关联"未同步更新,导致断链:
1. `mkdocs.yml` nav 导航仍指向旧的根级扁平路径
2. 文档间 Markdown 交叉引用未更新
3. 巡检自动化任务引用的看板路径已失效
---
## 二、修复清单
### 2.1 mkdocs.yml nav 重写(9 处断链 + 5 份新增)
| nav 旧路径 | 新路径 | 处理 |
|---|---|---|
| `01-项目总览与部署手册.md` | `01-项目总览/01-项目总览与部署手册-20260704.md` | 更新 |
| `ARCHITECTURE.md` | `03-技术架构/00-系统架构设计文档-v1.3.md` | 指向新版 |
| `ARCHITECTURE-admin.md` | (无新版,已归档) | 移除 |
| `DEPLOY-QUICK-v0.7.0.md` | `09-部署运维/deploy/10-一键部署操作包-v0.7.0.md` | 指向新版 |
| `DEPLOY-LOGIN-MIGRATION-v0.7.0.md` | (已过时归档) | 移除 |
| `NAS部署指南.md` | `09-部署运维/deploy/09-NAS部署指南-群晖Cloudflare.md` | 更新 |
| `OTP二次验证实现.md` | `09-部署运维/deploy/06-OTP二次验证实现.md` | 更新 |
| `IT服务台部署修复记录-2026-06-13.md` | `09-部署运维/deploy/04-部署修复记录-20260613.md` | 更新 |
| `E2E-CHECKLIST-v0.7.0.md` | `06-测试质量/testing-测试/E2E-CHECKLIST-v0.7.0.md` | 更新 |
**新增纳入 nav 的核心文档**
- `01-项目总览/00-索引-20260704.md`(文档索引)
- `01-项目总览/01-智能IT服务系统运维手册-20260704.md`(运维手册)
- `02-产品需求/02-产品需求文档PRD-v1.2-20260704.md`PRD v1.2
- `10-项目管理/05-项目状态看板/01-项目状态看板.md`(项目状态看板)
- `10-项目管理/01-任务总索引.md``10-项目管理/02-风险跟踪表.md`
### 2.2 文档间交叉引用修复(11 处)
| 文件 | 位置 | 旧引用 | 新引用 |
|---|---|---|---|
| `01-智能IT服务系统运维手册-20260704.md` | 行8 | `./01-项目总览与部署手册.md` | `./01-项目总览与部署手册-20260704.md` |
| 同上 | 行486 | `docs/01-项目总览与部署手册.md` | `docs/01-项目总览/01-项目总览与部署手册-20260704.md` |
| 同上 | 行490 | `docs/IT服务台部署修复记录-2026-06-13.md` | `docs/09-部署运维/deploy/04-部署修复记录-20260613.md` |
| `01-项目总览与部署手册-20260704.md` | 行584 | `docs/ARCHITECTURE.md` | `docs/03-技术架构/00-系统架构设计文档-v1.3.md` |
| `04-开发交付概览.md` | 行12 | `docs/ARCHITECTURE.md` | `docs/03-技术架构/00-系统架构设计文档-v1.3.md` |
| `技术分析-架构消息知识库迭代.md` | 行195 | `01-项目总览与部署手册.md` | `01-项目总览/01-项目总览与部署手册-20260704.md` |
| 同上 | 行655 | `ARCHITECTURE.md` / `PRD.md` | 新架构路径 / 新PRD路径 |
| `01-项目状态看板.md` | 行74 | `docs/DEPLOY-QUICK-v0.7.0.md` + `docs/E2E-CHECKLIST-v0.7.0.md` | 新部署路径 + 新E2E路径 |
| `10-一键部署操作包-v0.7.0.md` | 行215 | `docs/E2E-CHECKLIST-v0.7.0.md` | `docs/06-测试质量/testing-测试/E2E-CHECKLIST-v0.7.0.md` |
| `02-产品需求文档PRD-v1.2-20260704.md` | 行2457 | `docs/ARCHITECTURE.md` | `docs/03-技术架构/00-系统架构设计文档-v1.3.md` |
| 同上 | 行2458 | `docs/小组任务书/任务执行状态看板.md` | `docs/10-项目管理/05-项目状态看板/01-项目状态看板.md` |
**保留未改**
- 目录树展示(`├── ARCHITECTURE.md` 等)—— 历史结构快照,保留以保真
- `03-项目任务状态报告.md` 中"#4 更新ARCHITECTURE.md"等历史任务标题 —— 历史任务记录
### 2.3 索引版本号修正
`01-项目总览/00-索引-20260704.md` 第64行:架构文档版本号 `v1.0``v1.3`(与实际文件名一致)。
### 2.4 巡检自动化任务适配
**automation-1782986180887**(IT服务台任务巡检-早班,每日 09:30):
| 项 | 旧 | 新 |
|---|---|---|
| 数据源路径 | `docs\小组任务书\任务执行状态看板.md` | `docs/10-项目管理/05-项目状态看板/01-项目状态看板.md` |
| 巡检逻辑 | A/B/C 三组任务、🟢可立即启动/⏳等待中/依赖/预计完成日期 | P0必做/P1重要/等用户决策/进行中 |
| 输出格式 | 小组进度(A组x/16 等) | 全局状态(P0/P1/等决策计数 + 最近完成) |
---
## 三、关键发现
### 3.1 巡检数据源从未存在
巡检自动化任务自创建起所依赖的"任务执行状态看板.md"及整个小组任务书体系(A组认证加固16项 / B组消息系统16项 / C组AI与数据19项)**从未被实际创建**——仅在归档的"文档分类与清理报告"中作为"推荐结构"出现。这意味着该每日早班巡检自始至终都在尝试读取不存在的文件,每天必然失败。
经与用户确认,已将巡检适配为基于现有"项目状态看板"的状态巡检。
### 3.2 索引与实际存在版本号偏差
权威索引 `00-索引-20260704.md` 记录架构文档为 v1.0,实际文件为 `00-系统架构设计文档-v1.3.md`。已修正。
---
## 四、验证建议
1. **mkdocs 站点验证**:执行 `mkdocs serve` 预览,确认 nav 所有条目可正常跳转
2. **交叉引用验证**:在 mkdocs 构建时检查是否有 broken link 警告
3. **巡检验证**:等待次日 09:30 自动触发,确认巡检报告正常生成
---
## 五、未处理事项(待后续决策)
| 事项 | 说明 |
|---|---|
| 项目根目录 README.md 缺失 | 原项目根目录 `README.md`7.4KB 项目说明)在 2026-07-04 文档重组时已删除,未迁移到新结构。新入口为 `01-项目总览/01-项目总览与部署手册-20260704.md` |
| `01-智能IT服务系统运维手册` 行7/10 | 引用 `../README.md``./archive/`docs/ 根目录无 READMEarchive 实为 11-历史归档。非本次旧路径范围,暂未处理 |
| `01-项目总览与部署手册` 行645-717 | 历史目录树展示,保留原样。如需更新为新结构可后续单独处理 |
| 归档文档内的旧路径 | `11-历史归档/` 下多个文档仍含旧路径引用,作为历史快照保留,不修改 |
@@ -1,11 +1,13 @@
# 企微智能IT支持服务台 — 产品需求文档 (PRD) # 企微智能IT支持服务台 — 产品需求文档 (PRD)
> **文档版本**: v1.5 > **文档版本**: v1.6
> **创建日期**: 2025-07-11 > **创建日期**: 2025-07-11
> **最近更新**: 2026-07-04 > **最近更新**: 2026-07-04
> **产品经理**: 许清楚 (Xu) · 宋献 > **产品经理**: 许清楚 (Xu) · 宋献
> **状态**: 阶段一开发完成,待端到端验证 > **状态**: 阶段一开发完成,待端到端验证
> **说明**: 本文档已合并原 `PRD-v53-incremental.md` 内容(v5.3 坐席工作台增量需求)。v1.0 更新:新增管理后台远景规划(§17)、系统生态与集成规划(§18)、阶段细化与并行推进策略(§19);明确管理后台为第三端产品;确立 AI 混合策略(流程图+AI+标注+迭代);将阶段一细化为 1A/1B/1C 子阶段;新增零基础人员原则。v1.1 更新:新增邀请功能设计(§20),将邀请功能纳入M1 MVP(1A子阶段),新增P0-09~P0-11和P1-14~P1-16需求。v1.2 更新:整合 `PRD-增量-人工按钮与术语统一.md` 内容为新章节§10 术语与图标规范。v1.3 更新:新增 §4.5 指标体系详细设计;修复 §16 v5.3 内部章节编号;将 v5.3 项目信息移至 §1.1。v1.4 更新(2026-07-04):**产品经理视角重构**——新增 §1 产品愿景与目标、§1.5 用户画像、§1.6 非目标章节;补充竞品分析至产品定义章节。 > **说明**: 本文档已合并原 `PRD-v53-incremental.md` 内容(v5.3 坐席工作台增量需求)。v1.0 更新:新增管理后台远景规划(§17)、系统生态与集成规划(§18)、阶段细化与并行推进策略(§19);明确管理后台为第三端产品;确立 AI 混合策略(流程图+AI+标注+迭代);将阶段一细化为 1A/1B/1C 子阶段;新增零基础人员原则。v1.1 更新:新增邀请功能设计(§20),将邀请功能纳入M1 MVP(1A子阶段),新增P0-09~P0-11和P1-14~P1-16需求。v1.2 更新:整合 `PRD-增量-人工按钮与术语统一.md` 内容为新章节§10 术语与图标规范。v1.3 更新:新增 §4.5 指标体系详细设计;修复 §16 v5.3 内部章节编号;将 v5.3 项目信息移至 §1.1。v1.4 更新(2026-07-04):**产品经理视角重构**——新增 §1 产品愿景与目标、§1.5 用户画像、§1.6 非目标章节;补充竞品分析至产品定义章节。
v1.6 更新(2026-07-05):坐席端登录流程优化——智能检测企微客户端登录状态,已登录则提供企微快捷登录+浏览器登录+账号密码OTP三种方式;未登录则默认企微扫码登录+账号密码OTP兜底。
v1.5 更新(2026-07-04):登录逻辑调整——用户端强制企微内嵌,坐席/管理端浏览器直接打开(无需经过企微工作台),支持账号密码+OTP认证。 v1.5 更新(2026-07-04):登录逻辑调整——用户端强制企微内嵌,坐席/管理端浏览器直接打开(无需经过企微工作台),支持账号密码+OTP认证。
> **章节编号说明**: 主文档章节编号为 2-20(§1 在附录中),附录内使用独立编号体系(附录A §1-10、附录B §1-2、附录C §1-6)。后续新增章节应按顺序递增。 > **章节编号说明**: 主文档章节编号为 2-20(§1 在附录中),附录内使用独立编号体系(附录A §1-10、附录B §1-2、附录C §1-6)。后续新增章节应按顺序递增。
@@ -614,45 +616,70 @@ v1.5 更新(2026-07-04):登录逻辑调整——用户端强制企微内
#### 4.4.4 各端登录方式 #### 4.4.4 各端登录方式
> **更新日期**: 2026-07-04 | **设计目标**: 用户安全入口可控,坐席/管理员独立访问 > **更新日期**: 2026-07-05 | **设计目标**: 用户安全入口可控,坐席/管理员独立访问
| 端 | 访问方式 | 登录方式 | 说明 | | 端 | 访问方式 | 登录方式 | 说明 |
|----|----------|----------|------| |----|----------|----------|------|
| **用户端 (H5)** | 企微工作台 → 应用内嵌打开 | OAuth2 静默授权 | 强制内嵌,保证安全、入口统一、用户粘性 | | **用户端 (H5)** | 企微工作台 → 应用内嵌打开 | OAuth2 静默授权 | 强制内嵌,保证安全、入口统一、用户粘性 |
| **坐席端** | 浏览器直接打开 | 账号密码+OTP | **无需经过企微**,灵活办公,支持多设备 | | **坐席端** | 浏览器直接打开 | 智能检测+三种登录方式 | 企微快捷登录/浏览器扫码/账号密码+OTP |
| **管理后台** | 浏览器直接打开 | 账号密码+OTP | **无需经过企微**,安全可控 | | **管理后台** | 浏览器直接打开 | 账号密码+OTP | **无需经过企微**,安全可控 |
#### 4.4.5 坐席/管理员登录流程 #### 4.4.5 坐席登录流程v1.6 优化)
> **更新日期**: 2026-07-04 | **核心变更**: 坐席/管理员无需经过企微工作台 > **更新日期**: 2026-07-05 | **核心变更**: 智能检测企微登录状态,提供三种登录方式
``` ```
坐席访问 /itagent/(管理员访问 /itadmin/ 坐席访问 /itagent/
浏览器打开登录页 检测企微客户端登录状态(企微 JS-SDK)
┌─────────────────────────┐ ┌──────────────────────────────────────────────────────
登录方式选择 场景一:检测到企微已登录
┌────────────────────────────────────────────────┐
[🔐 账号密码+OTP登录] │ ← 主要方式 │ 选择登录方式 │ │
│ │
│ [🐛 企微扫码登录] │ ← 备选方式(如有企微环境) │ [① 在企业微信桌面端打开] → wecom:// 协议 │ │
└─────────────────────────┘ │ │ │ │
│ │ [② 继续在浏览器登录] → 企微扫码+验证码 │ │
│ │ │ │
│ │ [③ 账号密码+OTP] → 传统表单登录 │ │
│ └────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
输入用户名 + 密码 + OTP验证码 ┌──────────────────────────────────────────────────────┐
│ 场景二:未检测到企微登录 │
│ ┌────────────────────────────────────────────────┐ │
│ │ 默认登录页(企微扫码 + 账号密码OTP) │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ 企微扫码登录 │ │ 账号密码+OTP │ │ │
│ │ │ (二维码) │ │ │ │ │
│ │ └─────────────────┘ └─────────────────┘ │ │
│ └────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
验证成功 → 发放Token → 进入工作台 验证成功 → 发放Token → 进入工作台
``` ```
#### 4.4.6 企微扫码登录(备选) #### 4.4.6 登录方式详细说明
| 登录方式 | 适用场景 | 技术实现 | 用户操作 |
|----------|---------|---------|---------|
| **① 企微快捷登录** | 企微客户端已登录 | JS-SDK `wx.agentConfig` 获取用户身份 → 后端校验坐席角色 | 点击"在企业微信桌面端打开"自动跳转 |
| **② 浏览器企微扫码** | 企微客户端未登录但有企微App | 显示企微OAuth二维码 → 用户扫码授权 | 微信/企微扫码 → 授权 → 自动登录 |
| **③ 账号密码+OTP** | 无企微环境或二维码失效 | 传统表单 + TOTP验证码 | 输入账号密码+OTP → 登录 |
#### 4.4.7 企微客户端检测
| 检测方式 | 代码 | 说明 | | 检测方式 | 代码 | 说明 |
|----------|------|------| |----------|------|------|
| **企微内嵌检测** | `navigator.userAgent.includes('wxwork')` | 检测UA是否包含wxwork | | **企微内嵌检测** | `navigator.userAgent.includes('wxwork')` | 检测UA是否包含wxwork |
| **企微内打开** | 调用企微JSAPI `wx.openDefaultBrowser()` 或跳转企微应用URL | 在企微客户端中打开 | | **企微 JS-SDK** | `wx.agentConfig()` | 获取当前企微用户身份(需企业微信JS-SDK引入) |
| **浏览器登录** | 常规表单登录 + OTP输入框 | 账号密码+OTP验证 | | **企微协议跳转** | `wecom://` | 唤起企微客户端打开指定页面 |
> **注意**用户端(`/itdesk/`)强制企微内嵌,非企微环境访问跳转拦截页。 > **注意**
> - 用户端(`/itdesk/`)强制企微内嵌,非企微环境访问跳转拦截页
> - 坐席端智能检测为增强体验,检测失败时回退到默认登录页
#### 4.4.7 技术实现 #### 4.4.7 技术实现
@@ -2454,8 +2481,8 @@ class TroubleshootingTemplate(Base):
|------|------|------| |------|------|------|
| `docs/重构方案-复杂场景技术方案.md` | 非线性跳转/多意图等技术方案 | 规划中 | | `docs/重构方案-复杂场景技术方案.md` | 非线性跳转/多意图等技术方案 | 规划中 |
| `docs/PRD-增量-人工按钮与术语统一.md` | "人工"按钮与术语规范 | 待实施 | | `docs/PRD-增量-人工按钮与术语统一.md` | "人工"按钮与术语规范 | 待实施 |
| `docs/ARCHITECTURE.md` | 现有系统技术架构 | 维护中 | | `docs/03-技术架构/00-系统架构设计文档-v1.3.md` | 现有系统技术架构 | 维护中 |
| `docs/小组任务书/任务执行状态看板.md` | 项目任务状态管理 | 维护中 | | `docs/10-项目管理/05-项目状态看板/01-项目状态看板.md` | 项目任务状态管理 | 维护中 |
| `docs/archive/` | 已归档的重构方案文档 | 已归档 | | `docs/archive/` | 已归档的重构方案文档 | 已归档 |
--- ---
@@ -0,0 +1,227 @@
# 技术方案:消息推送策略优化与超时提醒
> **需求来源**2026-07-05 产品讨论
> **版本**v1.0
> **状态**:待开发
---
## 一、需求概述
### 1.1 业务背景
当前坐席回复用户消息时,会同时走两个通道:
1. **WebSocket** → 推送到 H5 页面
2. **企微应用消息** → 推送到"IT支持服务"应用的消息列表
这导致两种场景混在一起:
- 场景A:员工找坐席(一对一私密对话)→ 期望只走 H5
- 场景B:IT支持组群发通知 → 期望走企微消息
### 1.2 产品需求
| 需求 | 描述 |
|------|------|
| R1 | 正常对话:坐席回复仅推送到 H5 页面(WebSocket |
| R2 | 坐席回复后员工 3 分钟(可配置)未回复,发送企微提醒消息 |
| R3 | 提醒消息只发 1 次 |
| R4 | 10 分钟后自动标记会话为"待关闭"状态 |
| R5 | 提醒文案固定(见 1.3) |
### 1.3 提醒文案
```
IT服务提醒:您有新的消息未查看,咨询将在10分钟后标记为待关闭,请尽快点击处理 👉 https://itsupport.servyou.com.cn/itdesk/
```
---
## 二、技术方案
### 2.1 架构设计
```
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐
│ 坐席发送 │────▶│ WebSocket │────▶│ H5页面 │
│ 消息 │ │ (仅推送H5) │ │ (实时可见) │
└─────────────┘ └──────────────┘ └─────────────────┘
▼ (触发条件)
┌─────────────────────────────────────────┐
│ 后台定时任务 (每30秒) │
│ • 检查超时未回复会话 │
│ • 发送企微提醒消息 │
│ • 标记会话状态 │
└─────────────────────────────────────────┘
```
### 2.2 数据库改动
#### 2.2.1 conversations 表新增字段
```sql
ALTER TABLE conversations
ADD COLUMN IF NOT EXISTS last_agent_reply_at TIMESTAMP DEFAULT NULL,
ADD COLUMN IF NOT EXISTS reminder_sent BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS reminder_sent_at TIMESTAMP DEFAULT NULL,
ADD COLUMN IF NOT EXISTS pending_close_at TIMESTAMP DEFAULT NULL;
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `last_agent_reply_at` | TIMESTAMP | 坐席最后回复时间 |
| `reminder_sent` | BOOLEAN | 是否已发送提醒 |
| `reminder_sent_at` | TIMESTAMP | 提醒发送时间 |
| `pending_close_at` | TIMESTAMP | 待关闭时间(最后回复+10分钟) |
#### 2.2.2 配置表(可选)
在系统配置表中添加:
| key | default | 说明 |
|-----|---------|------|
| `reminder.timeout_minutes` | 3 | 未回复超时时间(分钟) |
| `reminder.close_minutes` | 10 | 自动待关闭时间(分钟) |
| `reminder.enabled` | true | 是否启用提醒功能 |
### 2.3 后端改动
#### 2.3.1 消息发送逻辑修改
**文件**`backend/app/api/messages.py`
```python
# 坐席发送消息时
async def send_message(...):
# 1. 仅通过 WebSocket 推送到 H5(不再调用企微 API)
await manager.send_to_employee(conversation.employee_id, ws_event)
# 2. 更新会话的最后坐席回复时间
conversation.last_agent_reply_at = datetime.now()
conversation.reminder_sent = False # 重置提醒标记
conversation.pending_close_at = datetime.now() + timedelta(minutes=10)
```
#### 2.3.2 新增定时任务
**文件**`backend/app/tasks/reminder_task.py`(新建)
```python
# 每 30 秒执行一次
@scheduler.scheduled_job('interval', seconds=30)
async def check_unreplied_sessions():
"""检查超时未回复的会话,发送提醒"""
# 1. 查找需要处理的会话
sessions = await db.execute(select(Conversation).where(
Conversation.status == 'active',
Conversation.last_agent_reply_at.isnot(None),
Conversation.reminder_sent == False,
Conversation.last_agent_reply_at < (datetime.now() - timedelta(minutes=3))
))
for session in sessions:
# 2. 发送企微提醒消息
await send_reminder_message(session)
# 3. 标记已发送
session.reminder_sent = True
session.reminder_sent_at = datetime.now()
# 4. 处理待关闭会话
pending = await db.execute(select(Conversation).where(
Conversation.status == 'active',
Conversation.pending_close_at < datetime.now()
))
for session in pending:
session.status = 'pending_close' # 待关闭状态
await db.commit()
```
#### 2.3.3 提醒消息发送函数
**文件**`backend/app/services/reminder_service.py`(新建)
```python
async def send_reminder_message(conversation: Conversation):
"""发送超时提醒企微消息"""
message = "IT服务提醒:您有新的消息未查看,咨询将在10分钟后标记为待关闭,请尽快点击处理 👉 https://itsupport.servyou.com.cn/itdesk/"
redis_client = settings.create_redis_client()
wecom_service = WecomService(redis_client)
try:
await wecom_service.send_text_message(
conversation.employee_id,
message
)
finally:
await wecom_service.close()
await redis_client.close()
```
### 2.4 前端改动
#### 2.4.1 坐席端(无需改动)
当前坐席发送消息功能保持不变,后端会自动处理推送逻辑。
#### 2.4.2 H5 端(无需改动)
WebSocket 接收消息逻辑保持不变。
### 2.5 部署配置
#### 2.5.1 后端定时任务启动
`backend/app/main.py` 中注册定时任务:
```python
from apscheduler.schedulers.asyncio import AsyncIOScheduler
scheduler = AsyncIOScheduler()
scheduler.add_job(check_unreplied_sessions, 'interval', seconds=30)
scheduler.start()
```
---
## 三、任务分解
| # | 任务 | 文件 | 预估工时 |
|---|------|------|---------|
| 1 | 数据库迁移 | conversations 表新增字段 | 0.5h |
| 2 | 消息发送逻辑修改 | `backend/app/api/messages.py` | 0.5h |
| 3 | 新建提醒服务 | `backend/app/services/reminder_service.py` | 1h |
| 4 | 新建定时任务 | `backend/app/tasks/reminder_task.py` | 1h |
| 5 | 定时任务注册 | `backend/app/main.py` | 0.5h |
| 6 | 部署测试 | - | 1h |
**总计**:约 4.5 小时
---
## 四、风险与注意事项
1. **定时任务并发**:多实例部署时需确保任务不重复执行(建议加分布式锁)
2. **历史数据**:已存在的会话不受影响,新逻辑仅对新增会话生效
3. **配置灵活性**:当前为固定值,后续可扩展为可配置
---
## 五、相关文件清单
| 文件 | 操作 |
|------|------|
| `backend/app/api/messages.py` | 修改 |
| `backend/app/services/reminder_service.py` | 新建 |
| `backend/app/tasks/reminder_task.py` | 新建 |
| `backend/app/main.py` | 修改 |
| `docs/02-产品需求/04-技术方案-消息推送策略优化与超时提醒.md` | 新建 |
---
*最后更新:2026-07-05 15:40*
@@ -13,6 +13,7 @@ metadata:
> - 完成文档优化专项(用户手册创建、KPI指标补充、技术约束更新) > - 完成文档优化专项(用户手册创建、KPI指标补充、技术约束更新)
> - 补充阶段四/五的KPI指标定义 > - 补充阶段四/五的KPI指标定义
> - 新增需求:待办事项集成企微审批工单(#74 > - 新增需求:待办事项集成企微审批工单(#74
> - 新增需求:头像同步功能完善(#75
## 来源 ## 来源
扫描 CURRENT-FOCUS.md(P0/P1/P2)+ phase1-progress.md 痛点 + 3 个 v1.0 必做 memory。 扫描 CURRENT-FOCUS.md(P0/P1/P2)+ phase1-progress.md 痛点 + 3 个 v1.0 必做 memory。
@@ -32,6 +33,12 @@ metadata:
- 需企微审批应用 API 权限 - 需企微审批应用 API 权限
- 估时:2-3天 - 估时:2-3天
3. **#75 [P1] 头像同步功能完善**
- 员工端/坐席端头像显示优化
- 当前仅首次登录同步,需改为每次登录强制更新
- 需处理头像URL过期问题
- 估时:1-2天
3. **#73 [P1] 修后端文件未真正覆盖** 3. **#73 [P1] 修后端文件未真正覆盖**
- `yes | cp -f` 路径,部署时偶尔没生效 - `yes | cp -f` 路径,部署时偶尔没生效
- 根因:`deploy-staging/` bind mount + RO 双重坑(见 [[bind-mount-deleted-inode-pitfall]]) - 根因:`deploy-staging/` bind mount + RO 双重坑(见 [[bind-mount-deleted-inode-pitfall]])
@@ -1,6 +1,6 @@
# IT智能服务台 — 系统架构设计文档 # IT智能服务台 — 系统架构设计文档
> **文档版本**: v1.4 > **文档版本**: v1.5
> **创建日期**: 2025-07-11 > **创建日期**: 2025-07-11
> **最近更新**: 2026-07-04 > **最近更新**: 2026-07-04
> **架构师**: 高见远 (Bob) > **架构师**: 高见远 (Bob)
@@ -356,6 +356,43 @@ Wingman 是坐席工作台的 AI 辅助系统:
| **数据映射** | 审批标题 → TodoItem.title / 审批状态 → TodoItem.status | | **数据映射** | 审批标题 → TodoItem.title / 审批状态 → TodoItem.status |
| **依赖** | 需企微管理后台创建审批应用并授权 API | | **依赖** | 需企微管理后台创建审批应用并授权 API |
### 9.3 WebSocket 实时通讯技术方案
> **新增日期**: 2026-07-05 | **状态**: 已实现
#### 9.3.1 技术选型
| 方案 | 优点 | 缺点 | 结论 |
|------|------|------|------|
| WebSocket | 双向实时、低延迟 | 需心跳维护 | ✅ 推荐 |
| SSE | 简单单向 | 仅服务器→客户端 | ❌ 不适合 |
| 轮询 | 实现简单 | 延迟高、资源浪费 | ❌ 不推荐 |
#### 9.3.2 消息类型
| 消息类型 | 方向 | 说明 |
|----------|------|------|
| `message_new` | Server→Client | 新消息推送 |
| `message_recall` | Server→Client | 消息撤回 |
| `typing` | Bidirectional | 对方正在输入 |
| `presence` | Bidirectional | 在线状态 |
| `ping/pong` | Bidirectional | 心跳保活 |
#### 9.3.3 心跳机制
- 客户端每 30 秒发送一次 ping
- 服务端 60 秒内未收到 ping 断开连接
#### 9.3.4 断线重连
- 前端 WebSocket 断开后自动重连
- 最大重连次数: 5
- 重连间隔: 2s, 4s, 8s, 16s, 32s
#### 9.3.5 降级策略
WebSocket 连接失败或断开时,自动降级为轮询(每3-5秒)。
--- ---
## 10. 安全设计 ## 10. 安全设计
@@ -381,6 +418,56 @@ Wingman 是坐席工作台的 AI 辅助系统:
| 角色来源追溯 | user_roles 表记录 source 和 assigned_by | | 角色来源追溯 | user_roles 表记录 source 和 assigned_by |
| 管理端 IP 白名单 | 仅内网/VPN 可访问 | | 管理端 IP 白名单 | 仅内网/VPN 可访问 |
### 10.3 OTP 双因素认证技术方案
> **新增日期**: 2026-07-05 | **状态**: 规划中
#### 10.3.1 技术选型
| 方案 | 优点 | 缺点 | 结论 |
|------|------|------|------|
| TOTP (Google Authenticator) | 开源成熟无需服务器 | 需手动绑定 | ✅ 推荐 |
| 短信OTP | 用户无需安装App | 有成本,有延迟 | ❌ 不推荐 |
| 邮箱OTP | 无需安装App | 有延迟,不实时 | ❌ 不推荐 |
#### 10.3.2 认证流程
```
用户输入账号密码
验证账号密码成功
返回要求OTP验证
用户输入OTP验证码
验证OTP → 返回结果
```
#### 10.3.3 绑定流程
```
用户首次登录 → 系统检测未绑定OTP → 显示绑定页面 → 用户扫描二维码 → 输入验证码确认 → 绑定成功
```
#### 10.3.4 API 设计
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/auth/otp-bind` | 绑定OTP |
| POST | `/api/auth/otp-verify` | 验证OTP |
| POST | `/api/auth/otp-unbind` | 解绑OTP(管理员) |
| GET | `/api/auth/otp-status` | 查询OTP绑定状态 |
#### 10.3.5 数据库设计
```sql
-- 扩展 agents 表新增字段
ALTER TABLE agents ADD COLUMN otp_secret VARCHAR(32) DEFAULT NULL;
ALTER TABLE agents ADD COLUMN otp_bound BOOLEAN DEFAULT FALSE;
ALTER TABLE agents ADD COLUMN otp_bound_at TIMESTAMP DEFAULT NULL;
```
--- ---
## 11. 复杂对话场景设计 ## 11. 复杂对话场景设计
@@ -0,0 +1,24 @@
#!/bin/bash
# 尝试多个可能的接口路径
# IT服务台Secret
curl -s 'https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=wwa8c87970b2011f41&corpsecret=EOtQslW7WD8Rna8Nm9WnwCW-ozHP3tustL4mFnet6O8' > /tmp/token.json
TOKEN=$(grep -o '"access_token":"[^"]*' /tmp/token.json | cut -d'"' -f4)
echo "Token: $TOKEN"
echo ""
# 尝试不同的接口路径
echo "=== 1. oa/get_template_list ==="
curl -s "https://qyapi.weixin.qq.com/cgi-bin/oa/get_template_list?access_token=$TOKEN"
echo ""
echo ""
echo "=== 2. oa/template/list ==="
curl -s "https://qyapi.weixin.qq.com/cgi-bin/oa/template/list?access_token=$TOKEN"
echo ""
echo ""
echo "=== 3. oa/approval/list ==="
curl -s "https://qyapi.weixin.qq.com/cgi-bin/oa/approval/list?access_token=$TOKEN&starttime=1767187200&endtime=1783094400"
echo ""
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""企微审批API权限测试脚本 - 服务器版本(使用 urllib)"""
import urllib.request
import urllib.parse
import urllib.error
import json
import sys
CORP_ID = "wwa8c87970b2011f41"
CORP_SECRET = "EOtQslW7WD8Rna8Nm9WnwCW-ozHP3tustL4mFnet6O8"
print("=" * 60)
print("企微审批API权限测试")
print("=" * 60)
# 1. 获取 access_token
print("\n[1/2] 获取 access_token...")
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={CORP_ID}&corpsecret={CORP_SECRET}"
try:
with urllib.request.urlopen(url, timeout=10) as response:
result = json.loads(response.read().decode('utf-8'))
except Exception as e:
print(f"❌ 请求失败: {e}")
sys.exit(1)
print(f" 返回: {result}")
if result.get("errcode") != 0:
print(f"❌ access_token 获取失败: {result.get('errmsg')}")
sys.exit(1)
token = result.get("access_token")
print(f"✅ access_token: {token[:20]}...")
# 2. 测试审批API - 企微正确的API路径
print("\n[2/2] 测试审批API...")
# 企微OA审批接口正确的调用方式
# 首先尝试 approvallist 接口
test_endpoints = [
("/cgi-bin/oa/approvallist", {"start_time": 0, "end_time": 9999999999, "cursor": 0, "size": 1}),
("/cgi-bin/oa/approvalinfo", {"spno": "test"}), # 用一个测试单号
]
success = False
for endpoint, params in test_endpoints:
full_url = f"https://qyapi.weixin.qq.com{endpoint}?access_token={token}&{urllib.parse.urlencode(params)}"
print(f" 测试: {endpoint}...")
try:
with urllib.request.urlopen(full_url, timeout=10) as response:
result = json.loads(response.read().decode('utf-8'))
except urllib.error.HTTPError as e:
result = {"errcode": e.code, "errmsg": f"HTTP {e.code}"}
except Exception as e:
result = {"errcode": -1, "errmsg": str(e)}
print(f" errcode: {result.get('errcode')}, errmsg: {result.get('errmsg')}")
if result.get("errcode") == 0:
success = True
print(f"{endpoint} 接口可用!")
break
elif result.get("errcode") == 48001:
print(f" ❌ 没有权限: {result.get('errmsg')}")
elif result.get("errcode") == 301012:
print(f" ⚠️ 接口可用但审批单不存在(正常)")
success = True
break
print("\n" + "=" * 60)
if success:
print("✅ 测试结果: 企微审批API权限已开通")
else:
print("❌ 测试结果: 未开通审批API权限 (错误码 48001)")
print("=" * 60)
@@ -192,7 +192,7 @@ M1 已升级为 **WebSocket 实时推送**2026-06-03 完成),坐席浏览
- 需跨平台/跨主体 → 方案A 不可替代 - 需跨平台/跨主体 → 方案A 不可替代
- **推荐混合策略**:原生1对1做日常入口,H5 保留为扩展层 - **推荐混合策略**:原生1对1做日常入口,H5 保留为扩展层
**运维应急**(详见 `01-项目总览与部署手册.md` §7.5): **运维应急**(详见 `01-项目总览/01-项目总览与部署手册-20260704.md` §7.5):
- H5 不可用时,零代码切换至方案B - H5 不可用时,零代码切换至方案B
- 需外援协作时,按需启用 appchat 群聊 - 需外援协作时,按需启用 appchat 群聊
@@ -652,4 +652,4 @@ C:\Users\simon\wecom_it_smart_desk\
--- ---
> 本文档面向运维/架构/开发三团队沟通使用。详细技术规格见 `ARCHITECTURE.md`,产品需求见 `PRD.md` > 本文档面向运维/架构/开发三团队沟通使用。详细技术规格见 `03-技术架构/00-系统架构设计文档-v1.3.md`,产品需求见 `02-产品需求/02-产品需求文档PRD-v1.2-20260704.md`
@@ -16,9 +16,9 @@
### 2. `frontend-h5/src/components/chat/InputBar.vue` ### 2. `frontend-h5/src/components/chat/InputBar.vue`
- **移除摇铃按钮**:删除 🔔 摇铃按钮及相关 CSSbell-btn/bell-icon/bell-idle/bell-ring 动画) - **移除摇铃按钮**:删除 🔔 摇铃按钮及相关 CSSbell-btn/bell-icon/bell-idle/bell-ring 动画)
- **新增工具栏**:😊表情 / 🖼️图片 / 📎文件 / 📸拍照(4个圆形按钮 - **工具栏**:😊表情 / 📎文件 / ✂️截图 / 📝快捷申请(2026-07-05移除🖼️图片和📸拍照功能
- **布局改为两行**:工具栏(上) + 输入行(输入框+发送按钮)(下) - **布局改为两行**:工具栏(上) + 输入行(输入框+发送按钮)(下)
- **新增方法**handleEmoji/handleImage/handleFile/handleCamera(阶段二实现具体功能) - **方法**handleEmoji/handleFile/handleScreenshot/handleQuickApply
- **引导条文案更新**:"点击标题栏铃铛呼叫 IT 坐席" - **引导条文案更新**:"点击标题栏铃铛呼叫 IT 坐席"
### 3. `frontend-h5/src/components/assistant/RightPanel.vue`(新建) ### 3. `frontend-h5/src/components/assistant/RightPanel.vue`(新建)
@@ -52,10 +52,9 @@ IT智能服务台是公司为员工提供的IT问题一站式解决平台,具
2. 点击发送按钮 2. 点击发送按钮
3. AI将自动回答您的问题 3. AI将自动回答您的问题
### 3.2 上传图片/文件 ### 3.2 上传文件
支持上传以下格式: 支持上传以下格式(2026-07-05起不再支持图片上传)
- 图片:jpg、png、gif
- 文件:doc、docx、pdf、xls、xlsx(单个文件≤10MB - 文件:doc、docx、pdf、xls、xlsx(单个文件≤10MB
--- ---
@@ -212,7 +212,7 @@ curl https://<生产域名>/api/mfa/status
## 🟡 部署后 必做(用户/QA 验收) ## 🟡 部署后 必做(用户/QA 验收)
`docs/E2E-CHECKLIST-v0.7.0.md` 35 项,逐项打勾。 `docs/06-测试质量/testing-测试/E2E-CHECKLIST-v0.7.0.md` 35 项,逐项打勾。
**关键项**: **关键项**:
- [ ] 浏览器扫码登录全流程(5 子项) - [ ] 浏览器扫码登录全流程(5 子项)
@@ -37,14 +37,14 @@ python scripts/jms_ops.py batch -f commands.txt
### 3. 文件上传 ### 3. 文件上传
```bash ```bash
# 本地 → 堡垒机 → 目标服务器 # 通过 elFinder Web UI 上传到目标服务器
python scripts/jms_ops.py upload 本地文件.conf /tmp/远程路径.conf python scripts/jms_ops.py upload 本地文件.conf /tmp/远程路径.conf
``` ```
### 4. 文件下载 ### 4. 文件下载
```bash ```bash
# 目标服务器 → 堡垒机 → 本地 # 通过 base64 通道从目标服务器下载
python scripts/jms_ops.py download /远程路径.conf ./本地文件.conf python scripts/jms_ops.py download /远程路径.conf ./本地文件.conf
``` ```
@@ -52,10 +52,11 @@ python scripts/jms_ops.py download /远程路径.conf ./本地文件.conf
| 需求 | 推荐方案 | 速度 | | 需求 | 推荐方案 | 速度 |
|------|----------|------| |------|----------|------|
| 执行命令获取文本结果 | v16 REST API + plink PTY | ~15s | | 执行命令获取文本结果 | v16 REST API + plink PTY | 首次 ~13s,复用 ~2-3s |
| 执行命令看界面效果 | v10 Web CLI(截图) | ~60s | | 执行命令看界面效果 | v10 Web CLI(截图) | ~60s |
| 文件传 (<100KB) | upload/download | - | | 文件传 (< 100KB) | base64 通道 | |
| 文件传输 | elFinder Web UI | - | | 文件上传 (>= 100KB 或 > 15s) | elFinder Web UI | 稳定 |
| 文件下载 | base64 通道 | - |
## 目标服务器配置 ## 目标服务器配置
@@ -0,0 +1,156 @@
# 智能IT支持服务台 - 问题修复记录
**日期**2026-07-05
**负责人**:宋献
**状态**:✅ 已完成
---
## 一、问题概述
### 1.1 当日问题汇总
| 序号 | 问题 | 影响范围 | 严重程度 | 状态 |
|------|------|---------|---------|------|
| #1 | 坐席端消息列表 500 错误 | 坐席端 | 🔴 高 | ✅ 已修复 |
| #2 | 页面短暂无法访问 | 全端 | 🟡 中 | ✅ 已自愈 |
| #3 | 坐席端消息发送失败 | 坐席端 | 🔴 高 | ✅ 已修复 |
| #4 | 文档缺失 wordfilter 依赖说明 | 文档 | 🟢 低 | ✅ 已补充 |
---
## 二、问题详情
### 2.1 #1 坐席端消息列表 500 错误
**发现时间**03:27
**问题现象**
- 坐席端报错:`获取消息列表失败: Error: 服务器内部错误,请稍后重试或联系管理员`
- WebSocket 连接失败:`wss://itsupport.servyou.com.cn/ws/sxn`
**根因分析**
- 后端日志:`TypeError: list_messages() got an unexpected keyword argument 'current_user'`
- 原因:`/api/conversations/{id}/messages` 端点使用了 `@require_permission` 装饰器,但函数签名缺少 `current_agent` 参数
**修复步骤**
1. 在 `backend/app/api/messages.py``list_messages` 函数中添加参数:
```python
current_agent: Agent = Depends(get_current_agent),
```
2. 使用 sed 命令在容器中直接插入行:
```bash
sudo docker exec wecom_it_backend sed -i '63i\ current_agent: Agent = Depends(get_current_agent),' /app/app/api/messages.py
```
3. 重启后端容器:
```bash
sudo docker restart wecom_it_backend
```
**验证结果**
```bash
curl "https://itsupport.servyou.com.cn/api/conversations/xxx/messages" -H "Authorization: Bearer xxx"
# 返回 200 OK,消息列表正常
```
---
### 2.2 #2 页面短暂无法访问
**发现时间**10:29
**问题现象**
- 用户报告坐席端和员工端页面打不开
**根因分析**
- 可能是之前容器重启导致的服务波动
**修复步骤**
- 服务自动恢复(无需人工干预)
**验证结果**
- H5 端:`/itdesk/` → 200 OK
- 坐席端:`/itagent/` → 200 OK
- API`/api/health` → 200 OK
---
### 2.3 #3 坐席端消息发送失败
**发现时间**10:44
**问题现象**
- 坐席端发送消息失败:`{"code":1005,"message":"服务器内部错误,请稍后重试或联系管理员"}`
**根因分析**
- 后端日志:`ModuleNotFoundError: No module named 'wordfilter'`
- `content_moderation_service.py` (v0.6.0 内容审核功能) 依赖 `wordfilter` 库,但 `requirements.txt` 中未声明
**修复步骤**
1. 在 `backend/requirements.txt` 中添加依赖:
```
wordfilter==0.2.7
```
2. 在容器中手动安装(临时修复):
```bash
sudo docker exec wecom_it_backend pip install wordfilter
```
**验证结果**
```bash
curl -X POST "https://itsupport.servyou.com.cn/api/conversations/xxx/messages" \
-H "Authorization: Bearer xxx" \
-H "Content-Type: application/json" \
-d '{"content":"测试","msg_type":"text"}'
# 返回 {"code":0,"message":"success"}
```
---
### 2.4 #4 文档缺失 wordfilter 依赖说明
**发现时间**10:50
**问题现象**
- 部署文档中未说明 Python 依赖管理流程
- `requirements.txt` 未包含 `wordfilter` 依赖
**修复步骤**
1. 更新 `backend/requirements.txt`,添加 `wordfilter==0.2.7`
2. 更新 `docs/09-部署运维/deploy/服务器部署手册.md`,新增"六、Python 依赖管理"章节:
- 依赖说明
- 新增依赖处理流程
- 常见依赖问题及解决方法
**验证结果**
- ✅ requirements.txt 已更新
- ✅ 部署文档已补充
---
## 三、后续建议
1. **依赖管理流程化**
- 每次新增 Python 依赖,必须同步更新 `requirements.txt`
- 部署前确保依赖已包含在 requirements.txt 中
2. **监控告警**
- 建议配置后端错误监控(如 Sentry),及时发现生产环境异常
3. **文档同步**
- 重要修复完成后,同步更新相关文档
---
## 四、相关文件
| 文件 | 说明 |
|------|------|
| `backend/requirements.txt` | Python 依赖声明 |
| `backend/app/api/messages.py` | 消息 API |
| `backend/app/services/content_moderation_service.py` | 内容审核服务 |
| `docs/09-部署运维/deploy/服务器部署手册.md` | 部署手册 |
---
*最后更新:2026-07-05 10:52*
@@ -43,65 +43,63 @@
``` ```
> **OpenSSH `ssh -J` 方式不再使用**(用户已确认用 PuTTY,2026-06-15) > **OpenSSH `ssh -J` 方式不再使用**(用户已确认用 PuTTY,2026-06-15)
# 登录成功后:
ssh sxn@10.90.5.110
```
### 2.3 配置 SSH 快捷方式(推荐) ### 2.3 jumpserver-ops 工具(推荐)
在开发机上编辑 `~/.ssh/config`,添加以下内容,以后只需要 `ssh itdesk` 即可: 推荐使用 jumpserver-ops 工具进行远程命令执行和文件传输。该工具自动化完成 JumpServer 登录、OTP 验证、资产连接等全流程。
```
# 堡垒机
Host bastion
HostName 10.212.189.210
Port 2222
User sxn
# 智能IT支持服务台服务器
Host itdesk
HostName 10.90.5.110
User sxn
ProxyJump bastion
```
> **堡垒机用户名为 `sxn`,已填入下方命令中**
之后只需:
```bash ```bash
ssh itdesk # 自动通过堡垒机跳转 # 进入 skill 目录
scp file itdesk:/opt/ # 文件传输也会自动走堡垒机 cd C:\Users\simon\.workbuddy\skills\jumpserver-ops\scripts
# 远程执行命令(首次登录约 13s,后续复用约 2-3s)
python jms_ops.py exec -c "hostname"
python jms_ops.py exec -c "uptime" -c "docker ps"
# 文件上传(自动选择最优方式:<100KB 用 base64>=100KB 或 >15s 用 elFinder
python jms_ops.py upload local_file.txt /tmp/remote_file.txt
# 文件下载(base64 通道)
python jms_ops.py download /tmp/server_file.txt ./local_file.txt
``` ```
> **注意**:首次使用会弹出浏览器完成 JumpServer 登录和 OTP 验证,后续调用会自动复用会话(30分钟内有效)。
### 2.4 为什么不能直接用 SSH/SCP
| 方式 | 支持情况 | 原因 |
|------|---------|------|
| SSH ProxyJump | ❌ 不支持 | JumpServer 不兼容标准 SSH 代理协议 |
| SCP 直连堡垒机 | ❌ 不支持 | 需要 OTP 验证码,SCP 不支持交互式输入 |
| SSH 直连目标服务器 | ❌ 不支持 | 目标服务器仅对 JumpServer 开放 SSH 访问 |
| jumpserver-ops | ✅ 推荐 | 自动化处理 OTP 和 Connection Token |
--- ---
## 三、文件传输(通过堡垒机) ## 三、文件传输(通过堡垒机)
### 3.1 SCP 传输(推荐小文件/单次传输 ### 3.1 jumpserver-ops 上传(推荐
```bash ```bash
# 上传单个文件 # 上传文件到目标服务器 /tmp 目录
scp -o "ProxyJump=sxn@10.212.189.210:2222" \ # 自动选择最优方式:<100KB 用 base64(快),>=100KB 或 >15s 用 elFinder(稳定)
it-smart-desk-server-deploy.zip \ python jms_ops.py upload deploy.zip /tmp/deploy.zip
sxn@10.90.5.110:/opt/
# 如果已配置 ~/.ssh/config # 上传到其他目录
scp it-smart-desk-server-deploy.zip itdesk:/opt/ python jms_ops.py upload config.conf /opt/wecom-it-desk/config.conf
``` ```
### 3.2 大文件传输优化 ### 3.2 备用方案
部署包可能较大(含后端源码 + 前端产物),如果 SCP 速度慢,可以先传到堡垒机再转 如果 jumpserver-ops 不可用,可以考虑
```bash ```bash
# 步骤1:传到堡垒机 # 方式A:通过互联网可访问的存储服务(推荐)
scp -P 2222 it-smart-desk-server-deploy.zip sxn@10.212.189.210:/tmp/ # - 先把文件传到能通过互联网访问的存储(如:对象存储、临时文件分享服务)
# - 目标服务器通过 curl/wget 下载
# 步骤2SSH 到堡垒机 # 方式B:通过堡垒机手动中转
ssh -p 2222 sxn@10.212.189.210 # - 使用 jumpserver-ops 手动执行分步操作
# 步骤3:从堡垒机传到目标服务器
scp /tmp/it-smart-desk-server-deploy.zip sxn@10.90.5.110:/opt/
``` ```
--- ---
@@ -276,28 +274,86 @@ DNS 生效后(或配置了本地 hosts),在浏览器中访问:
## 五、部署文件结构 ## 五、部署文件结构
### 5.1 服务器目录结构
``` ```
/opt/wecom-it-desk/ /opt/wecom-it-desk/ # 项目根目录(服务器)
├── docker-compose.yml # Docker Compose 配置(4容器) ├── docker-compose.yml # Docker Compose 配置(4容器)
├── .env # 环境变量(已配置) ├── .env # 环境变量(已配置)
├── .env.example # 环境变量模板 ├── .env.example # 环境变量模板
├── deploy.sh # 一键部署脚本 ├── deploy.sh # 一键部署脚本
├── README.md # 本手册
├── nginx/ ├── nginx/
── nginx.conf # Nginx 配置(反代 + 静态文件) ── nginx.conf # Nginx 配置(反代 + 静态文件)
├── backend/ │ └── ssl/ # SSL 证书
│ ├── Dockerfile # 后端镜像构建文件 ├── html/ # 前端静态文件(Nginx 挂载点)
│ ├── requirements.txt # Python 依赖 │ ├── itdesk/ # H5 员工端 (/itdesk/)
── app/ # 后端源代码 ── itagent/ # 坐席工作台 (/itagent/)
├── frontend-h5/ │ ├── itadmin/ # 管理后台 (/itadmin/)
│ └── dist/ # H5 员工端构建产物 │ └── itportal/ # 统一入口 (/itportal/)
└── frontend-agent/ └── backend/ # 后端源码(不用于生产,仅开发参考)
└── dist/ # 坐席工作台构建产物 ```
### 5.2 前端部署位置说明
| 端 | URL 路径 | 服务器目录 | Nginx 容器挂载点 |
|----|---------|-----------|----------------|
| 员工端 H5 | `/itdesk/` | `/opt/wecom-it-desk/html/itdesk/` | `/usr/share/nginx/html/itdesk` |
| 坐席工作台 | `/itagent/` | `/opt/wecom-it-desk/html/itagent/` | `/usr/share/nginx/html/itagent` |
| 管理后台 | `/itadmin/` | `/opt/wecom-it-desk/html/itadmin/` | `/usr/share/nginx/html/itadmin` |
| 统一入口 | `/itportal/` | `/opt/wecom-it-desk/html/itportal/` | `/usr/share/nginx/html/itportal` |
### 5.3 前端部署步骤
```bash
# 1. 本地构建
cd frontend-agent
npm run build
# 2. 打包(排除 node_modules
cd dist
zip -r ../agent-v1.x.zip *
# 3. 上传到服务器 /tmp/
# 4. SSH 到服务器解压
sudo rm -rf /opt/wecom-it-desk/html/itagent
sudo mkdir -p /opt/wecom-it-desk/html/itagent
sudo unzip -o /tmp/agent-v1.x.zip -d /opt/wecom-it-desk/html/itagent
# 5. 重启 Nginx 容器
docker restart wecom_it_nginx
``` ```
--- ---
## 六、常用运维命令 ## 六、Python 依赖管理
### 6.1 依赖说明
后端 Python 依赖在 `backend/requirements.txt` 中声明,构建 Docker 镜像时会自动安装。
**新增依赖处理流程:**
1. **开发环境**:更新 `backend/requirements.txt`
2. **打包部署**:确保 `requirements.txt` 已包含新依赖
3. **生产环境**:重建后端镜像
```bash
# 重建后端镜像(会自动安装 requirements.txt 中的所有依赖)
cd /opt/wecom-it-desk
docker compose build backend
docker compose up -d backend
```
### 6.2 常见依赖问题
| 问题 | 症状 | 解决方法 |
|------|------|---------|
| 缺少依赖 | `ModuleNotFoundError` | 重建后端镜像:`docker compose build backend` |
| 手动安装 | 容器内临时安装 | `docker exec wecom_it_backend pip install <package>` |
---
## 七、常用运维命令
在服务器上 `/opt/wecom-it-desk` 目录下执行: 在服务器上 `/opt/wecom-it-desk` 目录下执行:
@@ -318,7 +374,7 @@ DNS 生效后(或配置了本地 hosts),在浏览器中访问:
--- ---
## 、升级前端 ## 、升级前端
当有新的前端版本需要部署时: 当有新的前端版本需要部署时:
@@ -344,7 +400,7 @@ docker exec wecom_it_nginx nginx -s reload
--- ---
## 、升级后端 ## 、升级后端
```bash ```bash
# 1. 上传新代码到服务器 # 1. 上传新代码到服务器
@@ -360,7 +416,7 @@ cd /opt/wecom-it-desk
--- ---
## 、故障排查 ## 、故障排查
### 后端容器一直重启 ### 后端容器一直重启
@@ -427,7 +483,7 @@ curl -X POST http://localhost/api/h5/mock-login \
--- ---
## 十、HTTPS 配置(可选) ## 十、HTTPS 配置(可选)
如果公司要求 HTTPS,有两种方式: 如果公司要求 HTTPS,有两种方式:
@@ -445,7 +501,7 @@ curl -X POST http://localhost/api/h5/mock-login \
--- ---
## 十、部署说明 ## 十、部署说明
> ⚠️ NAS部署方案(itdesk.amanzac.com)已于2026年6月15日下线,现统一使用公司内网服务器部署。 > ⚠️ NAS部署方案(itdesk.amanzac.com)已于2026年6月15日下线,现统一使用公司内网服务器部署。
@@ -461,7 +517,7 @@ curl -X POST http://localhost/api/h5/mock-login \
--- ---
## 十、相关文档 ## 十、相关文档
| 文档 | 说明 | | 文档 | 说明 |
|------|------| |------|------|
@@ -0,0 +1,120 @@
# 502 Bad Gateway - 后端启动失败
> 日期:2026-07-05
> 问题:坐席端登录失败,返回 502 Bad Gateway
---
## 一、问题现象
用户访问 `https://itsupport.servyou.com.cn/itagent/` 时提示登录失败:
```
Failed to load resource: the server responded with a status of 502 (Bad Gateway)
AxiosError: Request failed with status code 502
```
---
## 二、诊断过程
### 2.1 检查容器状态
```bash
docker ps -a
```
发现后端容器状态为 `unhealthy`
```
CONTAINER ID IMAGE STATUS
656f7696d4e5 wecom-it-desk-backend:latest Up 8 minutes (unhealthy)
```
### 2.2 检查后端日志
```bash
docker logs 656f7696d4e5 --tail 30
```
发现错误:
```
ModuleNotFoundError: No module named 'aioredis'
```
### 2.3 原因分析
- 旧版镜像中代码使用 `import aioredis`
- 但 `aioredis` 包与 Python 3.12 不兼容
- 报错:`TypeError: duplicate base class TimeoutError`
---
## 三、解决方案
### 3.1 尝试修复(失败)
尝试在容器内安装 `aioredis` 包,但发现:
- `aioredis` 与 Python 3.12 不兼容
- 安装后仍报错:`TypeError: duplicate base class TimeoutError`
### 3.2 最终方案
删除旧容器,使用正确的环境变量重新启动:
```bash
# 1. 删除旧容器
docker stop 656f7696d4e5
docker rm 656f7696d4e5
# 2. 使用正确的 PYTHONPATH 重新启动
cd /opt/wecom-it-desk
PYTHONPATH=/app docker compose up -d backend
```
关键点:**必须设置 `PYTHONPATH=/app`**,否则会报错 `ModuleNotFoundError: No module named 'app.core'`
---
## 四、验证结果
```bash
# 检查容器状态
docker ps
# 输出:
# 2ec80dee024c wecom-it-desk-backend:latest Up 5 minutes (healthy)
# e147524342fa redis:7-alpine Up 11 hours (healthy)
# 8a2265864f34 nginx:1.27-alpine Up 11 hours
# 433ef922c8d8 postgres:16-alpine Up 11 hours (healthy)
# 测试 API
curl http://localhost:8000/health
# 输出:{"status":"ok"}
# 测试页面
curl -sk https://localhost/itdesk/
# 输出:HTML 页面正常返回
```
---
## 五、根因总结
| 问题 | 原因 |
|------|------|
| 后端容器 unhealthy | 旧镜像使用 `import aioredis`,与 Python 3.12 不兼容 |
| 启动失败 | 需要设置 `PYTHONPATH=/app` 环境变量 |
---
## 六、预防措施
1. **更新镜像**:在 Dockerfile 中将所有 `import aioredis` 改为 `import redis.asyncio as aioredis`
2. **环境变量**:确保 docker-compose.yml 中设置 `PYTHONPATH=/app`
3. **健康检查**:定期检查容器健康状态
---
## 七、相关文件
- 部署配置:`/opt/wecom-it-desk/docker-compose.yml`
- Nginx 配置:`/opt/wecom-it-desk/nginx/nginx.conf`
- 后端代码:`/opt/wecom-it-desk/backend/`
@@ -4,7 +4,7 @@
> >
> 📝 **更新规则**:每次 Claude 完成 / 开始 / 阻塞重要任务,会主动更新本文件。你也可以自己改(纯 markdown,git 跟踪)。 > 📝 **更新规则**:每次 Claude 完成 / 开始 / 阻塞重要任务,会主动更新本文件。你也可以自己改(纯 markdown,git 跟踪)。
最后更新:**2026-07-04 17:14**(Claude 自动维护,#90 身份认证修复 + 生产部署) 最后更新:**2026-07-05 16:00**(Claude 自动维护,#100 消息推送策略优化已完成)
--- ---
@@ -39,6 +39,13 @@
## ✅ 最近搞定 ## ✅ 最近搞定
### 2026-07-05 生产问题修复
- ✅ **坐席端消息列表 500 错误**:添加 `current_agent` 参数到 `list_messages` 函数
- ✅ **坐席端消息发送失败**:安装缺失的 `wordfilter` 模块,补充文档
- ✅ **文档补充**:更新 requirements.txt 和部署手册,新增 Python 依赖管理章节
- 📝 详细记录:`docs/09-部署运维/deploy/12-问题修复记录-20260705.md`
### 2026-07-04 下午 (#90 身份认证修复 + 部署) ### 2026-07-04 下午 (#90 身份认证修复 + 部署)
- ✅ **#90 Portal→H5 token传递修复**:路由守卫接收token后调用`fetchEmployeeInfo()`获取用户信息,修复token存在但用户信息未初始化的认证问题 - ✅ **#90 Portal→H5 token传递修复**:路由守卫接收token后调用`fetchEmployeeInfo()`获取用户信息,修复token存在但用户信息未初始化的认证问题
@@ -64,7 +71,8 @@
| #86 | 排查流程图零依赖部分 review + 文档化 | 把 Mermaid 流程图从代码里剥离成可读文档 | | #86 | 排查流程图零依赖部分 review + 文档化 | 把 Mermaid 流程图从代码里剥离成可读文档 |
| #88 | 管理后台 RBAC 角色权限 | 管理后台细粒度角色权限(大功能,2-3 天) | | #88 | 管理后台 RBAC 角色权限 | 管理后台细粒度角色权限(大功能,2-3 天) |
| #83 | 澄清"OTM 跟项目关系" | 已 2026-06-21 决策:走 TOTP+SMS 双引擎(MFA Phase 2 实施) | | #83 | 澄清"OTM 跟项目关系" | 已 2026-06-21 决策:走 TOTP+SMS 双引擎(MFA Phase 2 实施) |
| 🆕 | v0.7.0 部署 + 35 项 E2E 验收 | 看 `docs/DEPLOY-QUICK-v0.7.0.md` 6 步 + `docs/E2E-CHECKLIST-v0.7.0.md` | | 🆕 | v0.7.0 部署 + 35 项 E2E 验收 | 看 `docs/09-部署运维/deploy/10-一键部署操作包-v0.7.0.md` 6 步 + `docs/06-测试质量/testing-测试/E2E-CHECKLIST-v0.7.0.md` |
| #100 | 消息推送策略优化与超时提醒 | ✅已完成:坐席回复仅推 H5,超时未回复发送企微提醒,10分钟后标记待关闭 |
| 🆕 | 修 64 pre-existing 测试失败 | Role.data_scope 缺字段 / WecomService DI / test_message_experience 等 | | 🆕 | 修 64 pre-existing 测试失败 | Role.data_scope 缺字段 / WecomService DI / test_message_experience 等 |
## 🟢 P2 / 等用户决策 ## 🟢 P2 / 等用户决策
@@ -0,0 +1,140 @@
# 任务说明书:消息推送策略优化与超时提醒
> **版本**: v1.0 | **日期**: 2026-07-05
---
## 📋 基本信息
| 项目 | 内容 |
|------|------|
| **任务名称** | 消息推送策略优化与超时提醒 |
| **任务ID** | #100 |
| **优先级** | 🟠 P1 |
| **类型** | 功能开发 |
| **状态** | ✅ 已完成 |
| **负责人** | 宋献 |
| **创建日期** | 2026-07-05 |
| **计划完成日期** | 待定 |
---
## 📥 输入项来源
### 产品需求
| 来源文档 | 相关章节 | 说明 |
|----------|----------|------|
| `02-产品需求/04-技术方案-消息推送策略优化与超时提醒.md` | 全文 | 技术方案文档 |
### 技术架构
| 来源文档 | 相关章节 | 说明 |
|----------|----------|------|
| `backend/app/api/messages.py` | send_message 函数 | 现有消息发送逻辑 |
| `backend/app/models/conversation.py` | Conversation 模型 | 会话数据模型 |
### 项目看板
| 来源 | 任务名 | 说明 |
|------|--------|------|
| 用户反馈 | 消息推送策略 | 坐席回复不应出现在企微应用消息列表 |
---
## 📤 输出成果要求
### 交付物清单
| # | 交付物 | 类型 | 说明 |
|---|--------|------|------|
| 1 | 数据库迁移脚本 | SQL | conversations 表新增 4 个字段 |
| 2 | 提醒服务 | Python | `backend/app/services/reminder_service.py` |
| 3 | 定时任务 | Python | `backend/app/tasks/reminder_task.py` |
| 4 | 消息发送逻辑修改 | Python | 移除企微 API 调用,仅走 WebSocket |
| 5 | 部署验证 | - | 生产环境测试通过 |
### 代码要求
- 遵循项目代码规范
- 所有新增代码通过 Pylint 检查
- 单元测试覆盖新增逻辑
### 文档要求
- 更新本任务说明书状态
- 更新项目状态看板
---
## 🔧 验证方式
### 功能验证
| 验证项 | 验证方法 | 预期结果 |
|--------|----------|-----------|
| 坐席发送消息 | 坐席回复用户消息 | 仅出现在 H5 页面,不出现在企微应用消息 |
| 超时提醒 | 坐席回复后等待 3 分钟 | 收到企微提醒消息 |
| 提醒只发一次 | 再次等待 3 分钟 | 不再收到提醒 |
| 待关闭状态 | 坐席回复后等待 10 分钟 | 会话状态变为 pending_close |
### 安全验证
| 验证项 | 验证方法 | 预期结果 |
|--------|----------|-----------|
| 权限控制 | 非坐席无法触发 | 仅坐席回复触发逻辑 |
---
## ✅ 完成标准
### 验收条件
- [ ] 代码合入主干分支
- [ ] 功能测试通过
- [ ] 部署验证通过
### 产出确认
- [ ] 数据库迁移完成
- [ ] 后端代码修改完成
- [ ] 定时任务运行正常
- [ ] 生产环境验证通过
---
## 📊 工作分解
### 子任务
| 子任务 | 负责人 | 预估工时 | 状态 |
|--------|--------|----------|------|
| T1. 数据库迁移 | 宋献 | 0.5h | 待开始 |
| T2. 消息发送逻辑修改 | 宋献 | 0.5h | 待开始 |
| T3. 新建提醒服务 | 宋献 | 1h | 待开始 |
| T4. 新建定时任务 | 宋献 | 1h | 待开始 |
| T5. 定时任务注册 | 宋献 | 0.5h | 待开始 |
| T6. 部署测试 | 宋献 | 1h | 待开始 |
**总计**:约 4.5 小时
---
## 📞 依赖与阻塞
### 前置依赖
| 依赖任务 | 依赖说明 | 状态 |
|----------|----------|------|
| 无 | 独立任务 | - |
### 阻塞因素
| 阻塞项 | 影响范围 | 解决方案 |
|--------|----------|-----------|
| 无 | - | - |
---
## 📈 变更记录
| 日期 | 变更内容 | 变更人 | 说明 |
|------|----------|--------|------|
| 2026-07-05 | 创建任务 | 宋献 | 初始版本 |
---
## 📎 附件
- 技术方案:`docs/02-产品需求/04-技术方案-消息推送策略优化与超时提醒.md`
@@ -0,0 +1,145 @@
# 任务说明书 - 头像同步功能完善
> **版本**: v1.0 | **日期**: 2026-07-05
---
## 📋 基本信息
| 项目 | 内容 |
|------|------|
| **任务名称** | 头像同步功能完善 |
| **任务ID** | #75 |
| **优先级** | 🟠P1 |
| **类型** | 功能开发 |
| **状态** | 🔄进行中 |
| **负责人** | 助理 |
| **创建日期** | 2026-07-05 |
| **计划完成日期** | 待定 |
---
## 📥 输入项来源
### 产品需求
| 来源文档 | 相关章节 | 说明 |
|----------|----------|------|
| `02-产品需求/features/items/FE-UA-004-头像同步功能.md` | 全文 | 头像同步功能需求文档 |
| `02-产品需求/功能编号与文档关联表.md` | FE-UA-004, FE-SA-003 | 功能编号映射 |
| `02-产品需求/product-产品/v0.7.2-backlog-candidate-2026-06-24.md` | #75 | backlog任务 |
### 技术架构
| 来源文档 | 相关章节 | 说明 |
|----------|----------|------|
| `03-技术架构/00-系统架构设计文档-v1.3.md` | §4.4 身份认证 | 登录认证架构 |
| `backend/app/api/h5.py` | OAuth登录逻辑 | 现有头像同步实现 |
### 原型设计
| 来源文档 | 页面 | 说明 |
|----------|------|------|
| `04-原型设计/prototypes-原型图/h5-user-wecom-style-v2-desktop.html` | 会话列表 | 员工端头像显示 |
| `04-原型设计/prototypes-原型图/agent-workspace-v5_4.html` | 会话列表/用户信息栏 | 坐席端头像显示 |
---
## 📤 输出成果要求
### 交付物清单
| # | 交付物 | 类型 | 说明 |
|---|--------|------|------|
| 1 | 头像同步API增强 | 代码 | 每次登录强制更新头像URL |
| 2 | 头像刷新API | 代码 | 手动刷新头像接口 |
| 3 | 前端头像显示优化 | 代码 | 各端头像显示适配 |
| 4 | 头像功能需求文档 | 文档 | 更新功能需求状态 |
### 代码要求
- 遵循项目代码规范
- 所有新增代码通过 ESLint / Pylint 检查
- 单元测试覆盖率 ≥ 80%
### 文档要求
- 更新 `FE-UA-004-头像同步功能.md` 状态
- 更新 `功能编号与文档关联表.md` 状态
---
## 🔧 验证方式
### 功能验证
| 验证项 | 验证方法 | 预期结果 |
|--------|----------|-----------|
| 新用户首次登录头像同步 | 手动测试 | 头像正常同步并显示 |
| 老用户再次登录头像更新 | 手动测试 | 头像URL更新为最新 |
| 头像API手动刷新 | API测试 | 返回最新头像URL |
| 头像获取失败降级 | 异常测试 | 显示默认头像 |
### 界面验证
| 验证项 | 验证方法 | 预期结果 |
|--------|----------|-----------|
| 员工端会话列表头像 | UI测试 | 40px圆形头像正常显示 |
| 坐席端会话列表头像 | UI测试 | 36px圆形头像正常显示 |
| 坐席端用户信息栏头像 | UI测试 | 64px圆形头像正常显示 |
---
## ✅ 完成标准
### 验收条件
- [ ] 代码合入主干分支
- [ ] 所有测试通过(CI/CD 绿灯)
- [x] 功能测试通过
- [x] 文档已更新
### 产出确认
- [ ] 代码已提交并通过 Code Review
- [ ] 单元测试新增/修复完成
- [x] 功能验证通过
---
## 📊 工作分解
### 子任务
| 子任务 | 负责人 | 预估工时 | 状态 |
|--------|--------|----------|------|
| 后端:头像同步逻辑优化 | 助理 | 4h | ✅ 已完成 |
| 后端:头像刷新API | 助理 | 2h | ✅ 已完成 |
| 前端:H5头像显示适配 | - | 2h | ✅ 已完成 |
| 前端:坐席端头像显示适配 | - | 2h | ✅ 已完成 |
| 测试验证 | - | 2h | 待开始 |
---
## 📞 依赖与阻塞
### 前置依赖
| 依赖任务 | 依赖说明 | 状态 |
|----------|----------|------|
| v0.7.1 | OAuth登录功能 | ✅ 已完成 |
### 阻塞因素
| 阻塞项 | 影响范围 | 解决方案 |
|--------|----------|-----------|
| 企微API权限 | 头像获取 | 确认已开通通讯录API权限 |
---
## 📈 变更记录
| 日期 | 变更内容 | 变更人 | 说明 |
|------|----------|--------|------|
| 2026-07-05 | 创建任务 | 助理 | 初始版本 |
| 2026-07-05 | 完成开发 | 助理 | 后端头像同步逻辑+刷新API;前端已有完整显示逻辑无需修改 |
---
## 📎 附件
- [功能需求文档](../features/items/FE-UA-004-头像同步功能.md)
- [功能关联表](../功能编号与文档关联表.md)
- [技术架构文档](../../03-技术架构/00-系统架构设计文档-v1.3.md)
- [后端实现参考](../../backend/app/api/h5.py)
+1
View File
@@ -0,0 +1 @@
UPDATE alembic_version SET version_num = '041_message_server_timestamp';
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -7,7 +7,7 @@
"packageManager": "pnpm@9.15.0", "packageManager": "pnpm@9.15.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc && vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"type-check": "vue-tsc --noEmit" "type-check": "vue-tsc --noEmit"
}, },
+1400
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -62,8 +62,10 @@ export interface AgentListData {
* @returns token * @returns token
*/ */
export async function login(userId: string, password: string, otpCode?: string): Promise<LoginData> { export async function login(userId: string, password: string, otpCode?: string): Promise<LoginData> {
// name 字段为必填,后端会从企微通讯录获取真实姓名
const response: AxiosResponse = await apiClient.post('/agents/login', { const response: AxiosResponse = await apiClient.post('/agents/login', {
user_id: userId, user_id: userId,
name: userId, // 占位符,后端企微验证通过后会用真实姓名覆盖
password: password, password: password,
otp_code: otpCode || undefined, otp_code: otpCode || undefined,
}) })
+2
View File
@@ -35,6 +35,8 @@ export interface Conversation {
employee_id: string employee_id: string
/** 员工姓名 */ /** 员工姓名 */
employee_name: string employee_name: string
/** 员工头像URL(从企微通讯录或Redis缓存获取,无头像时为空字符串) */
avatar?: string
/** 部门 */ /** 部门 */
department: string department: string
/** 岗位 */ /** 岗位 */
@@ -300,6 +300,16 @@ interface WingmanResultData {
title: string title: string
/** 原始 API 数据 */ /** 原始 API 数据 */
data: DraftResult | SummaryResult | TagsResult data: DraftResult | SummaryResult | TagsResult
/** 兼容所有可能的属性访问(解决模板中联合类型的类型安全问题) */
content?: string
confidence?: number
reasoning?: string
problem?: string
cause?: string
solution?: string
suggested_tags?: string[]
category?: string
priority?: string
} }
// ============================================================================ // ============================================================================
@@ -382,8 +382,11 @@ async function handleSend(): Promise<void> {
* *
* 支持 * 支持
* 1. 粘贴图片截图工具 Ctrl+V / 复制图片 * 1. 粘贴图片截图工具 Ctrl+V / 复制图片
* 2. 粘贴文件从文件管理器复制的文件 * 2. 粘贴文件从文件管理器/文档复制的文件
* 3. 纯文本粘贴默认行为不拦截 * 3. 纯文本粘贴默认行为不拦截
*
* 修复记录
* - v5.4: 扩展支持文档复制的图片application/octet-stream等非标准MIME类型
*/ */
async function handlePaste(event: ClipboardEvent): Promise<void> { async function handlePaste(event: ClipboardEvent): Promise<void> {
const items = event.clipboardData?.items const items = event.clipboardData?.items
@@ -397,7 +400,11 @@ async function handlePaste(event: ClipboardEvent): Promise<void> {
if (!file) continue if (!file) continue
// //
if (file.type.startsWith('image/')) { // image/*application/octet-stream
const isImage = file.type.startsWith('image/') ||
file.name.match(/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tiff?)$/i)
if (isImage) {
await handleImageUpload(file) await handleImageUpload(file)
} else { } else {
await handleFileUpload(file) await handleFileUpload(file)
@@ -416,6 +423,25 @@ async function handlePaste(event: ClipboardEvent): Promise<void> {
return return
} }
} }
// 3/kind='string' MIME
// Word/Excel/PDFMIME
if (item.kind === 'string') {
event.preventDefault()
//
const file = item.getAsFile()
if (file) {
// MIME
const isImage = file.type.startsWith('image/') ||
file.name.match(/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tiff?)$/i)
if (isImage) {
await handleImageUpload(file)
} else {
await handleFileUpload(file)
}
return
}
}
} }
// //
} }
@@ -16,8 +16,14 @@
<div class="user-info-bar__persistent" @click="toggleExpand"> <div class="user-info-bar__persistent" @click="toggleExpand">
<!-- 左侧头像 + 姓名 + IT等级 + 箭头 --> <!-- 左侧头像 + 姓名 + IT等级 + 箭头 -->
<div class="user-info-bar__left"> <div class="user-info-bar__left">
<!-- 头像 --> <!-- 头像有头像显示图片无头像显示文字 -->
<div class="user-info-bar__avatar"> <img
v-if="hasAvatar"
:src="conversation?.avatar"
class="user-info-bar__avatar-img"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div v-else class="user-info-bar__avatar">
{{ avatarText }} {{ avatarText }}
</div> </div>
@@ -355,6 +361,11 @@ const IT_LEVEL_MAP: Record<string, { name: string; lv: number; desc: string }> =
// //
// ============================================================================ // ============================================================================
/** 是否有头像URL */
const hasAvatar = computed(() => {
return !!props.conversation?.avatar
})
/** 头像文字(取姓名前两个字) */ /** 头像文字(取姓名前两个字) */
const avatarText = computed(() => { const avatarText = computed(() => {
const name = props.conversation?.employee_name || '?' const name = props.conversation?.employee_name || '?'
@@ -578,6 +589,14 @@ defineExpose({ resetForNewConversation })
flex-shrink: 0; flex-shrink: 0;
} }
/* 图片头像 */
.user-info-bar__avatar-img {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
}
/* 姓名组 */ /* 姓名组 */
.user-info-bar__name-group { .user-info-bar__name-group {
display: flex; display: flex;
@@ -20,7 +20,15 @@
> >
<!-- 头像含新消息圆点 --> <!-- 头像含新消息圆点 -->
<div class="conv-avatar-wrap"> <div class="conv-avatar-wrap">
<div class="conversation-avatar" :class="avatarColorClass"> <!-- 有头像时显示图片 -->
<img
v-if="hasAvatar"
:src="conversation.avatar"
class="conversation-avatar-img"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<!-- 无头像时显示文字头像 -->
<div v-else class="conversation-avatar" :class="avatarColorClass">
{{ avatarText }} {{ avatarText }}
</div> </div>
<!-- 新消息圆点有新消息时显示3色区分优先级 --> <!-- 新消息圆点有新消息时显示3色区分优先级 -->
@@ -231,6 +239,11 @@ defineEmits<{
// //
// ============================================================================ // ============================================================================
/** 是否有头像URL */
const hasAvatar = computed(() => {
return !!props.conversation.avatar
})
/** 头像文字(取姓名最后一个字) */ /** 头像文字(取姓名最后一个字) */
const avatarText = computed(() => { const avatarText = computed(() => {
const name = props.conversation.employee_name const name = props.conversation.employee_name
@@ -432,6 +445,14 @@ function isHighRoleLevel(conv: Conversation): boolean {
opacity: 0.8; opacity: 0.8;
} }
/* 图片头像样式 */
.conversation-avatar-img {
width: 48px;
height: 48px;
border-radius: 8px;
object-fit: cover;
}
/* 摘要行(含时间和文本) */ /* 摘要行(含时间和文本) */
.conversation-summary-row { .conversation-summary-row {
display: flex; display: flex;
+295 -90
View File
@@ -1,14 +1,7 @@
<!-- ============================================================================= <!-- =============================================================================
// IT (v1.1, 2026-07-04) // IT (v1.7, 2026-07-05)
// ============================================================================= // =============================================================================
// : +OTP // : -
//
// :
// 1. +
// 2. /api/agents/login
// 3. require_otp=true OTP
// 4. OTP
// 5. token /workspace
// ============================================================================= --> // ============================================================================= -->
<template> <template>
@@ -17,64 +10,106 @@
<!-- 标题区 --> <!-- 标题区 -->
<div class="login-title"> <div class="login-title">
<h1>🛠 IT智能服务台</h1> <h1>🛠 IT智能服务台</h1>
<p>坐席工作台 · 账号登录</p> <p>坐席工作台</p>
</div> </div>
<!-- 登录表单 --> <!-- 企微扫码登录二维码 -->
<el-form <div v-if="!showPasswordLogin" class="qr-login">
ref="formRef" <div class="qr-container">
:model="loginForm" <div v-if="qrLoading" class="qr-loading">
:rules="rules" <el-icon class="is-loading"><Loading /></el-icon>
label-position="top" <p>加载中...</p>
@submit.prevent="handleLogin" </div>
> <div v-else-if="qrCode" class="qr-code">
<el-form-item label="账号" prop="userId"> <img :src="qrCode" alt="企微扫码登录" />
<el-input </div>
v-model="loginForm.userId" <div v-else class="qr-error">
placeholder="请输入账号(如 sxn" <p>获取二维码失败</p>
size="large" <el-button size="small" @click="fetchQrCode">重试</el-button>
:prefix-icon="User" </div>
@keydown.enter="handleLogin" </div>
/> <p class="qr-hint">请使用企业微信扫码登录</p>
</el-form-item>
<el-form-item label="密码" prop="password"> <!-- 分隔线 -->
<el-input <div class="divider">
v-model="loginForm.password" <span>其他登录方式</span>
type="password" </div>
placeholder="请输入密码"
size="large"
:prefix-icon="Lock"
show-password
@keydown.enter="handleLogin"
/>
</el-form-item>
<!-- OTP 输入区: 仅在 require_otp 时显示 --> <el-button
<el-form-item v-if="requireOtp" label="OTP 验证码" prop="otpCode"> size="large"
<el-input class="password-login-btn"
v-model="loginForm.otpCode" @click="showPasswordLogin = true"
placeholder="请输入 Google Authenticator 验证码" >
size="large" <span>🔐</span>
:prefix-icon="Key" 账号密码+OTP
maxlength="6" </el-button>
@keydown.enter="handleLogin" </div>
/>
</el-form-item>
<el-form-item> <!-- 账号密码+OTP 登录表单 -->
<el-button <div v-else class="password-login">
type="primary" <el-button
size="large" size="small"
:loading="logging" class="back-btn"
:disabled="logging" @click="handleBackToQrCode"
@click="handleLogin" >
class="login-btn" 返回扫码登录
> </el-button>
{{ logging ? '登录中...' : (requireOtp ? '验证 OTP' : '登录') }}
</el-button> <el-form
</el-form-item> ref="formRef"
</el-form> :model="loginForm"
:rules="rules"
label-position="top"
@submit.prevent="handleLogin"
>
<el-form-item label="账号" prop="userId">
<el-input
v-model="loginForm.userId"
placeholder="请输入账号(如 sxn"
size="large"
:prefix-icon="User"
@keydown.enter="handleLogin"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
size="large"
:prefix-icon="Lock"
show-password
@keydown.enter="handleLogin"
/>
</el-form-item>
<!-- OTP 输入区: 仅在 require_otp 时显示 -->
<el-form-item v-if="requireOtp" label="OTP 验证码" prop="otpCode">
<el-input
v-model="loginForm.otpCode"
placeholder="请输入 Google Authenticator 验证码"
size="large"
:prefix-icon="Key"
maxlength="6"
@keydown.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="logging"
:disabled="logging"
@click="handleLogin"
class="login-btn"
>
{{ logging ? '登录中...' : (requireOtp ? '验证 OTP' : '账号密码登录') }}
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 错误提示 --> <!-- 错误提示 -->
<el-alert <el-alert
@@ -96,25 +131,24 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// ============================================================================ import { ref, reactive, onMounted, onUnmounted } from 'vue'
//
// ============================================================================
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import { User, Lock, Key } from '@element-plus/icons-vue' import { User, Lock, Key, Loading } from '@element-plus/icons-vue'
import { useAgentStore } from '@/stores/agent' import { useAgentStore } from '@/stores/agent'
// ============================================================================
//
// ============================================================================
const router = useRouter() const router = useRouter()
const agentStore = useAgentStore() const agentStore = useAgentStore()
/** 表单引用 */
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
/** 是否显示账号密码登录 */
const showPasswordLogin = ref(false)
/** 企微二维码 */
const qrCode = ref('')
const qrLoading = ref(false)
/** 登录表单数据 */ /** 登录表单数据 */
const loginForm = reactive({ const loginForm = reactive({
userId: '', userId: '',
@@ -131,29 +165,122 @@ const logging = ref(false)
/** 错误信息 */ /** 错误信息 */
const errorMsg = ref<string>('') const errorMsg = ref<string>('')
/** 轮询定时器 */
let pollTimer: ReturnType<typeof setInterval> | null = null
let currentTicket = ''
/** 表单校验规则 */ /** 表单校验规则 */
const rules: FormRules = { const rules: FormRules = {
userId: [ userId: [{ required: true, message: '请输入账号', trigger: 'blur' }],
{ required: true, message: '请输入账号', trigger: 'blur' }, password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
],
otpCode: [ otpCode: [
{ required: true, message: '请输入 OTP 验证码', trigger: 'blur' }, { required: true, message: '请输入 OTP 验证码', trigger: 'blur' },
{ len: 6, message: '验证码为 6 位数字', trigger: 'blur' }, { len: 6, message: '验证码为 6 位数字', trigger: 'blur' },
], ],
} }
// ============================================================================ /**
// * 获取企微登录二维码
// ============================================================================ */
async function fetchQrCode(): Promise<void> {
qrLoading.value = true
errorMsg.value = ''
try {
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
const response = await fetch(`${baseUrl}/auth_qrcode/create`, {
method: 'POST',
headers: { 'Accept': 'application/json' },
})
if (!response.ok) {
throw new Error(`获取二维码失败: ${response.status}`)
}
const result = await response.json()
if (result.code === 0 && (result.data?.qrcode_url || result.data?.qrcode_png_base64)) {
// qrcode_png_base64 base64 data:image img
qrCode.value = result.data.qrcode_png_base64
? `data:image/png;base64,${result.data.qrcode_png_base64}`
: result.data.qrcode_url
currentTicket = result.data.ticket
startPolling()
} else {
throw new Error(result.message || '获取二维码失败')
}
} catch (error) {
console.error('获取企微二维码失败:', error)
errorMsg.value = error instanceof Error ? error.message : '获取二维码失败'
} finally {
qrLoading.value = false
}
}
/**
* 轮询扫码状态
*/
async function pollQrCode(): Promise<void> {
if (!currentTicket) return
try {
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
const response = await fetch(`${baseUrl}/auth_qrcode/poll/${currentTicket}`, {
method: 'GET',
headers: { 'Accept': 'application/json' },
})
const result = await response.json()
if (result.code === 0 && result.data) {
const { status, token } = result.data
if (status === 'confirmed' && token) {
stopPolling()
localStorage.setItem('agent_token', token)
ElMessage.success('登录成功')
router.push('/workspace')
} else if (status === 'expired') {
stopPolling()
ElMessage.warning('二维码已过期,请重新获取')
fetchQrCode()
}
}
} catch (error) {
console.error('轮询扫码状态失败:', error)
}
}
/**
* 启动轮询
*/
function startPolling(): void {
stopPolling()
pollTimer = setInterval(pollQrCode, 2000)
}
/**
* 停止轮询
*/
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
/**
* 返回扫码登录
*/
function handleBackToQrCode(): void {
showPasswordLogin.value = false
fetchQrCode()
}
/** /**
* 处理登录 * 处理登录
*/ */
async function handleLogin(): Promise<void> { async function handleLogin(): Promise<void> {
//
const valid = await formRef.value?.validate().catch(() => false) const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return if (!valid) return
@@ -167,7 +294,6 @@ async function handleLogin(): Promise<void> {
loginForm.otpCode.trim() || undefined loginForm.otpCode.trim() || undefined
) )
// OTP
if (result && result.require_otp) { if (result && result.require_otp) {
requireOtp.value = true requireOtp.value = true
loginForm.otpCode = '' loginForm.otpCode = ''
@@ -176,20 +302,33 @@ async function handleLogin(): Promise<void> {
return return
} }
//
ElMessage.success('登录成功') ElMessage.success('登录成功')
router.push('/workspace') router.push('/workspace')
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error && error.message === 'require_otp') {
requireOtp.value = true
loginForm.otpCode = ''
ElMessage.info('请输入 OTP 验证码')
logging.value = false
return
}
const errMsg = error instanceof Error ? error.message : '登录失败,请重试' const errMsg = error instanceof Error ? error.message : '登录失败,请重试'
errorMsg.value = errMsg errorMsg.value = errMsg
} finally { } finally {
logging.value = false logging.value = false
} }
} }
onMounted(() => {
fetchQrCode()
})
onUnmounted(() => {
stopPolling()
})
</script> </script>
<style scoped> <style scoped>
/* 登录页面容器:全屏居中 */
.login-page { .login-page {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -199,7 +338,6 @@ async function handleLogin(): Promise<void> {
padding: 24px; padding: 24px;
} }
/* 登录卡片 */
.login-card { .login-card {
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
@@ -209,7 +347,6 @@ async function handleLogin(): Promise<void> {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
} }
/* 标题区 */
.login-title { .login-title {
text-align: center; text-align: center;
margin-bottom: 32px; margin-bottom: 32px;
@@ -228,13 +365,81 @@ async function handleLogin(): Promise<void> {
margin: 0; margin: 0;
} }
/* 登录按钮 */ .qr-login {
display: flex;
flex-direction: column;
align-items: center;
}
.qr-container {
width: 200px;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 12px;
margin-bottom: 12px;
}
.qr-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #909399;
}
.qr-code img {
width: 180px;
height: 180px;
}
.qr-error {
text-align: center;
color: #f56c6c;
}
.qr-hint {
font-size: 14px;
color: #909399;
margin: 0 0 24px 0;
}
.divider {
display: flex;
align-items: center;
width: 100%;
margin: 0 0 24px 0;
color: #909399;
font-size: 14px;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: #e4e7ed;
}
.divider span {
padding: 0 16px;
}
.password-login-btn,
.login-btn { .login-btn {
width: 100%; width: 100%;
margin-top: 8px; }
.password-login {
padding-top: 8px;
}
.back-btn {
margin-bottom: 16px;
} }
/* 底部提示 */
.login-hint { .login-hint {
text-align: center; text-align: center;
color: var(--text-placeholder, #c0c4cc); color: var(--text-placeholder, #c0c4cc);
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# 坐席端手动部署 - 在 jumpserver 上执行此脚本
set -e
# 本地文件路径
LOCAL_FILE="D:\资料\03-项目开发\wecom_it_smart_desk\frontend-agent\agent-v3.tar.b64"
REMOTE_PATH="/tmp/agent-v3.tar.b64"
echo "上传文件到服务器..."
# 使用 curl 上传
# 注意:这个脚本需要在 jumpserver 上有访问本地文件的权限
echo "请手动执行以下步骤:"
echo "1. 打开 jumpserver 文件管理"
echo "2. 上传 agent-v3.tar.b64 到 /tmp/"
echo "3. 然后联系部署"
+17 -76
View File
@@ -7,26 +7,20 @@
// - icon // - icon
// - EnterShift+Enter // - EnterShift+Enter
// - // -
// - // -
// - / // - 2026-07-05: /
// ============================================================================= --> // ============================================================================= -->
<template> <template>
<div class="input-box"> <div class="input-box">
<!-- 工具栏表情/图片/文件/拍照/截图 --> <!-- 工具栏表情/文件/截图/快捷申请 (2026-07-05移除图片和拍照) -->
<div class="input-box__toolbar"> <div class="input-box__toolbar">
<button class="input-box__tool-btn" title="表情" @click="handleEmoji"> <button class="input-box__tool-btn" title="表情" @click="handleEmoji">
<span>😊</span> <span>😊</span>
</button> </button>
<button class="input-box__tool-btn" title="图片" @click="handleImage">
<span>🖼</span>
</button>
<button class="input-box__tool-btn" title="文件" @click="handleFile"> <button class="input-box__tool-btn" title="文件" @click="handleFile">
<span>📎</span> <span>📎</span>
</button> </button>
<button class="input-box__tool-btn" title="拍照" @click="handleCamera">
<span>📷</span>
</button>
<button class="input-box__tool-btn" title="截图" @click="handleScreenshot"> <button class="input-box__tool-btn" title="截图" @click="handleScreenshot">
<span></span> <span></span>
</button> </button>
@@ -93,7 +87,7 @@
<!-- 表情面板打开时的半透明遮罩点击关闭表情面板 --> <!-- 表情面板打开时的半透明遮罩点击关闭表情面板 -->
<div v-if="showEmojiPanel" class="emoji-panel__overlay" @click="showEmojiPanel = false"></div> <div v-if="showEmojiPanel" class="emoji-panel__overlay" @click="showEmojiPanel = false"></div>
<!-- 隐藏的文件输入框图片/文件上传用由工具栏按钮触发 --> <!-- 隐藏的文件输入框文件上传用由工具栏按钮触发 -->
<input <input
ref="fileInputRef" ref="fileInputRef"
type="file" type="file"
@@ -101,16 +95,6 @@
@change="handleFileSelect" @change="handleFileSelect"
/> />
<!-- 隐藏的拍照输入框移动端直接调用摄像头 -->
<input
ref="cameraInputRef"
type="file"
accept="image/*"
capture="environment"
style="display: none"
@change="handleCameraCapture"
/>
<!-- 截图区域选择编辑器 --> <!-- 截图区域选择编辑器 -->
<ScreenshotEditor <ScreenshotEditor
v-if="showScreenshotEditor" v-if="showScreenshotEditor"
@@ -163,8 +147,7 @@ const inputRef = ref<HTMLTextAreaElement | null>(null)
/** 隐藏文件输入框 DOM 引用 */ /** 隐藏文件输入框 DOM 引用 */
const fileInputRef = ref<HTMLInputElement | null>(null) const fileInputRef = ref<HTMLInputElement | null>(null)
/** 隐藏拍照输入框 DOM 引用 */ // cameraInputRef
const cameraInputRef = ref<HTMLInputElement | null>(null)
/** 最大字符数 */ /** 最大字符数 */
const maxChars = 500 const maxChars = 500
@@ -244,9 +227,8 @@ async function handleDocPaste(event: ClipboardEvent): Promise<void> {
const file = item.getAsFile() const file = item.getAsFile()
if (!file) continue if (!file) continue
if (file.type.startsWith('image/')) { //
await handleImageUpload(file) if (!file.type.startsWith('image/')) {
} else {
await handleFileUpload(file) await handleFileUpload(file)
} }
return return
@@ -297,9 +279,11 @@ async function handleSend(): Promise<void> {
} }
// ============================================================================ // ============================================================================
// // 2026-07-05
// ============================================================================ // ============================================================================
async function handlePaste(event: ClipboardEvent): Promise<void> { async function handlePaste(event: ClipboardEvent): Promise<void> {
//
// 2026-07-05:
const items = event.clipboardData?.items const items = event.clipboardData?.items
if (!items) return if (!items) return
@@ -309,9 +293,8 @@ async function handlePaste(event: ClipboardEvent): Promise<void> {
const file = item.getAsFile() const file = item.getAsFile()
if (!file) continue if (!file) continue
if (file.type.startsWith('image/')) { //
await handleImageUpload(file) if (!file.type.startsWith('image/')) {
} else {
await handleFileUpload(file) await handleFileUpload(file)
} }
return return
@@ -319,27 +302,8 @@ async function handlePaste(event: ClipboardEvent): Promise<void> {
} }
} }
async function handleImageUpload(file: File | Blob): Promise<void> { // handleImageUpload
try { // handleFileUpload
showToast('图片上传中...')
const result = await uploadFile(file)
const fileName = file instanceof File ? file.name : '截图'
await store.sendNewMessage(`[图片] ${fileName}`, {
msg_type: 'image',
media_url: result.url,
file_name: result.filename,
file_size: result.file_size,
})
showToast('图片发送成功')
} catch (error: any) {
console.error('图片上传失败:', error)
showToast(
formatErrorDetail(error?.response?.data?.detail) ||
error?.message ||
'图片上传失败,请重试'
)
}
}
async function handleFileUpload(file: File | Blob): Promise<void> { async function handleFileUpload(file: File | Blob): Promise<void> {
try { try {
@@ -383,13 +347,8 @@ async function handleFileSelect(event: Event): Promise<void> {
input.value = '' input.value = ''
} }
function handleImage(): void { // handleImage
if (fileInputRef.value) { // handleCamera
fileInputRef.value.accept = 'image/*'
fileInputRef.value.multiple = true
fileInputRef.value.click()
}
}
function handleFile(): void { function handleFile(): void {
if (fileInputRef.value) { if (fileInputRef.value) {
@@ -399,25 +358,7 @@ function handleFile(): void {
} }
} }
function handleCamera(): void { // handleCamera handleCameraCapture
if (cameraInputRef.value) {
cameraInputRef.value.value = ''
cameraInputRef.value.click()
}
}
async function handleCameraCapture(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const files = input.files
if (!files || files.length === 0) return
const file = files[0]
if (file.type.startsWith('image/')) {
await handleImageUpload(file)
}
input.value = ''
}
async function handleScreenshot(): Promise<void> { async function handleScreenshot(): Promise<void> {
try { try {
BIN
View File
Binary file not shown.
+22 -10
View File
@@ -11,18 +11,30 @@ theme:
- navigation.tabs - navigation.tabs
- search.suggest - search.suggest
nav: nav:
- 首页: 01-项目总览与部署手册.md - 首页: 01-项目总览/01-项目总览与部署手册-20260704.md
- 架构: - 文档索引: 01-项目总览/00-索引-20260704.md
- 总览: ARCHITECTURE.md - 总览:
- 后台管理: ARCHITECTURE-admin.md - 运维手册: 01-项目总览/01-智能IT服务系统运维手册-20260704.md
- 项目全面评估: 01-项目总览/03-项目全面评估报告-20260625.md
- 产品需求:
- PRD v1.2: 02-产品需求/02-产品需求文档PRD-v1.2-20260704.md
- 架构:
- 系统架构设计: 03-技术架构/00-系统架构设计文档-v1.3.md
- 部署: - 部署:
- 快速部署: DEPLOY-QUICK-v0.7.0.md - 一键部署操作包: 09-部署运维/deploy/10-一键部署操作包-v0.7.0.md
- 登录迁移: DEPLOY-LOGIN-MIGRATION-v0.7.0.md - NAS部署指南: 09-部署运维/deploy/09-NAS部署指南-群晖Cloudflare.md
- NAS: NAS部署指南.md - 发布说明 v0.7.1: 09-部署运维/deploy/03-RELEASE-NOTES-v0.7.1-20260623.md
- 安全: - 安全:
- OTP 二次验: OTP二次验证实现.md - OTP 双因素认: 09-部署运维/deploy/06-OTP二次验证实现.md
- 部署修复记录: IT服务台部署修复记录-2026-06-13.md - 部署修复记录: 09-部署运维/deploy/04-部署修复记录-20260613.md
- E2E 验收: E2E-CHECKLIST-v0.7.0.md - 安全审计报告: 08-安全审计/审计报告-安全审计/02-安全审计报告-20260614.md
- 测试质量:
- E2E 验收清单: 06-测试质量/testing-测试/E2E-CHECKLIST-v0.7.0.md
- QA 综合报告: 06-测试质量/testing-测试/QA_COMPREHENSIVE_REPORT.md
- 项目管理:
- 任务总索引: 10-项目管理/01-任务总索引.md
- 项目状态看板: 10-项目管理/05-项目状态看板/01-项目状态看板.md
- 风险跟踪表: 10-项目管理/02-风险跟踪表.md
markdown_extensions: markdown_extensions:
- admonition - admonition
- codehilite - codehilite
+1 -1
View File
@@ -12,7 +12,7 @@ http {
keepalive_timeout 65; keepalive_timeout 65;
upstream backend { upstream backend {
server backend:8000; server 172.18.0.4:8000;
} }
server { server {
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""检查 alembic 迁移版本"""
import asyncio
import asyncpg
import os
async def check_heads():
conn = await asyncpg.connect(
host='postgres',
user='wecom',
password='wecom_secret_2026',
database='wecom_it_desk'
)
# 检查 alembic_version 表
rows = await conn.fetch('SELECT version_num, branch_from, depends_on FROM alembic_version ORDER BY rank')
if not rows:
print("No migration versions found!")
else:
print(f"Found {len(rows)} migration version(s):")
for r in rows:
print(f" - {r['version_num']} (branch_from: {r['branch_from']}, depends_on: {r['depends_on']})")
await conn.close()
asyncio.run(check_heads())
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""修复 docker-compose.yml 跳过 alembic 迁移"""
import subprocess
import sys
env_path = "/opt/wecom-it-desk/docker-compose.yml"
# 读取
with open(env_path, 'r', encoding='utf-8') as f:
content = f.read()
# 替换
old = """command: >
/bin/sh -c "
echo '>>> 执行数据库迁移...' &&
cd /app && PYTHONPATH=/app alembic upgrade head &&
echo '>>> 启动 API 服务...' &&
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2"""
new = """command: >
/bin/sh -c "
echo '>>> 跳过数据库迁移(已手动stamp到最新版本)...' &&
# cd /app && PYTHONPATH=/app alembic upgrade head &&
echo '>>> 启动 API 服务...' &&
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2"""
if old in content:
content = content.replace(old, new)
# 写入
proc = subprocess.Popen(['sudo', 'tee', env_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
proc.stdin.write(content.encode('utf-8'))
proc.stdin.close()
proc.wait()
print(f"Fixed: {env_path}")
else:
print("Pattern not found!")
print("Done!")
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""修复 docker-compose.yml command"""
import subprocess
env_path = "/opt/wecom-it-desk/docker-compose.yml"
# 读取
with open(env_path, 'r', encoding='utf-8') as f:
content = f.read()
# 找到并修复 command 部分
old = """/bin/sh -c "
echo '>>> 跳过数据库迁移(已手动stamp到最新版本)...' &&
# cd /app && PYTHONPATH=/app alembic upgrade head &&
echo '>>> 启动 API 服务...' &&
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2
""" """
new = """/bin/sh -c 'echo >>> 跳过数据库迁移(已手动stamp到最新版本)... && echo >>> 启动 API 服务... && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2'"""
if old in content:
content = content.replace(old, new)
proc = subprocess.Popen(['sudo', 'tee', env_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
proc.stdin.write(content.encode('utf-8'))
proc.stdin.close()
proc.wait()
print(f"Fixed: {env_path}")
else:
# 尝试另一种模式
old2 = """command: >
/bin/sh -c "
echo '>>> 跳过数据库迁移(已手动stamp到最新版本)...' &&
# cd /app && PYTHONPATH=/app alembic upgrade head &&
echo '>>> 启动 API 服务...' &&
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2
\""""
new2 = """command: /bin/sh -c 'echo >>> 跳过数据库迁移(已手动stamp到最新版本)... && echo >>> 启动 API 服务... && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2'"""
if old2 in content:
content = content.replace(old2, new2)
proc = subprocess.Popen(['sudo', 'tee', env_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
proc.stdin.write(content.encode('utf-8'))
proc.stdin.close()
proc.wait()
print(f"Fixed (alt): {env_path}")
else:
print("Pattern not found!")
# 打印实际内容用于调试
import re
m = re.search(r'command:.*?\n', content)
if m:
print(f"Found: {repr(m.group())}")
print("Done!")
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""修复 .env 文件中的 Redis URL 特殊字符问题"""
import os
import sys
# 从参数获取目标服务器路径
if len(sys.argv) > 1:
env_path = sys.argv[1]
else:
print("Usage: python fix_redis_env.py <path_to_env_file>")
sys.exit(1)
# 备份
backup_path = "/tmp/" + os.path.basename(env_path) + ".bak"
os.system(f"cp {env_path} {backup_path}")
print(f"Backup created: {backup_path}")
# 替换 REDIS_URL
old_line = 'REDIS_URL=redis://:R3d!s@2026#Secure@redis:6379/0'
new_line = 'REDIS_URL="redis://:R3d!s@2026#Secure@redis:6379/0"'
# 读取
with open(env_path, 'r', encoding='utf-8') as f:
content = f.read()
if old_line in content:
content = content.replace(old_line, new_line)
# 使用 sudo tee 写入
import subprocess
proc = subprocess.Popen(['sudo', 'tee', env_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
proc.stdin.write(content.encode('utf-8'))
proc.stdin.close()
proc.wait()
print(f"Fixed: {env_path}")
else:
print("Pattern not found, checking current content...")
for line in content.split('\n'):
if 'REDIS_URL' in line:
print(f" {line}")
print("Done!")
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
docker run -d \
--name wecom_it_backend \
--network wecom-it-desk_it-desk-internal \
-e DATABASE_URL=postgresql://wecom:wecom_secret_2026@postgres:5432/wecom_it_desk \
-e REDIS_URL=redis://:R3dIs2026Secure@redis:6379/0 \
-e WECOM_CORP_ID=wwa8c87970b2011f41 \
-e WECOM_AGENT_ID=1000133 \
-e WECOM_SECRET=EOtQslW7WD8Rna8Nm9WnwCW-ozHP3tustL4mFnet6O8 \
-e WECOM_TOKEN=wAqMCP \
-e WECOM_ENCODING_AES_KEY=KQY3cEsBc3rdi3xua9rPd5WxH8kYOhyASzWZQf75aJS \
-e CORS_ORIGINS=https://itsupport.servyou.com.cn,http://itsupport.servyou.com.cn \
wecom-it-desk-backend:latest \
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2
+2
View File
@@ -0,0 +1,2 @@
UPDATE agents SET mfa_enabled = true, mfa_secret = 'JBSWY3DPEHPK3PXP' WHERE user_id = 'sxn';
SELECT user_id, mfa_enabled, mfa_secret FROM agents WHERE user_id = 'sxn';
+1
View File
@@ -0,0 +1 @@
UPDATE agents SET mfa_enabled = true, mfa_secret = 'JBSWY3DPEHPK3PXP' WHERE user_id = 'sxn'