2026-07-11 23:13:10 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# IT智能服务台 — 自备电脑补贴(BYOD)资格查询 API
|
|
|
|
|
|
# =============================================================================
|
2026-07-18 01:05:20 +08:00
|
|
|
|
# 说明:提供自备电脑补贴资格检查功能
|
2026-07-11 23:13:10 +08:00
|
|
|
|
# - 资格检查:通过企微通讯录API获取员工岗位 → 与资格清单匹配 → 返回结果
|
|
|
|
|
|
# - 岗位匹配支持精确匹配和模糊匹配(如"高级前端开发岗"匹配"前端开发岗")
|
2026-07-18 01:05:20 +08:00
|
|
|
|
# - BYOD 意图检测已并入主 Dify 对话链路(v4.0 P1-2 删除独立 detect-intent 端点)
|
2026-07-11 23:13:10 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends
|
|
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
import redis.asyncio as aioredis
|
|
|
|
|
|
|
|
|
|
|
|
from app.config import settings
|
|
|
|
|
|
from app.services.wecom_service import WecomService
|
|
|
|
|
|
from app.utils.response import success_response
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Redis客户端(依赖注入)
|
|
|
|
|
|
async def get_redis() -> aioredis.Redis:
|
|
|
|
|
|
"""获取Redis客户端依赖"""
|
|
|
|
|
|
from app.main import redis_client
|
|
|
|
|
|
return redis_client
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# BYOD 资格岗位清单(静态配置 — 13个岗位,4大类)
|
|
|
|
|
|
# 注:需求文档标注14个岗位,但实际清单列出13个岗位,以清单为准
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 数据来源:data/byod_eligible_positions.json
|
|
|
|
|
|
# 结构:序列 → 类别 → 岗位列表
|
|
|
|
|
|
|
|
|
|
|
|
BYOD_ELIGIBLE_POSITIONS: dict[str, dict[str, list[str]]] = {
|
|
|
|
|
|
"技术序列": {
|
|
|
|
|
|
"开发类": [
|
|
|
|
|
|
"算法岗",
|
|
|
|
|
|
"前端开发岗",
|
|
|
|
|
|
"后端开发岗",
|
|
|
|
|
|
"客户端开发岗",
|
|
|
|
|
|
"运维开发岗",
|
|
|
|
|
|
"移动端开发岗",
|
|
|
|
|
|
],
|
|
|
|
|
|
"数据类": [
|
|
|
|
|
|
"数据分析岗",
|
|
|
|
|
|
"大数据开发岗",
|
|
|
|
|
|
],
|
|
|
|
|
|
"测试类": [
|
|
|
|
|
|
"测试开发岗",
|
|
|
|
|
|
"业务测试岗",
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
"产品序列": {
|
|
|
|
|
|
"产品策划与设计类": [
|
|
|
|
|
|
"创意设计岗",
|
|
|
|
|
|
"用户体验设计岗",
|
|
|
|
|
|
"产品经理",
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 岗位模糊匹配关键词
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 从每个资格岗位提取核心关键词,用于模糊匹配。
|
|
|
|
|
|
# 企微通讯录返回的 position 可能包含额外描述(如"高级前端开发岗"),
|
|
|
|
|
|
# 只要 position 包含核心关键词即视为匹配。
|
|
|
|
|
|
# 关键词按长度降序排列,优先匹配更精确的关键词,避免误匹配。
|
|
|
|
|
|
# 例:"运维开发岗" 的关键词为 ["运维开发", "运维"],
|
|
|
|
|
|
# 先匹配"运维开发"(精确),不中再匹配"运维"(宽泛)。
|
|
|
|
|
|
|
|
|
|
|
|
BYOD_POSITION_MATCH_KEYWORDS: dict[str, list[str]] = {
|
|
|
|
|
|
"算法岗": ["算法"],
|
|
|
|
|
|
"前端开发岗": ["前端开发", "前端"],
|
|
|
|
|
|
"后端开发岗": ["后端开发", "后端"],
|
|
|
|
|
|
"客户端开发岗": ["客户端开发", "客户端"],
|
|
|
|
|
|
"运维开发岗": ["运维开发", "运维"],
|
|
|
|
|
|
"移动端开发岗": ["移动端开发", "移动端", "移动开发"],
|
|
|
|
|
|
"数据分析岗": ["数据分析"],
|
|
|
|
|
|
"大数据开发岗": ["大数据开发", "大数据"],
|
|
|
|
|
|
"测试开发岗": ["测试开发"],
|
|
|
|
|
|
"业务测试岗": ["业务测试"],
|
|
|
|
|
|
"创意设计岗": ["创意设计"],
|
|
|
|
|
|
"用户体验设计岗": ["用户体验设计", "用户体验", "UX设计", "UE设计", "交互设计"],
|
|
|
|
|
|
"产品经理": ["产品经理"],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# BYOD 预过滤关键词
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 用于快速过滤非 BYOD 相关消息,避免每条消息都调 Dify。
|
|
|
|
|
|
|
|
|
|
|
|
BYOD_PREFILTER_KEYWORDS: list[str] = [
|
|
|
|
|
|
"自备电脑",
|
|
|
|
|
|
"电脑补贴",
|
|
|
|
|
|
"BYOD",
|
|
|
|
|
|
"byod",
|
|
|
|
|
|
"自带电脑",
|
|
|
|
|
|
"个人电脑补贴",
|
|
|
|
|
|
"补贴资格",
|
|
|
|
|
|
"电脑补贴资格",
|
|
|
|
|
|
"自备电脑补贴",
|
|
|
|
|
|
"自带设备",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 申请链接 & 注意事项
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
BYOD_APPLICATION_URL: str = (
|
|
|
|
|
|
"https://ehr.servyou.com.cn/HRAPP/FlowMobile/InitiateIns.aspx"
|
|
|
|
|
|
"?flowid=7747&desc=自备电脑使用申请"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
BYOD_NOTES: list[str] = [
|
|
|
|
|
|
"补贴按月发放,需提供个人电脑的购买凭证",
|
|
|
|
|
|
"领取补贴期间不得再领用公司电脑",
|
|
|
|
|
|
"如已领用公司电脑,需先退还后方可申请补贴",
|
|
|
|
|
|
"补贴金额和发放规则以公司最新政策为准",
|
|
|
|
|
|
"申请提交后由部门审批,审批进度可在eHR系统查看",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
# 登记注意事项(非补贴岗位 — 仅登记无补贴)
|
|
|
|
|
|
BYOD_REGISTER_NOTES: list[str] = [
|
|
|
|
|
|
"自备电脑登记不享受电脑补贴",
|
|
|
|
|
|
"登记后仍需遵守公司信息安全管理规定",
|
|
|
|
|
|
"如已领用公司电脑,需先退还后方可登记自备电脑",
|
|
|
|
|
|
"自备电脑需满足公司办公基本配置要求",
|
|
|
|
|
|
"申请提交后由部门审批,审批进度可在eHR系统查看",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# Schema 定义
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
class ByodCheckEligibilityRequest(BaseModel):
|
|
|
|
|
|
"""BYOD 资格检查请求
|
|
|
|
|
|
|
|
|
|
|
|
Attributes:
|
|
|
|
|
|
employee_id: 员工的企微 UserID
|
|
|
|
|
|
"""
|
|
|
|
|
|
employee_id: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ByodEligibilityResponse(BaseModel):
|
|
|
|
|
|
"""BYOD 资格查询响应
|
|
|
|
|
|
|
|
|
|
|
|
Attributes:
|
|
|
|
|
|
is_byod_intent: 是否为自备电脑补贴意图
|
|
|
|
|
|
eligible: 是否有资格申请补贴(语义同 has_subsidy,保留兼容)
|
|
|
|
|
|
has_subsidy: 是否有补贴(True=有补贴, False=仅登记无补贴)
|
|
|
|
|
|
position: 员工岗位(企微通讯录返回的 position 字段)
|
|
|
|
|
|
matched_category: 匹配到的资格类别(如"技术序列 - 开发类"),未匹配时为空
|
|
|
|
|
|
application_url: 申请/登记链接(所有岗位都返回,获取失败除外)
|
|
|
|
|
|
notes: 注意事项列表(有补贴=补贴须知, 无补贴=登记须知)
|
|
|
|
|
|
reason: 提示信息(无补贴时说明"可登记但无补贴")
|
|
|
|
|
|
source: 结果来源 — dify(Dify识别) / keyword_prefilter(关键词预过滤未命中) / fallback(降级兜底) / wecom(企微通讯录查询)
|
|
|
|
|
|
"""
|
|
|
|
|
|
is_byod_intent: bool
|
|
|
|
|
|
eligible: bool
|
|
|
|
|
|
has_subsidy: bool = False
|
|
|
|
|
|
position: str = ""
|
|
|
|
|
|
matched_category: str = ""
|
|
|
|
|
|
application_url: str = ""
|
|
|
|
|
|
notes: list[str] = []
|
|
|
|
|
|
reason: str = ""
|
|
|
|
|
|
source: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 岗位匹配逻辑
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
def _match_position(position: str) -> tuple[bool, str, str]:
|
|
|
|
|
|
"""将员工岗位与资格清单进行匹配。
|
|
|
|
|
|
|
|
|
|
|
|
匹配策略(按优先级):
|
|
|
|
|
|
1. 精确匹配 — 员工岗位与资格岗位完全一致
|
|
|
|
|
|
2. 包含匹配 — 资格岗位是员工岗位的子串(如"前端开发岗"在"高级前端开发岗"中)
|
|
|
|
|
|
3. 关键词匹配 — 员工岗位包含资格岗位的核心关键词(如"前端"在"高级前端开发岗"中)
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
position: 企微通讯录返回的员工岗位字符串
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
tuple: (是否匹配, 匹配到的资格岗位名称, 匹配到的类别)
|
|
|
|
|
|
类别格式为"序列 - 类别"(如"技术序列 - 开发类"),未匹配时为空字符串
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not position:
|
|
|
|
|
|
return False, "", ""
|
|
|
|
|
|
|
|
|
|
|
|
position_stripped = position.strip()
|
|
|
|
|
|
|
|
|
|
|
|
for sequence, categories in BYOD_ELIGIBLE_POSITIONS.items():
|
|
|
|
|
|
for category, positions in categories.items():
|
|
|
|
|
|
for eligible_pos in positions:
|
|
|
|
|
|
# 1. 精确匹配
|
|
|
|
|
|
if position_stripped == eligible_pos:
|
|
|
|
|
|
return True, eligible_pos, f"{sequence} - {category}"
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 包含匹配(资格岗位是员工岗位的子串)
|
|
|
|
|
|
if eligible_pos in position_stripped:
|
|
|
|
|
|
return True, eligible_pos, f"{sequence} - {category}"
|
|
|
|
|
|
|
|
|
|
|
|
# 3. 核心关键词匹配
|
|
|
|
|
|
keywords = BYOD_POSITION_MATCH_KEYWORDS.get(eligible_pos, [])
|
|
|
|
|
|
for keyword in keywords:
|
|
|
|
|
|
if keyword in position_stripped:
|
|
|
|
|
|
return True, eligible_pos, f"{sequence} - {category}"
|
|
|
|
|
|
|
|
|
|
|
|
return False, "", ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
2026-07-18 01:05:20 +08:00
|
|
|
|
# 关键词预过滤
|
2026-07-11 23:13:10 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
def _byod_keyword_prefilter(text: str) -> bool:
|
|
|
|
|
|
"""BYOD 关键词预过滤:检查文本是否包含自备电脑补贴相关关键词。
|
|
|
|
|
|
|
|
|
|
|
|
只要命中 BYOD_PREFILTER_KEYWORDS 中的任意一个关键词即返回 True,
|
|
|
|
|
|
未命中返回 False。用于避免每条消息都调 Dify。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
text: 用户消息文本
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
bool: 是否包含 BYOD 相关关键词
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return False
|
|
|
|
|
|
lower_text = text.lower()
|
|
|
|
|
|
return any(kw.lower() in lower_text for kw in BYOD_PREFILTER_KEYWORDS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# API 端点
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/byod/check-eligibility")
|
|
|
|
|
|
async def check_byod_eligibility(
|
|
|
|
|
|
request: ByodCheckEligibilityRequest,
|
|
|
|
|
|
redis: aioredis.Redis = Depends(get_redis),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""BYOD 资格检查端点。
|
|
|
|
|
|
|
|
|
|
|
|
流程:
|
|
|
|
|
|
1. 通过企微通讯录 API 获取员工岗位(position)
|
|
|
|
|
|
2. 将岗位与资格清单进行匹配(支持精确匹配和模糊匹配)
|
|
|
|
|
|
3. 返回判定结果:
|
|
|
|
|
|
- 可申请 → 返回申请链接 + 注意事项
|
|
|
|
|
|
- 不可申请 → 返回原因
|
|
|
|
|
|
|
|
|
|
|
|
限制性条件(已领公司电脑等)当前版本作为注意事项提示,
|
|
|
|
|
|
暂不接入资产系统自动查询。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
request: 包含 employee_id(员工企微 UserID)
|
|
|
|
|
|
redis: Redis 客户端(依赖注入,用于 WecomService 的 token 缓存)
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
ByodEligibilityResponse: 资格检查结果
|
|
|
|
|
|
"""
|
|
|
|
|
|
employee_id = request.employee_id
|
|
|
|
|
|
|
|
|
|
|
|
if not employee_id:
|
|
|
|
|
|
return success_response(data=ByodEligibilityResponse(
|
|
|
|
|
|
is_byod_intent=True,
|
|
|
|
|
|
eligible=False,
|
|
|
|
|
|
reason="缺少员工ID,无法查询岗位信息",
|
|
|
|
|
|
source="wecom",
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
# 1. 通过企微通讯录 API 获取员工信息
|
|
|
|
|
|
wecom_service = WecomService(redis_client=redis)
|
|
|
|
|
|
try:
|
|
|
|
|
|
user_info = await wecom_service.get_user_info(employee_id)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"获取员工信息失败: employee_id={employee_id}, error={e}")
|
|
|
|
|
|
return success_response(data=ByodEligibilityResponse(
|
|
|
|
|
|
is_byod_intent=True,
|
|
|
|
|
|
eligible=False,
|
|
|
|
|
|
reason=f"获取员工信息失败:{e}",
|
|
|
|
|
|
source="wecom",
|
|
|
|
|
|
))
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await wecom_service.close()
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 提取岗位信息
|
|
|
|
|
|
position = user_info.get("position", "")
|
|
|
|
|
|
employee_name = user_info.get("name", "")
|
|
|
|
|
|
|
|
|
|
|
|
if not position:
|
|
|
|
|
|
logger.warning(f"员工岗位为空: employee_id={employee_id}, name={employee_name}")
|
|
|
|
|
|
return success_response(data=ByodEligibilityResponse(
|
|
|
|
|
|
is_byod_intent=True,
|
|
|
|
|
|
eligible=False,
|
|
|
|
|
|
position="",
|
|
|
|
|
|
reason="未能获取到您的岗位信息,请联系IT服务台人工核实",
|
|
|
|
|
|
source="wecom",
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
# 3. 岗位匹配
|
|
|
|
|
|
matched, matched_position, matched_category = _match_position(position)
|
|
|
|
|
|
|
|
|
|
|
|
if matched:
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"BYOD资格检查通过: employee_id={employee_id}, name={employee_name}, "
|
|
|
|
|
|
f"position={position}, matched={matched_position}, category={matched_category}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return success_response(data=ByodEligibilityResponse(
|
|
|
|
|
|
is_byod_intent=True,
|
|
|
|
|
|
eligible=True,
|
|
|
|
|
|
has_subsidy=True,
|
|
|
|
|
|
position=position,
|
|
|
|
|
|
matched_category=matched_category,
|
|
|
|
|
|
application_url=BYOD_APPLICATION_URL,
|
|
|
|
|
|
notes=BYOD_NOTES,
|
|
|
|
|
|
source="wecom",
|
|
|
|
|
|
))
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"BYOD资格检查未通过: employee_id={employee_id}, name={employee_name}, "
|
|
|
|
|
|
f"position={position}, 无匹配的资格岗位"
|
|
|
|
|
|
)
|
|
|
|
|
|
return success_response(data=ByodEligibilityResponse(
|
|
|
|
|
|
is_byod_intent=True,
|
|
|
|
|
|
eligible=False,
|
|
|
|
|
|
has_subsidy=False,
|
|
|
|
|
|
position=position,
|
|
|
|
|
|
application_url=BYOD_APPLICATION_URL, # 非补贴也提供登记链接
|
|
|
|
|
|
notes=BYOD_REGISTER_NOTES, # 登记注意事项(非补贴)
|
|
|
|
|
|
reason=f"您的岗位「{position}」不在自备电脑补贴资格清单中,但仍可进行自备电脑登记(无补贴)",
|
|
|
|
|
|
source="wecom",
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 辅助端点:获取资格岗位清单(供前端展示或调试用)
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/byod/eligible-positions")
|
|
|
|
|
|
async def get_byod_eligible_positions():
|
|
|
|
|
|
"""获取自备电脑补贴资格岗位清单。
|
|
|
|
|
|
|
|
|
|
|
|
返回所有有资格申请自备电脑补贴的岗位,按序列和类别分组。
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
资格岗位清单字典
|
|
|
|
|
|
"""
|
|
|
|
|
|
return success_response(data={
|
|
|
|
|
|
"positions": BYOD_ELIGIBLE_POSITIONS,
|
|
|
|
|
|
"total_count": sum(
|
|
|
|
|
|
len(positions)
|
|
|
|
|
|
for categories in BYOD_ELIGIBLE_POSITIONS.values()
|
|
|
|
|
|
for positions in categories.values()
|
|
|
|
|
|
),
|
|
|
|
|
|
"application_url": BYOD_APPLICATION_URL,
|
|
|
|
|
|
"notes": BYOD_NOTES,
|
|
|
|
|
|
})
|