diff --git a/migrations/001_init_schema.sql b/migrations/001_init_schema.sql
index 4b2a5b2..b93c9c8 100644
--- a/migrations/001_init_schema.sql
+++ b/migrations/001_init_schema.sql
@@ -163,13 +163,50 @@ CREATE TABLE IF NOT EXISTS evaluations (
);
-- ============================================
--- 7. 通知记录表
+-- 7. 通知渠道表
+-- ============================================
+CREATE TABLE IF NOT EXISTS notification_channels (
+ id VARCHAR(64) PRIMARY KEY,
+ name VARCHAR(128) NOT NULL, -- 渠道名称
+ channel_type VARCHAR(32) NOT NULL, -- 渠道类型: wechat_work, dingtalk, email, feishu, webhook
+ config JSON NOT NULL, -- 渠道配置JSON
+ status VARCHAR(32) DEFAULT 'active', -- active, inactive
+ description TEXT, -- 描述
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_channel_type (channel_type),
+ INDEX idx_status (status)
+);
+
+-- ============================================
+-- 8. 招聘者与通知渠道绑定关系表(多对多)
+-- ============================================
+CREATE TABLE IF NOT EXISTS recruiter_channel_bindings (
+ id VARCHAR(64) PRIMARY KEY,
+ recruiter_id VARCHAR(64) NOT NULL,
+ channel_id VARCHAR(64) NOT NULL,
+ is_enabled TINYINT(1) DEFAULT 1, -- 是否启用该渠道
+ notify_on_new_candidate TINYINT(1) DEFAULT 1, -- 新候选人时通知
+ notify_on_evaluation TINYINT(1) DEFAULT 1, -- 完成评价时通知
+ notify_on_high_score TINYINT(1) DEFAULT 0, -- 高分候选人时通知
+ high_score_threshold INT DEFAULT 85, -- 高分阈值
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (recruiter_id) REFERENCES recruiters(id) ON DELETE CASCADE,
+ FOREIGN KEY (channel_id) REFERENCES notification_channels(id) ON DELETE CASCADE,
+ UNIQUE KEY uk_recruiter_channel (recruiter_id, channel_id),
+ INDEX idx_recruiter_id (recruiter_id),
+ INDEX idx_channel_id (channel_id)
+);
+
+-- ============================================
+-- 9. 通知记录表
-- ============================================
CREATE TABLE IF NOT EXISTS notifications (
id VARCHAR(64) PRIMARY KEY,
candidate_id VARCHAR(64) NOT NULL,
evaluation_id VARCHAR(64),
- channel VARCHAR(32) NOT NULL, -- WECHAT_WORK, DINGTALK, EMAIL, WEBHOOK
+ channel_id VARCHAR(64), -- 关联的通知渠道ID
+ channel VARCHAR(32) NOT NULL, -- 渠道类型: WECHAT_WORK, DINGTALK, EMAIL, FEISHU, WEBHOOK
content TEXT,
status VARCHAR(32) DEFAULT 'PENDING', -- PENDING, SENT, FAILED
error_message TEXT,
@@ -177,13 +214,36 @@ CREATE TABLE IF NOT EXISTS notifications (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (candidate_id) REFERENCES candidates(id) ON DELETE CASCADE,
FOREIGN KEY (evaluation_id) REFERENCES evaluations(id) ON DELETE SET NULL,
+ FOREIGN KEY (channel_id) REFERENCES notification_channels(id) ON DELETE SET NULL,
INDEX idx_candidate_id (candidate_id),
+ INDEX idx_channel_id (channel_id),
INDEX idx_status (status),
INDEX idx_created_at (created_at)
);
-- ============================================
--- 8. 插入默认评价方案
+-- 10. 插入默认通知渠道(可选)
+-- ============================================
+INSERT INTO notification_channels (id, name, channel_type, config, description) VALUES
+('demo_wechat', '企业微信通知', 'wechat_work',
+ '{"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", "secret": ""}',
+ '企业微信机器人通知渠道(示例配置)')
+ON DUPLICATE KEY UPDATE name = VALUES(name);
+
+INSERT INTO notification_channels (id, name, channel_type, config, description) VALUES
+('demo_dingtalk', '钉钉通知', 'dingtalk',
+ '{"access_token": "YOUR_ACCESS_TOKEN", "sign_secret": ""}',
+ '钉钉机器人通知渠道(示例配置)')
+ON DUPLICATE KEY UPDATE name = VALUES(name);
+
+INSERT INTO notification_channels (id, name, channel_type, config, description) VALUES
+('demo_feishu', '飞书通知', 'feishu',
+ '{"feishu_webhook": "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN", "feishu_secret": ""}',
+ '飞书机器人通知渠道(示例配置)')
+ON DUPLICATE KEY UPDATE name = VALUES(name);
+
+-- ============================================
+-- 11. 插入默认评价方案
-- ============================================
INSERT INTO evaluation_schemas (id, name, description, dimensions, weights, is_default) VALUES
('general', '通用评价方案', '适用于各类岗位的通用评价方案',
diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/config/database.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/config/database.py
index a83ad9a..cd79e68 100644
--- a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/config/database.py
+++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/config/database.py
@@ -1,8 +1,8 @@
"""Database configuration using SQLAlchemy"""
-from sqlalchemy import create_engine, Column, String, DateTime, Text, DECIMAL, Integer, ForeignKey, JSON
-from sqlalchemy.orm import declarative_base, sessionmaker, Session
+from sqlalchemy import create_engine, Column, String, DateTime, Text, DECIMAL, Integer, ForeignKey, JSON, Boolean
+from sqlalchemy.orm import declarative_base, sessionmaker, Session, relationship
from sqlalchemy.sql import func
-from typing import Optional
+from typing import Optional, List
Base = declarative_base()
@@ -32,6 +32,9 @@ class RecruiterModel(Base):
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+
+ # 关联关系
+ channel_bindings = relationship("RecruiterChannelBindingModel", back_populates="recruiter", cascade="all, delete-orphan")
class CandidateModel(Base):
@@ -139,6 +142,48 @@ class EvaluationModel(Base):
created_at = Column(DateTime, server_default=func.now())
+class NotificationChannelModel(Base):
+ """通知渠道表"""
+ __tablename__ = 'notification_channels'
+
+ id = Column(String(64), primary_key=True)
+ name = Column(String(128), nullable=False) # 渠道名称
+ channel_type = Column(String(32), nullable=False) # 渠道类型: wechat_work, dingtalk, email, feishu, webhook
+ config = Column(JSON, nullable=False) # 渠道配置JSON
+ status = Column(String(32), default='active') # active, inactive
+ description = Column(Text) # 描述
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+
+ # 关联关系
+ recruiter_bindings = relationship("RecruiterChannelBindingModel", back_populates="channel", cascade="all, delete-orphan")
+
+
+class RecruiterChannelBindingModel(Base):
+ """招聘者与通知渠道绑定关系表(多对多)"""
+ __tablename__ = 'recruiter_channel_bindings'
+
+ id = Column(String(64), primary_key=True)
+ recruiter_id = Column(String(64), ForeignKey('recruiters.id'), nullable=False)
+ channel_id = Column(String(64), ForeignKey('notification_channels.id'), nullable=False)
+ is_enabled = Column(Boolean, default=True) # 是否启用该渠道
+ notify_on_new_candidate = Column(Boolean, default=True) # 新候选人时通知
+ notify_on_evaluation = Column(Boolean, default=True) # 完成评价时通知
+ notify_on_high_score = Column(Boolean, default=False) # 高分候选人时通知
+ high_score_threshold = Column(Integer, default=85) # 高分阈值
+ created_at = Column(DateTime, server_default=func.now())
+
+ # 关联关系
+ recruiter = relationship("RecruiterModel", back_populates="channel_bindings")
+ channel = relationship("NotificationChannelModel", back_populates="recruiter_bindings")
+
+ # 唯一约束:一个招聘者不能重复绑定同一个渠道
+ __table_args__ = (
+ # 使用唯一约束防止重复绑定
+ {'sqlite_autoincrement': True},
+ )
+
+
class NotificationModel(Base):
"""通知记录表"""
__tablename__ = 'notifications'
@@ -146,7 +191,8 @@ class NotificationModel(Base):
id = Column(String(64), primary_key=True)
candidate_id = Column(String(64), ForeignKey('candidates.id'), nullable=False)
evaluation_id = Column(String(64), ForeignKey('evaluations.id'))
- channel = Column(String(32), nullable=False)
+ channel_id = Column(String(64), ForeignKey('notification_channels.id')) # 关联渠道ID
+ channel = Column(String(32), nullable=False) # 渠道类型(冗余字段,方便查询)
content = Column(Text)
status = Column(String(32), default='PENDING')
error_message = Column(Text)
diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/api.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/api.py
index de26fcc..32941d3 100644
--- a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/api.py
+++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/api.py
@@ -10,7 +10,7 @@ FastAPI主应用入口
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
-from .routes import recruiter_router, scheduler_router, candidate_router, job_router
+from .routes import recruiter_router, scheduler_router, candidate_router, job_router, notification_channel_router
from .routes.system import router as system_router
@@ -49,6 +49,9 @@ def create_app() -> FastAPI:
# 职位管理路由
app.include_router(job_router)
+ # 通知渠道管理路由
+ app.include_router(notification_channel_router)
+
return app
diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/__init__.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/__init__.py
index e79e73e..a6a0ef3 100644
--- a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/__init__.py
+++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/__init__.py
@@ -6,6 +6,7 @@ API路由模块
- scheduler: 定时任务管理
- system: 系统接口
- candidate: 候选人管理
+- notification_channel: 通知渠道管理
"""
from .recruiter import router as recruiter_router
@@ -35,10 +36,17 @@ except ImportError:
from fastapi import APIRouter
job_router = APIRouter()
+try:
+ from .notification_channel import router as notification_channel_router
+except ImportError:
+ from fastapi import APIRouter
+ notification_channel_router = APIRouter()
+
__all__ = [
"recruiter_router",
"scheduler_router",
"system_router",
"candidate_router",
- "job_router"
+ "job_router",
+ "notification_channel_router"
]
diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/notification_channel.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/notification_channel.py
new file mode 100644
index 0000000..6c7e6a3
--- /dev/null
+++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/notification_channel.py
@@ -0,0 +1,463 @@
+"""
+通知渠道管理路由
+
+提供通知渠道的CRUD操作和与招聘者的绑定管理
+"""
+from typing import List, Optional
+
+from fastapi import APIRouter, Depends
+
+from ..schemas import (
+ BaseResponse, ChannelTypeInfo,
+ NotificationChannelCreate, NotificationChannelUpdate,
+ NotificationChannelResponse, NotificationChannelListResponse,
+ RecruiterChannelBindingCreate, RecruiterChannelBindingUpdate,
+ RecruiterChannelBindingResponse, RecruiterChannelBindingListResponse,
+ RecruiterWithChannelsResponse
+)
+from ...domain.notification_channel import (
+ NotificationChannel, ChannelType, ChannelStatus,
+ ChannelConfig, RecruiterChannelBinding
+)
+from ...mapper.notification_channel_mapper import NotificationChannelMapper
+from ...mapper.recruiter_mapper import RecruiterMapper
+from ...config.settings import get_settings
+
+
+router = APIRouter(prefix="/api/notification-channels", tags=["通知渠道"])
+
+
+def get_channel_mapper():
+ """依赖注入:获取NotificationChannelMapper"""
+ settings = get_settings()
+ return NotificationChannelMapper(db_url=settings.db_url)
+
+
+def get_recruiter_mapper():
+ """依赖注入:获取RecruiterMapper"""
+ settings = get_settings()
+ return RecruiterMapper(db_url=settings.db_url)
+
+
+def _build_channel_response(channel: NotificationChannel, mapper: NotificationChannelMapper = None) -> NotificationChannelResponse:
+ """构建通知渠道响应"""
+ # 获取关联的招聘者ID
+ recruiter_ids = channel.recruiter_ids if channel.recruiter_ids else []
+
+ # 如果没有 recruiter_ids 但传入了 mapper,则查询
+ if not recruiter_ids and mapper:
+ bindings = mapper.find_bindings_by_channel(channel.id)
+ recruiter_ids = [b.recruiter_id for b in bindings]
+
+ return NotificationChannelResponse(
+ id=channel.id,
+ name=channel.name,
+ channel_type=channel.channel_type.value,
+ config=channel.config.to_dict() if channel.config else {},
+ status=channel.status.value,
+ description=channel.description,
+ recruiter_ids=recruiter_ids,
+ created_at=channel.created_at,
+ updated_at=channel.updated_at
+ )
+
+
+def _build_binding_response(binding: RecruiterChannelBinding, channel: NotificationChannel = None) -> RecruiterChannelBindingResponse:
+ """构建绑定关系响应"""
+ return RecruiterChannelBindingResponse(
+ recruiter_id=binding.recruiter_id,
+ channel_id=binding.channel_id,
+ channel_name=channel.name if channel else None,
+ channel_type=channel.channel_type.value if channel else None,
+ is_enabled=binding.is_enabled,
+ notify_on_new_candidate=binding.notify_on_new_candidate,
+ notify_on_evaluation=binding.notify_on_evaluation,
+ notify_on_high_score=binding.notify_on_high_score,
+ high_score_threshold=binding.high_score_threshold,
+ created_at=binding.created_at
+ )
+
+
+@router.get("/types", response_model=BaseResponse[List[ChannelTypeInfo]])
+async def get_channel_types():
+ """获取支持的通知渠道类型列表"""
+ types = [
+ ChannelTypeInfo(
+ value="wechat_work",
+ label="企业微信",
+ description="通过企业微信机器人Webhook发送通知"
+ ),
+ ChannelTypeInfo(
+ value="dingtalk",
+ label="钉钉",
+ description="通过钉钉机器人Webhook发送通知"
+ ),
+ ChannelTypeInfo(
+ value="feishu",
+ label="飞书",
+ description="通过飞书机器人Webhook发送通知"
+ ),
+ ChannelTypeInfo(
+ value="email",
+ label="邮件",
+ description="通过SMTP发送邮件通知"
+ ),
+ ChannelTypeInfo(
+ value="webhook",
+ label="通用Webhook",
+ description="通过自定义Webhook发送通知"
+ )
+ ]
+ return BaseResponse.success(data=types, msg="获取通知渠道类型列表成功")
+
+
+@router.get("", response_model=BaseResponse[NotificationChannelListResponse])
+async def list_channels(
+ channel_type: Optional[str] = None,
+ include_bindings: bool = False,
+ mapper: NotificationChannelMapper = Depends(get_channel_mapper)
+):
+ """
+ 获取通知渠道列表
+
+ Args:
+ channel_type: 按类型筛选 (wechat_work, dingtalk, email, feishu, webhook)
+ include_bindings: 是否包含关联的招聘者信息
+ """
+ try:
+ if channel_type:
+ try:
+ ct = ChannelType(channel_type.lower())
+ channels = mapper.find_by_type(ct)
+ except ValueError:
+ return BaseResponse.error(msg=f"无效的渠道类型: {channel_type}", code=400)
+ else:
+ channels = mapper.find_all(include_bindings=include_bindings)
+
+ items = [_build_channel_response(c, mapper) for c in channels]
+
+ response = NotificationChannelListResponse(total=len(items), items=items)
+ return BaseResponse.success(data=response, msg="获取通知渠道列表成功")
+ except Exception as e:
+ return BaseResponse.error(msg=f"获取通知渠道列表失败: {str(e)}")
+
+
+@router.get("/{channel_id}", response_model=BaseResponse[NotificationChannelResponse])
+async def get_channel(
+ channel_id: str,
+ include_bindings: bool = True,
+ mapper: NotificationChannelMapper = Depends(get_channel_mapper)
+):
+ """获取单个通知渠道详情"""
+ try:
+ channel = mapper.find_by_id(channel_id, include_bindings=include_bindings)
+ if not channel:
+ return BaseResponse.error(msg="通知渠道不存在", code=404)
+
+ return BaseResponse.success(data=_build_channel_response(channel, mapper), msg="获取通知渠道详情成功")
+ except Exception as e:
+ return BaseResponse.error(msg=f"获取通知渠道详情失败: {str(e)}")
+
+
+@router.post("", response_model=BaseResponse[NotificationChannelResponse])
+async def create_channel(
+ data: NotificationChannelCreate,
+ mapper: NotificationChannelMapper = Depends(get_channel_mapper)
+):
+ """创建通知渠道"""
+ try:
+ # 验证渠道类型
+ try:
+ channel_type = ChannelType(data.channel_type.lower())
+ except ValueError:
+ return BaseResponse.error(msg=f"无效的渠道类型: {data.channel_type}", code=400)
+
+ # 构建配置
+ config = ChannelConfig(
+ webhook_url=data.config.webhook_url,
+ secret=data.config.secret,
+ access_token=data.config.access_token,
+ sign_secret=data.config.sign_secret,
+ smtp_host=data.config.smtp_host,
+ smtp_port=data.config.smtp_port,
+ username=data.config.username,
+ password=data.config.password,
+ use_tls=data.config.use_tls,
+ sender_name=data.config.sender_name,
+ feishu_webhook=data.config.feishu_webhook,
+ feishu_secret=data.config.feishu_secret,
+ custom_headers=data.config.custom_headers or {},
+ custom_payload_template=data.config.custom_payload_template
+ )
+
+ # 创建渠道实体
+ channel = NotificationChannel(
+ name=data.name,
+ channel_type=channel_type,
+ config=config,
+ description=data.description,
+ status=ChannelStatus.ACTIVE
+ )
+
+ # 验证配置
+ is_valid, error_msg = channel.validate_config()
+ if not is_valid:
+ return BaseResponse.error(msg=f"配置验证失败: {error_msg}", code=400)
+
+ # 保存
+ saved_channel = mapper.save(channel)
+
+ return BaseResponse.success(data=_build_channel_response(saved_channel), msg="创建通知渠道成功")
+ except Exception as e:
+ return BaseResponse.error(msg=f"创建通知渠道失败: {str(e)}")
+
+
+@router.put("/{channel_id}", response_model=BaseResponse[NotificationChannelResponse])
+async def update_channel(
+ channel_id: str,
+ data: NotificationChannelUpdate,
+ mapper: NotificationChannelMapper = Depends(get_channel_mapper)
+):
+ """更新通知渠道"""
+ try:
+ channel = mapper.find_by_id(channel_id)
+ if not channel:
+ return BaseResponse.error(msg="通知渠道不存在", code=404)
+
+ # 更新字段
+ if data.name:
+ channel.name = data.name
+ if data.description is not None:
+ channel.description = data.description
+ if data.status:
+ try:
+ channel.status = ChannelStatus(data.status.lower())
+ except ValueError:
+ return BaseResponse.error(msg=f"无效的状态: {data.status}", code=400)
+
+ if data.config:
+ # 更新配置
+ if data.config.webhook_url is not None:
+ channel.config.webhook_url = data.config.webhook_url
+ if data.config.secret is not None:
+ channel.config.secret = data.config.secret
+ if data.config.access_token is not None:
+ channel.config.access_token = data.config.access_token
+ if data.config.sign_secret is not None:
+ channel.config.sign_secret = data.config.sign_secret
+ if data.config.smtp_host is not None:
+ channel.config.smtp_host = data.config.smtp_host
+ if data.config.smtp_port is not None:
+ channel.config.smtp_port = data.config.smtp_port
+ if data.config.username is not None:
+ channel.config.username = data.config.username
+ if data.config.password is not None:
+ channel.config.password = data.config.password
+ if data.config.use_tls is not None:
+ channel.config.use_tls = data.config.use_tls
+ if data.config.sender_name is not None:
+ channel.config.sender_name = data.config.sender_name
+ if data.config.feishu_webhook is not None:
+ channel.config.feishu_webhook = data.config.feishu_webhook
+ if data.config.feishu_secret is not None:
+ channel.config.feishu_secret = data.config.feishu_secret
+ if data.config.custom_headers is not None:
+ channel.config.custom_headers = data.config.custom_headers
+ if data.config.custom_payload_template is not None:
+ channel.config.custom_payload_template = data.config.custom_payload_template
+
+ # 验证配置
+ is_valid, error_msg = channel.validate_config()
+ if not is_valid:
+ return BaseResponse.error(msg=f"配置验证失败: {error_msg}", code=400)
+
+ updated = mapper.save(channel)
+
+ return BaseResponse.success(data=_build_channel_response(updated), msg="更新通知渠道成功")
+ except Exception as e:
+ return BaseResponse.error(msg=f"更新通知渠道失败: {str(e)}")
+
+
+@router.delete("/{channel_id}", response_model=BaseResponse[dict])
+async def delete_channel(
+ channel_id: str,
+ mapper: NotificationChannelMapper = Depends(get_channel_mapper)
+):
+ """删除通知渠道"""
+ try:
+ channel = mapper.find_by_id(channel_id)
+ if not channel:
+ return BaseResponse.error(msg="通知渠道不存在", code=404)
+
+ success = mapper.delete(channel_id)
+ if success:
+ return BaseResponse.success(data={"deleted_id": channel_id}, msg="删除通知渠道成功")
+ else:
+ return BaseResponse.error(msg="删除通知渠道失败")
+ except Exception as e:
+ return BaseResponse.error(msg=f"删除通知渠道失败: {str(e)}")
+
+
+@router.post("/{channel_id}/activate", response_model=BaseResponse[dict])
+async def activate_channel(
+ channel_id: str,
+ mapper: NotificationChannelMapper = Depends(get_channel_mapper)
+):
+ """启用通知渠道"""
+ try:
+ success = mapper.update_status(channel_id, ChannelStatus.ACTIVE)
+ if not success:
+ return BaseResponse.error(msg="通知渠道不存在", code=404)
+
+ return BaseResponse.success(data={"channel_id": channel_id}, msg="启用通知渠道成功")
+ except Exception as e:
+ return BaseResponse.error(msg=f"启用通知渠道失败: {str(e)}")
+
+
+@router.post("/{channel_id}/deactivate", response_model=BaseResponse[dict])
+async def deactivate_channel(
+ channel_id: str,
+ mapper: NotificationChannelMapper = Depends(get_channel_mapper)
+):
+ """停用通知渠道"""
+ try:
+ success = mapper.update_status(channel_id, ChannelStatus.INACTIVE)
+ if not success:
+ return BaseResponse.error(msg="通知渠道不存在", code=404)
+
+ return BaseResponse.success(data={"channel_id": channel_id}, msg="停用通知渠道成功")
+ except Exception as e:
+ return BaseResponse.error(msg=f"停用通知渠道失败: {str(e)}")
+
+
+# ==================== 绑定关系管理 ====================
+
+@router.get("/{channel_id}/recruiters", response_model=BaseResponse[RecruiterChannelBindingListResponse])
+async def get_channel_recruiters(
+ channel_id: str,
+ mapper: NotificationChannelMapper = Depends(get_channel_mapper)
+):
+ """获取绑定到该通知渠道的所有招聘者"""
+ try:
+ channel = mapper.find_by_id(channel_id)
+ if not channel:
+ return BaseResponse.error(msg="通知渠道不存在", code=404)
+
+ bindings = mapper.find_bindings_by_channel(channel_id)
+
+ # 获取渠道信息用于响应
+ items = []
+ for binding in bindings:
+ items.append(_build_binding_response(binding, channel))
+
+ response = RecruiterChannelBindingListResponse(total=len(items), items=items)
+ return BaseResponse.success(data=response, msg="获取渠道绑定的招聘者列表成功")
+ except Exception as e:
+ return BaseResponse.error(msg=f"获取渠道绑定的招聘者列表失败: {str(e)}")
+
+
+@router.post("/{channel_id}/recruiters/{recruiter_id}", response_model=BaseResponse[RecruiterChannelBindingResponse])
+async def bind_recruiter(
+ channel_id: str,
+ recruiter_id: str,
+ data: RecruiterChannelBindingCreate,
+ channel_mapper: NotificationChannelMapper = Depends(get_channel_mapper),
+ recruiter_mapper: RecruiterMapper = Depends(get_recruiter_mapper)
+):
+ """绑定招聘者到通知渠道"""
+ try:
+ # 验证渠道存在
+ channel = channel_mapper.find_by_id(channel_id)
+ if not channel:
+ return BaseResponse.error(msg="通知渠道不存在", code=404)
+
+ # 验证招聘者存在
+ recruiter = recruiter_mapper.find_by_id(recruiter_id)
+ if not recruiter:
+ return BaseResponse.error(msg="招聘者不存在", code=404)
+
+ # 创建绑定关系
+ binding = RecruiterChannelBinding(
+ recruiter_id=recruiter_id,
+ channel_id=channel_id,
+ is_enabled=data.is_enabled,
+ notify_on_new_candidate=data.notify_on_new_candidate,
+ notify_on_evaluation=data.notify_on_evaluation,
+ notify_on_high_score=data.notify_on_high_score,
+ high_score_threshold=data.high_score_threshold
+ )
+
+ saved_binding = channel_mapper.bind_recruiter(binding)
+
+ return BaseResponse.success(
+ data=_build_binding_response(saved_binding, channel),
+ msg=f"绑定招聘者 '{recruiter.name}' 到通知渠道 '{channel.name}' 成功"
+ )
+ except Exception as e:
+ return BaseResponse.error(msg=f"绑定招聘者失败: {str(e)}")
+
+
+@router.put("/{channel_id}/recruiters/{recruiter_id}", response_model=BaseResponse[RecruiterChannelBindingResponse])
+async def update_binding(
+ channel_id: str,
+ recruiter_id: str,
+ data: RecruiterChannelBindingUpdate,
+ mapper: NotificationChannelMapper = Depends(get_channel_mapper)
+):
+ """更新绑定配置"""
+ try:
+ # 检查绑定是否存在
+ existing = mapper.get_binding(recruiter_id, channel_id)
+ if not existing:
+ return BaseResponse.error(msg="绑定关系不存在", code=404)
+
+ # 构建更新参数
+ update_data = {}
+ if data.is_enabled is not None:
+ update_data['is_enabled'] = data.is_enabled
+ if data.notify_on_new_candidate is not None:
+ update_data['notify_on_new_candidate'] = data.notify_on_new_candidate
+ if data.notify_on_evaluation is not None:
+ update_data['notify_on_evaluation'] = data.notify_on_evaluation
+ if data.notify_on_high_score is not None:
+ update_data['notify_on_high_score'] = data.notify_on_high_score
+ if data.high_score_threshold is not None:
+ update_data['high_score_threshold'] = data.high_score_threshold
+
+ if not update_data:
+ return BaseResponse.error(msg="没有提供要更新的字段", code=400)
+
+ success = mapper.update_binding(recruiter_id, channel_id, **update_data)
+ if not success:
+ return BaseResponse.error(msg="更新绑定配置失败")
+
+ # 获取更新后的绑定
+ updated = mapper.get_binding(recruiter_id, channel_id)
+ channel = mapper.find_by_id(channel_id)
+
+ return BaseResponse.success(
+ data=_build_binding_response(updated, channel),
+ msg="更新绑定配置成功"
+ )
+ except Exception as e:
+ return BaseResponse.error(msg=f"更新绑定配置失败: {str(e)}")
+
+
+@router.delete("/{channel_id}/recruiters/{recruiter_id}", response_model=BaseResponse[dict])
+async def unbind_recruiter(
+ channel_id: str,
+ recruiter_id: str,
+ mapper: NotificationChannelMapper = Depends(get_channel_mapper)
+):
+ """解绑招聘者与通知渠道"""
+ try:
+ success = mapper.unbind_recruiter(recruiter_id, channel_id)
+ if not success:
+ return BaseResponse.error(msg="绑定关系不存在", code=404)
+
+ return BaseResponse.success(
+ data={"recruiter_id": recruiter_id, "channel_id": channel_id},
+ msg="解绑成功"
+ )
+ except Exception as e:
+ return BaseResponse.error(msg=f"解绑失败: {str(e)}")
diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/recruiter.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/recruiter.py
index e483bc9..d2a59ce 100644
--- a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/recruiter.py
+++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/recruiter.py
@@ -12,13 +12,17 @@ from ..schemas import (
RecruiterCreate, RecruiterRegister, RecruiterUpdate,
RecruiterResponse, RecruiterListResponse, RecruiterRegisterResponse,
RecruiterPrivilegeInfo, RecruiterSyncInfo, RecruiterSourceInfo,
- BaseResponse
+ RecruiterChannelBindingCreate, RecruiterChannelBindingUpdate,
+ RecruiterChannelBindingResponse, RecruiterChannelBindingListResponse,
+ RecruiterWithChannelsResponse, BaseResponse
)
from ...domain.candidate import CandidateSource
from ...domain.recruiter import Recruiter, RecruiterStatus
+from ...domain.notification_channel import RecruiterChannelBinding
from ...service.recruiter_service import RecruiterService
from ...service.crawler import BossCrawler
from ...mapper.recruiter_mapper import RecruiterMapper
+from ...mapper.notification_channel_mapper import NotificationChannelMapper
from ...config.settings import get_settings
@@ -32,6 +36,12 @@ def get_recruiter_service():
return RecruiterService(mapper=mapper)
+def get_channel_mapper():
+ """依赖注入:获取NotificationChannelMapper"""
+ settings = get_settings()
+ return NotificationChannelMapper(db_url=settings.db_url)
+
+
def _build_recruiter_response(recruiter: Recruiter) -> RecruiterResponse:
"""构建招聘者账号响应"""
# 构建权益信息
@@ -357,3 +367,164 @@ async def sync_recruiter(
)
except Exception as e:
return BaseResponse.error(msg=f"同步账号失败: {str(e)}")
+
+
+# ==================== 通知渠道绑定管理 ====================
+
+def _build_binding_response(binding, channel=None) -> RecruiterChannelBindingResponse:
+ """构建绑定关系响应"""
+ return RecruiterChannelBindingResponse(
+ recruiter_id=binding.recruiter_id,
+ channel_id=binding.channel_id,
+ channel_name=channel.name if channel else None,
+ channel_type=channel.channel_type.value if channel else None,
+ is_enabled=binding.is_enabled,
+ notify_on_new_candidate=binding.notify_on_new_candidate,
+ notify_on_evaluation=binding.notify_on_evaluation,
+ notify_on_high_score=binding.notify_on_high_score,
+ high_score_threshold=binding.high_score_threshold,
+ created_at=binding.created_at
+ )
+
+
+@router.get("/{recruiter_id}/channels", response_model=BaseResponse[RecruiterChannelBindingListResponse])
+async def get_recruiter_channels(
+ recruiter_id: str,
+ service: RecruiterService = Depends(get_recruiter_service),
+ channel_mapper: NotificationChannelMapper = Depends(get_channel_mapper)
+):
+ """获取招聘者绑定的所有通知渠道"""
+ try:
+ recruiter = service.get_recruiter(recruiter_id)
+ if not recruiter:
+ return BaseResponse.error(msg="招聘者不存在", code=404)
+
+ bindings = channel_mapper.find_bindings_by_recruiter(recruiter_id)
+
+ items = []
+ for binding in bindings:
+ channel = channel_mapper.find_by_id(binding.channel_id)
+ items.append(_build_binding_response(binding, channel))
+
+ response = RecruiterChannelBindingListResponse(total=len(items), items=items)
+ return BaseResponse.success(data=response, msg="获取招聘者绑定的通知渠道成功")
+ except Exception as e:
+ return BaseResponse.error(msg=f"获取招聘者绑定的通知渠道失败: {str(e)}")
+
+
+@router.post("/{recruiter_id}/channels", response_model=BaseResponse[RecruiterChannelBindingResponse])
+async def bind_channel_to_recruiter(
+ recruiter_id: str,
+ data: RecruiterChannelBindingCreate,
+ service: RecruiterService = Depends(get_recruiter_service),
+ channel_mapper: NotificationChannelMapper = Depends(get_channel_mapper)
+):
+ """为招聘者绑定通知渠道"""
+ try:
+ # 验证招聘者存在
+ recruiter = service.get_recruiter(recruiter_id)
+ if not recruiter:
+ return BaseResponse.error(msg="招聘者不存在", code=404)
+
+ # 验证渠道存在
+ channel = channel_mapper.find_by_id(data.channel_id)
+ if not channel:
+ return BaseResponse.error(msg="通知渠道不存在", code=404)
+
+ # 创建绑定关系
+ binding = RecruiterChannelBinding(
+ recruiter_id=recruiter_id,
+ channel_id=data.channel_id,
+ is_enabled=data.is_enabled,
+ notify_on_new_candidate=data.notify_on_new_candidate,
+ notify_on_evaluation=data.notify_on_evaluation,
+ notify_on_high_score=data.notify_on_high_score,
+ high_score_threshold=data.high_score_threshold
+ )
+
+ saved_binding = channel_mapper.bind_recruiter(binding)
+
+ return BaseResponse.success(
+ data=_build_binding_response(saved_binding, channel),
+ msg=f"成功为招聘者 '{recruiter.name}' 绑定通知渠道 '{channel.name}'"
+ )
+ except Exception as e:
+ return BaseResponse.error(msg=f"绑定通知渠道失败: {str(e)}")
+
+
+@router.put("/{recruiter_id}/channels/{channel_id}", response_model=BaseResponse[RecruiterChannelBindingResponse])
+async def update_recruiter_channel_binding(
+ recruiter_id: str,
+ channel_id: str,
+ data: RecruiterChannelBindingUpdate,
+ service: RecruiterService = Depends(get_recruiter_service),
+ channel_mapper: NotificationChannelMapper = Depends(get_channel_mapper)
+):
+ """更新招聘者的通知渠道绑定配置"""
+ try:
+ # 验证招聘者存在
+ recruiter = service.get_recruiter(recruiter_id)
+ if not recruiter:
+ return BaseResponse.error(msg="招聘者不存在", code=404)
+
+ # 检查绑定是否存在
+ existing = channel_mapper.get_binding(recruiter_id, channel_id)
+ if not existing:
+ return BaseResponse.error(msg="绑定关系不存在", code=404)
+
+ # 构建更新参数
+ update_data = {}
+ if data.is_enabled is not None:
+ update_data['is_enabled'] = data.is_enabled
+ if data.notify_on_new_candidate is not None:
+ update_data['notify_on_new_candidate'] = data.notify_on_new_candidate
+ if data.notify_on_evaluation is not None:
+ update_data['notify_on_evaluation'] = data.notify_on_evaluation
+ if data.notify_on_high_score is not None:
+ update_data['notify_on_high_score'] = data.notify_on_high_score
+ if data.high_score_threshold is not None:
+ update_data['high_score_threshold'] = data.high_score_threshold
+
+ if not update_data:
+ return BaseResponse.error(msg="没有提供要更新的字段", code=400)
+
+ success = channel_mapper.update_binding(recruiter_id, channel_id, **update_data)
+ if not success:
+ return BaseResponse.error(msg="更新绑定配置失败")
+
+ # 获取更新后的绑定
+ updated = channel_mapper.get_binding(recruiter_id, channel_id)
+ channel = channel_mapper.find_by_id(channel_id)
+
+ return BaseResponse.success(
+ data=_build_binding_response(updated, channel),
+ msg="更新绑定配置成功"
+ )
+ except Exception as e:
+ return BaseResponse.error(msg=f"更新绑定配置失败: {str(e)}")
+
+
+@router.delete("/{recruiter_id}/channels/{channel_id}", response_model=BaseResponse[dict])
+async def unbind_channel_from_recruiter(
+ recruiter_id: str,
+ channel_id: str,
+ service: RecruiterService = Depends(get_recruiter_service),
+ channel_mapper: NotificationChannelMapper = Depends(get_channel_mapper)
+):
+ """解绑招聘者的通知渠道"""
+ try:
+ # 验证招聘者存在
+ recruiter = service.get_recruiter(recruiter_id)
+ if not recruiter:
+ return BaseResponse.error(msg="招聘者不存在", code=404)
+
+ success = channel_mapper.unbind_recruiter(recruiter_id, channel_id)
+ if not success:
+ return BaseResponse.error(msg="绑定关系不存在", code=404)
+
+ return BaseResponse.success(
+ data={"recruiter_id": recruiter_id, "channel_id": channel_id},
+ msg="解绑通知渠道成功"
+ )
+ except Exception as e:
+ return BaseResponse.error(msg=f"解绑通知渠道失败: {str(e)}")
diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/schemas.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/schemas.py
index 1696228..a2056b1 100644
--- a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/schemas.py
+++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/schemas.py
@@ -305,3 +305,122 @@ class EvaluationSchemaListResponse(BaseModel):
"""评价方案列表响应"""
total: int
items: List[EvaluationSchemaResponse]
+
+
+# ============== 通知渠道 ==============
+
+class ChannelTypeInfo(BaseModel):
+ """通知渠道类型信息"""
+ value: str = Field(..., description="类型值")
+ label: str = Field(..., description="显示名称")
+ description: Optional[str] = Field(None, description="描述")
+
+
+class ChannelConfigBase(BaseModel):
+ """通知渠道配置基础"""
+ # 企业微信/通用Webhook配置
+ webhook_url: Optional[str] = Field(None, description="Webhook地址")
+ secret: Optional[str] = Field(None, description="密钥")
+
+ # 钉钉配置
+ access_token: Optional[str] = Field(None, description="钉钉Access Token")
+ sign_secret: Optional[str] = Field(None, description="钉钉签名密钥")
+
+ # 邮件配置
+ smtp_host: Optional[str] = Field(None, description="SMTP服务器")
+ smtp_port: Optional[int] = Field(None, description="SMTP端口")
+ username: Optional[str] = Field(None, description="邮箱用户名")
+ password: Optional[str] = Field(None, description="邮箱密码")
+ use_tls: bool = Field(True, description="是否使用TLS")
+ sender_name: Optional[str] = Field(None, description="发件人名称")
+
+ # 飞书配置
+ feishu_webhook: Optional[str] = Field(None, description="飞书Webhook地址")
+ feishu_secret: Optional[str] = Field(None, description="飞书密钥")
+
+ # 通用配置
+ custom_headers: Optional[Dict[str, str]] = Field(None, description="自定义请求头")
+ custom_payload_template: Optional[str] = Field(None, description="自定义Payload模板")
+
+
+class NotificationChannelCreate(BaseModel):
+ """创建通知渠道请求"""
+ name: str = Field(..., description="渠道名称")
+ channel_type: str = Field(..., description="渠道类型: wechat_work, dingtalk, email, feishu, webhook")
+ config: ChannelConfigBase = Field(..., description="渠道配置")
+ description: Optional[str] = Field(None, description="渠道描述")
+
+
+class NotificationChannelUpdate(BaseModel):
+ """更新通知渠道请求"""
+ name: Optional[str] = Field(None, description="渠道名称")
+ config: Optional[ChannelConfigBase] = Field(None, description="渠道配置")
+ description: Optional[str] = Field(None, description="渠道描述")
+ status: Optional[str] = Field(None, description="状态: active, inactive")
+
+
+class NotificationChannelResponse(BaseModel):
+ """通知渠道响应"""
+ id: str
+ name: str
+ channel_type: str
+ config: Dict[str, Any]
+ status: str
+ description: Optional[str] = None
+ recruiter_ids: List[str] = Field(default_factory=list, description="关联的招聘者ID列表")
+ created_at: Optional[datetime] = None
+ updated_at: Optional[datetime] = None
+
+
+class NotificationChannelListResponse(BaseModel):
+ """通知渠道列表响应"""
+ total: int
+ items: List[NotificationChannelResponse]
+
+
+# ============== 招聘者与通知渠道绑定 ==============
+
+class RecruiterChannelBindingCreate(BaseModel):
+ """创建招聘者与通知渠道绑定请求"""
+ channel_id: str = Field(..., description="通知渠道ID")
+ is_enabled: bool = Field(True, description="是否启用")
+ notify_on_new_candidate: bool = Field(True, description="新候选人时通知")
+ notify_on_evaluation: bool = Field(True, description="完成评价时通知")
+ notify_on_high_score: bool = Field(False, description="高分候选人时通知")
+ high_score_threshold: int = Field(85, ge=0, le=100, description="高分阈值")
+
+
+class RecruiterChannelBindingUpdate(BaseModel):
+ """更新绑定配置请求"""
+ is_enabled: Optional[bool] = Field(None, description="是否启用")
+ notify_on_new_candidate: Optional[bool] = Field(None, description="新候选人时通知")
+ notify_on_evaluation: Optional[bool] = Field(None, description="完成评价时通知")
+ notify_on_high_score: Optional[bool] = Field(None, description="高分候选人时通知")
+ high_score_threshold: Optional[int] = Field(None, ge=0, le=100, description="高分阈值")
+
+
+class RecruiterChannelBindingResponse(BaseModel):
+ """招聘者与通知渠道绑定响应"""
+ recruiter_id: str
+ channel_id: str
+ channel_name: Optional[str] = Field(None, description="渠道名称")
+ channel_type: Optional[str] = Field(None, description="渠道类型")
+ is_enabled: bool
+ notify_on_new_candidate: bool
+ notify_on_evaluation: bool
+ notify_on_high_score: bool
+ high_score_threshold: int
+ created_at: Optional[datetime] = None
+
+
+class RecruiterChannelBindingListResponse(BaseModel):
+ """绑定列表响应"""
+ total: int
+ items: List[RecruiterChannelBindingResponse]
+
+
+class RecruiterWithChannelsResponse(BaseModel):
+ """招聘者及其绑定的通知渠道响应"""
+ recruiter_id: str
+ recruiter_name: str
+ channels: List[RecruiterChannelBindingResponse]
diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/domain/notification_channel.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/domain/notification_channel.py
new file mode 100644
index 0000000..fe9552f
--- /dev/null
+++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/domain/notification_channel.py
@@ -0,0 +1,194 @@
+"""Notification channel entity definitions"""
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Optional, Dict, Any, List
+from enum import Enum
+
+
+class ChannelType(Enum):
+ """通知渠道类型"""
+ WECHAT_WORK = "wechat_work" # 企业微信
+ DINGTALK = "dingtalk" # 钉钉
+ EMAIL = "email" # 邮件
+ FEISHU = "feishu" # 飞书
+ WEBHOOK = "webhook" # 通用Webhook
+
+
+class ChannelStatus(Enum):
+ """通知渠道状态"""
+ ACTIVE = "active" # 启用
+ INACTIVE = "inactive" # 停用
+
+
+@dataclass
+class ChannelConfig:
+ """通知渠道配置"""
+ # 企业微信配置
+ webhook_url: Optional[str] = None # Webhook地址
+ secret: Optional[str] = None # 密钥
+
+ # 钉钉配置
+ access_token: Optional[str] = None # Access Token
+ sign_secret: Optional[str] = None # 签名密钥
+
+ # 邮件配置
+ smtp_host: Optional[str] = None # SMTP服务器
+ smtp_port: Optional[int] = None # SMTP端口
+ username: Optional[str] = None # 用户名
+ password: Optional[str] = None # 密码
+ use_tls: bool = True # 是否使用TLS
+ sender_name: Optional[str] = None # 发件人名称
+
+ # 飞书配置
+ feishu_webhook: Optional[str] = None # 飞书Webhook
+ feishu_secret: Optional[str] = None # 飞书密钥
+
+ # 通用Webhook配置
+ custom_headers: Optional[Dict[str, str]] = field(default_factory=dict) # 自定义请求头
+ custom_payload_template: Optional[str] = None # 自定义payload模板
+
+ def to_dict(self) -> Dict[str, Any]:
+ """转换为字典"""
+ return {
+ "webhook_url": self.webhook_url,
+ "secret": self.secret,
+ "access_token": self.access_token,
+ "sign_secret": self.sign_secret,
+ "smtp_host": self.smtp_host,
+ "smtp_port": self.smtp_port,
+ "username": self.username,
+ "password": self.password,
+ "use_tls": self.use_tls,
+ "sender_name": self.sender_name,
+ "feishu_webhook": self.feishu_webhook,
+ "feishu_secret": self.feishu_secret,
+ "custom_headers": self.custom_headers,
+ "custom_payload_template": self.custom_payload_template
+ }
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> "ChannelConfig":
+ """从字典创建"""
+ if not data:
+ return cls()
+ return cls(
+ webhook_url=data.get("webhook_url"),
+ secret=data.get("secret"),
+ access_token=data.get("access_token"),
+ sign_secret=data.get("sign_secret"),
+ smtp_host=data.get("smtp_host"),
+ smtp_port=data.get("smtp_port"),
+ username=data.get("username"),
+ password=data.get("password"),
+ use_tls=data.get("use_tls", True),
+ sender_name=data.get("sender_name"),
+ feishu_webhook=data.get("feishu_webhook"),
+ feishu_secret=data.get("feishu_secret"),
+ custom_headers=data.get("custom_headers", {}),
+ custom_payload_template=data.get("custom_payload_template")
+ )
+
+
+@dataclass
+class NotificationChannel:
+ """通知渠道实体"""
+ id: Optional[str] = None
+ name: str = "" # 渠道名称
+ channel_type: ChannelType = ChannelType.WEBHOOK
+ config: ChannelConfig = field(default_factory=ChannelConfig)
+ status: ChannelStatus = ChannelStatus.ACTIVE
+ description: Optional[str] = None # 描述
+
+ # 关联的招聘者ID列表(多对多关系)
+ recruiter_ids: List[str] = field(default_factory=list)
+
+ created_at: Optional[datetime] = None
+ updated_at: Optional[datetime] = None
+
+ def __post_init__(self):
+ if self.created_at is None:
+ self.created_at = datetime.now()
+ if self.updated_at is None:
+ self.updated_at = datetime.now()
+ if self.config is None:
+ self.config = ChannelConfig()
+
+ def is_active(self) -> bool:
+ """检查渠道是否启用"""
+ return self.status == ChannelStatus.ACTIVE
+
+ def get_config_for_type(self) -> Dict[str, Any]:
+ """根据渠道类型返回相关配置"""
+ config_dict = self.config.to_dict()
+
+ if self.channel_type == ChannelType.WECHAT_WORK:
+ return {
+ "webhook_url": config_dict.get("webhook_url"),
+ "secret": config_dict.get("secret")
+ }
+ elif self.channel_type == ChannelType.DINGTALK:
+ return {
+ "access_token": config_dict.get("access_token"),
+ "sign_secret": config_dict.get("sign_secret")
+ }
+ elif self.channel_type == ChannelType.EMAIL:
+ return {
+ "smtp_host": config_dict.get("smtp_host"),
+ "smtp_port": config_dict.get("smtp_port"),
+ "username": config_dict.get("username"),
+ "password": config_dict.get("password"),
+ "use_tls": config_dict.get("use_tls"),
+ "sender_name": config_dict.get("sender_name")
+ }
+ elif self.channel_type == ChannelType.FEISHU:
+ return {
+ "webhook_url": config_dict.get("feishu_webhook"),
+ "secret": config_dict.get("feishu_secret")
+ }
+ else:
+ return {
+ "webhook_url": config_dict.get("webhook_url"),
+ "custom_headers": config_dict.get("custom_headers"),
+ "custom_payload_template": config_dict.get("custom_payload_template")
+ }
+
+ def validate_config(self) -> tuple[bool, str]:
+ """验证配置是否完整"""
+ config = self.get_config_for_type()
+
+ if self.channel_type == ChannelType.WECHAT_WORK:
+ if not config.get("webhook_url"):
+ return False, "企业微信Webhook地址不能为空"
+ elif self.channel_type == ChannelType.DINGTALK:
+ if not config.get("access_token"):
+ return False, "钉钉Access Token不能为空"
+ elif self.channel_type == ChannelType.EMAIL:
+ required = ["smtp_host", "smtp_port", "username", "password"]
+ for field_name in required:
+ if not config.get(field_name):
+ return False, f"邮件配置缺少必填项: {field_name}"
+ elif self.channel_type == ChannelType.FEISHU:
+ if not config.get("webhook_url"):
+ return False, "飞书Webhook地址不能为空"
+ elif self.channel_type == ChannelType.WEBHOOK:
+ if not config.get("webhook_url"):
+ return False, "Webhook地址不能为空"
+
+ return True, "配置验证通过"
+
+
+@dataclass
+class RecruiterChannelBinding:
+ """招聘者与通知渠道绑定关系"""
+ recruiter_id: str
+ channel_id: str
+ is_enabled: bool = True # 是否启用该渠道
+ notify_on_new_candidate: bool = True # 新候选人时通知
+ notify_on_evaluation: bool = True # 完成评价时通知
+ notify_on_high_score: bool = False # 高分候选人时通知
+ high_score_threshold: int = 85 # 高分阈值
+ created_at: Optional[datetime] = None
+
+ def __post_init__(self):
+ if self.created_at is None:
+ self.created_at = datetime.now()
diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/mapper/notification_channel_mapper.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/mapper/notification_channel_mapper.py
new file mode 100644
index 0000000..abddf81
--- /dev/null
+++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/mapper/notification_channel_mapper.py
@@ -0,0 +1,376 @@
+"""Notification channel data mapper using SQLAlchemy"""
+from typing import List, Optional
+from datetime import datetime
+import uuid
+
+from sqlalchemy import select, update, delete, and_
+from sqlalchemy.orm import Session, joinedload
+
+from ..domain.notification_channel import (
+ NotificationChannel, ChannelType, ChannelStatus,
+ ChannelConfig, RecruiterChannelBinding
+)
+from ..config.database import (
+ get_db_manager, NotificationChannelModel, RecruiterChannelBindingModel
+)
+
+
+class NotificationChannelMapper:
+ """通知渠道数据访问 - SQLAlchemy实现"""
+
+ def __init__(self, db_url: Optional[str] = None):
+ self.db_manager = get_db_manager(db_url)
+ # 确保表存在
+ self.db_manager.create_tables()
+
+ def _get_session(self) -> Session:
+ """获取数据库会话"""
+ return self.db_manager.get_session()
+
+ def _model_to_entity(self, model: NotificationChannelModel, include_bindings: bool = True) -> NotificationChannel:
+ """将模型转换为实体"""
+ channel = NotificationChannel(
+ id=model.id,
+ name=model.name,
+ channel_type=ChannelType(model.channel_type),
+ config=ChannelConfig.from_dict(model.config) if model.config else ChannelConfig(),
+ status=ChannelStatus(model.status) if model.status else ChannelStatus.ACTIVE,
+ description=model.description,
+ created_at=model.created_at,
+ updated_at=model.updated_at
+ )
+
+ if include_bindings and hasattr(model, 'recruiter_bindings'):
+ channel.recruiter_ids = [
+ binding.recruiter_id
+ for binding in model.recruiter_bindings
+ ]
+
+ return channel
+
+ def _entity_to_model(self, entity: NotificationChannel) -> NotificationChannelModel:
+ """将实体转换为模型"""
+ return NotificationChannelModel(
+ id=entity.id or str(uuid.uuid4()),
+ name=entity.name,
+ channel_type=entity.channel_type.value,
+ config=entity.config.to_dict() if entity.config else {},
+ status=entity.status.value,
+ description=entity.description
+ )
+
+ def _binding_model_to_entity(self, model: RecruiterChannelBindingModel) -> RecruiterChannelBinding:
+ """将绑定模型转换为实体"""
+ return RecruiterChannelBinding(
+ recruiter_id=model.recruiter_id,
+ channel_id=model.channel_id,
+ is_enabled=model.is_enabled,
+ notify_on_new_candidate=model.notify_on_new_candidate,
+ notify_on_evaluation=model.notify_on_evaluation,
+ notify_on_high_score=model.notify_on_high_score,
+ high_score_threshold=model.high_score_threshold,
+ created_at=model.created_at
+ )
+
+ def save(self, channel: NotificationChannel) -> NotificationChannel:
+ """保存通知渠道"""
+ session = self._get_session()
+ try:
+ if channel.id:
+ # 更新
+ stmt = (
+ update(NotificationChannelModel)
+ .where(NotificationChannelModel.id == channel.id)
+ .values(
+ name=channel.name,
+ channel_type=channel.channel_type.value,
+ config=channel.config.to_dict() if channel.config else {},
+ status=channel.status.value,
+ description=channel.description,
+ updated_at=datetime.now()
+ )
+ )
+ session.execute(stmt)
+ else:
+ # 插入
+ channel.id = str(uuid.uuid4())
+ model = self._entity_to_model(channel)
+ session.add(model)
+
+ session.commit()
+ return channel
+ finally:
+ session.close()
+
+ def find_by_id(self, channel_id: str, include_bindings: bool = False) -> Optional[NotificationChannel]:
+ """根据ID查询通知渠道"""
+ session = self._get_session()
+ try:
+ query = select(NotificationChannelModel).where(NotificationChannelModel.id == channel_id)
+
+ if include_bindings:
+ query = query.options(joinedload(NotificationChannelModel.recruiter_bindings))
+
+ result = session.execute(query)
+ model = result.unique().scalar_one_or_none()
+ return self._model_to_entity(model, include_bindings) if model else None
+ finally:
+ session.close()
+
+ def find_all(self, include_bindings: bool = False) -> List[NotificationChannel]:
+ """查询所有通知渠道"""
+ session = self._get_session()
+ try:
+ query = select(NotificationChannelModel).order_by(NotificationChannelModel.created_at.desc())
+
+ if include_bindings:
+ query = query.options(joinedload(NotificationChannelModel.recruiter_bindings))
+
+ result = session.execute(query)
+ models = result.unique().scalars().all()
+ return [self._model_to_entity(m, include_bindings) for m in models]
+ finally:
+ session.close()
+
+ def find_by_type(self, channel_type: ChannelType) -> List[NotificationChannel]:
+ """根据类型查询通知渠道"""
+ session = self._get_session()
+ try:
+ result = session.execute(
+ select(NotificationChannelModel)
+ .where(NotificationChannelModel.channel_type == channel_type.value)
+ .order_by(NotificationChannelModel.created_at.desc())
+ )
+ models = result.scalars().all()
+ return [self._model_to_entity(m) for m in models]
+ finally:
+ session.close()
+
+ def find_active(self) -> List[NotificationChannel]:
+ """查询所有启用的通知渠道"""
+ session = self._get_session()
+ try:
+ result = session.execute(
+ select(NotificationChannelModel)
+ .where(NotificationChannelModel.status == 'active')
+ .order_by(NotificationChannelModel.created_at.desc())
+ )
+ models = result.scalars().all()
+ return [self._model_to_entity(m) for m in models]
+ finally:
+ session.close()
+
+ def delete(self, channel_id: str) -> bool:
+ """删除通知渠道"""
+ session = self._get_session()
+ try:
+ # 先删除关联的绑定关系
+ session.execute(
+ delete(RecruiterChannelBindingModel)
+ .where(RecruiterChannelBindingModel.channel_id == channel_id)
+ )
+
+ # 再删除渠道
+ stmt = delete(NotificationChannelModel).where(NotificationChannelModel.id == channel_id)
+ result = session.execute(stmt)
+ session.commit()
+ return result.rowcount > 0
+ finally:
+ session.close()
+
+ def update_status(self, channel_id: str, status: ChannelStatus) -> bool:
+ """更新渠道状态"""
+ session = self._get_session()
+ try:
+ stmt = (
+ update(NotificationChannelModel)
+ .where(NotificationChannelModel.id == channel_id)
+ .values(status=status.value, updated_at=datetime.now())
+ )
+ result = session.execute(stmt)
+ session.commit()
+ return result.rowcount > 0
+ finally:
+ session.close()
+
+ # ==================== 绑定关系操作 ====================
+
+ def bind_recruiter(self, binding: RecruiterChannelBinding) -> RecruiterChannelBinding:
+ """绑定招聘者与通知渠道"""
+ session = self._get_session()
+ try:
+ # 检查是否已存在绑定
+ existing = session.execute(
+ select(RecruiterChannelBindingModel)
+ .where(
+ and_(
+ RecruiterChannelBindingModel.recruiter_id == binding.recruiter_id,
+ RecruiterChannelBindingModel.channel_id == binding.channel_id
+ )
+ )
+ ).scalar_one_or_none()
+
+ if existing:
+ # 更新现有绑定
+ stmt = (
+ update(RecruiterChannelBindingModel)
+ .where(RecruiterChannelBindingModel.id == existing.id)
+ .values(
+ is_enabled=binding.is_enabled,
+ notify_on_new_candidate=binding.notify_on_new_candidate,
+ notify_on_evaluation=binding.notify_on_evaluation,
+ notify_on_high_score=binding.notify_on_high_score,
+ high_score_threshold=binding.high_score_threshold
+ )
+ )
+ session.execute(stmt)
+ binding.created_at = existing.created_at
+ else:
+ # 创建新绑定
+ model = RecruiterChannelBindingModel(
+ id=str(uuid.uuid4()),
+ recruiter_id=binding.recruiter_id,
+ channel_id=binding.channel_id,
+ is_enabled=binding.is_enabled,
+ notify_on_new_candidate=binding.notify_on_new_candidate,
+ notify_on_evaluation=binding.notify_on_evaluation,
+ notify_on_high_score=binding.notify_on_high_score,
+ high_score_threshold=binding.high_score_threshold
+ )
+ session.add(model)
+
+ session.commit()
+ return binding
+ finally:
+ session.close()
+
+ def unbind_recruiter(self, recruiter_id: str, channel_id: str) -> bool:
+ """解绑招聘者与通知渠道"""
+ session = self._get_session()
+ try:
+ stmt = (
+ delete(RecruiterChannelBindingModel)
+ .where(
+ and_(
+ RecruiterChannelBindingModel.recruiter_id == recruiter_id,
+ RecruiterChannelBindingModel.channel_id == channel_id
+ )
+ )
+ )
+ result = session.execute(stmt)
+ session.commit()
+ return result.rowcount > 0
+ finally:
+ session.close()
+
+ def find_bindings_by_recruiter(self, recruiter_id: str) -> List[RecruiterChannelBinding]:
+ """查询招聘者的所有绑定关系"""
+ session = self._get_session()
+ try:
+ result = session.execute(
+ select(RecruiterChannelBindingModel)
+ .where(RecruiterChannelBindingModel.recruiter_id == recruiter_id)
+ )
+ models = result.scalars().all()
+ return [self._binding_model_to_entity(m) for m in models]
+ finally:
+ session.close()
+
+ def find_bindings_by_channel(self, channel_id: str) -> List[RecruiterChannelBinding]:
+ """查询通知渠道的所有绑定关系"""
+ session = self._get_session()
+ try:
+ result = session.execute(
+ select(RecruiterChannelBindingModel)
+ .where(RecruiterChannelBindingModel.channel_id == channel_id)
+ )
+ models = result.scalars().all()
+ return [self._binding_model_to_entity(m) for m in models]
+ finally:
+ session.close()
+
+ def find_channels_by_recruiter(self, recruiter_id: str, only_enabled: bool = True) -> List[NotificationChannel]:
+ """查询招聘者绑定的所有通知渠道"""
+ session = self._get_session()
+ try:
+ query = (
+ select(NotificationChannelModel)
+ .join(RecruiterChannelBindingModel)
+ .where(RecruiterChannelBindingModel.recruiter_id == recruiter_id)
+ )
+
+ if only_enabled:
+ query = query.where(RecruiterChannelBindingModel.is_enabled == True)
+
+ query = query.order_by(NotificationChannelModel.created_at.desc())
+
+ result = session.execute(query)
+ models = result.scalars().all()
+ return [self._model_to_entity(m) for m in models]
+ finally:
+ session.close()
+
+ def find_recruiters_by_channel(self, channel_id: str, only_enabled: bool = True) -> List[str]:
+ """查询绑定到指定渠道的所有招聘者ID"""
+ session = self._get_session()
+ try:
+ query = (
+ select(RecruiterChannelBindingModel.recruiter_id)
+ .where(RecruiterChannelBindingModel.channel_id == channel_id)
+ )
+
+ if only_enabled:
+ query = query.where(RecruiterChannelBindingModel.is_enabled == True)
+
+ result = session.execute(query)
+ return [row[0] for row in result.all()]
+ finally:
+ session.close()
+
+ def update_binding(self, recruiter_id: str, channel_id: str, **kwargs) -> bool:
+ """更新绑定关系配置"""
+ session = self._get_session()
+ try:
+ allowed_fields = [
+ 'is_enabled', 'notify_on_new_candidate',
+ 'notify_on_evaluation', 'notify_on_high_score', 'high_score_threshold'
+ ]
+
+ update_values = {k: v for k, v in kwargs.items() if k in allowed_fields}
+
+ if not update_values:
+ return False
+
+ stmt = (
+ update(RecruiterChannelBindingModel)
+ .where(
+ and_(
+ RecruiterChannelBindingModel.recruiter_id == recruiter_id,
+ RecruiterChannelBindingModel.channel_id == channel_id
+ )
+ )
+ .values(**update_values)
+ )
+ result = session.execute(stmt)
+ session.commit()
+ return result.rowcount > 0
+ finally:
+ session.close()
+
+ def get_binding(self, recruiter_id: str, channel_id: str) -> Optional[RecruiterChannelBinding]:
+ """获取单个绑定关系"""
+ session = self._get_session()
+ try:
+ result = session.execute(
+ select(RecruiterChannelBindingModel)
+ .where(
+ and_(
+ RecruiterChannelBindingModel.recruiter_id == recruiter_id,
+ RecruiterChannelBindingModel.channel_id == channel_id
+ )
+ )
+ )
+ model = result.scalar_one_or_none()
+ return self._binding_model_to_entity(model) if model else None
+ finally:
+ session.close()
diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/service/notification/channels/__init__.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/service/notification/channels/__init__.py
index 95720ad..1f0816a 100644
--- a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/service/notification/channels/__init__.py
+++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/service/notification/channels/__init__.py
@@ -4,6 +4,7 @@ from .base_channel import NotificationChannel, NotificationMessage, SendResult
from .wechat_work_channel import WeChatWorkChannel
from .dingtalk_channel import DingTalkChannel
from .email_channel import EmailChannel
+from .feishu_channel import FeishuChannel, FeishuTextChannel
__all__ = [
"NotificationChannel",
@@ -12,4 +13,6 @@ __all__ = [
"WeChatWorkChannel",
"DingTalkChannel",
"EmailChannel",
+ "FeishuChannel",
+ "FeishuTextChannel",
]
diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/service/notification/channels/feishu_channel.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/service/notification/channels/feishu_channel.py
new file mode 100644
index 0000000..2498882
--- /dev/null
+++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/service/notification/channels/feishu_channel.py
@@ -0,0 +1,281 @@
+"""Feishu (Lark) notification channel"""
+import json
+import base64
+import hashlib
+import hmac
+import time
+from typing import Optional, Dict, Any
+
+from .base_channel import NotificationChannel, NotificationMessage, SendResult
+from ....domain.enums import ChannelType
+
+
+class FeishuChannel(NotificationChannel):
+ """
+ 飞书通知渠道
+
+ 通过飞书机器人 Webhook 发送消息
+ 支持加签验证
+ """
+
+ def __init__(self, webhook_url: str, secret: Optional[str] = None):
+ """
+ 初始化飞书渠道
+
+ Args:
+ webhook_url: 飞书机器人 Webhook 地址
+ secret: 飞书机器人密钥(用于加签验证)
+ """
+ self.webhook_url = webhook_url
+ self.secret = secret
+
+ @property
+ def channel_type(self) -> ChannelType:
+ return ChannelType.WEBHOOK # 使用通用WEBHOOK类型,实际通过配置区分
+
+ def is_configured(self) -> bool:
+ """检查是否已配置"""
+ return bool(self.webhook_url)
+
+ def _generate_sign(self, timestamp: int) -> str:
+ """
+ 生成飞书签名
+
+ 飞书加签算法:
+ 1. 将 timestamp + "\n" + secret 作为签名字符串
+ 2. 使用 HMAC-SHA256 算法计算签名
+ 3. 对结果进行 Base64 编码
+ """
+ if not self.secret:
+ return ""
+
+ string_to_sign = f"{timestamp}\n{self.secret}"
+ hmac_code = hmac.new(
+ string_to_sign.encode('utf-8'),
+ digestmod=hashlib.sha256
+ ).digest()
+ sign = base64.b64encode(hmac_code).decode('utf-8')
+ return sign
+
+ async def send(self, message: NotificationMessage) -> SendResult:
+ """发送飞书消息"""
+ if not self.is_configured():
+ return SendResult(
+ success=False,
+ error_message="Webhook URL not configured"
+ )
+
+ try:
+ payload = self._build_payload(message)
+
+ # 发送请求
+ import aiohttp
+ async with aiohttp.ClientSession() as session:
+ async with session.post(
+ self.webhook_url,
+ json=payload,
+ timeout=aiohttp.ClientTimeout(total=30)
+ ) as response:
+ result = await response.json()
+
+ if result.get("code") == 0:
+ return SendResult(
+ success=True,
+ message_id=result.get("data", {}).get("message_id"),
+ response_data=result
+ )
+ else:
+ return SendResult(
+ success=False,
+ error_message=f"Feishu API error: {result.get('msg')}"
+ )
+
+ except Exception as e:
+ return SendResult(
+ success=False,
+ error_message=f"Failed to send Feishu message: {str(e)}"
+ )
+
+ def _build_payload(self, message: NotificationMessage) -> Dict[str, Any]:
+ """构建飞书消息体"""
+ timestamp = int(time.time())
+ sign = self._generate_sign(timestamp)
+
+ # 构建卡片消息
+ card_content = self._build_card_content(message)
+
+ payload = {
+ "timestamp": timestamp,
+ "msg_type": "interactive",
+ "card": card_content
+ }
+
+ if sign:
+ payload["sign"] = sign
+
+ return payload
+
+ def _build_card_content(self, message: NotificationMessage) -> Dict[str, Any]:
+ """构建飞书卡片消息内容"""
+ elements = []
+
+ # 标题
+ if message.title:
+ elements.append({
+ "tag": "div",
+ "text": {
+ "tag": "lark_md",
+ "content": f"**{message.title}**"
+ }
+ })
+
+ # 内容
+ if message.content:
+ elements.append({
+ "tag": "div",
+ "text": {
+ "tag": "lark_md",
+ "content": message.content
+ }
+ })
+
+ # 候选人信息
+ if message.candidate:
+ candidate = message.candidate
+ info_lines = ["**候选人信息:**"]
+ info_lines.append(f"• 姓名:{candidate.name}")
+ if candidate.age:
+ info_lines.append(f"• 年龄:{candidate.age}岁")
+ if candidate.work_years:
+ info_lines.append(f"• 工作年限:{candidate.work_years}年")
+ if candidate.current_company:
+ info_lines.append(f"• 当前公司:{candidate.current_company}")
+ if candidate.current_position:
+ info_lines.append(f"• 当前职位:{candidate.current_position}")
+ if candidate.phone:
+ info_lines.append(f"• 联系方式:{candidate.phone}")
+
+ elements.append({
+ "tag": "div",
+ "text": {
+ "tag": "lark_md",
+ "content": "\n".join(info_lines)
+ }
+ })
+
+ # 评价信息
+ if message.evaluation:
+ evaluation = message.evaluation
+ eval_lines = ["**AI 评价:**"]
+ eval_lines.append(f"• 综合评分:**{evaluation.overall_score}/100**")
+ if evaluation.recommendation:
+ eval_lines.append(f"• 推荐意见:{self._format_recommendation(evaluation.recommendation.value)}")
+ if evaluation.summary:
+ eval_lines.append(f"• 评价摘要:{evaluation.summary}")
+ if evaluation.strengths:
+ eval_lines.append(f"• 优势:{', '.join(evaluation.strengths[:3])}")
+
+ elements.append({
+ "tag": "div",
+ "text": {
+ "tag": "lark_md",
+ "content": "\n".join(eval_lines)
+ }
+ })
+
+ # 添加分隔线
+ if len(elements) > 0:
+ elements.append({"tag": "hr"})
+
+ # 添加时间戳
+ from datetime import datetime
+ elements.append({
+ "tag": "note",
+ "elements": [
+ {
+ "tag": "plain_text",
+ "content": f"发送时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
+ }
+ ]
+ })
+
+ return {
+ "config": {
+ "wide_screen_mode": True
+ },
+ "header": {
+ "template": "blue",
+ "title": {
+ "tag": "plain_text",
+ "content": "简历智能体通知"
+ }
+ },
+ "elements": elements
+ }
+
+ def _format_recommendation(self, value: str) -> str:
+ """格式化推荐意见"""
+ mapping = {
+ "strong_recommend": "**强烈推荐** 🌟",
+ "recommend": "**推荐** ✅",
+ "consider": "**考虑** 🤔",
+ "not_recommend": "**不推荐** ❌"
+ }
+ return mapping.get(value, value)
+
+
+class FeishuTextChannel(FeishuChannel):
+ """
+ 飞书文本消息渠道
+
+ 发送纯文本消息(更简洁)
+ """
+
+ def _build_payload(self, message: NotificationMessage) -> Dict[str, Any]:
+ """构建文本消息体"""
+ timestamp = int(time.time())
+ sign = self._generate_sign(timestamp)
+
+ # 构建文本内容
+ content = self._format_text_content(message)
+
+ payload = {
+ "timestamp": timestamp,
+ "msg_type": "text",
+ "content": {
+ "text": content
+ }
+ }
+
+ if sign:
+ payload["sign"] = sign
+
+ return payload
+
+ def _format_text_content(self, message: NotificationMessage) -> str:
+ """格式化文本内容"""
+ lines = []
+
+ if message.title:
+ lines.append(f"【{message.title}】")
+
+ if message.content:
+ lines.append(message.content)
+
+ if message.candidate:
+ candidate = message.candidate
+ lines.append(f"\n候选人:{candidate.name}")
+ if candidate.current_company:
+ lines.append(f"公司:{candidate.current_company}")
+ if candidate.current_position:
+ lines.append(f"职位:{candidate.current_position}")
+ if candidate.phone:
+ lines.append(f"电话:{candidate.phone}")
+
+ if message.evaluation:
+ evaluation = message.evaluation
+ lines.append(f"\nAI评分:{evaluation.overall_score}/100")
+ if evaluation.recommendation:
+ lines.append(f"推荐:{self._format_recommendation(evaluation.recommendation.value)}")
+
+ return "\n".join(lines)
diff --git a/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/package.json b/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/package.json
index 8b1d171..ae6f15e 100644
--- a/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/package.json
+++ b/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/package.json
@@ -10,17 +10,19 @@
"preview": "vite preview"
},
"dependencies": {
- "vue": "^3.4.0",
- "vue-router": "^4.2.0",
- "pinia": "^2.1.0",
- "axios": "^1.6.0",
- "element-plus": "^2.5.0",
"@element-plus/icons-vue": "^2.3.0",
- "dayjs": "^1.11.0"
+ "axios": "^1.6.0",
+ "dayjs": "^1.11.0",
+ "element-plus": "^2.5.0",
+ "pinia": "^2.1.0",
+ "tdesign-icons-vue-next": "^0.4.2",
+ "tdesign-vue-next": "^1.18.6",
+ "vue": "^3.4.0",
+ "vue-router": "^4.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
- "vite": "^5.0.0",
- "sass": "^1.70.0"
+ "sass": "^1.70.0",
+ "vite": "^5.0.0"
}
}
diff --git a/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/pnpm-lock.yaml b/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/pnpm-lock.yaml
index b055f54..a1e15d4 100644
--- a/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/pnpm-lock.yaml
+++ b/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/pnpm-lock.yaml
@@ -23,6 +23,12 @@ importers:
pinia:
specifier: ^2.1.0
version: 2.3.1(vue@3.5.30)
+ tdesign-icons-vue-next:
+ specifier: ^0.4.2
+ version: 0.4.2(vue@3.5.30)
+ tdesign-vue-next:
+ specifier: ^1.18.6
+ version: 1.18.6(vue@3.5.30)
vue:
specifier: ^3.4.0
version: 3.5.30
@@ -55,6 +61,10 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ '@babel/runtime@7.29.2':
+ resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
+ engines: {node: '>=6.9.0'}
+
'@babel/types@7.29.0':
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
@@ -300,6 +310,9 @@ packages:
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
+ '@popperjs/core@2.11.8':
+ resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
+
'@rollup/rollup-android-arm-eabi@4.60.0':
resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==}
cpu: [arm]
@@ -437,6 +450,15 @@ packages:
'@types/lodash@4.17.24':
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
+ '@types/sortablejs@1.15.9':
+ resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==}
+
+ '@types/tinycolor2@1.4.6':
+ resolution: {integrity: sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==}
+
+ '@types/validator@13.15.10':
+ resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
+
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
@@ -647,6 +669,9 @@ packages:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
+ mitt@3.0.1:
+ resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
+
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -695,10 +720,31 @@ packages:
engines: {node: '>=14.0.0'}
hasBin: true
+ sortablejs@1.15.7:
+ resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==}
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ tdesign-icons-vue-next@0.4.2:
+ resolution: {integrity: sha512-mTPk1ApcCA9oxDiSs9ttMdd09H8ICBooZIr2bwDEELnYr60sYSUbvWojQ2tp84MUAMuw21HgyVyGkT49db0GFg==}
+ peerDependencies:
+ vue: ^3.0.0
+
+ tdesign-vue-next@1.18.6:
+ resolution: {integrity: sha512-oc7wOE5awfWd0/mqCVOESv3rg1Nh6HJGr9vAvgjUkRTp6/KS9gexiKU2qR1hBKqh6cmBQkbFMWmX8MLnJJ6zLQ==}
+ engines: {node: '>= 18'}
+ peerDependencies:
+ vue: '>=3.1.0'
+
+ tinycolor2@1.6.0:
+ resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
+
+ validator@13.15.26:
+ resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==}
+ engines: {node: '>= 0.10'}
+
vite@5.4.21:
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -767,6 +813,8 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
+ '@babel/runtime@7.29.2': {}
+
'@babel/types@7.29.0':
dependencies:
'@babel/helper-string-parser': 7.27.1
@@ -921,6 +969,8 @@ snapshots:
'@parcel/watcher-win32-x64': 2.5.6
optional: true
+ '@popperjs/core@2.11.8': {}
+
'@rollup/rollup-android-arm-eabi@4.60.0':
optional: true
@@ -1006,6 +1056,12 @@ snapshots:
'@types/lodash@4.17.24': {}
+ '@types/sortablejs@1.15.9': {}
+
+ '@types/tinycolor2@1.4.6': {}
+
+ '@types/validator@13.15.10': {}
+
'@types/web-bluetooth@0.0.20': {}
'@vitejs/plugin-vue@5.2.4(vite@5.4.21(sass@1.98.0))(vue@3.5.30)':
@@ -1271,6 +1327,8 @@ snapshots:
dependencies:
mime-db: 1.52.0
+ mitt@3.0.1: {}
+
nanoid@3.3.11: {}
node-addon-api@7.1.1:
@@ -1340,8 +1398,36 @@ snapshots:
optionalDependencies:
'@parcel/watcher': 2.5.6
+ sortablejs@1.15.7: {}
+
source-map-js@1.2.1: {}
+ tdesign-icons-vue-next@0.4.2(vue@3.5.30):
+ dependencies:
+ '@babel/runtime': 7.29.2
+ vue: 3.5.30
+
+ tdesign-vue-next@1.18.6(vue@3.5.30):
+ dependencies:
+ '@babel/runtime': 7.29.2
+ '@popperjs/core': 2.11.8
+ '@types/lodash-es': 4.17.12
+ '@types/sortablejs': 1.15.9
+ '@types/tinycolor2': 1.4.6
+ '@types/validator': 13.15.10
+ dayjs: 1.11.20
+ lodash-es: 4.17.23
+ mitt: 3.0.1
+ sortablejs: 1.15.7
+ tdesign-icons-vue-next: 0.4.2(vue@3.5.30)
+ tinycolor2: 1.6.0
+ validator: 13.15.26
+ vue: 3.5.30
+
+ tinycolor2@1.6.0: {}
+
+ validator@13.15.26: {}
+
vite@5.4.21(sass@1.98.0):
dependencies:
esbuild: 0.21.5
diff --git a/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/App.vue b/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/App.vue
index 02e6d3e..f8f70ff 100644
--- a/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/App.vue
+++ b/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/App.vue
@@ -3,6 +3,40 @@
diff --git a/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/components/Layout.vue b/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/components/Layout.vue
index 2f8cff7..30b8d84 100644
--- a/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/components/Layout.vue
+++ b/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/components/Layout.vue
@@ -1,63 +1,93 @@
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
升级到 PRO 版本
+
解锁更多高级功能
+
立即升级
+
+
-
+
-
+
-
+
-
+
-
-
-
+
+
+